Try   HackMD

I/O 模型演化: Linux 的 io_uring

資料整理: eecheng87, jserv

點題

報告:〈The Evolution of Linux I/O Models: A Path towards IO_uring〉及其錄影

為何需要 io_uring?

在 Linux 環境下,傳統的檔案 I/O 操作方式,如 read, write, pread, preadv 等,均為同步 (synchronous) 操作。這意味著當應用程式發起一個 I/O 請求時,它必須等待該操作完成後才能繼續執行後續的程式碼,這在高並行或 I/O 密集的場景下會嚴重影響應用程式的效能與吞吐量。

為解決同步 I/O 的阻塞問題,POSIX 標準定義非同步 I/O (Asynchronous I/O, AIO) 介面,例如 aio_read。然而,Linux 上的 POSIX AIO 實作 (通常基於 glibc 的執行緒池) 長期以來因其效能欠佳、使用複雜以及諸多限制而飽受詬病。

io_uring 引入前,Linux 核心已提供原生的非同步 I/O 介面 (Linux native AIO),但此介面亦存在一些顯著的不足。首先,它僅支援以 O_DIRECT 旗標開啟的檔案描述子進行直接 I/O,這限制其在緩衝 I/O 或網路通訊端等情境的應用。其次,即使號稱零複製,其 I/O 提交過程仍可能涉及少量資料複製,且一次完整的非同步操作通常需要二次系統呼叫 (io_submitio_getevents),在高頻率 I/O 場景下開銷可觀。最後,儘管名為非同步,原生 AIO 在某些情況下 (如等待元資料 I/O 或核心內部請求插槽耗盡時) 的提交操作仍可能發生阻塞。

面對這些挑戰,Linux 核心開發者 Jens Axboe 在嘗試改進現有 Linux 原生 AIO 未果後,決定從頭設計一個全新的非同步 I/O 介面。這個新介面的核心設計目標,按重要性遞增排列,包括:

  1. 易於理解且使用直觀
  2. 具備可擴展性 (extendable):不僅能高效處理傳統的區塊儲存 I/O,還要能無縫支援網路 I/O、非區塊儲存裝置,甚至更多未來可能出現的 I/O 類型
  3. 高效率:最小化每次 I/O 操作的開銷,包括 CPU 使用率和延遲
  4. 高可擴展性 (scalability):能夠充分利用現代多核處理器的能力,並在大規模並行請求下保持高效能

既生 epoll,何生 io_uring

自 2002 年 epoll 隨 Linux v2.5 系列核心問世以來,它已成功支撐起諸如 Nginx、Redis 等眾多高效能伺服器的運作,足以應對大量的網路 I/O 場景。然而,在過去近二十年間,硬體技術的飛速發展及新型態網路應用程式模式的演變,逐漸暴露 epoll 及其他既有非同步介面的不足之處,這些因素共同催生 io_uring 的誕生。

其中一個日益突出的問題是系統呼叫開銷的惡化。epoll 機制下,應用程式每次需要「收割」(reap) I/O 事件時,都必須切換到核心模式;在獲取到就緒的檔案描述子後,使用者空間還需要再次呼叫如 readwritesend 等函式才能真正執行 I/O 操作。隨著 Spectre 與 Meltdown 等一系列源於處理器內部設計的安全弱點被揭露,核心為防禦這些攻擊而引入的措施 (如 KPTI),使得處理器模式切換 (即使用者態與核心模式之間的切換) 的延遲成本大幅提升。在高速 NVMe 儲存裝置與高頻寬網路裝置普及的背景下,這種高頻率的系統呼叫逐漸成為效能瓶頸。

其次,epoll 並非完整的非同步 I/O 模型,它更像是個 I/O 事件通知機制:它告知使用者態哪些檔案描述子已變為可讀或可寫狀態,但實際的 I/O 操作 (讀取或寫入資料) 仍需由使用者態程式以同步或非阻塞的方式完成。面對現代程式對零複製 (zero-copy) 與批次處理 (batch processing) 的強烈需求,這種模式無法在核心端一次性完成整個 I/O 生命週期,可能導致快取使用效率不佳以及增加 CPU 排程的成本。

再者,既有的非同步相關介面功能分散且存在侷限。例如,epoll 主要針對網路 I/O 進行改進,而 Linux 原生的非同步 I/O 函式庫 (libaio) 則主要支援直接 I/O (Direct I/O),且通常僅用於儲存裝置。這使得應用程式若需要同時高效處理檔案 I/O 與網路 socket I/O,往往必須維護兩套獨立的事件迴圈和處理邏輯。

Stefan Hajnoczi 在 FOSDEM 2021 的演講 High-performance I/O with io_uring and virtio-fs 和 Davidlohr Bueso 在 FOSDEM 2019 的演講 The Evolution of File Descriptor Monitoring in Linux — From select(2) to io_uring 中,都展現 EPOLLEXCLUSIVE 旗標對於改善 epoll驚群效應 (thundering-herd problem) 的實測結果:監控 16,384 個檔案描述子的基準測試中,隨著工作執行緒數量從 1 個增加到 32 個,啟用 EPOLLEXCLUSIVE 的情境下,每個 CPU 每秒處理的事件往返次數(round trips per second per CPU)顯著高於未啟用該旗標的情況。具體而言,啟用後的效能曲線從約每秒 2 萬次往返線性增長至接近每秒 20 萬次,而未啟用時,效能曲線在僅有數個工作執行緒時便迅速達到飽和。

EPOLLEXCLUSIVE 旗標(自 Linux v4.5 引入)的設計,旨在當一個事件發生在被多個 epoll 實例監控的同一個檔案描述子上時,僅喚醒其中一個正在等待的執行緒。該機制有效地減少因多個執行緒被同時喚醒而僅有一個能成功處理事件所導致的無謂上下文切換與資源競爭,從而改善傳統 epoll 在此類場景下的驚群問題。

