為什麼一個用戶端簡單、一千個就難
在前幾篇裡,你做出了一個能運作的 TCP 伺服器:你呼叫 accept 接下一條新連線,然後像對檔案一樣在它上面讀寫。那個伺服器是正確的,面對單一使用者也很討喜。麻煩在兩個人同時出現的那一刻就開始了。要理解原因,我們得仔細看一個一直藏在你每個呼叫裡的小詞:阻塞。
一個阻塞呼叫,就是會讓你的程式睡著、直到它能完成為止的呼叫。當你對一個通訊端呼叫 read 而還沒有資料抵達時,核心不會回傳錯誤;它只是把你的執行緒停在那裡、凍結著,直到有一個位元組出現。當沒有用戶端在等時的 accept、以及送出緩衝區滿了時的 write,都是同樣的情形。從你的程式碼角度看,那一行執行就這麼僵住了——有時是幾毫秒,有時是永遠。對一個用戶端來說這沒問題,因為反正你也沒別的事想做。
現在想像兩個用戶端,Mia 和 Theo。你那唯一的執行緒卡在 read 裡,等著 Mia 送點什麼來。Mia 跑去喝咖啡了。同一時間,Theo 正用一個個緊急請求猛敲伺服器,而每一個都得不到回應,因為你的執行緒正睡在 Mia 身上。一個緩慢或閒置的用戶端,凍結了所有人。這就是大規模網路程式設計的核心問題:一台伺服器必須服務許多連線,而每一條連線都在無法預測、彼此獨立的時刻才就緒,但一個阻塞呼叫永遠只能一次等一件事。
顯而易見的解法,以及它最終為何破功
每個人最先想到的解法,是給每個用戶端配一個自己的小幫手。當 accept 交給你一條新連線時,你就生出一條全新的執行緒(或行程),它唯一的工作就是讀寫那一個通訊端。現在 Mia 的執行緒可以安心睡她的咖啡時間,完全不會碰到 Theo 的執行緒。這行得通,也容易推理,對幾十條、甚至幾百條連線而言,它是個完全合理的設計。許多真實的伺服器就是這麼做的。
但把規模放大,破綻就露出來了。每條執行緒都需要自己的堆疊,常常約一 MB 記憶體,所以一萬條閒置連線可能讓你白白花掉好幾 GB 的 RAM 什麼也沒做。更糟的是,作業系統必須不斷地在這麼多執行緒之間切換 CPU,而每次切換都要花時間儲存與還原狀態。當你的一萬條連線裡大多數在大多數時間都閒置——這正是真實聊天伺服器或網頁伺服器的樣貌——你就是在耗費龐大資源,只為了讓上千條睡著的執行緒「以防萬一」地待命。浪費就在等待裡,而等待的人實在太多了。
非阻塞通訊端:永不等待,只管詢問
突破的前半段,是改變通訊端本身的行為。你可以把一個通訊端切換成非阻塞模式(用一個小小的系統呼叫,在 Unix 上是帶 O_NONBLOCK 的 fcntl)。在那之後,原本會讓你睡著的呼叫,改成立刻回傳。如果你呼叫 read 而沒有資料在等,它不會停住你的執行緒;它馬上回來,帶著一個特殊訊號(錯誤碼 EAGAIN,意思是「待會再試」)。如果 write 因為送出緩衝區滿了而無法全部收下,它會收下能收的部分並告訴你收了多少,而不是凍結到有空間為止。
單就這點看,它似乎沒用、甚至更糟。如果 read 只是一直說「待會再試」,那有什麼能阻止你陷進一個緊湊的迴圈裡,一秒問上千萬次、燒掉一整顆 CPU 核心,卻只發現什麼都沒變?那種忙碌等待會是一場災難。非阻塞模式本身並不解決問題;它只是拿掉了凍結。我們仍然需要某個東西來回答一個不同、而且有用得多的問題:在我所有連線之中,此刻哪些是就緒的,好讓我只去問那些?
注意這乾淨地對應到你已經知道的東西。TCP 給你的應用程式的是一條位元組串流,而不是整齊的訊息,所以單次 read 可能回傳半個請求、或兩個黏在一起的請求。非阻塞模式讓這件事更明顯:你讀進此刻碰巧就緒的那些位元組,先塞進一個緩衝區,等到照你自己的訊息框界規則湊出一個完整訊息時,才動手處理。非阻塞的世界逼你誠實面對一個事實:資料是零零星星抵達的,不照你的任何時程表。
I/O 多工:一個監看者照看每條連線
缺的那一塊是 I/O 多工:一種把一整份通訊端清單交給核心、然後說「讓我睡,但只要這裡面任何一個變就緒就立刻叫醒我,並告訴我是哪一個」的辦法。現在你那唯一的執行緒只在一個地方阻塞——在監看者上——而不是在任何單一通訊端上。當每條連線都安靜時,這條執行緒便宜地睡著;而 Theo 的資料一抵達,它就醒來、得知 Theo 就緒、只服務他,把 Mia 的閒置通訊端原封不動地留著。一條執行緒、一萬條連線,而力氣嚴格地按真實的活動量來花。
做這件事的經典工具是 select 和 poll。兩者都讓你傳入一組通訊端,並阻塞到其中一些就緒為止。它們行得通、也具可攜性,但在大規模下都共有一個致命缺陷:每一次呼叫,你都要把整份(比方說)一萬個通訊端的清單交出去,而核心會掃描全部一萬個,才找出就緒的那少數幾個。每次喚醒你都付出與連線總數成正比的代價,即使只有一條連線有消息。監看的東西越多,監看本身就越貴——這恰恰和你想要的相反。
這就是 epoll 登場之處(它在 BSD 與 macOS 上的表親是 kqueue,在 Windows 上是 IOCP;想法是一樣的)。你不再每次重交整份清單,而是把每個通訊端向核心註冊一次,核心便替你維護這份「關注清單」。當你詢問哪些通訊端就緒時,它只回傳就緒的那些,完全不掃描那閒置的大多數。現在每次喚醒的成本是隨「活躍連線數」而非「總連線數」成長。一萬條多半閒置的連線,監看起來變得真的很便宜;正是這一項改變,讓單執行緒服務上千連線成為家常便飯。
事件迴圈,逐步走一遍
把兩半兜在一起——非阻塞通訊端,加上一個 epoll 監看者——你就得到了幾乎每個高效能伺服器(從 Nginx 到 Redis 到 Node.js)的核心:事件迴圈。它是一條單一執行緒,一生都在一個緊湊的節奏裡度過:睡到有東西就緒,然後恰好做那些就緒的工作,然後再睡。它的樣子如下。
set listening socket to non-blocking
epoll_create -> make the watcher
epoll_ctl ADD -> register the listening socket
loop forever:
ready = epoll_wait(...) # sleep here until something happens
for each socket in ready:
if it is the listening socket:
accept all pending clients (loop until EAGAIN)
set each new socket non-blocking, epoll_ctl ADD it
else if readable:
read whatever bytes are there (loop until EAGAIN)
append to that connection's buffer
while buffer holds a full message (your framing rule):
process it, queue a reply
else if writable:
send as much of the queued reply as fits, keep the rest- 執行緒在 epoll_wait 裡阻塞。它睡著了、不用 CPU,而全部一萬條連線都靜靜待著。這是它唯一會等待的地方。
- Theo 的資料抵達網路卡;核心把他的通訊端標記為就緒並喚醒迴圈,只交回就緒的通訊端——只有他,而不是那閒置的 9,999 條。
- 迴圈以非阻塞模式從 Theo 的通訊端讀取,把那裡有的位元組全榨乾、直到拿到 EAGAIN 為止,然後把它們接到 Theo 自己的緩衝區後面。
- 它套用訊息框界規則。如果有一個完整訊息,就處理它並準備回覆;如果只到了一個片段,就把位元組留在緩衝區裡、繼續往下走。半個訊息是常態,不是錯誤。
- 當回覆無法一次全部送出時(送出緩衝區滿了),它送出能塞下的部分、記住剩下的,並請 epoll 在這個通訊端再次可寫時叫醒它,然後繞回去睡覺。
誠實的限制,以及接下來是什麼
把這個設計買到了什麼、又付出了什麼,看清楚。事件迴圈會贏,是因為它幾乎不在閒置連線上花資源——而這正是聊天、網頁與串流伺服器的主流現實。但它那條單一執行緒,絕不能在迴圈中間跑一段又長又慢的工作,因為當它忙於一個請求時,所有其他連線都在等。一筆沉重的計算,或更糟、一個不小心的阻塞呼叫(像是會停住執行緒的同步磁碟讀取或網域名稱查詢),都會讓整台伺服器停擺。事件迴圈的紀律說起來簡單、守起來難:絕不阻塞,絕不拖拉。
最後一個誠實的提醒:非阻塞 I/O 與 epoll 講的是在一台機器上有效率地處理「許多」連線。它們完全不會讓任何「單一」連線變快,也完全不改變 TCP 本身——連線依舊是一條可靠的位元組串流、依舊沒有加密、依舊受制於同樣的來回延遲。它們是並行的工具,不是速度的工具。在本級的最後一篇,我們會轉向那些連寫得完美無缺的伺服器都會被咬到的、又小又尖的陷阱:機器之間的位元組順序、你關閉之後仍逗留的 TIME-WAIT 狀態,以及 Nagle 演算法悄悄把你那些細小的寫入緩衝起來。