[UNP] TCP 多進程伺服器

📖 UNP Part-2: Chapter 5. TCP Client/Server Example 的讀書筆記。

閱讀本文前,建議先閱讀多執行緒伺服器的實現,熟悉常見的 TCP 網路通訊 API 的基本使用。

本章的主要內容是基於 TCP 協議,實現一個多進程伺服器的 Demo,作者假設了若干個場景,藉此來說明在程式碼細節上需要注意的一些問題。

常用命令

netstat -a | grep 9877
ps -t pts/16 -o pid,ppid,tty,stat,args,wchan

pts/16 中的 16 需要修改。

文件說明

文件 描述
client-v1.cserver-v1.c 原始版本的多進程伺服器
server-v2.c 添加捕獲訊號 SIGCHLD
client-v2.c 發起 5 個 TCP 連接的客戶端
unp.h 頭文件聲明和一些輔助函數

預備知識

  • 進程式控制制 API:fork, signal .
  • 網路通訊 API:socket, listen, bind, accept, connect .

程式碼://github.com/sinkinben/unp-code/tree/master/ch05

client-v1 和 server-v1

本次實驗基於 {client, server}-v1.c 兩個程式。

程式碼

程式碼邏輯沒什麼好講的,TCP 編程的幾個流程都是固定的。

client-v1.c 程式碼如下:

#include "unp.h"
int main(int argc, char *argv[])
{
    int sockfd;
    struct sockaddr_in servaddr;

    sockfd = socket(AF_INET, SOCK_STREAM, 0);

    bzero(&servaddr, sizeof(servaddr));

    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(SERVE_PORT);
    servaddr.sin_addr.s_addr = inet_addr(SERVE_IP);

    if (connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0)
        err_sys("connect error");
    str_cli(stdin, sockfd);
}

server-v1.c 程式碼如下:

#include "unp.h"
int main()
{
    int listenfd, connfd;
    pid_t childpid;
    socklen_t clilen;
    struct sockaddr_in cliaddr, servaddr;

    listenfd = socket(AF_INET, SOCK_STREAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(SERVE_PORT);

    bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

    listen(listenfd, LISTENQ);

    while (1)
    {
        clilen = sizeof(cliaddr);
        connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
        if ((childpid = fork()) == 0)
        {
            close(listenfd);
            str_echo(connfd);
            exit(0);
        }
        close(connfd);
    }
}

str_clistr_echo 這 2 個函數都是在 unp.h 中定義的。

啟動

運行 server 後,通過 netstat -a 查看網路狀態:

$ netstat -a
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State      
tcp        0      0 *:9877                  *:*                     LISTEN

此時,server 處於 accept 阻塞狀態。

運行一個 client , 再次查看網路狀態:

Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State   
tcp        0      0 *:9877                  *:*                     LISTEN     
tcp        0      0 localhost:9877          localhost:45004         ESTABLISHED
tcp        0      0 localhost:45004         localhost:9877          ESTABLISHED

可以看到,serverclient 已經完成 3 次握手 🤝,建立 TCP 連接。

此時,有 3 個進程處於阻塞狀態:

  • 進入下一次等待 acceptserver 進程;
  • fgets 上等待輸入的客戶進程 client ;
  • server 進程 fork 出來的子進程,等待來自於 connfd 的輸入。

通過命令 ps -t pts/16 -o pid,ppid,tty,stat,args,wchan 查看這幾個進程的狀態:

  PID  PPID TT       STAT COMMAND     WCHAN
18394 24824 pts/16   S    ./server    inet_csk_accept
18449 24824 pts/16   S+   ./client    wait_woken
18450 18394 pts/16   S    ./server    sk_wait_data
24824 24823 pts/16   Ss   -bash       wait

終止

client 中輸入一些內容,檢查是否能正常工作。

$ ./client 
sinkinben
sinkinben
hello, world
hello, world
^D

通過 Ctrl+D 結束輸入,終止 client

再次查看 9877 埠的相關連接:

Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State   
tcp        0      0 *:9877                  *:*                     LISTEN     
tcp        0      0 localhost:45004         localhost:9877          TIME_WAIT  

可以看到一個處於 TIME-WAIT 狀態的 TCP 連接。

下面看分析一下終止的過程,以下描述中,「伺服器」特指在 serverfork 出來與客戶端通訊的子進程。

  1. 當客戶端輸入 Ctrl+D 時,fgets 返回一個空指針,str_cli 函數結束;隨後 client 的 main 函數也結束,內核關閉當前進程的所有描述符。
  2. 在關閉 socket 描述符之前,發送一個 FIN 到伺服器,伺服器 TCP 給予一個 ACK 響應。此時,伺服器進入 CLOSE-WAIT 狀態,客戶端進入 FIN-WAIT2 狀態(下圖中的前 2 個箭頭)。
  3. 當伺服器接收到 FIN 時,伺服器的子進程在 read 函數上阻塞,接收到 FIN,read 函數返回 0 ,因此 str_echo 結束,隨後子進程也通過 exit(0) 退出。此時,子進程的 socket 描述符也會被內核關閉,關閉之前,向客戶發送 FIN,進入 LAST-ACK 狀態(下圖的第 3 個箭頭)。
  4. 客戶端收到來自服務端的 FIN,發送 ACK 後,進入 TIME-WAIT 狀態;服務端收到 ACK 後,斷開 TCP 連接,進程結束(下圖的第 4 個箭頭)。

但伺服器的子進程真的結束了嗎

再次查看進程狀態:

$ ps -t pts/16 -o pid,ppid,tty,stat,args,wchan
  PID  PPID TT       STAT COMMAND                     WCHAN
18394 24824 pts/16   S    ./server                    inet_csk_accept
18450 18394 pts/16   Z    [server] <defunct>          exit
24824 24823 pts/16   Ss+  -bash                       wait_woken

這是,我們會發現子進程處於僵死狀態 <defunct> ,這是因為父進程沒有調用 wait/waitpid .

當一個子進程結束(不論是正常終止還是異常中止),內核會向父進程發送 SIGCHILD 訊號。但是這裡我們既沒有調用 wait/waitpid,也沒有捕獲這個訊號,所以子進程就進入 <defunct> 狀態。

⚠️ 區分 2 個重要概念

  • 孤兒進程:一個父進程退出,而它的一個或多個子進程還在運行,那麼那些子進程將成為孤兒進程。孤兒進程將被 init 進程所收養,並由 init 進程對它們完成狀態收集工作。
  • 僵死進程:一個進程使用 fork 創建子進程,如果子進程退出,而父進程並沒有調用 waitwaitpid 獲取子進程的狀態資訊,那麼子進程的進程描述符仍然保存在系統中。

server-v2: 捕獲 SIGCHLD

實驗程式:server-v2.cclient-v1.c

改進後的版本為 server-v2.c ,加入 SIGCHLD 的訊號處理:

void sigchild_handler(int signo)
{
    pid_t pid;
    int status;
    pid = wait(&status);
    printf("child pid [%d] terminated. \n", pid);
    return;
}

client-v1.c 一起運行,可以正常使用,不會產生僵死進程。

client-v2: 多個客戶連接

實驗程式:{server-v2, client-v2}.c .

client-v2.c 的主要改動是:新建 5 個 socket,發起 5 次 connect 。程式碼如下:

#include "unp.h"
int main(int argc, char *argv[])
{
    int i, sockfd[5];
    struct sockaddr_in servaddr;

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(SERVE_PORT);
    servaddr.sin_addr.s_addr = inet_addr(SERVE_IP);

    for (i = 0; i < 5; i++)
    {
        sockfd[i] = socket(AF_INET, SOCK_STREAM, 0);
        connect(sockfd[i], (struct sockaddr *)&servaddr, sizeof(servaddr));
    }
    str_cli(stdin, sockfd[0]);
}

運行結果:

$ ./server &
[1] 21499
$ ./client 
sss
sss
sss
sss
^D
child pid [21597] terminated. 
child pid [21596] terminated. 
child pid [21595] terminated. 

查看進程:

$ ps -t pts/16 -o pid,ppid,tty,stat,args,wchan
  PID  PPID TT       STAT COMMAND                     WCHAN
21499 24824 pts/16   S    ./server                    inet_csk_accept
21598 21499 pts/16   Z    [server] <defunct>          exit
21599 21499 pts/16   Z    [server] <defunct>          exit
24824 24823 pts/16   Ss+  -bash                       wait_woken

可以發現,這一版本產生了異常:有 2 個僵死進程(多試幾次,數量不一樣)。

為什麼會這樣呢?

如下圖所示,客戶端終止前,其 5 個 TCP 連接分別向服務端的 5 個子進程發送 FIN,子進程接收到 FIN,read 調用返回 0 ,str_echo 結束,隨後調用 exit ,退出前向父進程發送 SIGCHLD 訊號(一共 5 個),而這 5 個 SIGCHLD 訊號幾乎是同一時間內發送到父進程的

按道理來說,訊號處理程式 sigchild_handler 一共調用 5 次才符合我們預期的結果,但實際上並沒有。這是因為 Unix 訊號是不排隊的,「不排隊」的意思指的是:針對同一類型的訊號,只能有一個待處理訊號。例如,一個進程接受了一個 SIGCHLD 的訊號,在執行 SIGCHLD 的訊號處理程式的時候,來了兩個 SIGCHLD 訊號,那麼只有一個 SIGCHLD 會成為待處理訊號。

server-v3: 改進 sigchild_handler

本次實驗基於 server-v3.cclient-v2.c

關於 wait/waitpid 的使用可以參考 APUE 一書,或者這一篇 blog

改進後的 sigchild_handler 如下:

void sigchild_handler(int signo)
{
    pid_t pid;
    int status;
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0)
        printf("child pid [%d] terminated. \n", pid);
    return;
}

運行測試結果:

$ ./client 
sss
sss
^D
$ child pid [28022] terminated. 
child pid [28023] terminated. 
child pid [28024] terminated. 
child pid [28025] terminated. 
child pid [28026] terminated. 