然而,儘管 EPOLLEXCLUSIVE 能改進喚醒行為,它並未改變 epoll 模型中「一個 I/O 事件的處理通常需要至少二次(甚至更多)系統呼叫」的基本模式:一次 epoll_wait (或其變體) 以獲知事件就緒,隨後還需至少一次如 readwriteaccept 等系統呼叫來實際執行 I/O 操作。此外,epoll 本身主要針對網路 I/O 和類似的基於檔案描述子的事件通知,它並未能提供一個統一的框架來高效處理儲存 I/O 與網路 I/O。

io_uring 的出現,正是為了解決上述這些難題:

  • 它以共享記憶體為核心通訊機制:提交佇列 (SQ) 與完成佇列 (CQ) 都藉由 mmap 系統呼叫映射到使用者空間。在多數情況下,應用程式僅需藉由 atomic 操作更新佇列指標即可推進 I/O 流程,無需切換到核心模式。僅在確實需要通知核心處理新請求或等待完成事件時,才藉由 io_uring_enter() 這個單一系統呼叫來同時完成提交與等待操作
  • 它採用真正的完整非同步執行模式。諸如讀寫、splice, sendfile, accept, timeout 甚至裝置直通 (passthrough) 等眾多操作,都能在核心模式一次處理完畢,結果直接放入完成佇列。此外,藉由固定緩衝區 (fixed buffers) 與固定檔案描述子 (fixed files) 等特性,得以進一步降低核心在處理請求時,搜尋資源和鎖定記憶體頁面的成本
  • 將檔案 I/O 與網路 I/O 整合到單一的事件處理模型中,使得使用者空間可運用一統的程式開發介面,來管理不同類型的非同步操作,不僅簡化應用程式的設計,也有助於在多核處理器中提升並行程度和批次處理效率

因此,在高度並行的網路伺服器等情境下,將事件迴圈從 epoll 遷移到 io_uring,往往可觀察到 CPU 使用率的降低以及尾端延遲 (tail latency) 的改善。對於那些追求高吞吐、高頻寬、低延遲的現代服務而言,io_uring 提供更符合時代需求的非同步 I/O 解決方案。

延伸閱讀:

io_uring 內部設計

io_uring 的設計著重於應用程式與核心空間之間的高效通訊。為徹底避免傳統介面因資料複製和系統呼叫所帶來的效能瓶頸,Jens Axboe 採用幾項關鍵設計。

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

其關鍵是共享記憶體與環形緩衝區。io_uring 在應用程式與核心之間建立一對共享記憶體區域,並在這片區域上實作二個環形緩衝區:

  • 提交佇列 (Submission Queue, SQ),應用程式作為生產者將 I/O 請求 (SQE) 填入,核心作為消費者讀取並執行
  • 完成佇列 (Completion Queue, CQ),核心作為生產者將已完成的 I/O 操作結果 (CQE) 填入,應用程式作為消費者讀取結果

這種基於共享記憶體的設計,使得雙方能在不進行額外資料複製的情況下交換大量 I/O 請求與結果資訊。

為了在多核環境下達成高效同步,SQ 和 CQ 均採用「單一生產者/單一消費者」的 lock-less 設計模型。對 SQ 而言,只有應用程式可寫入 (更新尾端指標) ,只有核心可讀取 (更新開頭指標) ;CQ 則相反。藉由精巧的記憶體屏障 (memory barrier) 和 atomic 操作來管理佇列的頭尾指標,io_uring 避免傳統鎖機制所帶來的競爭與開銷。

此外,io_uring 鼓勵批次處理並致力於最小化系統呼叫。應用程式可一次性向 SQ 提交多個請求,核心也可一次性向 CQ 填充多個完成事件。其主要系統呼叫 io_uring_enter() 功能多樣,既可用於通知核心處理新的 SQE,也可用於等待 CQE,甚至能同時執行這兩項任務。更進一步,藉由 SQPOLL (提交佇列輪詢) 等特性,在許多情境下甚至能完全避免 io_uring_enter() 系統呼叫,達成真正的零系統呼叫 I/O 提交。

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

取自 Understanding Modern Storage APIs: A systematic study of libaio, SPDK, and io_uring

liburing 函式庫

liburing 是 Jens Axboe 維護的輔助函式庫,其主要目的是簡化 io_uring 的使用。它藉由封裝底層的複雜性、提供便捷的 API 介面以及移除重複的樣板程式碼,讓開發者能更容易地利用 io_uring 的強大功能。對於大多數開發情境,應當優先考慮使用 liburing

直接操作 io_uring:深入底層的挑戰

雖然 liburing 大幅降低 io_uring 的使用門檻,但了解其底層的運作方式,尤其是直接操作 io_uring 時所面臨的挑戰,有助於更深刻地體會 io_uring 的設計精髓以及 liburing 的價值。

從使用者自行達成的角度來看,直接使用 io_uring 的前置作業相當繁瑣。開發者不僅需要自行處理環形緩衝區的記憶體映射、指標管理,還必須從一開始就面對無鎖 (lock-free) 程式設計的複雜性,例如手動管理記憶體屏障以確保應用程式與核心之間的資料可見性。此外,io_uring 眾多的特性是藉由旗標組合來啟用,這些組合在不同的核心版本上可能存在細微的行為差異,這使得開發一個具備良好可移植性的底層 io_uring 封裝庫變得極具挑戰性。

為求行文一致,本節將使用以下縮寫:SQ = 提交佇列 (Submission Queue);CQ = 完成佇列 (Completion Queue);E = 佇列項目 (Entry)。

uring.c 以 C11 atomics 撰寫,作為下方各節直接運用 io_uring 系統呼叫的範例程式碼。

環形緩衝區初始化

