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

零複製、向量化 I/O,與擴展 accept

你現在已經能不靠「每通訊端一執行緒」就盯住十萬條連線。這最後一篇要追的是位元組本身:如何用更少的複製、更少的系統呼叫去搬動它們,並讓一個傾聽通訊端跨遍每一顆核心去擴展,而不是卡死在單一一顆上。

位元組到底跑去了哪裡

到現在,你已經能把一條執行緒停泊在十萬個非阻塞通訊端上、只在真正有工作時醒來,並且能對就緒做出反應、或接過完成通知。但退一步,盯著單獨一個位元組的旅程看。一個從磁碟讀檔案、再把它推進通訊端的網頁伺服器,做了一件悄悄很浪費的事:核心先把檔案讀進分頁快取(page cache)(一次複製,進到核心記憶體),接著 read() 把它從分頁快取複製進你在使用者空間的緩衝區(第二次複製),然後 write() 又把它從你的使用者緩衝區複製回一個核心的通訊端緩衝區(第三次複製),網路卡最後才從那裡把它取走。同樣的位元組複製了三次,而你連看都沒看過它們。

那每一次複製都花掉記憶體頻寬與 CPU 週期,而每一次跨越使用者/核心的邊界又是另一筆開銷——回想先前學過的,系統呼叫不是免費的函式呼叫:它陷入核心、切換模式,而在現代硬體上還要為了抵禦推測執行攻擊的緩解措施繳一筆稅。當你每秒要把好幾 GiB 推給上千個用戶端時,去複製你從不碰一下的資料,正是效能剖析火焰圖上最難堪的那一行。這一節兩個想法的全部用意,就是刪掉你本不該付的那些複製與系統呼叫。

零複製:讓核心替你搬

零複製 I/O 是一族技巧,它們把資料從一個檔案描述符搬到另一個,而完全不把它拖過使用者空間。Linux 經典的原語是 sendfile():你說 sendfile(out_sock, in_file, &offset, count),核心就把檔案的分頁快取頁面直接拼接(splice)向通訊端。你那三次複製便朝著一次塌縮——甚至朝著零次資料複製,因為網路卡能透過 DMA 直接去取那些分頁快取頁面。關鍵在於,位元組從頭到尾根本沒進入你的位址空間,所以那兩次跨邊界的複製就這麼憑空消失了。

誠實的但書是,零複製只有在你確實不需要看那份資料時才管用。如果你的伺服器必須把檔案 gzip、用 TLS 加密、或改寫一個標頭,那位元組就必須進到使用者空間,好讓你的程式碼去轉換它——這時 sendfile() 就不再適用了。這就是為什麼一個純靜態檔案伺服器能從 sendfile() 拿到巨大的好處,而一個會碰每個位元組的應用程式卻一點也拿不到。對任何把零複製講成萬用加速的部落格文章都要起疑;它是一個精準的工具,專給「原封不動轉送過去」的情況,而現代把 TLS 卸載到網路卡上做的努力,正是想替加密流量把這個好處贏回來。

向量化 I/O:一次系統呼叫,多個緩衝區

現在來談系統呼叫次數這條軸。真正的訊息很少是一塊扁平的資料——一個網路封包也許是一個小小的固定標頭、接著一段可變的本體、再接著一個尾端的檢查碼,每一段都住在你程式裡不同的緩衝區。天真的做法是三次分開的 write() 呼叫,把跨邊界的稅繳三次。向量化 I/O(又叫散佈/聚集 I/O,scatter/gather)把那塌縮成一次。你建一個小小的(指標、長度)對所組成的陣列——在 POSIX 上這是一個 struct iovec 的陣列——再把整個陣列交給 writev() 或 readv(),在單一一次系統呼叫裡完成。

在寫入這側,writev() 做的是聚集:核心走過你的 iovec 陣列,把標頭、本體與檢查碼當成線路上一段連續的串流送出去,你不必先 memcpy() 把它們拼進一個大的暫存緩衝區。在讀取這側,readv() 做的是散佈:核心拿一段進來的資料、把它鋪散到你那幾個分開的緩衝區——比方說頭 16 個位元組進到一個標頭結構、其餘進到一個本體緩衝區——同樣在一次呼叫裡。你既省下了多餘的系統呼叫,也省下了那次暫存複製,順帶白賺一個小小的零複製好處。

/* send a 3-part message in ONE writev() instead of 3 writes */
struct iovec iov[3];
iov[0].iov_base = header;  iov[0].iov_len = 16;
iov[1].iov_base = body;    iov[1].iov_len = body_len;
iov[2].iov_base = crc;     iov[2].iov_len = 4;

ssize_t n = writev(sock, iov, 3);   /* one syscall, one stream */
if (n < 0) { perror("writev"); /* handle EAGAIN, EINTR, real errors */ }
/* PARTIAL WRITE WARNING: n may be < 16 + body_len + 4.
   you must advance past the n bytes already sent and writev the rest. */
三個緩衝區,一次聚集的系統呼叫。陷阱和第一篇那條短寫入規則一樣:n 可能小於總長,所以你必須從偏移處續傳,而不是假設全部都送出去了。

