io_uring

為什麼需要 io_uring

一個繁忙的資料庫 server 每秒要處理數十萬筆查詢,每筆查詢可能都需要從 SSD 讀取資料。傳統的做法是每次 I/O 都呼叫一次 system call,但 system call 本身不是免費的:CPU 要從 user mode 切換到 kernel mode、儲存 register、做安全檢查,然後再切回來。當你每秒要做幾十萬次這種切換,CPU 花在「進出 kernel 的門」上的時間比花在「實際搬資料」上的時間還多。io_uring 就是為了解決這個問題而被創造出來的。

Linux 很早就有 asynchronous I/O 的機制(POSIX AIOlibaio),但它們都有明顯的限制。POSIX AIO 在 glibc 裡是用 thread pool 模擬的,本質上還是 synchronous。libaio 只支援 O_DIRECT(不能用 page cache),而且每次提交都要呼叫 io_submit() system call,每次收割完成結果都要呼叫 io_getevents() system call。

在高 IOPS 場景下,system call 本身的成本就很可觀。一次 system call 涉及 user-to-kernel mode switch,大約需要幾百個 nanoseconds(包括 register save/restore、security checks 等等)。如果你每秒要做 100 萬次 I/O,那就是每秒 100 萬次 submit syscall 加上數十萬次 reap syscall,光是 syscall overhead 就會吃掉大量 CPU 時間。

io_uring 是 Linux kernel 從 5.1 版(2019 年)開始引入的全新 async I/O 框架,由 Jens Axboe 設計。它的核心目標就是:大幅減少甚至完全消除 I/O 路徑上的 system call

Shared Ring Buffer 設計