liburing 函式庫提供 io_uring_queue_init() 作為初始化 io_uring 實例的主要介面,並衍生出 io_uring_queue_init_params()io_uring_queue_init_mem() 這二個功能更豐富的函式。這些函式除了允許指定提交佇列與完成佇列 (SQ/CQ) 的深度 (entries) 及相關標記外,呼叫端還能傳入更進階的初始化參數結構體 (params),或是預先對映好的記憶體位址 (mem)。

若想完全不依賴 liburing,就必須直接使用 io_uring_setup 系統呼叫來建立 io_uring 實例。該系統呼叫會回傳代表 io_uring 實例的檔案描述子 (fd),後者在預設情況下與一般檔案描述子無異;但若啟用「註冊環 fd」(registered ring fd) 特性,fd 會變成 io_uring 執行緒私有的註冊檔案描述子,從而能省去核心內部因 fgetfput 所需的 atomic 操作,根據 Jens Axboe 在〈What's new with io_uring〉簡報第 16 頁指出,移除這些 atomic 操作通常可減少約 3–5 % 的執行期開銷。io_uring_setup() 呼叫同時也會藉由其輸出參數回報 SQ/CQ 環在核心位址空間中的各項偏移量資訊,應用程式後續需要再藉由 mmap() 系統呼叫,完成核心空間與使用者空間之間的共享記憶體對映。

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

io_uring 的共享記憶體機制有幾項值得注意的演進特性:

  • IORING_FEAT_SINGLE_MMAP (自 Linux v5.4 引入):此特性將原先可能需要 3 次 mmap() 呼叫 (分別映射 SQ 環指標區、CQ 環指標區以及 SQE 陣列) 的過程,簡化為 2 次 (SQ/CQ 環指標區共用一塊映射,SQE 陣列獨立映射)
  • IORING_SETUP_NO_MMAP (自 Linux v6.5 引入):在此模式下,SQ/CQ 環與 SQE 陣列不再由核心進行記憶體配置,而是改由應用程式在呼叫 io_uring_setup() 之前自行配置記憶體。常見的用途是讓應用程式能主動使用巨頁 (huge page) 特性以改進效能
  • IORING_SETUP_NO_SQARRAY (自 Linux v6.6 引入):此特性移除 SQE 索引陣列 (即 SQ array) 這一中間層。核心會依照 SQE 在環形緩衝區中的遞增順序直接處理它們,應用程式不再需要手動維護索引與實際 SQE 之間的對映關係

相對於 SQE 可能需要 (或在 IORING_SETUP_NO_SQARRAY 啟用後不需要) 一個中介的索引陣列,CQE (完成佇列項目) 本身是永遠直接內嵌在 CQ 環形緩衝區內的。

在大小方面,預設情況下,每個 SQE 為 64 個位元組,每個 CQE 為 16 個位元組。不過,io_uring 也支援如 SQE128CQE32 等特性,它們會使得 SQE 或 CQE 的大小加倍。這些功能主要服務於如 NVMe passthrough 這類有特殊需求的場景,但也增加開發可移植封裝庫的難度。

以下是一個 C11 的 struct uring_rings 結構體範例,用於管理直接初始化 io_uring 後映射到使用者空間的各項指標與資訊:

struct uring_rings {
    /* Submission Queue (SQ) related pointers */
    _Atomic unsigned int *sq_head, *sq_tail;
    _Atomic unsigned int *sq_flags;
    uint32_t *sq_array; /* May not be used if NO_SQARRAY is active */
    struct io_uring_sqe *sqes;
    uint32_t sq_ring_mask;
    uint32_t sq_ring_entries;

    /* Completion Queue (CQ) related pointers */
    _Atomic unsigned int *cq_head, *cq_tail;
    _Atomic unsigned int *cq_overflow;
    struct io_uring_cqe *cqes;
    uint32_t cq_ring_mask;
    uint32_t cq_ring_entries;

    /* Mapped memory regions for cleanup */
    void *sq_cq_ring_ptr;   /* Pointer to the mmap'd SQ/CQ ring area */
    size_t sq_cq_ring_size; /* Size of the mmap'd SQ/CQ ring area */
    void *sqes_ptr;         /* Pointer to the mmap'd SQEs array (if separate) */
    size_t sqes_size;       /* Size of the mmap'd SQEs array */
};

直接使用 io_uring_setup() 初始化 io_uring 實例的主要步驟如下:

  1. 準備 struct io_uring_params 結構體,並將其清零以確保未定義欄位有可預期的初始值。
  2. 呼叫 syscall(SYS_io_uring_setup, entries, &params)。核心會根據 entries (佇列深度) 與 params 中的旗標來初始化 io_uring 實例,並回傳一個檔案描述子 (ring_fd)。同時,核心會將 SQ/CQ 的各種偏移量與偵測到的特性旗標寫回 params 結構體
  3. 檢查 params.features 是否包含必要的特性 (例如,簡化的實作可能依賴 IORING_FEAT_SINGLE_MMAP)
  4. 計算 SQ/CQ 環共享區域的總映射大小。這通常需要參考 params.cq_off.cqes (CQE 陣列的起始偏移) 加上 CQE 區域的總大小,並確保也覆蓋 SQ 環的相關指標區域 (如 params.sq_off.array)
  5. 使用 mmap() 系統呼叫,以 ring_fdIORING_OFF_SQ_RING 為參數,將計算出大小的 SQ/CQ 環共享區域映射到使用者空間。使用 MAP_POPULATE 旗標可以讓核心提早分配實體頁面
  6. 若未使用 IORING_SETUP_NO_SQARRAY 特性,則需要再次呼叫 mmap(),以 ring_fdIORING_OFF_SQES 為參數,獨立映射 SQE 陣列區域
  7. 根據從 params.sq_offparams.cq_off 中獲取的各項偏移量,以及先前 mmap 得到的基底指標,初始化一個使用者定義的輔助結構體 (如上述的 struct uring_rings) 中的所有指標和元資料。例如,sq_head 指標的計算方式為 (atomic_uint *)((uintptr_t)base_ring_ptr + params.sq_off.head)sq_ring_masksq_ring_entries 這些靜態資訊可直接從映射區域取值

完成這些步驟後,應用程式便擁有可直接操作 io_uring 環形緩衝區的各項指標。之後可以:

  • 藉由讀取 sq_headsq_tail 指標來檢查或更新提交佇列的索引狀態
  • 直接修改 sqes 指向的記憶體區域來填充 I/O 請求
  • 讀取 cqes 指向的記憶體區域以獲取 I/O 完成結果

在整個過程中,除了最終提交請求時可能需要呼叫 io_uring_enter() 外,大部分操作都僅涉及對共享記憶體的 atomic 存取。

關於特定初始化旗標的運用,例如 IORING_FEAT_SINGLE_MMAP,官方文件與範例程式已有展示,核心在於檢查此旗標後進行單一的 mmap 操作來獲取 SQ/CQ 環的共享區域。對於 IORING_SETUP_NO_MMAP,由於應用程式層難以直接保證分配到連續的實體記憶體頁面,liburing 的實作對此特性有所限制,通常僅在總記憶體需求較小 (例如 2 MiB 以內) 時啟用,並可能嘗試使用匿名標準頁或匿名 huge page 進行配置。至於 IORING_SETUP_NO_SQARRAY,即使呼叫端未在 params.flags 中顯式設定此旗標,liburing 在較新的核心上,可能會偵測核心是否支援並嘗試藉此簡化操作。

提交 I/O 請求

當應用程式準備好一個或多個 SQE 之後,下一步便是通知核心來處理這些請求。這個過程的核心動作是更新共享的 SQ 尾端指標,並在必要時呼叫 io_uring_enter 系統呼叫。

在範例程式碼中,submit_operations 函式在填充完所有 SQE 並更新完本地的 tail 計數器後,首先執行關鍵的步驟來更新共享的提交佇列 (SQ) 尾端指標:

    /* 3. Update the submission queue tail */
    atomic_store_explicit(rings->sq_tail, tail, memory_order_release);

此處使用 atomic_store_explicit 並配合 memory_order_release 至關重要。memory_order_release 確保在此儲存操作之前,應用程式對所有已準備 SQE 內容的修改對於即將讀取這些 SQE 的核心來說都是可見的。

在更新完共享的 sq_tail 後,根據 io_uring 實例的設定模式以及應用程式的需求,可能需要呼叫 io_uring_enter 系統呼叫。io_uring_enter (及其較新版本 io_uring_enter2,後者允許傳遞更大的參數結構體) 是一個多功能的介面,其設計旨在盡可能減少系統呼叫次數。它可以同時執行提交新的 I/O 請求和等待已完成的 I/O 操作這兩項任務。需要注意,若未使用任何特定旗標,io_uring_enter 預設僅具有提交語義;啟用 IORING_ENTER_GETEVENTS 旗標後,它才具有提交並等待的語義。

submit_operations 函式中,若 to_submit > 0,則會進行如下的系統呼叫:

    /* 4. Submit all operations */
    if (to_submit > 0) {
        int ret = syscall(SYS_io_uring_enter, ring_fd, to_submit, to_submit,
                          IORING_ENTER_GETEVENTS, NULL, 0); /* sz is for enter2; NULL for sigset_t */
        if (ret < 0) {
            perror("syscall(SYS_io_uring_enter) failed");
            /* ... error handling ... */
            return -1;
        }
    }

此呼叫中,第一個 to_submit 告知核心此次要提交的 SQE 數量。第二個 to_submit 作為 min_complete 參數,結合 IORING_ENTER_GETEVENTS 旗標,表示應用程式希望等待,直到至少有這麼多操作完成後,系統呼叫才返回。

image

關於輪詢式 I/O (IORING_SETUP_IOPOLL) 的行為,Jens Axboe 在〈Efficient IO with io_uring〉的 8.2 節 (POLLED IO) 中解釋道:

When polling is utilized, the application can no longer check the CQ ring tail for availability of completions, as there will not be an async hardware side completion event that triggers automatically. Instead the application MUST actively find and reap these events by calling io_uring_enter(2) with IORING_ENTER_GETEVENTS set and min_complete set to the desired number of events.

這段話指出,當使用輪詢時,應用程式不能再僅藉由檢查 CQ 環的尾端指標來判斷是否有完成事件,因為可能不會有非同步的硬體完成事件自動觸發。相反地,應用程式必須藉由呼叫設定 IORING_ENTER_GETEVENTSmin_completeio_uring_enter(2) 來主動尋找和獲取這些事件。
io_uring 的預設模式是中斷驅動的 I/O。在這種模式下,即使 io_uring_enter 僅用於提交 (即僅有提交語義,未設定 IORING_ENTER_GETEVENTS) ,當 I/O 操作完成後,核心也會使得 CQ 環的 tail 指標推進。此時,處於非等待狀態的應用程式只需要主動檢查 tail 指標即可獲知完成情況。然而,對於低延遲的輪詢 I/O 模式 (IORING_SETUP_IOPOLL),則要求必須使用 IORING_ENTER_GETEVENTS 來獲取完成事件,不能僅靠非等待地查詢 CQ 環。

SQE 的生命週期與複用性也與核心版本和特性有關。自 Linux v5.5 開始,若支援 IORING_FEAT_SUBMIT_STABLE 特性,SQE 在提交後即可被應用程式安全地重用。在不支援此特性的舊版核心上,通常需要等到該 SQE 對應的 CQE 產生後,才能回收並重用該 SQE 槽位。

