[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.c 和 server-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_cli
和 str_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
可以看到,server
與 client
已經完成 3 次握手 🤝,建立 TCP 連接。
此時,有 3 個進程處於阻塞狀態:
- 進入下一次等待
accept
的server
進程; - 在
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 連接。
下面看分析一下終止的過程,以下描述中,「伺服器」特指在 server
上 fork
出來與客戶端通訊的子進程。
- 當客戶端輸入 Ctrl+D 時,
fgets
返回一個空指針,str_cli
函數結束;隨後client
的 main 函數也結束,內核關閉當前進程的所有描述符。 - 在關閉
socket
描述符之前,發送一個 FIN 到伺服器,伺服器 TCP 給予一個 ACK 響應。此時,伺服器進入 CLOSE-WAIT 狀態,客戶端進入 FIN-WAIT2 狀態(下圖中的前 2 個箭頭)。 - 當伺服器接收到 FIN 時,伺服器的子進程在
read
函數上阻塞,接收到 FIN,read
函數返回 0 ,因此str_echo
結束,隨後子進程也通過exit(0)
退出。此時,子進程的socket
描述符也會被內核關閉,關閉之前,向客戶發送 FIN,進入 LAST-ACK 狀態(下圖的第 3 個箭頭)。 - 客戶端收到來自服務端的 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
創建子進程,如果子進程退出,而父進程並沒有調用wait
或waitpid
獲取子進程的狀態資訊,那麼子進程的進程描述符仍然保存在系統中。
server-v2: 捕獲 SIGCHLD
實驗程式:server-v2.c
和 client-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.c
和 client-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
。
- 運行伺服器和客戶端,查看相關進程:
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
- 關閉一個子進程:
kill 3783
,子進程向客戶端會發送 FIN,(隨後應當會接收來自客戶端的 ACK,即完成 TCP 四次揮手的前 2 次),然後子進程正式結束。 - 運行
server
的終端會輸出:
child pid [3783] terminated.
- 查看各個 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
,但客戶端還不知道,這時候,客戶端繼續發送數據會怎麼樣呢?
- 回到運行
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),這也許是下一篇部落格的內容了。