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

阻塞、非阻塞,與 C10k 問題

你已經知道怎麼 read() 一個通訊端,也知道一個執行緒要花多少記憶體。這一級要問的是那個會擊垮顯而易見設計的問題:當一個阻塞的 read() 會凍結一整條執行緒、而「每連線一執行緒」又會把空間耗盡時,你要怎麼同時服務一萬條連線?答案從一個旗標和一個想法開始。

「阻塞」到底做了什麼

在檔案與通訊端那幾級,你寫過世界上最自然的迴圈:開一條連線、呼叫 `read(fd, buf, n)`、拿到一些位元組、處理它們、再重複。你也許沒仔細看過的,是當還沒有任何位元組抵達時 `read()` 做了什麼。預設情況下,一個檔案描述符阻塞(blocking)的:如果你要求讀取而資料不在,核心不會空手而回。它讓你的執行緒去睡覺——完全移出執行佇列——只有在位元組出現時才喚醒它。從你程式內部看,`read()` 看起來只是一個很慢的函式呼叫;而在底層,你的執行緒在那段時間裡根本沒有在執行

對於「一次做一件事」而言,這是一個確實很好的設計。執行緒沒有空轉、沒有燒 CPU、沒有輪詢——它被停泊著,不花任何代價,直到核心有真正的工作給它。代價是隱藏卻絕對的:一個阻塞呼叫恰好只能服務一個檔案描述符。當你的執行緒在連線 A 的 `read()` 裡睡覺時,它無法察覺連線 B 剛送來一個完整請求、正等著回應。執行緒才是會阻塞的那個單位,而一個睡著的執行緒對除了它所停泊的那個 fd 之外的一切都是聾的。

顯而易見的修法,以及它為何撞牆

如果一條阻塞的執行緒只能盯一條連線,那顯而易見的答案就是「每連線一執行緒」。每條執行緒跑自己那個天真的阻塞迴圈,在通訊端閒置時睡覺、在位元組抵達時醒來——而它們全都平行推進。這行得通,而且對幾百條連線而言行得很好;很多伺服器就是這樣寫的,你從執行緒那一級也已經有了做這件事的工具。對一個五十人的聊天伺服器來說,這是對的選擇。這個模型只有在數量變大時才會崩潰。

現在把數字變成一萬。每條作業系統執行緒都揹著一個真實的堆疊——常見的預設是保留大約 8 MiB 的虛擬位址空間,即使實際只碰到其中一小部分。一萬個這種就在數十 GiB 的保留量等級,再加上每條執行緒的常駐成本與核心記帳,遠遠不是免費的。比記憶體更糟的是排程:有一萬條「可執行或睡眠中」的執行緒,核心排程器光是決定誰來跑就要花掉真實的時間,而每當一條醒來、另一條睡去,就有一次脈絡切換——存暫存器、換分頁表狀態、逐出快取列。執行緒的成本在五十條時是個註腳,到一萬條時卻成了主導成本。

這道牆有個名字。1999 年,Dan Kegel 把這個問題收攏在一個標籤之下:C10k 問題——你要怎麼在一台機器上處理一萬條並行連線?當時的硬體輕鬆就能把位元組推出去;瓶頸在於程式設計模型,具體說就是「每連線一條阻塞執行緒」這個假設。打破這道牆的洞見,是乾脆不再把執行緒和連線配對。你不需要一萬條執行緒去盯一萬個通訊端——你需要一個方法,讓一條執行緒去盯許多通訊端,而不在其中任何一個上面睡死。這正是非阻塞輸入輸出讓人能做到的事。

撥動開關:O_NONBLOCK 與 EAGAIN

這個開關,是檔案描述符上的一個位元狀態。你設定 `O_NONBLOCK` 旗標(通常用 `fcntl(fd, F_SETFL, O_NONBLOCK)`),現在這個 fd 就是非阻塞(non-blocking)的:它讓同樣的 `read()` 與 `write()` 呼叫在資料還沒就緒時表現得不一樣。核心不再讓執行緒睡覺,而是立刻回傳。如果沒有東西可讀,`read()` 回傳 -1 並把 errno 設為 `EAGAIN`(在 Linux 上與 `EWOULDBLOCK` 同值)——這不是嚇人意義上的錯誤,而是核心客氣地說「現在沒有,晚點再試」。你的執行緒繼續執行,CPU 仍握在它手裡。

