? 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
.
程式碼: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_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 還沒讀取到。回到上面的第 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),這也許是下一篇部落格的內容了。