關於 CQ 環的大小,io_uring_queue_init 提到:

By default, the CQ ring will have twice the number of entries as specified by entries for the SQ ring. This is adequate for regular file or storage workloads, but may be too small for networked workloads.

預設情況下 CQ 環的大小是 SQ 環的 2 倍,這對常規檔案或儲存工作負載足夠,但對網路工作負載可能偏小。由於 SQE 的生命週期相對較短 (提交後可能很快產生 CQE 並被回收) ,而 CQE 則需要等待應用程式處理,因此讓 CQE 數目多於 SQE 數目是合理的。對於 SQE/CQE 生命週期差異較大的網路環境,可能需要使用者進一步調整 CQ 的大小。同時,若核心支援 IORING_FEAT_NODROP 特性,CQE 允許溢出,核心會嘗試內部緩存它們直到記憶體不足;這些溢出的 CQE 可能需要藉由設定 IORING_ENTER_GETEVENTSio_uring_enter 呼叫才能重新刷回到 CQ 環中。SQE 則不允許溢出。

liburingio_uring_submit() 等函式會智能地處理這些細節,根據 io_uring 實例的設定和目前狀態決定是否以及如何呼叫 io_uring_enter

收割 I/O 結果

當核心完成一個或多個 I/O 操作後,它會將相應的結果填充到完成佇列項目 (CQE) 中,並更新共享記憶體中的完成佇列 (CQ) 尾端指標 (rings->cq_tail)。由於 CQ 環的內容,包括 CQE 陣列以及 CQ 的開頭和尾端指標,都已經藉由 mmap() 映射到應用程式的虛擬位址空間,因此應用程式通常不需要額外的系統呼叫來獲取 (或稱 reap「收割」) 已完成的 I/O 結果。應用程式可以直接讀取 CQ 環的內容來檢查和處理完成事件。

在範例程式碼中,submit_operations 函式在藉由 io_uring_enter 提交並等待操作 (至少部分) 完成後,接著執行以下步驟來處理完成事件。

首先,應用程式需要讀取 CQ 開頭和尾端指標以檢查是否有可用的 CQE。核心在產生新的 CQE 後會推進 rings->cq_tail。應用程式讀取此共享指標時,必須使用帶有獲取語義 (memory_order_acquire) 的 atomic 操作:

    /* 5. Process completions (non-blocking check after potential blocking enter) */
    int completions = 0;
    head = atomic_load_explicit(rings->cq_head, memory_order_acquire); /* Or memory_order_relaxed if only this thread advances head */
    uint32_t cq_tail = atomic_load_explicit(rings->cq_tail, memory_order_acquire);

memory_order_acquire 確保應用程式能看到核心對 cq_tail 指標以及相應 CQE 內容的最新寫入,並防止不當的指令重排。rings->cq_head 代表應用程式已經處理到的位置。當 head != cq_tail 時,表示在 headcq_tail 之間存在一個或多個待處理的 CQE。

若存在可用的 CQE,程式可依序走訪它們:

    while (head != cq_tail) {
        uint32_t cq_index = head & rings->cq_ring_mask;
        struct io_uring_cqe *cqe = &rings->cqes[cq_index];

        /* Process the CQE based on user_data or other fields */
        if (cqe->user_data >= 100 && cqe->user_data < 200) { /* NOP completion */
            printf("NOP operation %llu completed with result: %d\n",
                   (unsigned long long)cqe->user_data - 100, cqe->res);
        } else if (cqe->user_data == 200) { /* File read completion */
            if (cqe->res < 0) {
                fprintf(stderr, "File read failed with error: %d\n", cqe->res);
            } else {
                printf("File read completed, bytes read: %d\n", cqe->res);
                if (cqe->res > 0 && read_buffer) { /* Check read_buffer pointer */
                    printf("First 16 bytes: %.16s...\n", (char *)read_buffer);
                }
            }
            /* Cleanup for this specific operation's resources */
            if (read_buffer) { free(read_buffer); read_buffer = NULL; }
            if (fd >= 0) { close(fd); fd = -1; } /* Assuming fd was for this read */
        } else {
            fprintf(stderr, "Unexpected CQE user_data: %llu\n",
                    (unsigned long long)cqe->user_data);
        }

        head++; /* Move to the next CQE */
        completions++;
        /* It's good practice to re-load cq_tail within the loop if many CQEs are processed,
           as the kernel might be adding more CQEs concurrently.
           The example re-loads it once per loop for simplicity. */
        cq_tail = atomic_load_explicit(rings->cq_tail, memory_order_acquire);
    }

在迴圈中,首先根據目前的 headrings->cq_ring_mask 計算出 CQE 在 rings->cqes 陣列中的索引,然後獲取該 CQE 的指標。接著,應用程式可以檢查 cqe->res (操作結果) 和 cqe->user_data (用於關聯請求) 來執行相應的處理邏輯。處理完一個 CQE 後,遞增本地的 head 計數器,準備處理下一個。範例中,在每次迴圈迭代後都重新讀取 cq_tail,這是個好的方法,因為核心可能在應用程式處理 CQE 的同時完成更多的操作並更新 cq_tail

當應用程式處理完一個或多個 CQE 後,必須通知核心這些 CQE 已使用,即更新 CQ 環的共享開頭指標 rings->cq_head。這是藉由將處理過的 CQE 數量加到目前的 head 值上 (或者直接使用迴圈結束後的 head 值) ,然後將這個新的 head 值寫回 *rings->cq_head 來完成。

    atomic_store_explicit(rings->cq_head, head, memory_order_release);

這個寫入操作必須使用帶有釋放語義 (memory_order_release) 的 atomic 操作。memory_order_release 確保核心能正確地看到應用程式對 head 指標的推進,且該更新發生在程式實際處理完這些 CQE 之後。