5 個子進程都能正常結束。

模擬伺服器端進程終止

本次實驗基於 server-v3.c, client-v2.c

  1. 運行伺服器和客戶端,查看相關進程:
sinkinben@adc-Vostro-270:~/workspace/unp$ ps -t pts/1 -o pid,ppid,tty,stat,args,wchan
  PID  PPID TT       STAT COMMAND                     WCHAN
 3377  3376 pts/1    Ss   -bash                       wait
 3740  3377 pts/1    S    ./server                    inet_csk_accept
 3782  3377 pts/1    S+   ./client                    wait_woken
 3783  3740 pts/1    S    ./server                    sk_wait_data
 3784  3740 pts/1    S    ./server                    sk_wait_data
 3785  3740 pts/1    S    ./server                    sk_wait_data
 3786  3740 pts/1    S    ./server                    sk_wait_data
 3787  3740 pts/1    S    ./server                    sk_wait_data
  1. 關閉一個子進程: kill 3783,子進程向客戶端會發送 FIN,(隨後應當會接收來自客戶端的 ACK,即完成 TCP 四次揮手的前 2 次),然後子進程正式結束。
  2. 運行 server 的終端會輸出:
child pid [3783] terminated.
  1. 查看各個 TCP 連接的狀態:
Proto Recv-Q Send-Q Local Address           Foreign Address         State
tcp        0      0 *:9877                  *:*                     LISTEN     
tcp        0      0 localhost:59852         localhost:9877          ESTABLISHED
tcp        0      0 localhost:59858         localhost:9877          ESTABLISHED
tcp        0      0 localhost:59856         localhost:9877          ESTABLISHED
tcp        0      0 localhost:9877          localhost:59856         ESTABLISHED
tcp        0      0 localhost:9877          localhost:59852         ESTABLISHED
tcp        1      0 localhost:59850         localhost:9877          CLOSE_WAIT 
tcp        0      0 localhost:9877          localhost:59854         ESTABLISHED
tcp        0      0 localhost:9877          localhost:59858         ESTABLISHED
tcp        0      0 localhost:59854         localhost:9877          ESTABLISHED

可以發現,伺服器子進程結束之後,(重點看第 8 行)客戶端還存在著一個單向的 TCP 連接 localhost:59850 -> localhost:9877 ,其狀態處於 CLOSE-WAIT

理論上,處於 CLOSE-WAIT 狀態的 TCP,應當是能夠單向發送數據的。但這裡情況比較特殊:TCP 另一端的子進程已經被 kill ,但客戶端還不知道,這時候,客戶端繼續發送數據會怎麼樣呢?

  1. 回到運行 client 的終端,嘗試繼續輸入一些內容:
$ ./client 
sss
sss
child pid [3783] terminated.            // kill 3783
ssss                                    // new input
str_cli: server terminated prematurely  // crash
child pid [3784] terminated. 
child pid [3786] terminated. 
child pid [3785] terminated. 
child pid [3787] terminated. 

server terminated prematurely 這一字元串是在 str_cli 中的 if 分支輸出的(參考 unp.h 的相關)。

那麼,發生這種情況的原因是什麼呢?我們結合上述過程來分析一下 str_cli 的程式碼:

void str_cli(FILE *fp, int sockfd)
{
    char sendline[MAXLINE], recvline[MAXLINE];
    while (fgets(sendline, MAXLINE, fp) != NULL)
    {
        write(sockfd, sendline, strlen(sendline));
        if (Readline(sockfd, recvline, MAXLINE) == 0)
            err_quit("str_cli: server terminated prematurely");
        fputs(recvline, stdout);
    }
}

kill 3783 執行時,client 進程阻塞於 fgets服務端發送過來的 FIN 還沒讀取到

當輸入 ssss 按下回車鍵後,服務端和客戶端的情況如下:

  • 客戶端:調用 write 發數據發送到伺服器的 sockfd ,之後調用 Readline -> readline -> read 會讀取到 FIN ,然後 read 返回 0 ,最後執行 err_quit("str_cli: server terminated prematurely") 這一行程式碼。

  • 服務端:打開該 sockfd 的子進程已經終止,於是響應一個 RST,但客戶端「看不到」這個 RTS 。這個「看不到」可能有 2 種情況:一是 RTS 到達前客戶端已經 err_quit;二是子進程調用 err_quit 前,RTS 已到達,但是沒有通過 read 讀取。

上面的致命問題是:當 FIN 到達 sockfd 時,client 進程阻塞於標準輸入 fgets 上,不能及時處理這一個 FIN。

從這一場景可以看出,目前的伺服器-客戶端模型存在這麼一個問題:客戶端同時存在 socket 和 stdin 兩種 I/O ,但是它僅僅是「運行到哪就讀取哪」,不能及時處理另外一個 I/O 所輸入的資訊(如上面所述的情況)。因此,需要所謂的 I/O 復用 (I/O Multiplexing),這也許是下一篇部落格的內容了。

Tags: