行程與事件驅動並行 (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。應用層並行的五大用途:

現代 OS 提供三種建構並行程式的基本途徑:

途徑 排程者 位址空間 特性
Processes kernel 自動排程 各自獨立的 virtual address space 溝通須用顯式 IPC 機制
I/O multiplexing 應用程式自行顯式排程 單一行程,所有流共享 邏輯流建模為狀態機,靠描述子上的資料到達驅動轉移
Threads kernel 自動排程 單一行程內共享 兩者的混合體 (hybrid)
Tip

全章以同一個動機範例貫穿:把 11.4.9 的 iterative echo server 改造成 concurrent echo server。Threads 途徑見 12-Concurrent-Programming/02-Threads


12.1 以行程實作並行 (p.1009-1011)

最簡單的並行作法:用熟悉的 forkexecwaitpid。自然設計是父行程在 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 繼續等下一個連線
Important

父行程必須 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 的三個重點:

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,連線終止。

Warning

Practice Problem 12.2 的例外說明:即使刪掉子行程Close(connfd)(Figure 12.5 中 child 分支那行),也不會 leak——因為子行程 exitkernel 會自動關閉其所有 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 高
Aside — Unix IPC (p.1013)

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 事件發生才把控制權還給應用程式,例如:

書中只討論第一種情境(等待一組描述子 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)

Warning

因為這個副作用,每次呼叫 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 關閉連線。

Warning

此版本仍是 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,伺服器建立新狀態機 sk 並與 connected descriptor dk 關聯(Figure 12.7):

                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、更新 maxfdmaxi;滿了(i == FD_SETSIZE)報 "Too many clients"
check_clients 執行狀態轉移 / 刪除狀態機 每個 ready connfd echo 一行並累計全域 byte_cnt;EOF 時 Close + FD_CLR + 槽位還原 -1

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 設計不能充分利用多核處理器
Aside — Event-driven Web servers (p.1021)

儘管有上述缺點,Node.js、nginx、Tornado 等現代高效能伺服器都採用基於 I/O multiplexing 的事件驅動程式設計——主因是相對於 processes 與 threads 的顯著效能優勢。

三途徑收斂比較(銜接 12.3)

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 處理器