[UNP] TCP 多程式伺服器

sinkinben發表於2021-03-03

? 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 .

程式碼:https://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 個箭頭)。
[UNP] TCP 多程式伺服器

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

再次檢視程式狀態:

$ 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 會成為待處理訊號。

[UNP] TCP 多程式伺服器

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 還沒讀取到。回到上面的第 4 步看一下,client 的 TCP 連線的 Recv-Q = 1,其實就是指這個 FIN 。

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

  • 客戶端:呼叫 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),這也許是下一篇部落格的內容了。

相關文章