liburing 提供多種介面來簡化 CQE 的收割過程,例如 io_uring_peek_cqe(), io_uring_wait_cqe(), io_uring_peek_batch_cqe()io_uring_for_each_cqe()。在使用 liburing 時,通常呼叫 io_uring_cq_advance()io_uring_cqe_seen() 函式來通知 liburing 更新 CQ 的 head 指標。

關於記憶體順序 (memory order) 的考量:
io_uring 的 CQ tail 指標確實可能與核心執行緒競爭更新。例如,當 SQE 設定 IOSQE_ASYNC 旗標後,io_uring 允許以核心執行緒池 (io-wq) 的方式去完成 (強制) 非同步的 I/O 請求,這些執行緒完成操作後會更新 cq_tail。因此,應用程式在讀取 cq_tail 時使用 memory_order_acquire 是必要的,以確保觀察到所有先前由核心 (或其他執行緒) 對 CQ 環的寫入。

至於 CQ head 指標,它主要由應用程式 (消費者) 更新。然而,核心 (或其內部機制,例如處理 CQ 環溢出的邏輯) 也可能需要讀取 head 指標來判斷可用空間。因此,應用程式在更新 head 指標時使用 memory_order_release 也是必要的。這確保在 head 更新對核心可見之前,應用程式對相應 CQE 的所有處理都已完成,防止因指令重排或快取不同步導致的潛在問題。

Linux 核心 io_uring 的排程細節

理解 io_uring 如何在核心層面處理 I/O 請求的提交與完成通知,對於深入掌握其效能特性至關重要。此處的「任務排程」涵蓋以下:

  1. I/O 提交時:io_uring 會將請求分派至何種執行脈絡 (context)
  2. I/O 就緒時:io_uring 如何通知應用程式 (上層)

內嵌展開操作

在提交 I/O 請求時,io_uring 有能力在不額外使用執行緒 (thread) 的情況下處理 I/O,此即為內嵌展開 (inline) 的操作。這意味著 I/O 操作可能在應用程式呼叫 io_uring_enter() 的目前執行緒上下文中直接嘗試執行。

/* SQE submission call stack - conceptual */
io_issue_defs[req->opcode]->issue(req, issue_flags);
  io_issue_sqe(req, issue_flags = IO_URING_F_NONBLOCK | ...);
    io_queue_sqe(req);
      io_submit_sqe(req);
        io_submit_sqes(ctx, ...);
          sys_io_uring_enter(...);

當一個 SQE 提交時,issue_flags 通常會帶有 IO_URING_F_NONBLOCK 旗標,促使 io_uring 首先嘗試以非阻塞 (non-blocking) 方式執行 I/O。以檔案讀取 (io_read() 這個 issue 回呼) 為例,它會使流程滿足 force_nonblock 條件,進而為 kiocb (核心 I/O 控制區塊) 控制結構體加上 IOCB_NOWAIT 旗標。若 I/O 堆疊在處理過程中需要阻塞 (例如,對應的 page cache 不存在,必須等待磁碟 I/O 完成才能建立映射) ,則會立即返回 -EAGAIN,表示無法立即完成。

注意:

  • 多數檔案系統預設會在開啟檔案時加上 FMODE_NOWAIT 旗標,表示其支援內嵌展開操作 (例如:f2fs)
  • IOCB_NOWAIT 仍然允許同步的預先讀取 (readahead) 行為,因為預先讀取本身也是非阻塞的
  • 內嵌展開操作不會阻塞目前執行緒,也不會產生新的執行緒。值得注意的是,這點對於一般檔案 亦成立。使用者空間 (user space) 通常難以對一般檔案進行非阻塞式 I/O 操作 (preadv2() 搭配 RWF_NOWAIT 旗標是個例外) ,但 io_uring 能輕易達成此點

非同步緩衝操作 (Async Buffered Operations)

當非阻塞的內嵌展開操作失敗後 (例如返回 -EAGAIN) ,io_uring 會嘗試進行非同步緩衝 (async buffered) 操作。這主要針對緩衝 I/O,利用檔案系統的特性來非同步地處理因快取未命中 (cache miss) 等情況。

/* Implementation relies on VFS interfaces - conceptual */
io_rw_should_retry(rw_flags, kiocb);
  vfs_read(file, buf, count, pos); /* or vfs_write */

非同步緩衝操作會在 io_rw_should_retry() (或類似函式) 中為 kiocb 設定 IOCB_WAITQ 旗標,並註冊一個當 folio (核心中代表記憶體頁面的結構體) 解鎖時的回呼函式 (在此情境下可能為 io_async_buf_func) 。此回呼的註冊時機在 __folio_lock_async() 內。從其呼叫邏輯可知,若此時 folio_trylock() 失敗 (表示 folio 已被其他執行緒鎖定) ,核心內部將返回 -EIOCBQUEUED,並在 folio 成功非同步解鎖後才執行該回呼。本地持鎖失敗,意味著有其他執行脈絡正在競爭或使用该 folio,因此該回呼的執行相對於本地是非同步的。

注意:

  • 關於 async buffered read 的更多資訊,可參考 Add support for async buffered reads
  • 對於返回 -EIOCBQUEUED 的情況,io_read() (或相應的 I/O 函式) 會結束目前的重試,等待回呼被觸發
  • io_uring 處理部分讀取 的做法通常是再次 (基於新的偏移量) 重試
  • 核心版本和檔案系統對此特性的支援情況會隨時間演進

io-wq (I/O Worker Queue)

對於一般檔案 I/O,若上述的內嵌展開操作與非同步緩衝操作均失敗 (例如都返回 -EAGAIN) ,或者請求本身被標記為必須非同步執行 (如 IOSQE_ASYNC) ,請求將會被轉發至 io-wq 進行真正的非同步處理。io-wqio_uring 內部維護的一個專用執行緒池。

