翻轉:從「就緒了」到「做好了」
第二、三篇把整台機器都圍著核心給的一個承諾建起來:某個描述符就緒(ready)了。epoll 叫醒你並說「現在對這個 fd 做 read() 不會阻塞」——然後你,在使用者空間裡,自己呼叫 read() 去真正搬動那些位元組。那就是 就緒模型,而它是 select()、poll()、epoll 與 kqueue 唯一提供的東西。它們每一個都告訴你該何時動手;沒有一個替你動手。這一篇講的就是把那份契約丟掉。
完成模型問的是一個不同的問題。你不再說「能讀的時候通知我」,而是說「這裡有一個緩衝區——去讀 4 KiB 進來,並在你做好時告訴我」。核心接管了整個操作的所有權:它等資料、它把位元組複製進你的緩衝區,然後才交還給你一筆小小的完成記錄,上面寫著「你要的那次讀取完成了;它搬了 1280 個位元組;結果在這裡」。你根本完全不呼叫 read()。你提交一個願望,稍後再領取一個結果。就緒是門鈴;完成是送貨到府。
反應器變成前攝器
第三篇給了那個以就緒為基礎的事件迴圈一個名字:反應器(reactor)。反應器等待事件,而當某個描述符變就緒時,它就反應過來、分派給一個去執行 I/O 的處理常式。完成式 I/O 有它自己的名字、也有它自己的形狀:前攝器(proactor)。前攝器不是先等就緒再動手;它前攝地在一開始就把操作發動出去、交給核心,然後只等那個做好的結果回來。同樣的事件迴圈骨架,相反的分工。
讓一條連線分別走過兩者,差別就變得很具體。在反應器裡:epoll_wait 回「fd 7 可讀」→ 你呼叫 read(7, buf, 4096) → 核心把位元組複製上來給你 → 你處理它們。讀取發生在你的執行緒裡、在喚醒之後。在前攝器裡:你提交「從 fd 7 讀 4096 進 buf」→ 你的執行緒睡著 → 核心在資料抵達時做那次讀取 → 你被「fd 7 的讀取做好了,1280 個位元組」叫醒 → 你處理它們。讀取發生在核心裡、在喚醒之前。反應器裡的處理常式開始工作;前攝器裡的處理常式完成工作。
io_uring:兩個共享環,近乎零系統呼叫
Linux 的完成引擎是 io_uring,於 2019 年在核心 5.1 加入——年輕,而且仍在演進。它核心的訣竅既機械又漂亮:不再是每個操作一次系統呼叫,而是核心與你的程式在記憶體裡共享兩個環形緩衝區、兩邊都看得見。一個是提交佇列(submission queue,SQ):你把你的願望寫進去。另一個是完成佇列(completion queue,CQ):核心把做好的結果寫進去。兩者都被映射進你的位址空間,所以讀寫項目就只是普通的記憶體存取——張貼一個請求或看見一個結果,根本不必跨進核心。
your program (user space) kernel +---------------------+ | submission queue SQ | ---you write---> reads SQEs, | [read fd7 ->bufA] | does the I/O, | [write fd9 bufB ] | writes results +---------------------+ +---------------------+ | completion queue CQ | <---kernel writes--- [fd7 done: 1280] | [fd7 done: 1280 ] | ---you read---> [fd9 done: 512] +---------------------+ fill SQ entries in memory (no syscall), then optionally io_uring_enter() ONCE to submit a whole batch + reap completions. with SQPOLL, a kernel thread polls the SQ and you skip even that.
跟著一個請求走過這些環。你在 SQ 裡取一個空槽、填入一筆提交佇列項目(SQE):操作碼 IORING_OP_READ、fd、緩衝區指標、長度,以及一個你自選的 user_data 標籤,好讓你稍後認得出答案。那純粹是寫記憶體——還沒有系統呼叫。當你排好想要的這麼多筆之後,你做一次呼叫 io_uring_enter(),告訴核心「我加了 N 筆;開始」。核心抽乾 SQ、執行每一個操作,並為每一個張貼一筆完成佇列項目(CQE)——載著你的 user_data 標籤與結果(搬了多少位元組,或一個負的 errno)。你直接從共享記憶體裡讀那些 CQE。一次系統呼叫就搬動了一整批讀取與寫入。
現在是讓 io_uring 與眾不同的那句妙語。因為這些環住在共享記憶體裡,批次化意味著許多操作只花一次 io_uring_enter(),而不是各一次系統呼叫。而且你還能再進一步:在 SQPOLL 模式下,一條專屬的核心執行緒不停地盯著提交佇列,於是當你加入 SQE 時它會注意到並開始執行它們、完全不需要你做任何系統呼叫。到那地步,一台繁忙的伺服器可以提交數千次讀取與寫入、並收割它們的完成,而跨進核心的次數基本上是零——就緒模型那筆每事件的系統呼叫稅(第二篇量出它正是 C10k 的瓶頸)便朝著零崩塌。
IOCP:Windows 早就有了前攝器
如果 io_uring 感覺像個新點子,這裡有一段誠實的歷史:Windows 早在二十多年前就出貨了一個以完成為基礎的設計。I/O 完成埠(IOCP)隨 1990 年代中期的 Windows NT 而來,它就是前攝器模型、發育完全,遠早於 Linux 有任何類似的東西。它的形狀現在會讓你覺得熟悉。你用一個重疊(overlapped)呼叫在一開始就發動一個操作——交給 ReadFile() 或 WSARecv() 一個 OVERLAPPED 結構——它立刻返回、不做那次讀取;核心接走了這件工作與那個緩衝區。
聰明的組織關鍵就是完成埠(completion port)本身:一個你把許多通訊端與檔案綁上去的核心佇列。它們之中任何一個上的每一個重疊操作,完成時都會把它的結果張貼到那一個埠上。你的工作執行緒坐在 GetQueuedCompletionStatus() 裡,各自阻塞著、直到一個做好的操作彈出來,然後處理它、再迴圈回去拿下一個。一個埠、一小群執行緒,核心把每個做好的 I/O 交給任一個空閒的工作執行緒——它甚至會限制同時跑幾個來配合你的 CPU 數,好讓執行緒不會亂衝。這是一個乾淨、批次化的完成派送迴圈,只是用 Win32 寫成。
於是兩大巨頭從相反的方向相遇。Windows*生來就是前攝器:它可擴展的路徑一直都是以完成為基礎,而 Windows 沒有 epoll,是因為它從來不需要一個。Linux生來*是反應器:epoll 當了它二十年的可擴展路徑,而 io_uring 是那個終於給了它一個真正前攝器的新近到來者。這也是為什麼跨平台執行期在這裡做著真正的工作。像 libuv 這樣的函式庫呈現單一個非同步介面,在底下於 Unix 上悄悄驅動 epoll/kqueue、在 Windows 上驅動 IOCP——而在現代 Linux 上愈來愈多地驅動 io_uring——好讓你的應用程式碼永遠不必知道作業系統說的是哪一種模型。
翻轉的代價:所有權、生命週期,與誠實的限度
完成式 I/O 並不是免費的優雅;它把真實的責任移到你身上,而這個移動微妙到值得明白地點名。最深的一個是緩衝區所有權。當你提交一次讀取,你是把你的緩衝區借給核心、借滿整個操作的期間,而在相對應的完成抵達之前,你不可以碰它、釋放它、或讓它離開作用域。在 C 裡這是一個活生生的陷阱:一個放在某函式堆疊上的緩衝區,若該函式在完成落地之前就返回了,就變成一塊核心仍在往裡寫的懸空區域——正是除錯與未定義行為那幾級警告過的那種無聲記憶體損毀。完成記錄的 user_data 標籤就是你把結果與正確且仍存活的緩衝區重新湊在一起的辦法;把那份帳記錯,你就損毀記憶體、或處理到一次過時的讀取。
核心擁有這個操作還有第二個、比較令人開心的後果:它打開了通往 零複製(zero-copy) 的門。因為核心從頭到尾掌控整個傳輸,它有時能把資料從網路卡或頁快取直接搬到目的地,而省去 read() 進使用者緩衝區通常會逼出的那一次額外複製——第五篇會直接接手這件事。也有一個誠實的但書來平衡這份讚美:io_uring 確實很新、且有過一段崎嶇的安全史,有一連串真實的核心漏洞,導致某些加固過的環境(Google、Android、ChromeOS 的部分)預設把它停用。它強大、且是 Linux I/O 的未來,但它還不是 epoll 那種無聊、久經沙場的預設選項。
把整個形狀握住
把它組起來。第二、三篇的就緒模型讓核心當一個門鈴——「這個 fd 就緒了,現在由你讀」——它每個事件至少花兩次核心跨越,並組織成一個反應器。完成模型讓核心當一個送貨服務——「你要的那次讀取做好了,位元組已經在你的緩衝區裡」——它把發現與執行摺進單一個提交出去的操作,並組織成一個 前攝器。Linux 用 io_uring 的兩個共享環與近乎零的系統呼叫抵達它;Windows 以 IOCP 的完成埠擁有它已有數十年。同樣的目標、相反的母語史。
把三件事帶進第五篇。第一,這個翻轉的代價要由你來付:當核心擁有這個操作時,那個緩衝區就是核心的,而生命週期的失誤會無聲地損毀記憶體。第二,正是同一份核心所有權解鎖了 零複製 與向量化 I/O(vectored I/O),也就是第五篇開場的正題。第三,這一切都沒有取代那個「接受並擴展」的問題——你仍然得把連線弄進門、並把它們散佈到各核心上、連同驚群在內,而那正是這一級最後一篇落腳的地方。你現在握住了高效能 I/O 圖景的兩半:核心如何告訴你(就緒對完成),以及接下來,當它告訴你之後,如何搬動那些位元組、又如何擴展那次接受。