JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

事件迴圈、反應器與前攝器

第二篇把一個會說「哪些描述符就緒了」的原始核心機制交到你手上。這一篇把那個機制變成一個真正的程式:事件迴圈、組織它的反應器(reactor)模式,以及前攝器(proactor)——它那面鏡像,正是第四篇的完成模型所要求的。

從一個系統呼叫到一個迴圈

第二篇把 epoll_wait() 留在你手上:一個會睡到你登記的某些描述符就緒、然後恰好把那些交還給你的系統呼叫。那是一個動詞,不是一個程式。要建一台伺服器,你把它包進第一篇最早命名的那個迴圈——事件迴圈——而這個迴圈簡單到幾乎令人難為情。阻塞在 epoll_wait() 上;當它回傳一批就緒描述符時,走過那一批、對每一個做它現在已就緒的那一小段非阻塞讀或寫;然後回頭再次阻塞。那個外層循環,永遠重複下去,就是 nginx、Redis、Node.js 以及你將遇見的每一個非同步執行期的整副骨幹。

具體地說,迴圈主體是一個 for(;;):呼叫 epoll_wait(epfd, events, MAXEV, -1) 停泊到就緒(在 EINTR 訊號這種情況下重試、遇到真正的錯誤才退出),接著一個小小的內層 for 迴圈走過回傳的 events。對每一個,你從 events[i].data.fd 取出它的描述符,若 EPOLLIN 位元有設就呼叫你的 on_readable() 處理函式、若 EPOLLOUT 有設就呼叫 on_writable()——其中 on_readable() 把通訊端抽乾到 EAGAIN,正如第二篇邊緣觸發的契約所要求。那就是十來行裡的整個程式,而第二篇那個 多工 呼叫是它唯一睡覺的地方。

盯緊那個形狀的一個後果,因為它支配著其餘的一切。只有一條執行緒,而它要嘛停泊在 epoll_wait() 裡、要嘛正在跑某個處理函式——絕不會兩者同時。當 on_readable() 在跑時,迴圈沒在盯任何東西;epoll_wait() 要等到這一批裡每一個處理函式都返回後才會再次被呼叫。整個架構靠的是一個承諾:每個處理函式只做一小片、絕不阻塞的工作,然後快快返回。這條執行緒的時間在數千條連線之間被協作式地共享,就像一位廚師照看一整排鍋——這裡攪一下、那裡翻一下,絕不站著盯住一個鍋直到它做完。這是協作式、而非搶佔式的共享,而這個區別馬上就要咬人了。

唯一的規則:絕不阻塞迴圈

回想執行緒那一級裡協作式對搶佔式的區別。核心排程器是搶佔式的:如果一條執行緒霸佔 CPU,一個計時器中斷會把它揪走、讓別人輪到,不管它願不願意。一個事件迴圈沒有這種安全網。沒有任何東西會在處理函式跑到一半時打斷它;迴圈就只是等它返回。所以一個行為不端的處理函式——一個呼叫了阻塞 read() 的、一個磨過 50 毫秒運算的、或是去睡覺的——拖慢的不只是它自己那條連線。它凍結整個迴圈,連同其他一萬條連線中的每一條,因為在那條執行緒回到 epoll_wait() 裡之前,沒有任何一條能被服務。

這正是為什麼一個處理函式無法像阻塞程式碼讓你寫的那樣,是一個整潔、自成一體的函式。在阻塞的世界裡,你把「讀請求;再讀內文;再查資料庫;再寫回應」寫成一個直線到底的函式,執行緒在每個等待處睡去。在迴圈的世界裡你不准睡,所以那單一函式必須在它原本會等待的每一點上被切開。每一刀都變成一次獨立的處理函式呼叫,而連線在兩刀之間的進度必須被記在某個地方——「這條連線已經讀完表頭、現在正等著內文」。那份被存下的進度就是每連線的狀態(state),而把它管理好,正是事件迴圈程式設計真正的手藝。下一節給這門手藝一個名字。

反應器:把就緒組織起來

原始的迴圈用一個對描述符與它事件位元的大型 switch 來分派——對玩具而言沒問題,過了寥寥幾種連線型別就管不動了。反應器(reactor)就是把這件事收拾整齊的設計模式,而它不過是你已經半建好的一小組有名字的零件。一個解多工器(epoll_wait 本身)一次等待許多描述符上的就緒。一個分派器(那個 for 迴圈)拿到那一批就緒描述符、把每一個導向對的程式碼。還有一個事件處理器——每個描述符一個、或每條連線一個——同時握著要跑的回呼,與那條連線存下的狀態。你把處理器登記到反應器上;當它們的描述符就緒時,反應器回呼它們。這個名字抓住了精神:程式反應(react)於核心回報的就緒事件。

反應器的決定性特徵——也是要釘進記憶的那個——是每一次 read() 與 write() 仍然由你自己做。反應器永遠只告訴你「描述符 7 現在可讀了」;實際搬動位元組是你那個處理器的工作,在迴圈執行緒裡被同步地呼叫。這就是第二篇的就緒模型,如今披上了一個架構性的名字。epoll、kqueue、select、poll——每一個就緒多工器都是反應器形狀的,因為就緒正正是「核心通知了你;由你動手」。libuv、libevent、Java 的 NIO Selector、Linux 上 Python 的 asyncio:全都是建立在底層 epoll 或 kqueue 之上的反應器。