/* Path for a request to enter io-wq - conceptual */
io_wq_enqueue(work);
  io_queue_iowq(req, work);
    io_queue_async_work(req, work);
      /* This eventually leads to the work being processed by an io-wq thread */
      /* which might call io_submit_sqe or a similar issue function. */

註記:非同步緩衝操作若啟動成功 (返回 -EIOCBQUEUED) 並不會立刻將請求分派到 io-wq。而是會藉由 task_work (稍後介紹) 機制,在 folio 解鎖後重新嘗試提交。只有當這些嘗試都無法立即完成時,才可能最終走向 io-wq

由於一般檔案對應的 file_operations 並不支援 poll 介面,因此也不存在對應的 POLLINPOLLOUT 事件。所以,若嘗試對一般檔案進行基於 poll 的操作,io_arm_poll_handler()switch-case 判斷通常會匹配到 IO_APOLL_ABORTED 分支,這也可能間接觸發請求進入 io-wq

io-wq 的建立與執行緒管理:
io-wqio_uring_setup() 階段僅進行必要初始化,並不會預先啟動執行緒池。工作執行緒的建立 (藉由 io_uring 客製化的 create_io_thread()) 與啟動會延遲至首次有任務需要 io-wq 處理時才進行。在早期的 io_uring 實作中,工作執行緒採用的是標準的 kthread 機制,而現今版本則使用客製化的 IO thread (本質上仍是藉由 clone 系統呼叫的變體) 。

註記:若 IO thread 建立失敗,則會降級 (fallback) 採用核心既有的通用 workqueue 機制。

io-wq 的執行緒模型選用執行緒池的實作。執行緒數量則依據任務類型區分為有界 (BOUND) 與無界 (UNBOUND) 的情境。有界並行執行緒數取決於 io_uring_setup 時的參數或核心內部計算 (通常與 CPU 核數和 SQ 深度相關) 。無界並行數則通常非常大。一般檔案與區塊裝置 I/O 屬於有界任務,網路 I/O 則可能屬於無界任務。

/* io-wq task execution - conceptual */
/* In an io-wq worker thread: */
io_wqe_worker_running(worker); /* or similar worker entry point */
  io_wq_worker(wq);            /* Worker main loop */
    io_worker_handle_work(work);
      work->func(work);        /* This could be io_wq_submit_work */
        /* which might retry io_issue_sqe or similar logic */

io-wq 實際執行的工作任務仍然是 issue 相關操作。但因為已經在非同步的執行緒中,所以可以在其中進行必要的重試 (例如循環 issue) 而無需擔心阻塞主應用程式執行緒,並且也能處理 poll/iopoll 等細節。

註記:若 SQE 設定 IOSQE_ASYNC 旗標,請求將無條件進入 io-wq 處理流程,這通常是藉由 io_queue_sqe_fallback() 達成。

核心視角的 SQPOLL

io_uring 提供 SQPOLL (Submission Queue Polling) 特性,使得 I/O 提交可交由一個名為 iou-sqp 的核心執行緒以輪詢 (polling) 方式收集和處理 SQE,從而進一步避免應用程式進行 io_uring_enter 系統呼叫。

iou-sqp 核心執行緒的建立也採用 io_uring 客製化的 create_io_thread()。從 __io_sq_thread() (iou-sqp 執行緒的主函式) 可以看出,它的核心任務同樣是執行 io_submit_sqes(),不斷檢查 SQ 是否有新的請求。

註記:SQPOLL 特性有其複雜的使用規範 (例如,若 iou-sqp 執行緒閒置超過 sq_thread_idle 設定的時間,應用程式下次提交時可能需要主動設定 IORING_ENTER_SQ_WAKEUP 旗標來喚醒它) 。強烈建議參考 man 2 io_uring_setup 的說明文件。實務上,開發者可將這些複雜性交由 liburing 處理。

內嵌展開完成 (Inline Completion)

io_uring 的內嵌展開操作能夠成功,那麼 I/O 可以在應用程式呼叫 io_uring_enter() 的目前呼叫堆疊中直接完成,並產生 CQE。

/* After SQE submission, if inline operation succeeds and deferred completion is set - conceptual */
void io_uring_submit_and_flush_logic(struct io_ring_ctx *ctx, unsigned int submitted_count) {
    struct io_submit_state state;
    io_submit_state_init(&state, ctx, submitted_count);

    /* Loop through submitted SQEs */
    /* if (io_submit_sqe_inline(req, &state, issue_flags | IO_URING_F_COMPLETE_DEFER) == 0) { */
    /*     // Success, request is added to state.compl_reqs for deferred completion */
    /* } */

    if (state.compl_reqs_cnt > 0) {
        io_submit_flush_completions(&state); /* Batch fill CQEs from state.compl_reqs */
                                             /* This internally iterates and calls __io_fill_cqe_req */
    }
    io_submit_state_finish(&state);
}

issue_flags 添加 IO_URING_F_COMPLETE_DEFER 旗標時,已成功執行內嵌展開 I/O 的請求會被加入一個批次處理結構體 (submit_state) 的內部鏈結串列中。隨後,藉由 io_submit_flush_completions 批次填充 CQE,即可完成這些請求。

task_work

io_uring 使用 task_work 機制,作為除了內嵌展開完成之外的另一種 I/O 完成或推進的輔助手段。簡單來說,task_work 是一種在特定任務 (task, 即核心中的 task_struct 結構體) 即將返回使用者空間或被重新排程前執行的回呼機制。

以前述的非同步緩衝操作為例,當 folio 非同步解鎖後,會執行 io_async_buf_func() 回呼。此回呼會藉由 io_req_task_work_add() (內部呼叫 task_work_add) 將一個 task_work 回呼添加到指定的 req->task 任務中。這個指定的任務通常就是在提交 I/O 請求時的應用程式任務。當該任務下次即將返回使用者空間時,task_work 機制會觸發執行預先註冊的回呼 (例如 io_req_task_submit) ,該回呼會嘗試重新提交或處理該 I/O 請求。