io_uring 的核心機制是 kernel 和 user space 共享兩個 ring buffer:Submission Queue(SQCompletion Queue(CQ

初始化的時候,application 呼叫 io_uring_setup() 建立一個 io_uring instance。Kernel 會分配 SQCQ 的記憶體,然後把它們 mmap 到 user space。從此之後,kernel 和 application 直接透過 shared memory 溝通,不需要經過 syscall 來傳遞 I/O request 的內容。

提交 I/O:Application 把一個 SQE(Submission Queue Entry)寫入 SQ ring buffer 的下一個空位。SQE 包含操作類型(read、write、fsync 等)、file descriptor、buffer address、offset、length 等所有必要資訊。寫完之後,application 更新 SQ 的 tail pointer。

收割結果:Kernel 完成 I/O 之後,把結果寫成一個 CQE(Completion Queue Entry)放入 CQ ring buffer。CQE 包含結果狀態碼和一個 user_data field(application 在 SQE 裡面設的,用來辨別是哪個 request 完成了)。Application 讀取 CQ 的 head 到 tail 之間的所有 CQE 來收割結果,然後更新 CQ 的 head pointer。

SQCQ 都是 ring buffer,所以 head/tail pointer 到尾端之後會自動 wrap around。整個機制跟 NVMe 的 SQ/CQ 非常像,這不是巧合。io_uring 的設計明確受到 NVMe 的 command queue 模型啟發。

Batched Submission

即使有了 shared ring buffer,application 還是需要呼叫 io_uring_enter() system call 來通知 kernel「我放了新的 SQE 進去,請處理」。

io_uring 的巧妙之處在於:你可以一次寫入多個 SQE,然後只呼叫一次 io_uring_enter()。假設你有 32 個 I/O request 要發,你先把 32 個 SQE 依序寫入 SQ,然後呼叫一次 io_uring_enter(submit=32),kernel 就會一口氣處理這 32 個 request。

libaioio_submit() 比較:libaio 每次呼叫也可以提交多個 request,但 io_uring 的 batching 更高效,因為 SQE 已經在 shared memory 裡了,kernel 不需要從 user space 複製 request 的內容。

Batched submission 在高 IOPS 場景下效果非常明顯。假設你每秒要做 50 萬次 I/O,如果每次只 submit 1 個就 enter 一次,那就是每秒 50 萬次 syscall。如果你每次攢 32 個再 enter,syscall 降到每秒約 1.5 萬次,減少了 97%。

SQPOLL 模式:零 Syscall 提交

Batched submission 把 syscall 次數降了很多,但還是有 syscall。對於那些需要榨乾每一分效能的場景,例如高頻交易系統的 storage layer、或是每秒處理百萬筆請求的 key-value store,即使是少量的 syscall overhead 也嫌太多。io_uring 提供了更激進的 SQPOLL 模式,可以完全消除 submit 端的 syscall

開啟 SQPOLL 之後,kernel 會啟動一個專屬的 kernel polling thread。這個 thread 不斷地去看 SQ ring buffer 有沒有新的 SQE(透過檢查 SQ 的 tail pointer 是否被 application 更新了)。只要發現新的 SQE,kernel thread 就自動取出來處理。

Application 的角度完全變了:你只需要把 SQE 寫進 SQ ring buffer、更新 tail pointer,然後什麼都不用做。不需要呼叫任何 syscall。Kernel thread 會自己來取。

代價是什麼?那個 kernel polling thread 會持續佔用一個 CPU core(或至少佔用它的很大比例),即使沒有任何 I/O 要處理,它也在空轉 poll。所以 SQPOLL 適合的場景是 I/O 非常密集、需要最低延遲的 workload。如果你的 I/O 是偶爾才來一個,SQPOLL 就是在浪費 CPU。

為了緩解這個問題,SQPOLL 有一個 idle timeout 機制:如果 polling thread 發現超過一段時間都沒有新的 SQE,它會自動進入 sleep 狀態。下次 application 寫了新的 SQE 之後如果發現 polling thread 已經 sleep,就需要呼叫一次 io_uring_enter() 把它叫醒。所以 SQPOLL 不是「永遠零 syscall」,而是「在 I/O 持續密集的時候零 syscall」。

IOPOLL 模式:Polling 取代 Interrupt

前面的 SQPOLL 解決的是 submit 端 的 syscall 問題。IOPOLL 解決的則是 completion 端 的 interrupt 問題。

在正常模式下,I/O 完成的時候裝置送 interrupt 通知 kernel,kernel 把結果填入 CQ,然後透過某種機制(eventfd、signal、或 application 下次呼叫 io_uring_enter 順便 reap)通知 application。每次 interrupt 都有幾個 microseconds 的 overhead。

IOPOLL 模式開啟之後,kernel 不等 interrupt。Application 呼叫 io_uring_enter(flags=IORING_ENTER_GETEVENTS) 的時候,kernel 直接去 poll 裝置的 completion queue(對 NVMe 來說就是去檢查 NVMe CQ 有沒有新的 completion entry)。如果有,立刻處理並填入 io_uringCQ。如果沒有,繼續 poll,直到有結果或超時。

IOPOLL 的好處是延遲更低:I/O 完成之後不需要等 interrupt delivery 和 handler 的開銷,下一次 poll 就能拿到結果。在 NVMe SSD 的 read latency 只有幾十個 microseconds 的情況下,省掉 interrupt 的幾個 microseconds 是有意義的改善。

IOPOLL 必須搭配 O_DIRECT 使用(因為 buffered I/O 的 completion path 不支援 polling)。而且 IOPOLL 會讓呼叫 io_uring_enter 的 thread 在 kernel space busy-wait,所以它會佔用 CPU。跟 SQPOLL 類似,適合 I/O 密集且對延遲極度敏感的場景。

SQPOLLIOPOLL 同時開啟,就能得到一個「submit 不需要 syscall、completion 不需要 interrupt」的極致 I/O 路徑。在這個模式下,application 只需要操作 shared memory 的 SQCQ,kernel polling thread 處理 submit,completion 也走 polling。整條路徑上幾乎沒有任何 mode switch。

io_uring_cmd:NVMe Passthrough

io_uring 預設的 I/O 路徑還是要經過 Linux 的 block layer。Block layer 提供了很多有用的功能(I/O scheduler、I/O accounting、encryption 等等),但在極端效能場景下,它也是一層 overhead。

io_uring_cmd 機制允許 application 透過 io_uring 直接向 NVMe driver 發送 passthrough command,完全繞過 block layer。Application 可以自己組裝 NVMe command(包括 opcode、LBA、PRP 等),然後用 io_uring_cmd 直接把這個 raw NVMe command 丟給 driver。

這有什麼用?一般的 read() / write() 經過 block layer 之後,中間有 I/O scheduler 的 reordering、merging、plugging 等處理,這些東西在 HDD 時代很有價值(可以減少 disk seek),但在 NVMe SSD 上反而是多餘的 overhead。透過 io_uring_cmd passthrough,application 可以跳過這些中間層,直接操作 NVMe 的 command queue。

io_uring_cmd 也支援 NVMe 的特殊 command,比如 zone management command(for Zoned Namespace SSD)或 vendor-specific command,這些是一般 block layer 不支援的。

不過 io_uring_cmd 的使用門檻比較高:application 必須自己理解 NVMe command format,自己管理 DMA buffer,而且失去了 block layer 提供的所有便利功能(file system、encryption、I/O priority)。它的定位是給高效能 storage application(例如 database engine、storage target)使用的進階功能,不是給一般 application 用的。

自問自答

試著用自己的話回答以下問題。如果卡住了,回去重讀對應的段落。

  1. io_uring 的 shared ring buffer 設計和傳統 libaio 比起來,在 submit 和 reap 的流程上各省掉了什麼開銷?為什麼光是「不需要 copy request 內容」就能帶來明顯的效能差異?

  2. 假設你的 application 每秒需要做 50 萬次 4KB random read。在不使用 batching 的情況下需要多少次 syscall?使用 batch size 64 的 batched submission 之後需要多少次?SQPOLL 模式下需要多少次?