陷阱是你已經遇過的那個:writev() 可能寫得比你要求的少,就和 write() 一模一樣。當 n 回來比總長小,核心接受的是你那段聚集串流的一個前綴——也許是整個標頭加上半個本體。你不能就拿同一個 iovec 陣列重試,否則你會把對方已經收到的位元組再送一遍。你必須往前走 n 個位元組,把 iovec 陣列裁掉已送出的部分,再對剩下的呼叫 writev()。把這段續傳邏輯做對,是向量化 I/O 最常見的單一臭蟲,這也是為什麼正式環境的程式碼會把它包進一個小小的輔助函式裡。

擴展 accept:一個傾聽通訊端,多顆核心

現在從單一連線拉遠,看連線誕生的那一刻。每個伺服器在它的連接埠上都有一個傾聽通訊端,新的用戶端到那裡來被 accept()。只有一條事件迴圈執行緒時這沒問題,但若要用上你所有的核心,你會想要好幾條執行緒——而現在它們必須共用那一個傾聽描述符。連線一抵達,核心可能把每一條阻塞在那個通訊端 epoll 上的執行緒全部叫醒;它們全衝去呼叫 accept(),恰好一條成功,其餘的拿到 EAGAIN、做了純粹的白工後又回去睡。這就是你在第二篇歸檔起來的驚群效應,而在高接受率下它可能主宰你的 CPU。

現代而乾淨的修法是 SO_REUSEPORT。有了這個通訊端選項,好幾個各自獨立的傾聽通訊端——每個工作執行緒或行程一個——可以全都 bind() 到同一個位址與連接埠。核心不再讓 N 條執行緒去搶一個佇列,而是保有 N 個分開的 accept 佇列,並用「用戶端與伺服器位址」的元組把每一條進來的連線雜湊到其中恰好一個佇列。每個工作執行緒只在自己的通訊端上呼叫 accept(),也只為自己的連線被喚醒。驚群效應從結構上就消失了:根本沒有什麼好讓人蜂擁而去的,因為每條執行緒都有一扇私人的門。

  1. 在每個工作執行緒裡,建立它自己的傾聽 socket()——不要跨執行緒共用同一個描述符。
  2. 在它上面用 setsockopt() 設定 SO_REUSEPORT,要在 bind() 之前,並檢查回傳值——沒有這個選項,第二次 bind() 到同一個連接埠會以 EADDRINUSE 失敗。
  3. 把每個通訊端 bind() 到同一個位址與連接埠,再對每個 listen()——核心現在每個通訊端各擁有一個 accept 佇列。
  4. 把每個工作執行緒自己的傾聽通訊端登記進那個工作執行緒自己的 epoll,再跑一個普通的 accept 迴圈——每條執行緒只會看到核心路由給它的那些連線。

天花板,以及它之外的東西

把這些好處疊起來,你就抵達一個可觀的天花板:非阻塞通訊端、用 epoll 或 io_uring 盯著它們、對轉送的位元組做零複製、用向量化 I/O 把其餘的批次化、再用 SO_REUSEPORT 把 accept 攤到每一顆核心上。這就是那些在一般硬體上撐住一百萬條連線的伺服器背後的配方——也就是 C10M 的目標。但誠實要求我們點出還在限制你的東西:每一個封包仍要穿過核心的網路堆疊,而在每秒數千萬個封包時,即使是一個調校到完美的堆疊也會變成瓶頸,被每封包的系統呼叫與中斷開銷所主宰。

在那道天花板之外的,是核心旁路網路(kernel-bypass)——像 DPDK 這樣的框架、與 AF_XDP 這樣的途徑,讓一個使用者空間的行程幾乎直接和網路卡對話,輪詢它的佇列而不必每封包一次系統呼叫。你拿掉了核心的保護、它現成的 TCP/IP 堆疊、以及大量的簡單性,去換取赤裸的每秒封包數;你常常得在使用者空間重新實作網路,並把整顆整顆的 CPU 核心專門拿去忙碌輪詢。它對路由器、負載平衡器與交易系統是對的工具,對幾乎其他一切都是錯的工具。知道它存在、知道它的代價,這樣你才能認出那個真正需要它的罕見問題。

臨走前最後一個提醒:這些技巧沒有一個是你該盲目動用的。它們每一個——零複製、向量化 I/O、SO_REUSEPORT、核心旁路——都增添了複雜度與新的失效模式,而且唯有當位元組或系統呼叫確實是你的瓶頸時才划算。這一級的紀律貫徹到底:先量測,在火焰圖上找出真正的成本,再施以那個能把它刪掉的精準工具。一個靜態檔案伺服器渴望 sendfile();一個封包路由器渴望 DPDK;而介於兩者之間的多數服務兩者都不要,用乾淨、正確、懂得處理部分寫入的程式碼來服務反而更好。你現在握有了高效能 I/O 的整張地圖——從單一一個阻塞的 read() 到一百萬條連線——而同樣重要的是,你有了判斷力去分辨你真正需要它的哪一階。