/* Logic of io_req_task_submit triggered by task_work (conceptual) */
static void io_req_task_submit(struct callback_head *cb) {
    struct io_kiocb *req = container_of(cb, struct io_kiocb, io_task_work.work);

    /* If request should be forced async (e.g. IOSQE_ASYNC flag was set) */
    if (req->flags & REQ_F_FORCE_ASYNC) { /* REQ_F_FORCE_ASYNC is a conceptual flag here */
        io_queue_iowq(req); /* Queue to io-wq */
    } else {
        /* Otherwise, attempt to resubmit the request, possibly inline
         * This might involve calling io_issue_sqe or similar functions again
         * which could lead to inline completion, async buffered, or io-wq.
         */
        int ret = io_resubmit_sqe(req); /* Conceptual resubmit function */
        if (ret != 0 && ret != -EAGAIN && ret != -EIOCBQUEUED) {
            /* Handle error or complete with error */
        }
    }
}

「添加一個 task_work 回呼」更精確地說是將代表特定 I/O 請求的工作加入與該請求關聯任務的 io_uring 任務脈絡 (tctx) 的 task_work 處理佇列中。實際執行時,是 tctx 中註冊的 task_work 函式 (tctx_task_work) 被觸發,它會走訪並執行佇列中所有待處理的 I/O 請求的 task_work 函式。

task_work 的執行時機通常有:

  1. 系統呼叫過程中,從核心空間返回使用者空間前。
  2. 中斷處理完畢,從核心空間返回使用者空間前。

req->task 是一個核心執行緒 (如 SQPOLL 模式下的 iou-sqp) ,它會在自身的執行迴圈中檢查並處理 task_work

協同式排程

io_uring 針對 task_work 機制提供協同式排程 (cooperative scheduling) 機制,例如 IORING_SETUP_COOP_TASKRUN。此模式假定應用程式後續仍會主動執行系統呼叫 (例如再次呼叫 io_uring_enter() 獲取完成事件),因此 task_work 的通知不再強制目標任務立即切換到核心空間處理,藉此提高整體吞吐量。

這主要藉由 task_work_add 時傳遞的通知方法 (notify_method) 達成:

  • 預設情況:TWA_SIGNAL (會設定 TIF_NOTIFY_SIGNAL 並可能發送 IPI 喚醒任務)
  • IORING_SETUP_COOP_TASKRUN 時:TWA_SIGNAL_NO_IPI (僅設定 TIF_NOTIFY_SIGNAL,不主動發送 IPI)

不發出 IPI,目標 CPU 上的任務就不會被立即中斷,避免對使用者空間目前執行的任務的干擾。

io_uring 還提供 IORING_SETUP_TASKRUN_FLAGIORING_SETUP_DEFER_TASKRUN 等更細緻的控制,允許應用程式在使用者空間感知 task_work 的存在,或將 task_work 的處理延遲到特定的 io_uring_enter 呼叫時,以獲取更好的批次處理機會。

io_uring 排程小節

io_uring 在 I/O 提交時,會優先嘗試內嵌展開操作與非同步緩衝操作,以避免額外執行緒的開銷。若這些嘗試失敗,則考慮使用 io-wq 執行緒池來執行請求,或者應用程式從一開始就可選擇使用 SQPOLL 模式。

在 I/O 就緒通知方面:

  • 若為內嵌展開完成的 I/O,結果直接在目前脈絡中產生
  • 其他情況 (如非同步緩衝操作的回呼、arm_poll 偵測到就緒等) 會利用 task_work 機制通知提交方進行後續處理或重試
  • io_uring 還提供協同式排程的最佳化選項,以進一步提升效能

io_uring 的重要特性與使用

請求連結 (Linking Requests)

預設情況下,提交到 SQ 的請求是獨立執行的,其完成順序不一定與提交順序一致。但在某些場景下,我們需要保證一組操作的依序執行,例如 write 操作後必須接著 fsyncclose 操作。io_uring 提供 IOSQE_IO_LINK 旗標來達成請求連結。

詳細用法可參考 Linking requests

使用者視角的提交佇列輪詢

SQPOLL (Submission Queue Polling) 模式下,核心會啟動一個 iou-sqp 執行緒來輪詢 SQ。這極大地減少系統呼叫的次數。若 iou-sqp 執行緒閒置超過 sq_thread_idle 時間,它將進入休眠狀態。此時,若應用程式有新的請求提交,就需要呼叫 io_uring_enter() 並帶上 IORING_ENTER_SQ_WAKEUP 旗標來喚醒它。

注意:使用 liburing 時,開發者通常不需要直接處理 SQPOLL 模式下的喚醒邏輯。

詳細用法可參考 Submission Queue Polling

記憶體順序 (Memory Ordering)

由於 io_uring 的 SQ 和 CQ 是共享記憶體,必須正確處理記憶體操作的順序。應用程式和核心之間藉由記憶體屏障 (memory barrier) 和 atomic 操作來確保資料可見性。liburing 會自動處理這些必要的記憶體屏障。

進階使用情境

io_uring 的進階使用情境:

  • 固定檔案與緩衝區 (Fixed Files and Buffers): 藉由 IORING_REGISTER_FILESIORING_REGISTER_BUFFERS 預先註冊資源,提升效能
  • 輪詢式 I/O (Polled I/O - CQ Polling): 藉由 IORING_SETUP_IOPOLL 啟用,核心進行忙碌等待,適用於極低延遲場景
  • 核心層輪詢 (Kernel-Side Polling - SQ Polling)
    SQPOLL 機制,由核心執行緒 iou-sqp 主動輪詢 SQ

參考資料