/* one non-blocking read attempt, checked honestly */
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) {
    /* got n bytes -- process them */
} else if (n == 0) {
    /* peer closed the connection (clean EOF) */
} else { /* n == -1 */
    if (errno == EAGAIN || errno == EWOULDBLOCK)
        ; /* not ready yet -- DO NOT treat as failure; move on */
    else if (errno == EINTR)
        ; /* interrupted by a signal -- retry the read */
    else
        perror("read"); /* a real error */
}
非阻塞讀取的三種結果,每一種都被處理。EAGAIN 是那條新的、正常的、非錯誤的路徑——它的意思是「晚點再來」,而一個把它當成失敗的伺服器會把活著的連線丟掉。

但請注意,我們只是用一個問題換了另一個問題。非阻塞的 fd 讓一條執行緒能許多通訊端而不睡覺——但執行緒該怎麼知道哪些通訊端其實已經就緒?天真的答案是對全部一萬個 fd 跑迴圈、對每一個都呼叫 `read()`,在有位元組的地方收集、在其他地方拿到 `EAGAIN`。這叫忙碌輪詢(busy polling),而它是場災難:在多半閒置的連線下,你會燒掉 100% 的一顆 CPU,去問一萬個通訊端「有嗎?有嗎?有嗎?」而幾乎每次都拿到 `EAGAIN`。我們去掉了睡眠執行緒的成本,卻只是換上了空轉執行緒的成本。非阻塞輸入輸出是必要的,但並不充分;它只是答案的一半。

另一半:問核心哪些就緒了

缺的那一半,是讓核心來做盯梢,因為核心才是真正知道位元組何時落到某個通訊端上的那一方。這就是 輸入輸出多工(I/O multiplexing):你把一整組檔案描述符交給核心,再用一個會阻塞的呼叫,直到其中至少一個就緒,然後回傳並告訴你是哪些。現在一條執行緒睡在整個集合上——不是忙碌空轉,而是真正地停泊——只有當集合中任何地方有真正的工作時才醒來。最早的 select() 與 poll() 系統呼叫做的正是這件事,它們是每一個事件驅動伺服器的概念核心。

把這些拼起來,新的形狀就浮現了。把每個通訊端設成非阻塞、用一個多工呼叫把它們全部登記、阻塞在那一個呼叫上,而當它帶著一份「就緒 fd 清單」回傳時,只對那些做保證不會睡覺的非阻塞讀寫——然後回頭再次阻塞在多工器上。那個外層循環就是事件迴圈(event loop),是接下來好幾篇的骨幹。一條執行緒,閒置時停泊、在數千條連線中任何一條有資料時瞬間回應:C10k 那道牆消失了,而我們從不需要一萬條執行緒。

就緒對完成:道路的分岔

在這一級展開之前,還有一個想法要先種下,因為它悄悄地把每一個高效能輸入輸出系統劈成兩個家族。上面那種 `select`/`poll`/`epoll` 的風格屬於就緒模型(readiness model):核心告訴你某個操作何時不會阻塞——「這個通訊端現在可讀了」——然後由自己去執行 `read()`。核心負責盯梢;工作由你做。這正是 Linux 的 epoll 與 BSD 的 kqueue 背後的模型,也是這一級第 2、3 篇要立基的東西。

另一個家族是完成模型(completion model):你不是被告知某個操作不會阻塞,而是請核心把整個操作做完——「幫我讀 4 KiB 進這個緩衝區」——核心只在位元組已經躺在你的緩衝區裡、大功告成時才通知你。你根本不呼叫 `read()`;核心去做,並回報完成的結果。Windows 的 IOCP 數十年來就是這樣運作的,而 Linux 的 io_uring 把一個現代的完成介面帶進了 Linux。這個就緒對完成的區分,正是第 2 篇與第 4 篇分道揚鑣的那條軸,所以現在值得把這一句話的差別釘牢:就緒說的是你現在可以動手了;完成說的是我已經替你做好了

往上爬時,把整張地圖放在眼前。你現在握有擊敗 C10k 的兩半——非阻塞檔案描述符,讓沒有任何單一呼叫會凍結執行緒;以及多工,讓一條執行緒不靠輪詢就能盯住數千條——還有那組織起其餘一切的分岔:就緒(epoll、kqueue)緊接著來,完成(io_uring、IOCP)兩篇之後到。前方的一切——邊緣對水平觸發、reactor 與 proactor 模式、零複製與向量化輸入輸出、以及把 accept 迴圈擴展到 C10M——全都建立在正是這些地基之上。記住那幅「一條停泊的執行緒、只被真正的工作喚醒」的圖像,你就不會迷失方向。