在理解伺服器架構前,掌握 I/O 模型至關重要,不僅決定程式開發模式,更影響延遲與效能表現。本文介紹 Blocking、Non-blocking、I/O Multiplexing 與 Asynchronous 等常見模型,並說明其在並行處理場景的取捨。藉由解析 Linux 核心的 select 和 epoll 系列系統呼叫,本文探討事件驅動式伺服器的設計理念,最終以 NGINX 為例,說明 I/O 模型在實務中如何影響伺服器行為與整體效能。
在探討伺服器架構之前,理解 I/O 模型至關重要。I/O 模型不僅決定應用程式對輸入輸出的處理方式,更直接影響其存取延遲與整體效能表現。本文將簡要介紹幾種常見的 I/O 模型,包括:
這些模型各具特性,適用於不同規模與設計需求的系統,其選擇將直接左右伺服器在高度並行、低延遲等場景下的行為表現。
在深入探討 I/O 事件模型之前,應先理解雙工(duplex)的基本概念,即二個通訊端之間進行雙向資料傳輸的方式,可區分為以下:
UNIX 系統在 1969 年初問世時,其 shell 與終端機的互動形式正是典型的半雙工:使用者輸入命令後,系統才會回傳對應的執行結果,二者的資料傳輸不能重疊進行。這種模式反映當時以命令驅動為主的使用習慣,系統不會主動傳送資訊,僅在接收到輸入後才進行回應,正如無線對講機的交互模式般,一方發話結束後,另一方才開始回應。
在這種互動模式下,系統不會主動回應未發出的請求,因此半雙工已足矣。
然而,考慮一個更複雜的情境 —— 電話總機操作員負責監聽並中繼多條終端線路:
這種情境需能即時察覺哪條線路有可讀資料。若單純對其中一個終端機呼叫 read,而該終端尚無資料,行程將被阻塞。若其他終端此時已有資料抵達,系統卻無法即時處理,導致效率低落。
例如:若終端機 1 的資料需等 2 分鐘,而終端機 2 的資料在第 10 秒即到達,但行程正阻塞於終端機 1,便會錯失即時處理的機會。
為解決這問題,系統需提供一種機制:
「告訴我哪些終端機目前可讀取資料!」
這正是 4.2BSD 所引入的 select 系統呼叫的目的:能一次監控多個檔案描述子 (file descriptor ,簡稱 fd
),回報當中哪些可進行 I/O 操作。若無任何檔案描述子可讀,select 會阻塞等待;一旦任一檔案描述子可用,系統呼叫便返回,程式可據此立即處理對應的事件。
此設計不僅完美支援全雙工通訊模式,也落實 UNIX 哲學中「一切皆為檔案」(Everything is a file) 的關鍵概念。正因如此,單一行程便可透過 select 高效處理多條連線,是達成高度並行伺服器的基礎。
延伸閱讀: 「一切皆為檔案」的理念與解讀
圖片來源:programmersought.com
在阻塞式 I/O 模型中,當行程在使用者空間呼叫 read()
系統呼叫後,系統會進行一次模式切換 (CPU mode transition),切入核心空間並等待資料就緒。若所需資料尚未抵達核心緩衝區,行程將持續停留在核心空間,直到資料可用。
從使用者空間的視角來看,這表示行程會「阻塞」在該系統呼叫上,無法繼續執行後續邏輯:
由於阻塞會導致整個行程在等待期間無法處理其他任務,此模型不利於高並行場景的應用。在需要同時處理大量連線的伺服器設計中,通常會避免單獨採用 blocking I/O,以免造成資源閒置與效能瓶頸。
圖片來源:programmersought.com
非阻塞式 I/O 模型中,當行程呼叫 read()
後,雖然仍會發生一次模式切換進入核心空間,但若資料尚未準備好,核心不會讓行程停留等待,而是立即將控制權返回使用者空間,避免阻塞行為。
從使用者空間的觀點來看,系統呼叫不再因資料尚未到達而停住整個行程,使應用程式得以進行其他邏輯或延後重試 I/O 操作。
啟用非阻塞 I/O 的方式是透過 fcntl()
系統呼叫,將目標檔案描述子設定為 O_NONBLOCK
模式,告知核心後續所有操作應以非阻塞方式處理。
高效能伺服器框架 lwan 即大量採用 non-blocking I/O 技術,在其 lwan-readahead.c
檔案中,可見如下處理方式:
由於該 read()
已設定為非阻塞,即便資料尚未可用,系統也會立即返回。此時應判斷錯誤代碼 errno
是否為 EAGAIN
,若是,代表資料尚未就緒,程式可稍後再次嘗試讀取,從而達成高效的 I/O 輪詢與非同步處理流程。
圖片來源:programmersought.com
I/O Multiplexing 是指透過系統呼叫如 select()
或 epoll()
所達成的輸入輸出監控機制。儘管二者在實作細節上有所差異,例如:
select()
受限於固定大小的監控描述子數量,且每次呼叫都需重建監聽集合,效能為 epoll
無監聽上限,內部使用紅黑樹管理事件,搜尋複雜度為 ,並透過事件驅動模型主動通知,就緒事件才會回傳但從行為觀點來看,二者皆屬於相同類型的 I/O 多工模式。
以 epoll
為例,當行程呼叫 epoll_wait()
時,會觸發模式切換,進入核心空間等待事件。等待期間的行為取決於傳入的 timeout
參數,若時間到或有事件發生,系統會返回至使用者空間,並回傳已就緒事件的數量。程式再依據這些事件進行相應處理,通常以 callback 或狀態機方式實現。
常見的處理方式如下:
I/O 多工的優勢在於,單一執行緒即可「同時處理」多個 I/O 操作。這裡所謂的「同時」是指在巨觀時間軸下交錯處理,而非微觀層級的真正並行。其實作模型為事件驅動),僅在發生事件 (如新連線、可讀資料、可寫緩衝) 時才觸發對應邏輯,避免傳統阻塞式模型中對 CPU 資源的浪費。
需特別注意的是,epoll_wait()
雖然用於非阻塞架構中,但其本身仍為阻塞性系統呼叫。若需完全非同步行為,仍需結合訊號、非同步 I/O (AIO) 或執行緒進行整體架構設計。
圖片來源:programmersought.com
若說 I/O multiplexing 已令人驚艷,那麼 Asynchronous I/O 則更為精妙。可視為強化版的非阻塞 I/O,其核心特徵在於:當行程呼叫 read()
進入核心空間時,即使資料尚未就緒,系統仍會立即返回使用者空間,讓應用程式得以繼續執行。後續的資料讀取工作則交由核心內部其他執行緒(kernel thread)在背景完成,並透過 signal 或其他機制通知應用層作後續處理。
典型處理流程如下:
這種模型可說是極具彈性,將 I/O 操作的延遲完全隱藏於核心空間,可有效拆解處理邏輯和 I/O 等待之間的緊密關聯。POSIX.1
標準早已定義 Asynchronous I/O (AIO) 相關介面,Linux 亦實作二種主要方案:
正因上述實作挑戰,AIO 概念上儘管具有高效潛力,實務上卻難以廣泛部署。目前常見的高效能伺服器架構仍多採 I/O 多工為主(如 epoll
),結合非阻塞 socket 與事件驅動模型,搭配使用者層級的協同式多工排程機制(如 coroutine),可兼顧良好效能與易於維護。
隨著 io_uring 等新技術於 Linux v5.1 問世,非同步 I/O 的實用性逐漸提升,可能會改變現有 I/O 處理模式的主流選擇。
可將 I/O 模型對應到日常的便利商店情境,直觀地理解不同模型的差異: (顧客對應到應用程式,店員對應到核心)
以下分別說明各種 I/O 模型與便利商店購買咖啡的情境對應:
📌 Blocking I/O
顧客進入便利商店點咖啡後,店員開始製作咖啡。在咖啡製作完成前,顧客只能站在櫃檯前等待。此時後方其他顧客無法提前點餐,必須依序等待。
📌 Non-blocking I/O
顧客點咖啡時,如果店員告知咖啡尚未完成,顧客就先去做其他事 (例如逛其他商家),之後再主動回來詢問店員咖啡是否完成。
📌 I/O Multiplexing
為了避免每喝完一杯咖啡就得重新回到櫃檯排隊,這位愛喝咖啡的顧客一次點了多種口味 (如摩卡、拿鐵、美式) 的咖啡若干杯。店員開始準備所有品項,只要其中任一杯完成,便會立即通知顧客領取。顧客雖可在櫃檯旁邊滑手機、拍照打卡或慢慢品嚐已完成的咖啡,但整體過程中仍需留在店內等待,無法自由離開。
📌 Asynchronous I/O (AIO)
顧客 (應用程式) 點完咖啡後,店員 (核心) 立即發出號碼牌,顧客隨即自由離開,不用在櫃檯等待,也不用主動詢問咖啡的狀態。此後,店員會自行在背景完成咖啡製作,並主動透過叫號機器通知顧客領取咖啡。
換個視角:顧客點餐後,店員便直接將訂單放入專屬的「咖啡製作區」(對應核心內部的排程和處理機制),顧客無需與店員確認或透過收銀台 (系統呼叫),即能直接離開並處理其他事務。
店員在背景自動處理並完成製作後,且收款/製作/交付的過程甚至不用是同一位店員,只要其中一位店員將咖啡直接放置於自取櫃台 (核心將資料自動寫入使用者空間),同時主動通知顧客取餐 (核心通知應用程式)。顧客只需在適當時間檢視自取區是否有完成的咖啡即可,無須再次透過店員或收銀台確認 (避免再次使用系統呼叫)。其關鍵操作如下:
該情境對應非同步 I/O 的特點,即 I/O 任務的提交與完成皆 offload 至核心處理,省去傳統系統呼叫的額外成本。
此分類探討的是 I/O 操作的「觸發階段」:當行程發出 I/O 請求時,如果資料尚未可用,該行程是否會被中斷執行,等待資料就緒。
read()
, recvfrom()
, accept()
)的預設模式。即使是 select()
, poll()
或 epoll_wait()
等監控介面,也會使行程等待,直到有至少一個檔案描述子就緒EAGAIN
或 EWOULDBLOCK
),而不會讓行程等待。此模式強調「立即返回」,將等待責任交由應用層進行輪詢處理此分類關注的是 I/O 操作的「完成階段」:行程在發出 I/O 請求後,是否必須等待整個操作(含資料複製)完全完成,才能繼續執行後續程式邏輯。
select()
/ epoll()
通知後再進行讀寫的多工模型,本質上仍屬同步 I/O。原因在於:一旦進入資料搬移階段 (如 read()
),該系統呼叫仍然會阻塞行程直到搬移結束read()
/ recv()
系列操作,即使搭配非阻塞設定或 I/O multiplexing 監控,仍是同步行為select 系統呼叫可與 read 搭配達成 I/O 多工 (multiplexing) 行為。雖然其本質仍屬於 Blocking I/O,但 select()
的優勢在於能同時監控多個檔案描述子 (file descriptors, fd
),而非一次僅處理單一 read()
的阻塞呼叫。
當 select()
被提出於 4.2BSD 時,其設計並非著眼於提升單一連線的處理效率,而是在只使用一個行程或執行緒的情境下,仍可有效處理大量連線。若同時處理的連線數量不多,使用 I/O multiplexing 反而可能導致更高的延遲,原因在於其內部邏輯與檢查開銷較高。
呼叫 select()
時,傳入的不是單一 fd
,而是多個 fd set
(包含讀取、寫入與例外監控集合)。只要其中任一個 fd
的 I/O 狀態準備就緒,select()
就會返回。使用者需再次走訪 fd set
,找出哪些 fd
就緒,並針對這些 fd
執行對應的系統呼叫 (如 read()
, recvfrom()
) 以完成資料處理。
理論上,此時資料已可立即讀取,但在實務中仍存在例外狀況,如 select 文件所示:
Under Linux,
select()
may report a socket file descriptor as "ready for reading", while nevertheless a subsequent read blocks. This could for example happen when data has arrived but upon examination has wrong checksum and is discarded. There may be other circumstances in which a file descriptor is spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block.
(select()
可能回報某個 socket fd 為「可讀」,但接下來的read
卻仍可能阻塞。例如,資料雖到達,但因檢查碼錯誤被丟棄,導致後續無資料可讀。故建議對不應阻塞的 socket 加上O_NONBLOCK
旗標)
因此,select()
本身並無法保證後續 read()
或 recvfrom()
的非阻塞性,是否阻塞仍取決於該檔案描述子是否開啟 O_NONBLOCK
模式。
另一限制是:select()
可監控的 fd
數量通常限制為 1024 (由 FD_SETSIZE
所定),且當 select()
返回後,使用者層級程式仍需線性掃描整個 fd set
,找出可處理的 fd
,這在高度並行連線下帶來效能瓶頸。正因如此,Linux 才進一步引入 epoll 作為更具擴展性與效率的替代方案。
關於 epoll 系列系統呼叫(注意:epoll 並非單一系統呼叫),可參考〈The method to epoll's madness〉一文的說明。
epoll_create 系統呼叫會建立一個 epoll 實體,並回傳其對應的檔案描述子。應用程式後續可透過該 fd,新增、刪除或修改欲監控的其他 fd。
從 Linux v2.6.8 起,size
參數雖已不再使用,但仍需指定一個大於 0 的數值,以維持與舊版核心的相容性。
In the initial
epoll_create()
implementation, the size argument informed the kernel of the number of file descriptors that the caller expected to add to the epoll instance. The kernel used this information as a hint for the amount of space to initially allocate in internal data structures describing events. (If necessary, the kernel would allocate more space if the caller's usage exceeded the hint given in size.) Nowadays, this hint is no longer required (the kernel dynamically sizes the required data structures without needing the hint), but size must still be greater than zero, in order to ensure backward compatibility when new epoll applications are run on older kernels.
(初始實作中,size
參數是給核心的配置提示,如今已不再需要,但仍須大於 0 以支援舊版核心)
epoll_create1 是 epoll_create
的擴充版本,其唯一參數 flags
目前支援:
0
: 行為等同於 epoll_create
EPOLL_CLOEXEC
: 若建立該 epoll fd 的行程透過 fork()
建立子行程,在子行程 exec()
之前,自動關閉該 epoll fd (避免子行程意外繼承不該存取的 epoll 實體)epoll_ctl 用來操作 epoll 實體,其功能是將特定 fd
加入、移除或更新於 epoll 的監控清單中。
該清單稱為 interest list:
All the file descriptors registered with an epoll instance are collectively called an epoll set or the interest list.
(所有註冊於某 epoll 實體的 fd 稱為 epoll 集或 interest list)
而觸發事件所對應的 fd 會暫存於 ready list,屬於 interest list 的子集合:
可使用以下三種操作(由 op
參數決定):
EPOLL_CTL_ADD
:將 fd
加入 epoll 監控清單,設定所關注的事件(如 EPOLLIN
、EPOLLOUT
等)EPOLL_CTL_DEL
:將 fd
自 epoll 清單移除,行程將不再收到該 fd 的事件通知。若該 fd 被加入至多個 epoll 實體,關閉該 fd 會自動從所有 epoll 清單中移除。EPOLL_CTL_MOD
:更新已在 epoll 中註冊的 fd
的事件關注設定事件驅動式 (Event-driven) 並非具備嚴格定義的技術,而是程式架構上的設計理念。要理解其行為模式,可參考〈如何向你阿嬤解釋 Event-Driven Web Servers〉中所使用的比喻:
想像一間披薩店只聘用一位店員。當有顧客來電訂餐時,店員只能維持與顧客保持通話,直到披薩製作完成並通知顧客後才結束通話。
該模式的問題在於,店員於整個製作期間無法接聽其他顧客的來電,導致系統延遲 (latency) 提高、整體處理量 (throughput) 下降,影響服務效能與使用者體驗。
同樣是這間披薩店,店員接到訂單後立即掛斷電話,並將訂單交給廚房處理。待披薩完成後,再主動撥電通知顧客前來取餐。由於不需維持通話,店員可以持續接收新訂單,有效提升接單效率。
若進一步增加廚房人手(類比為 thread pool 中的 worker thread),即可提升同時處理訂單的能力(throughput)。但當工作人員數量持續增加,瓶頸會轉移至實體空間、烤箱數量等資源限制(即系統容量)。
此類設計正是事件驅動伺服器 (如 nginx) 背後的關鍵思維:以非阻塞 I/O 搭配事件 callback,讓少量執行緒或行程即可應對大量網路連線,在有限資源下達成高度並行的高效處理模式。
NGINX 採用主從架構(master-worker model),由一個 master 行程負責初始化任務,如載入組態、建立 worker 行程、管理 worker 狀態 (接收外部 signal,向 worker 傳送 signal 並監控其存活)。
worker 則專門處理用戶端請求(如瀏覽器的 HTTP 連線)。NGINX 對 worker 行程進行 CPU affinity 設定(透過 sched_setaffinity),使每個 worker 固定綁定至特定 CPU,以減少 context switch 次數,提高效能穩定性。
為避免多個 worker 同時接受連線,NGINX 設計 accept lock 機制,每次僅允許一個 worker 接收新連線。每個 worker 採用 asynchronous 且 non-blocking 的方式監聽與處理事件,達成高度並行。
所有基於事件驅動模型的伺服器皆有主事件迴圈 (main loop),在 NGINX 中,每個 worker 擁有獨立的事件迴圈,負責處理新連線、接收資料、傳送回應等任務。
NGINX 在 Linux 平台上的事件處理函式可見於 ngx_epoll_process_events
:
儘管整體程式碼超過 200 行,但其主幹仍可概括為:呼叫 epoll_wait()
監聽事件,並逐一處理已就緒事件。
NGINX 處理一筆連線請求的流程牽涉大量使用者空間邏輯,以下僅節錄從事件迴圈到底層系統呼叫 sendfile
的主要函式鏈:
ngx_http_wait_request_handler
→ ngx_http_process_request_line
ngx_http_process_request_line
→ ngx_http_process_request_headers
ngx_http_write_filter
→ c->send_chain
c->send_chain
→ ngx_send_chain
ngx_send_chain
→ ngx_io.send_chain
ngx_io
→ ngx_os_io
(位於 ngx_epoll_module.c
)ngx_os_io
→ ngx_linux_io
(於 ngx_linux_init.c
)ngx_linux_io
中定義:static ngx_os_io_t ngx_linux_io = { .. ngx_linux_sendfile_chain }
sendfile64
[註 1] 所有輸出皆會經過一連串的 filter 模組,
ngx_http_write_filter
為最末節點,負責將最終輸出傳送給對端。
[註 2] filter 模組的順序在編譯時就已固定,可見於 auto/modules
[註 3] 可透過 GDB 命令catch syscall SYSCALL_NUMBER
,set follow-fork-mode child
,r
,再用where
觀察請求處理堆疊
為何需要 Thread Pool?根據《NGINX Development Guide》:
Keep in mind that the threads interface is a helper for the existing asynchronous approach to processing client connections, and by no means intended as a replacement.
(執行緒介面是用來輔助現有的非同步處理模型,而非作為替代方案)
Linux 長期缺乏完整的 AIO 支援,限制非同步架構的彈性。雖然 FreeBSD 原生支援 AIO,但在 Linux 上仍須透過 thread pool 彌補部分非同步能力。例如,在傳送大型檔案時透過 thread pool 可提升 throughput,詳見〈Thread Pools in NGINX Boost Performance 9x!〉。
worker process 取得任務後,會將任務提交至 thread pool 執行,自己則可立刻處理下一個任務,從而達成高度非同步。
位於 ngx_linux_sendfile()
中的邏輯如下:
啟用 --with-threads
後,將以 thread 版本 ngx_linux_sendfile_thread()
執行。
三個關鍵點:
task->handler
指向 thread 執行的邏輯:ngx_linux_sendfile_thread_handler
file->thread_handler
呼叫 ngx_thread_task_post()
將任務加入 thread queuengx_linux_sendfile_thread()
執行完畢即返回 NGX_DONE
,表示非同步執行中在 ngx_linux_sendfile_thread_handler()
中,thread 將持續嘗試 sendfile
直到成功:
此函式在 ngx_thread_pool_cycle()
中被呼叫:
這個 thread loop 為 consumer 角色,負責:
綜合前述的架構說明與程式流程,可見下圖呈現 thread pool 與 epoll 主事件迴圈之間的互動關係:
以 epoll
為例,通知主程式透過 ngx_epoll_notify()
完成,對應事件處理函式為 ngx_epoll_notify_handler()
:
read 操作將觸發 EPOLLIN
事件,使 epoll_wait()
可察覺並由主迴圈處理。
此外,thread_handler
可能指向如 ngx_http_cache_thread_handler
、ngx_http_upstream_thread_handler
等,不論指向何處,其皆透過 ngx_thread_task_post()
將任務排入 thread queue,形成一套完整的非同步處理流程。