行程與事件驅動並行 (Process & Event-Based Concurrency)
Overview Table
| 主題 | 核心內容 | 書頁 |
|---|---|---|
| 應用層並行的動機 | 慢速 I/O 重疊、人機互動、延遲工作降低 latency、服務多個網路 client、multi-core 平行運算 | p.1008 |
| 三種並行程式設計途徑 | Processes(kernel 排程、獨立位址空間)、I/O multiplexing(單一行程、應用自行排程)、Threads(兩者混合) | p.1009 |
| Process-based 並行伺服器 | fork 子行程服務每個連線;需 SIGCHLD handler 收割 zombie;父子各自 close 不需要的描述子 |
p.1009-1012 |
| Processes 優缺點 | 位址空間隔離安全但共享困難(需 IPC)、process control 開銷大而較慢 | p.1011 |
select 與描述子集合 |
fd_set 位元向量;FD_ZERO/FD_SET/FD_CLR/FD_ISSET;select 阻塞至有描述子 ready |
p.1013-1016 |
| Event-driven 伺服器 | 邏輯流建模為狀態機 (state machine);pool 結構維護活躍 client |
p.1016-1020 |
| I/O multiplexing 優缺點 | 排程控制權、單行程易共享/除錯、無 context switch 開銷;但程式複雜、怕部分輸入、無法用滿 multi-core | p.1021 |
並行程式設計的動機與三種途徑 (p.1008-1009)
第 8 章定義:邏輯控制流若在時間上重疊 (overlap in time) 即為並行 (concurrent)。並行不僅是 kernel 執行多程式的機制,應用程式本身也能利用應用層並行 (application-level concurrency),這類程式稱為 concurrent programs。應用層並行的五大用途:
- 存取慢速 I/O 裝置:等待磁碟等慢速裝置時,用其他有用工作重疊 I/O 請求(仿效 kernel 讓 CPU 忙碌的作法)。
- 與人互動:視窗系統對每個使用者動作(如滑鼠點擊)建立獨立並行邏輯流,才能同時 resize 視窗與列印文件。
- 延後工作以降低延遲:如動態配置器把
free的 coalescing 延後給低優先權的並行流吸收閒置 CPU 週期。 - 服務多個網路 client:第 11 章的 iterative server 一次只能服務一個 client,一個慢 client 就能讓所有人被拒絕服務;concurrent server 為每個 client 建立獨立邏輯流。
- 在 multi-core 機器上平行計算:切成並行流的程式在多核上是真正平行 (parallel) 執行而非交錯 (interleaved)。
現代 OS 提供三種建構並行程式的基本途徑:
| 途徑 | 排程者 | 位址空間 | 特性 |
|---|---|---|---|
| Processes | kernel 自動排程 | 各自獨立的 virtual address space | 溝通須用顯式 IPC 機制 |
| I/O multiplexing | 應用程式自行顯式排程 | 單一行程,所有流共享 | 邏輯流建模為狀態機,靠描述子上的資料到達驅動轉移 |
| Threads | kernel 自動排程 | 單一行程內共享 | 兩者的混合體 (hybrid) |
全章以同一個動機範例貫穿:把 11.4.9 的 iterative echo server 改造成 concurrent echo server。Threads 途徑見 12-Concurrent-Programming/02-Threads。
12.1 以行程實作並行 (p.1009-1011)
最簡單的並行作法:用熟悉的 fork、exec、waitpid。自然設計是父行程在 accept 接受連線請求,再 fork 一個子行程服務每個新 client。描述子演變流程(listenfd = 3):
Step 1: accept client 1 → connfd(4) Step 2: fork 子行程後各自 close
Client 1 ⇄ Child 1 (connfd 4)
Client 1 ──connection──▶ listenfd(3) Child: close(listenfd)
clientfd Server Parent: close(connfd 4)
connfd(4) Server 只留 listenfd(3) 繼續等
Step 3: accept client 2 → connfd(5) Step 4: 再 fork Child 2
Client 2 ──connection──▶ listenfd(3) Client 1 ⇄ Child 1 (connfd 4)
Server Client 2 ⇄ Child 2 (connfd 5)
connfd(5) Parent 繼續等下一個連線
- fork 後子行程取得父的描述子表完整副本 (complete copy of descriptor table),父子的 connfd 指向同一個 file table entry。
- 子行程關掉它不需要的
listenfd副本;父行程關掉它的connfd副本。
父行程必須 close 它的 connfd 副本。否則該 file table entry 的 reference count 永遠不會歸零、永遠不被釋放,造成 memory leak,最終耗盡記憶體使系統 crash(p.1010)。
12.1.1 Process-based concurrent echo server(Figure 12.5, p.1012)
echoserverp.c 的三個重點:
- SIGCHLD handler 收割 zombie:伺服器長期執行,必須安裝 handler。因 handler 執行期間 SIGCHLD 被 block、且 Linux signals 不會排隊 (not queued),handler 必須用迴圈
while (waitpid(-1, 0, WNOHANG) > 0);準備一次收割多個 zombie(對照 08-Exceptional-Control-Flow/05-Signals)。 - 父子各自 close 自己的 connfd 副本:child 在
echo完後 close,parent 在 fork 後立即 close(尤其父端最關鍵)。 - Reference count 語意:socket file table entry 的 refcount 使得父與子的 connfd 副本都關閉後,連線才真正終止。
while (1) {
connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
if (Fork() == 0) {
Close(listenfd); /* Child 關 listening socket */
echo(connfd); /* Child 服務 client */
Close(connfd); /* Child 關連線 */
exit(0);
}
Close(connfd); /* Parent 關 connected socket(重要!) */
}
Refcount 追蹤(Practice Problem 12.1):accept 回傳 → refcount 0→1;fork → 1→2;parent Close(connfd) → 2→1;child 端關閉 → 1→0,連線終止。
Practice Problem 12.2 的例外說明:即使刪掉子行程的 Close(connfd)(Figure 12.5 中 child 分支那行),也不會 leak——因為子行程 exit 時 kernel 會自動關閉其所有 open descriptors。但長駐的 parent 不會終止,所以父行程那行 Close(connfd) 絕不能省。
12.1.2 Processes 的優缺點 (p.1011)
| 面向 | 內容 |
|---|---|
| 共享模型 | 乾淨明確:file tables 共享、user address spaces 不共享 |
| 優點 | 行程間不可能意外覆寫彼此的 virtual memory,消除大量難解的錯誤 |
| 缺點 1 | 獨立位址空間使共享狀態困難,必須用顯式 IPC(pipes、FIFOs、System V shared memory / semaphores 等,詳見 Kerrisk) |
| 缺點 2 | 較慢:process control 與 IPC 的 overhead 高 |
waitpid 與 signals 是同主機行程間的原始 IPC;sockets 是跨主機 IPC。「Unix IPC」一詞通常專指同主機技術:pipes、FIFOs、System V shared memory、System V semaphores。
12.2 以 I/O Multiplexing 實作並行 (p.1013-1016)
動機情境:echo server 同時要回應兩種獨立 I/O 事件——(1) 網路 client 的連線請求、(2) 使用者在 stdin 打字。先等哪個都不對:卡在 accept 就無法回應鍵盤;卡在 read 就無法回應連線。解法是 I/O multiplexing:用 select 要求 kernel 暫停行程,直到一個或多個 I/O 事件發生才把控制權還給應用程式,例如:
- read set {0, 4} 中任一描述子 ready for reading 時返回
- write set {1, 2, 7} 中任一描述子 ready for writing 時返回
- 等待 152.13 秒後 timeout
書中只討論第一種情境(等待一組描述子 ready for reading):
#include <sys/select.h>
int select(int n, fd_set *fdset, NULL, NULL, NULL);
/* 回傳 ready 描述子個數(非零),錯誤回傳 -1 */
FD_ZERO(fd_set *fdset); /* 清除 fdset 全部位元 */
FD_CLR(int fd, fd_set *fdset); /* 清除位元 fd */
FD_SET(int fd, fd_set *fdset); /* 設定位元 fd */
FD_ISSET(int fd, fd_set *fdset); /* 位元 fd 是否為 1? */
描述子集合 (descriptor sets) 的語意 (p.1014)
fd_set邏輯上是大小為 n 的位元向量;描述子 k 屬於集合 ⟺ 。 - 對 descriptor sets 只允許三件事:(1) 配置、(2) 同型別變數相互指定、(3) 用四個 FD_* macro 修改與檢查。
select兩個輸入:read set(fdset)與其 cardinality n(實際上是任何描述子集合的最大 cardinality,慣用maxfd+1)。select阻塞直到 read set 至少一個描述子 ready for reading。描述子 k ready for reading ⟺ 對它讀 1 byte 不會阻塞。- 副作用 (side effect):select 就地修改 fdset,改成 ready set(read set 中已 ready 的子集);回傳值 = ready set 的 cardinality。
因為這個副作用,每次呼叫 select 前都必須重新更新 read set(慣用寫法 ready_set = read_set; 再把 ready_set 傳入)。忘記還原是經典 bug。
範例:select 版 iterative echo server(Figure 12.6, p.1015)
初始 read_set ({0,3}): fd: 3(listenfd) 2 1 0(stdin)
bit: 1 0 0 1
使用者按 Enter 後 select 回傳的 ready_set ({0}):
bit: 0 0 0 1
主迴圈:ready_set = read_set; Select(listenfd+1, &ready_set, ...),然後用 FD_ISSET 判斷:stdin ready → 呼叫 command() 讀命令;listenfd ready → Accept 後呼叫 echo(connfd) 直到 client 關閉連線。
此版本仍是 iterative 的粗粒度多工:一旦連上某 client,echo 會持續到該 client 關閉連線,期間 stdin 命令得不到回應。Practice Problem 12.3:若在 echo 進行中對 server 的 stdin 按 Ctrl+D(EOF),不會立刻結束——要等 echo 迴圈結束回到主迴圈後,select 才會再看 stdin。更好的作法是細粒度多工:每輪迴圈最多 echo 一行,即 12.2.1 的 event-driven server。
12.2.1 基於 I/O multiplexing 的並行事件驅動伺服器 (p.1016-1020)
I/O multiplexing 可作為 concurrent event-driven programs 的基礎:流因事件 (events) 而推進。核心手法是把邏輯流建模為狀態機 (state machine)——狀態 (states)、輸入事件 (input events)、轉移 (transitions) 的集合;轉移把 (輸入狀態, 輸入事件) 對映到輸出狀態;self-loop 是輸入輸出同一狀態的轉移。狀態機畫成有向圖:節點=狀態、有向邊=轉移、邊標籤=輸入事件。
對每個新 client k,伺服器建立新狀態機
input event:
"descriptor dk is ready for reading"
│
▼
┌─────────────────────────────┐
│ State: │ ──┐ transition:
│ "waiting for descriptor dk │ │ "read a text line
│ to be ready for reading" │ ◀─┘ from descriptor dk"
└─────────────────────────────┘ (self-loop)
echoservers.c 架構(Figure 12.8-12.11):活躍 client 集合維護在 pool 結構中:
typedef struct {
int maxfd; /* read_set 中最大描述子 */
fd_set read_set; /* 所有活躍描述子集合 */
fd_set ready_set; /* ready for reading 的子集 */
int nready; /* select 回傳的 ready 個數 */
int maxi; /* clientfd 陣列 high-water index */
int clientfd[FD_SETSIZE]; /* 活躍描述子(-1 = 空槽) */
rio_t clientrio[FD_SETSIZE]; /* 對應的 Rio read buffers */
} pool;
事件迴圈資料流:
┌──────────────────────────────────────────────┐
▼ │
ready_set = read_set │
nready = Select(maxfd+1, &ready_set, ...) │
│ │
├─ listenfd ∈ ready_set? ──▶ Accept → │
│ add_client(connfd) │
│ (建立新狀態機/邏輯流) │
▼ │
check_clients(&pool) ── 對每個 ready 的 connfd: │
Rio_readlineb 讀到一行 → echo 回去(執行狀態轉移) │
讀到 EOF → Close + FD_CLR + clientfd[i] = -1 │
(刪除狀態機)────────────────────────────┘
各函式與狀態機模型的對應(p.1020):
| 函式 | 狀態機角色 | 重點 |
|---|---|---|
select |
偵測輸入事件 | 每輪偵測兩種事件:新連線請求、既有 connfd ready |
init_pool |
初始化 | clientfd[i] = -1(空槽)、maxi = -1;read set 初始只含 listenfd,maxfd = listenfd |
add_client |
建立新邏輯流(狀態機) | 找空槽存 connfd、Rio_readinitb 初始化讀緩衝、FD_SET 加入 read set、更新 maxfd 與 maxi;滿了(i == FD_SETSIZE)報 "Too many clients" |
check_clients |
執行狀態轉移 / 刪除狀態機 | 每個 ready connfd echo 一行並累計全域 byte_cnt;EOF 時 Close + FD_CLR + 槽位還原 -1 |
maxi讓check_clients不必掃整個陣列;迴圈條件(i <= p->maxi) && (p->nready > 0)靠遞減nready提早結束。- Practice Problem 12.4:
pool.nready每輪都要用 select 回傳值重新初始化,因為它儲存的是本輪 ready 描述子總數,供add_client與check_clients遞減計數。
12.2.2 I/O Multiplexing 的優缺點 (p.1021)
| 優點 | 說明 |
|---|---|
| 對程式行為的控制權 | 程式設計師自行排程,可寫出對特定 client 提供優先服務的伺服器——process-based 設計很難做到 |
| 共享容易 | 單一行程,每個邏輯流都能存取整個位址空間 |
| 易於除錯 | 如同除錯循序程式,可直接用 gdb 等熟悉工具 |
| 效率高 | 不需 process context switch 即可排程新的流,常顯著快於 process-based 設計 |
| 缺點 | 說明 |
|---|---|
| 程式碼複雜 | 事件驅動 echo server 程式碼量是 process 版的三倍;且並行粒度 (granularity) 越細,複雜度越高(granularity = 每個邏輯流每個 time slice 執行的指令數;本例 = 讀完一整行的指令數) |
| 易受部分輸入攻擊 | 只要某邏輯流忙著讀一行,其他流都無法推進 → 惡意 client 只送半行文字就停,可癱瘓整個伺服器;處理 partial text lines 很棘手,process-based 設計則天然免疫 |
| 無法用滿 multi-core | 單一行程單一控制流,event-based 設計不能充分利用多核處理器 |
儘管有上述缺點,Node.js、nginx、Tornado 等現代高效能伺服器都採用基於 I/O multiplexing 的事件驅動程式設計——主因是相對於 processes 與 threads 的顯著效能優勢。
Processes:kernel 自動排程 + 私有位址空間(共享難)。I/O multiplexing:自行排程 + 完全共享位址空間。Threads 是兩者混合:kernel 排程如 process、共享位址空間如 I/O multiplexing → 見 12-Concurrent-Programming/02-Threads。
Exam/Test Patterns
| 情境 / 關鍵字 | 答案 |
|---|---|
| 應用層並行的用途(舉例題) | 慢速 I/O 重疊、人機互動、延後工作降延遲、服務多 client、multi-core 平行 |
| 三種並行途徑各由誰排程?位址空間? | Processes:kernel/私有;I/O multiplexing:應用程式/共享;Threads:kernel/共享 |
| process-based server 中 parent 不 close connfd 會怎樣? | file table entry refcount 不歸零 → 永不釋放 → memory leak → 耗盡記憶體 |
| Figure 12.5 的 connfd refcount 追蹤 | accept: 0→1;fork: 1→2;parent close: 2→1;child 端關閉: 1→0(連線終止) |
| 刪掉某個 close 仍無 leak,為何? | 行程終止時 kernel 自動關閉所有 open descriptors(child 會 exit;長駐 parent 不適用) |
| 為何 SIGCHLD handler 要用 while 迴圈 waitpid? | Linux signals 不排隊,一個 pending SIGCHLD 可能對應多個 zombie |
| 連線何時真正終止? | parent 與 child 的 connfd 副本都關閉後(refcount 歸零) |
| 描述子 k「ready for reading」的定義 | 對 k 讀 1 byte 的請求不會阻塞 |
為何每輪迴圈要 ready_set = read_set? |
select 有副作用:就地把 fdset 改寫成 ready set |
| select 回傳值的意義 | ready set 的 cardinality(ready 描述子個數);-1 表錯誤 |
| select 第一個參數 n 該傳什麼? | read set 的(最大)cardinality,慣用 maxfd + 1 |
| 對 fd_set 只能做哪三件事? | 配置、同型別指定、用 FD_ZERO/FD_SET/FD_CLR/FD_ISSET 操作 |
| Figure 12.6 echo 中按 Ctrl+D 會怎樣? | 被視為 stdin 的 EOF;但要等 echo 完當前 client、select 再回報 stdin 才處理(粗粒度多工的缺陷) |
| event-driven server 中誰偵測事件/建流/轉移/刪流? | select 偵測事件;add_client 建立狀態機;check_clients 執行轉移並在 EOF 時刪除狀態機 |
| 為何 pool.nready 每輪重新初始化? | 儲存本輪 select 回傳的 ready 描述子總數,供後續遞減計數用 |
| event-driven 設計的最大安全弱點 | 惡意 client 送部分文字行後停住 → 其他所有邏輯流停擺(DoS);process-based 天然免疫 |
| granularity(粒度)的定義 | 每個邏輯流在每個 time slice 執行的指令數;粒度越細、事件驅動程式越複雜 |
| Node.js / nginx / Tornado 用哪種模型?為何? | I/O multiplexing 事件驅動;相對 processes/threads 有顯著效能優勢 |
| event-based 為何不適合計算密集? | 單行程無法充分利用 multi-core 處理器 |
Related Notes
- 12-Concurrent-Programming/02-Threads
- 12-Concurrent-Programming/03-Shared-Variables-and-Semaphores
- 12-Concurrent-Programming/04-Parallelism-and-Thread-Safety
- 08-Exceptional-Control-Flow/04-Process-Control
- 08-Exceptional-Control-Flow/05-Signals
- 10-System-IO/03-File-Sharing-Redirection-and-Standard-IO
- 10-System-IO/02-Rio-Package-and-File-Metadata
- 11-Network-Programming/03-Sockets-Interface
- 11-Network-Programming/04-Web-Servers-and-Tiny
- 01-Computer-Systems-Tour/04-Amdahls-Law-and-Concurrency-Themes