第一篇把我們留在哪裡:一次盯住許多個
第一篇結束在一個懸念上。你學會了把一個通訊端設成非阻塞,於是一個沒有資料可讀的 read() 會立刻返回、把 errno 設成 EAGAIN,而不是讓你的執行緒去睡覺。這解決了一個問題卻製造出另一個:如果現在單一執行緒要同時雜耍一萬條連線,它不能只是在一個迴圈裡對每一個都呼叫 read()。那些呼叫大多會回 EAGAIN,於是燒掉一整顆 CPU 核心,一毫秒裡問上一萬次「好了嗎?好了嗎?」。你需要去問核心:你的哪些檔案描述符已經就緒了,並且在至少有一個就緒之前一直睡著。
這個能力有個名字:I/O 多工。這筆交易簡單而有力。你把一組檔案描述符交給核心並說「讓我睡著,並在這些之中任何一個能在不阻塞的情況下被讀或被寫的那一刻叫醒我」。一條執行緒、一次系統呼叫、一萬條連線、零忙等。這是你用過的每一個事件迴圈背後那根承重的念頭——nginx、Redis、Node.js、Rust 或 Go 裡的非同步執行期——而接下來三篇都直接建立在它之上。這一篇講的是機制本身:核心如何盯著,以及它用什麼方式告訴你之中那個出奇尖銳的抉擇。
舊辦法:select 與 poll,以及它們為何撐不住
做這件事最早的可攜工具是 select() 與 poll(),而值得精確理解它們,正因為它們的缺陷會教你 epoll 修好了什麼。兩者形狀相同:你建一份你在意的每一個描述符的清單,每次呼叫都把整份清單交給核心,核心掃過全部、把就緒的標記起來、再把整份清單交還給你。然後你自己走過清單去找那些標記。它能用、它在 POSIX 裡、它到處都跑——但它有一個隨連線數而非隨活動量增長的成本。
好好體會這個成本,因為它正是第一篇那個 C10k 問題 的核心。假設有 10,000 條連線開著,但此刻只有 5 條有資料。用 select(),你仍要把一份一萬個項目的集合複製進核心,核心仍要掃過全部一萬個,而你仍要走過全部一萬個去找那 5 個標記——每一次呼叫、每一次都如此。那是 O(n) 的工作去發現 O(1) 個事件。更糟的是,原始的 select() 在 FD_SETSIZE 個描述符處有一道硬上限、通常是 1024,在編譯期就釘死了。poll() 拿掉了那道上限,卻保留了線性的複製與掃描。在一萬條連線這個量級,這就是一台閒置時涼快的伺服器與一台燒掉一顆核心去做帳的伺服器之間的差別。
這個診斷直接指向修法。浪費的部分在於每次呼叫都把整組重新告訴核心一遍,又重新掃過全部去找那少數觸發的。如果你只登記一次你的興趣,而核心自己保有一份只含真正變就緒之描述符的滾動清單,會怎樣?那麼一次等待就只回那些就緒的,成本便隨活動量、而非隨連線總數而伸縮。這正是接下來的機制所跨出的那一步。
epoll 與 kqueue:登記一次,等待多次
Linux 的答案是 epoll;BSD 與 macOS 的答案是 kqueue。它們拼法不同,卻共享那個致勝的念頭,所以把形狀學會一次就好。不再是一個呼叫包辦一切,而是把工作拆成兩個階段。首先你建立一個核心物件——Linux 上是 epoll_create1()、BSD 上是 kqueue()——它跨呼叫地保有你的興趣集。接著,每條連線一次,你登記它:epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event) 告訴核心「盯著這個 fd 的可讀性,並把它記住」。這份登記成本你只付一次,在連線抵達的時候。
第二階段是那個熱迴圈。你呼叫 epoll_wait(epfd, events, maxevents, timeout),它睡到有東西就緒,然後只回那些就緒的描述符,寫進你提供的 events 陣列裡。如果登記了 10,000 條連線而只有 5 條觸發,epoll_wait 就恰好給你 5 個項目——而不是要你篩過 10,000 個。沒有重新複製興趣集、你這邊也沒有線性掃描。每次喚醒的成本如今隨就緒描述符的數目而伸縮,而那正是整盤棋的關鍵。這就是單一執行緒如何能從容地撐住 10,000、甚至 100,000 條連線;它是 C10k 與 C10M 解法的機械核心。
邊緣對水準:那個會咬人的抉擇
現在輪到尖銳的部分。當 epoll 回報某個描述符可讀時,「可讀」究竟是什麼意思?有兩種答案,而在兩者之間做選擇,就是邊緣觸發對水準觸發的決定——它是事件迴圈神祕臭蟲最常見的單一來源。這些名字來自電子學:水準觸發在意的是一個訊號的狀態(它現在是高的嗎?),而邊緣觸發在意的是一次轉變(它剛剛從低變成高了嗎?)。epoll 讓你用 EPOLLET 旗標逐描述符地選;預設是水準觸發。
水準觸發是寬容的那個。只要通訊端緩衝區裡還坐著任何未讀的資料,每一次 epoll_wait 的呼叫都會持續把那個描述符回報為就緒——一遍又一遍,直到你把它抽乾為止。這符合你的直覺,也符合 select()/poll() 一向的行為。實際的結果是:你可以只讀走一部分可用的位元組、處理它們、然後回到迴圈;下一輪 epoll 會愉快地提醒你還有更多。要弄丟一個事件是很難的。代價是當你沒有完全抽乾時,會多幾次喚醒。
邊緣觸發(EPOLLET)是高效卻不寬容的那個。它每次轉變只回報一次——只在新資料抵達、把緩衝區從空翻成非空時。如果你沒在那一次通知裡把所有東西讀完,epoll 不會提醒你。剩下的位元組就坐在那裡、沒被讀走,而 epoll_wait 對它們保持沉默,因為沒有新的抵達來製造一道新的邊緣。你的連線會看似毫無理由地卡住——這是經典的邊緣觸發臭蟲,而你至少會寫出它一次。
level-triggered (default): edge-triggered (EPOLLET): arrives: 200 bytes arrives: 200 bytes epoll_wait -> READY epoll_wait -> READY (one edge) read 100, handle, loop read 100, handle, loop epoll_wait -> READY (still!) epoll_wait -> (silent, hangs!) read 100, handle, loop 100 bytes stuck, no new edge ever epoll_wait -> EAGAIN, done the edge-triggered rule: on each notification, read in a loop until read() returns EAGAIN, THEN go back to epoll_wait.
邊緣觸發的契約:抽乾到 EAGAIN
如果邊緣觸發這麼容易誤用,為什麼還有人選它?因為它在負載下高效。水準觸發可能為了同一批積壓的資料反覆喚醒你,而在繁忙的伺服器上,那些多餘的喚醒會累積起來。邊緣觸發每次抵達只觸發一次,是最低限度,這就是為什麼高吞吐的伺服器、以及建立在 epoll 上的非同步執行期通常跑在邊緣模式。訣竅是遵守它那條嚴格的規則,而那條規則短到足以背下來。
- 把描述符設成非阻塞——在邊緣模式裡這是必須、而非可選(你即將在迴圈裡讀,需要最後一次 read() 乾淨地返回、而不是阻塞住)。
- 用 EPOLLET 登記它,讓 epoll 只在每次新轉變時通知你。
- 當 epoll_wait 回報描述符可讀時,在一個迴圈裡反覆呼叫 read(),把它交還的每一個位元組都消化掉。
- 持續迴圈直到 read() 回傳 -1 且 errno == EAGAIN(或 EWOULDBLOCK)——那、且唯有那,才代表緩衝區現在真的空了。
- 到此才回到 epoll_wait。略過這次抽乾,正是讓一條邊緣觸發連線無聲卡住的那個臭蟲。
注意這如何倚賴第一篇的東西:抽乾迴圈之所以會結束,正因為 read() 可能回傳比要求更少的位元組,並終於在一個空的非阻塞通訊端上回傳 EAGAIN。你必須把 EAGAIN 當成一個正常、預期之中的訊號,意思是「全部抽乾了,回去睡吧」,而不是一個要記錄下來的錯誤。寫入這側也要照鏡子做:在邊緣模式裡,能寫多少寫多少,而當 write() 因為傳送緩衝區滿了而回傳 EAGAIN 時,就登記 EPOLLOUT,讓 epoll 在又有空間時叫醒你——然後繼續。忘記對稱的寫入這一側,是第二常見的邊緣臭蟲。
把整個形狀握住
退一步,把整幅圖拼起來。多工解決了第一篇提出的問題:一條執行緒可以盯住一萬個非阻塞通訊端,方法是去問核心哪些就緒了。select() 與 poll() 做得到這件事,卻每次呼叫都重新複製、重新掃描整組,於是成本隨連線、而非隨事件增長——那道 C10k 的牆。epoll 與 kqueue 把登記與等待分開來打破那道牆:你只宣告一次興趣,而每次等待只回那些真正觸發的描述符。這一切仍然是就緒模型——核心說「就緒」,讀取由你來做。
而在效能底下坐著那個你絕不能弄錯的設計抉擇:水準觸發對邊緣觸發。水準在資料還在時持續提醒你;邊緣每次抵達恰好觸發一次,並要求你在再度入睡前把每一個位元組都抽乾到 EAGAIN。把這三件事帶向前——登記一次等待多次、是就緒而非完成、以及邊緣觸發的抽乾契約——進入第三篇,在那裡你會把這個原始機制包成一個真正的事件迴圈,並遇見組織起這一切的反應器(reactor)模式。