Linux 非同步 I/O:Epoll vs. io_uring

io_uring 是 Linux 上現代的非同步 I/O 標準,提供了一種基於完成(completion-based)的模型,能大幅減少處理高流量網路傳輸時所需的系統呼叫(system calls)次數。雖然 epoll 幾十年來一直是業界標準,但 io_uring 消除了與就緒通知(readiness notifications)相關的冗餘 syscalls,讓開發者可以批次處理操作並實現更高的吞吐量。

就緒模型:epoll

Epoll 是一個基於就緒(readiness-based)的系統。它在檔案描述符(file descriptor)就緒進行 I/O 操作時通知應用程式,但它本身並不執行該操作。這會因為每個事件所需的系統呼叫次數而造成顯著的效能瓶頸:

  • 註冊: 一次性的 epoll_ctl 呼叫來註冊 socket。
  • 通知: 呼叫 epoll_wait 來確定哪些 socket 是就緒的。
  • 執行: 另外呼叫 read()write() 來實際移動數據。

由於每次系統呼叫都會觸發使用者模式(user mode)與核心模式(kernel mode)之間的上下文切換(context switch),當處理數千個並行連接時,開銷會變得非常高昂。這就是一種先驗的架構限制,通常會阻止簡單的 epoll-based 代理伺服器超越像 Nginx 或 HAProxy 這樣經過高度優化的工具。

完成模型:io_uring

於 Linux kernel v5.1 (2019) 引入,io_uring 將架構從就緒轉向完成。io_uring 不再是告訴應用程式何時可以讀取,而是告訴應用程式讀取何時完成

運作方式

Io_uring 利用了應用程式與核心之間共享的兩個環形緩衝區(ring buffers):

  1. 提交隊列 (SQ): 應用程式在此處放置 I/O 請求。
  2. 完成隊列 (CQ): 核心在此處放置已完成操作的結果。

這種共享記憶體架構允許應用程式透過單個 io_uring_enter() 系統呼叫提交一批次的複數個 I/O 操作,並一次獲取多個完成結果。在某些情況下,開銷可以降低到接近於零。

進階效能特性

  • SQPOLL (提交隊列輪詢): 透過使用 IORING_SETUP_SQPOLL,會建立一個專用的核心執行緒來輪詢提交隊列。這消除了應用程式在穩定狀態下完全不需要呼叫 io_uring_enter() 的需求,儘管當隊列為空時,執行緒旋轉(spinning)會增加 CPU 使用率。
  • 零拷貝 I/O (Zero-Copy I/O): 開發者可以使用 io_uring_register_buffers() 來避免每次操作時核心對記憶體的重新映射(re-mapping)。對於網路傳送,IORING_OP_SEND_ZC (可用於 kernel 6.0+) 允許核心完全跳過將緩衝區複製到核心空間的過程。

比較總結

特性 epoll io_uring
模型 就緒 (就緒時通知) 完成 (完成時通知)
Syscall 開銷 高 (每個事件 2+ 次 syscalls) 低 (1 次 syscall per batch 或使用 SQPOLL 時為 0)
Kernel 版本 舊版 (自 2002 年起) 現代 (v5.1+, 2019)
複雜度 相對簡單 較高 (需要管理環形緩衝區)

實作考量與權衡

雖然 io_uring 提供卓越的效能,但它也引入了特定的工程挑戰與安全性疑慮:

安全性與穩定性

由於某些安全性風險,部分環境預設會停用 io_uring。因為它使用核心與使用者空間之間的直接記憶體共享,它一直是多個漏洞利用(exploits)的目標。這就是為什麼像 Go 這樣的某些高效能執行環境(runtimes)預設不會使用它。

錯誤處理

與立即回傳錯誤的同步 syscalls 不同,io_uring 的錯誤是異步回傳的。它們存在於完成隊列條目 (CQE) 的 res 欄位中,這需要不同的錯誤管理方法。

進階效能優化

為了將效能推向超越 io_uring 的極限,開發者可以探索:

  • CPU Pinning: 將執行緒與監聽 socket (SO_INCOMING_CPU) 綁定到特定核心,以避免跨 CPU 通訊。
  • Memory Alignment: 使用專門的配置器(allocators)如 mimallocconcurrencykit 來進行記憶體對齊的緩衝區。
  • Kernel Bypass: 在極端情況下,使用 DPDK (Data Plane Development Kit) 可以完全繞過核心,儘管這會顯著增加實作複雜度。

Sources