用一條走過反應器的連線把這件事弄具體。一個客戶端連上來;你 accept() 它、把通訊端設成非阻塞、用 EPOLLIN 把它登記到反應器、並掛上一個狀態為「等待請求行」的處理器。稍後 epoll_wait 回報它可讀;反應器呼叫你的處理器;處理器把通訊端抽乾到 EAGAIN(第二篇邊緣觸發的契約)、剖析已抵達的部分、把狀態推進到「等待內文」,然後立刻返回——它不等內文,它返回迴圈、讓另外九千條連線輪到。每一次喚醒都把一條連線往前推進一個非阻塞的步子。那就是反應器,也就是 Linux 上經典高效能輸入輸出的全部。

前攝器:把完成組織起來

現在翻轉那面鏡子。第一篇種下了第二個家族——完成模型——它需要自己的架構。前攝器(proactor)是反應器的鏡像——同樣的迴圈形狀、相反的分工。你不是登記對就緒的興趣、然後自己去讀,而是把一個完整的操作預先交給核心:「從描述符 7 讀 4 KiB 進這個緩衝區」。核心在背景裡把這次讀取執行掉。當位元組已經躺在你的緩衝區裡時,它張貼一個完成事件(completion event),而你迴圈的工作是反應於那些已經完成的操作。決定性的差別,用一句對比說:反應器告訴你你現在可以讀了;前攝器告訴你我已經替你讀好了

  reactor  (readiness):            proactor (completion):
  ---------------------            ----------------------
  register: "watch fd 7"           submit:   "read 4 KiB from fd 7"
  wait     -> "fd 7 readable"      wait     -> "that read is DONE"
  you call read() yourself         kernel already filled the buffer
  you handle the bytes            you handle the bytes

  kernel says: you may act         kernel says: I have acted
同樣的事件迴圈骨架、鏡像的分工。反應器回報就緒、輸入輸出由你做;前攝器做輸入輸出、回報完成。

到底為什麼要費心搞這面鏡像?三個誠實的理由,與一項誠實的代價。其一,完成把「等就緒」「讀取」摺進單一次核心往返,砍掉一半的系統呼叫開銷——第一篇警告過,那在 C10M 量級是要緊的。其二,它乾淨地推廣到那些沒有就緒可言的操作,尤其是真正的磁碟檔案——一個一般檔案本質上總是「可讀的」,所以 epoll 沒辦法有用地盯它,但一個完成介面會像排程任何其他操作一樣排程那次磁碟讀取。其三,它是 Windows 的原生模型:它的 IOCP——這個平台的高效能路徑——是一個前攝器,而且一向如此。代價是:在核心擁有那個緩衝區的整段時間裡,必須讓它活著且不被碰——這是反應器從不強加的一項真實的所有權負擔。

執行緒去了哪裡,以及 async-await 從哪裡來

退一步,注意你做的這筆交易。阻塞的、每連線一執行緒的模型把每條連線的進度放在某條執行緒的呼叫堆疊裡——它在程式碼裡的位置、它的區域變數、它在「讀—剖析—回應」中的位置——並讓核心排程器搶佔式地交錯數千個堆疊。反應器把執行緒扔掉,於是失去了堆疊這個存放進度的地方;這正是為什麼它逼你扛起每連線的狀態,手刻成一個結構或一台狀態機。你並沒有消滅執行緒原本在做的那份記帳;你只是把它從核心的堆疊裡搬進你自己的資料結構,換來不必為一萬個堆疊與一萬次脈絡切換付帳。

手寫那些狀態機是受罪,而那份受罪正是 async-await 存在的全部理由。當你寫 async fn handle(conn)、並在裡頭對每次讀取 await 時,編譯器讀你那個直線到底的函式、機械地把它切成你原本得手寫的那些反應器處理器——把函式變成一台狀態機,它的各個變體就是它的 await 點,並把每次 await 之後必須存活的區域變數塞進一個自動產生的結構。await 這個關鍵字標記出一刀。一個 非同步執行器(Tokio、asyncio、Node 的 libuv 核心)就是底下那個反應器,在它的事件迴圈上驅動那些產生出來的狀態機。你一次得到阻塞程式碼那種可讀的形狀,與反應器那種單執行緒的效率。

現在,先前那則警告以全副力道回來了,因為 async-await 把迴圈藏得太好,好到很容易忘記它在那裡。一個 await 點把那唯一一條迴圈執行緒讓回去、好讓它服務其他連線——但只在 await 處。如果你的非同步函式在兩個 await 之間做了一個阻塞呼叫或一段長運算,沒有任何東西被讓出,你就會把執行器卡住,跟一個原始處理器卡住的方式一模一樣;更漂亮的語法並沒有替你買到搶佔。這是這一級到此為止最尖銳的單一教訓:絕不阻塞執行器,無論那個處理器是你手寫的、還是你讓 async-await 替你寫的。把這一點、連同反應器對前攝器那面鏡子,直接帶進第四篇——在那裡 io_uring 把前攝器在 Linux 上化為真實。