一個行程、許多通道、一個問題
走到本階的尾聲,你收集了一抽屜的通道。一條通往子行程的匿名管線、幾條具名 FIFO、也許一個通往對等行程的 Unix 網域通訊端,還有來自鍵盤的你自己的標準輸入。多虧了一切皆檔案,它們每一個都不過是你拿來 read() 與 write() 的一個檔案描述符。這份一致性一路上都是一份安靜的祝福——但現在它咬人了。想像一個小小的聊天樞紐:一個行程握著三條連線,而一則訊息可能在任何時刻、從它們任何一個上頭抵達,順序誰也料不準。你想處理最先開口的那一個。怎麼辦?
那個顯而易見的動作失敗了。如果你對第一個描述符呼叫 read(),那個呼叫會阻塞——你的行程在核心裡凍住,直到一號描述符上冒出位元組為止。同一時間,第二條連線可能正扯著嗓子喊,而你永遠不會察覺,因為你正睡著、等在錯的那一個上頭。這就是你在檔案那一階遇過的阻塞行為,如今變成了一個陷阱:一個阻塞的 read() 把你整個單執行緒的注意力,全部押在恰好一個描述符上,而世界有許多個。你沒辦法一次 read() 三樣東西。
這裡有兩條糟糕的逃生口在誘惑你,而把它們點名出來,能讓真正的解法該長什麼樣更清楚。你可以替每個描述符生出一條執行緒,各自阻塞在自己的 read() 上——行得通,但你現在背上了執行緒、共享可變狀態,以及上一階那套上鎖的全副重量,只為了等待。或者你可以把每個描述符都設成非阻塞,然後永遠迴圈、輪流戳每一個的 read();這樣永不睡眠,於是它把一個 CPU 核心釘在 100%、燒著週期去問「有了嗎?有了嗎?」。兩者都行得通,兩者都是錯的。你真正想要的,是把你的整份清單交給核心,然後睡著,直到它們任何一個就緒的那一瞬間核心把你叫醒——閒著的時候不付任何代價。
多工:一個呼叫、許多描述符
核心提供的正是這筆交易,而它有個名字:I/O 多工(I/O multiplexing)。I/O 多工背後的想法是把問題反過來。不是你挑一個描述符、問「把這個的資料給我」(那會阻塞),而是你把一組描述符交給核心,問一個比較溫和的問題:「告訴我這些當中哪些就緒了——可讀、可寫——並讓我睡著,直到至少有一個就緒」。單獨一個系統呼叫,一口氣盯著整組。當它回來時,它告訴你此刻就能動手處理的那個子集,於是你只去 read() 那些,並且知道每一個都會立刻回傳、不會阻塞。
注意這一切多麼乾淨地立基在某個你從本階第一篇就一路帶著的東西上。因為一條管線、一條 FIFO、一個通訊端、還有鍵盤全都是描述符,單獨一個 select() 呼叫就能在一口氣裡等待它們的一團大雜燴——一條來自子行程的管線、一條來自陌生人的 FIFO,和一個已連線的通訊端,並肩躺在同一組裡。核心不在乎每個號碼背後是什麼。這是一切皆檔案至今付出的最大一筆紅利:一個統一的等待原語,跨越本階引入的每一種通道都管用。
select():就緒迴圈,一步一步來
我們用這兩個呼叫裡比較老的那個——select()——把它弄具體。你建一個描述符集——一個 fd_set 型別的值,你可以把它想成一張位元圖(bitmap),每個可能的描述符號碼一個位元。你用 FD_ZERO() 把它清空,用 FD_SET(fd, &set) 把你在乎的每個描述符那一位打開,然後把這組連同「最大的描述符號碼加一」一起交給 select()。select() 睡著,直到組裡至少有一個描述符就緒,接著回來時就地改掉了你的那組:如今只有就緒的描述符還留著它們的位元開著。你走過你的那些描述符,用 FD_ISSET(fd, &set) 測試每一個,看哪些活下來了。
- 用 FD_ZERO(&readset) 從一個空集開始,然後對每一個你想盯著看有沒有資料進來的描述符做 FD_SET(fd, &readset)。記下你加進去的最大那個 fd;select() 需要 maxfd + 1 當它的第一個引數。
- 呼叫 select(maxfd + 1, &readset, NULL, NULL, NULL)。那兩個 NULL 跳過了「可寫」與「錯誤」這兩組;最後那個 NULL 表示永遠等下去(改傳一個 timeout 結構,就能在一段期限後即使什麼都沒就緒也醒來)。
- 當 select() 回傳一個正的計數,就在你的描述符上迴圈,用 FD_ISSET(fd, &readset) 測試每一個。對每一個位元仍開著的 fd,呼叫一次 read(fd, ...)——它保證不會阻塞。永遠檢查 read() 的回傳值:0 代表檔案結尾,所以把那個 fd 關掉、並從你的組裡剔除。
- 關鍵在於,在下一次 select() 之前要重建那組。因為 select() 用「只剩就緒位元」覆寫了那組,你必須每一輪都重新 FD_ZERO、再 FD_SET 你那些還活著的描述符——忘了這件事,正是那個經典的 select() 臭蟲:描述符悄悄地不再被盯著了。
poll():同樣的想法、少了些尖角
select() 背著一些遠古的包袱,而 poll() 是那個比較乾淨的手足,它修掉了其中最糟的部分,同時保住了一模一樣的就緒模型。select() 的第一個毛病是一道硬上限:它的 fd_set 位元圖有一個固定寬度,通常是 1024 位元,所以一個號碼為 1024 或更高的描述符根本塞不進去——而寫到位元圖之外,是赤裸裸的未定義行為,不是一個乾淨的錯誤。第二個是你剛看到的那件重建的苦差:因為 select() 把那組弄壞了,你每一輪都得把每個描述符重新列一遍。第三個是那個彆扭的 maxfd + 1 引數。poll() 把這三樣全都丟掉了。
poll() 拿的不是一張固定的位元圖,而是一個你建出來的 struct pollfd 的陣列,每個描述符一筆。每一筆有三個欄位:fd(描述符)、events(一個你在等什麼的位元遮罩,例如 POLLIN 代表「可讀」),以及 revents(由核心填入實際上發生了什麼)。你把這個陣列、它的長度,和一個逾時值傳進去。妙處在於 poll() 把它的答案寫進獨立的 revents 欄位、而讓你的 fd 與 events 欄位原封不動——所以你不必每一輪都重建陣列;你只要把 revents 清零(或乾脆不理它)再呼叫一次就好。也沒有 1024 的上限:陣列你想開多長就多長。
struct pollfd fds[2];
fds[0].fd = fd_a; fds[0].events = POLLIN; /* watch for readable */
fds[1].fd = fd_b; fds[1].events = POLLIN;
for (;;) {
int n = poll(fds, 2, -1); /* -1 = wait forever; array unchanged */
if (n == -1) { perror("poll"); break; }
if (fds[0].revents & POLLIN) { /* kernel reports fd_a readable */
char buf[256];
ssize_t r = read(fd_a, buf, sizeof buf);
if (r <= 0) { close(fd_a); fds[0].fd = -1; } /* -1 fd is ignored */
else { write(1, buf, r); }
}
if (fds[1].revents & POLLIN) {
char buf[256];
ssize_t r = read(fd_b, buf, sizeof buf);
if (r <= 0) { close(fd_b); fds[1].fd = -1; }
else { write(1, buf, r); }
}
}誠實的極限,以及之後是什麼
select() 與 poll() 共有一個你在伸手去用它們之前該知道的結構性缺陷,而它關乎規模。每一次呼叫都把整份描述符清單交過去給核心,核心掃過它們全部看哪些就緒,回來時你又掃過它們全部去找出就緒的那些。這是描述符數量上的 O(n) 工作量,每一次呼叫都是。在十個或一百個描述符的情況下,這看不見。但在數萬個閒置連線的情況下——一個真實的網頁伺服器握著五萬個大多很安靜的通訊端——你在每一次醒來時都複製並掃過五萬筆,只為了發現其中兩個有資料。成本隨著你盯著的連線數成長,而不是隨著真正活躍的那少數幾個——這恰好顛倒了。
這不是一個避開它們的理由——對少數幾個描述符而言,select() 與 poll() 完美、可攜,而且就在標準裡。它是一個該知道它們天花板的理由。業界最後定下來的解法,是一個有狀態的介面:你把你的描述符向核心註冊一次,之後它在每次等待時只交回就緒的那些、不再重掃那一大群閒置的。那就是 Linux 的 epoll(epoll_create()、epoll_ctl()、epoll_wait()),而在各家 BSD 與 macOS 上扮演同樣角色的是 kqueue。這些不在 C 標準或 POSIX 裡,而且是各平台特有的,這正是那個取捨:你拿可攜性,去換一個能廉價地擴展到成千上萬條連線的能力。
一路退到最遠,看看本階造出了什麼。你一開始連把一個位元組送過兩個行程之間那道牆都做不到;你結束時,能跑一個從容地玩弄著數十個通道——管線、FIFO、通訊端——的單一行程,閒著時睡覺、在它們任何一個開口的那一瞬間醒來。那個單執行緒、事件驅動的形狀——一個被就緒喚醒的閒置行程——正是你日後會遇到的幾乎每一個高效能伺服器與事件迴圈的核心,從 nginx 到 Node.js。底下那套機制永遠是你剛才親手造出來的那同一套:把你的描述符清單交給核心,讓它告訴你哪些就緒了。