chapter_05 TCP客戶/服務器示例
Write for myself:閱讀《unix網絡編程》,整理自己的思路,僅放在網上便於保存&&分享。
相對於書而言,內容沒有什麼價值。書上的內容全面可靠。
PS:文章是建立在我的知識體系之上。因而,文中也不會有多餘解釋。(如果有不清楚的閱讀書本/google)
必要的內容放在正文中。相對而言非主線的內容放在附錄中。這兩個集合之外的內容,隨它而去。
爲了好看增加顏色標識。提問內容顏色標識:綠色; 重點程度顏色標識:紅色>藍色>黑體。
(csdn自身的顏色不在考慮範圍內。)
一、內容
1、概述(摘要)
1.1、簡單的實現一個回射服務器示例。
(1) 客戶從標準輸入讀入一行文本, 並寫給服務器;
(2) 服務器從網絡輸入讀入這行文本, 並回射給客戶;
(3) 客戶從網絡輸入讀入這行回射文本, 並顯示在標準輸出上。
我們在客戶與服務器之間畫了兩個箭頭, 不過它們實際上構成一個全雙工的TCP連接。
1.2、擴展上面的示例(略)
當上面程序完成的時候,修改服務器對客戶輸入內容的處理過程。
上面是回射,即原樣返回。
我們也可以(eg):在客戶端將數字作爲字符串輸入,發送給服務器;在服務器端:將字符串轉換成數字,處理完,再以字符串的形式,發送回客戶端。
1.3、邊界條件⭐️
這是重點。
我們還會探討它的許多邊界條件: 客戶和服務器啓動時發生什麼? 客戶正常終止時發生什麼? 若服務器進程在客戶之前終止,則客戶會發生什麼? 若服務器主機崩潰, 則客戶發生什麼? 如此等等。 通過觀察這些情形, 弄清在網絡層次發生什麼以及它們如何反映到套接字API, 我們將更多地理解這些層次的工作原理, 並體會如何編寫應用程序代碼來處理這些情形。
2、回射服務器
代碼,見代碼部分。
2.1、正常啓動
2.2、正常終止
連接已經建立, 不論我們在客戶的標準輸入中鍵入什麼, 都會回射到它的標準輸出中。
我們接着鍵入終端EOF字符(Control-D) 以終止客戶。
(1) 當我們鍵入EOF字符時, fgets返回一個空指針, 於是str_cli函數返回。
(2) 當str_cli返回到客戶的main函數時, main通過調用exit終止。
(3) 進程終止處理的部分工作是關閉所有打開的描述符, 因此客戶打開的套接字由內核關閉。 這導致客戶TCP發送一個FIN給服務器, 服務器TCP則以ACK響應, 這就是TCP連接終止序列的前半部分。 至此, 服務器套接字處於CLOSE_WAIT狀態, 客戶套接字則處於FIN_WAIT_2狀態。
(4) 當服務器TCP接收FIN時, 服務器子進程阻塞於readline調用 , 於是readline返回0。 這導致str_echo函數返回服務器子進程的main函數。
(5) 服務器子進程通過調用exit來終止.
(6) 服務器子進程中打開的所有描述符隨之關閉。 由子進程來關閉已連接套接字會引發TCP連接終止序列的最後兩個分節: 一個從服務器到客戶的FIN和一個從客戶到服務器的ACK 。 至此, 連接完全終止, 客戶套接字進入TIME_WAIT狀態。
(7) 進程終止處理的另一部分內容是: 在服務器子進程終止時, 給父進程發送一個SIGCHLD信號。如果我們沒有在代碼中捕獲該信號,並處理。 該信號的默認行爲是被忽略。 既然父進程未加處理, 子進程於是進入僵死狀態。
#驗證
ps -a -o pid,ppid,tty,stat,args,wchan
參考: 孤兒進程與殭屍進程總結
2.3、信號處理
主要:使用waitpid函數,防止子進程變成僵死進程。
2.3.1 信號
信號(signal) 就是告知某個進程發生了某個事件的通知, 有時也稱爲軟件中斷(software interrupt) 。 信號通常是異步發生的, 也就是說進程預先不知道信號的準確發生時刻。
上一節結尾提到的SIGCHLD信號就是由內核在任何一個進程終止時發給它的父進程的一個信號。
我們可以提供一個函數, 只要有特定信號發生它就被調用。 這樣的函數稱爲信號處理函數(signal handler) , 這種行爲稱爲捕獲(catching)信號。
我們的解決辦法是定義自己的signal函數, 它只是調用POSIX的sigaction函數。
關於信號函數部分的分析,見附錄。
2.3.2 信號處理
無論何時我們fork子進程都得wait(wait和waitpid函數)它們, 以防它們變成僵死進程。
當SIGCHLD信號遞交時, 父進程阻塞於accept調用。 sig_chld函數(信號處理函數) 執行, 其wait調用取到子進程的PID和終止狀態, 隨後是printf調用, 最後返回。
既然該信號是在父進程阻塞於慢系統調用(accept) 時由父進程捕獲的, 內核就會使accept返回一個EINTR錯誤(被中斷的系統調用) 。 而父進程不處理該錯誤(圖5-2) , 於是中止。(有的系統會自動重啓被中斷的系統調用)
關於慢系統調用,見附錄。
建立一個信號處理函數並在其中調用wait並不足以防止出現僵死進程。 本問題在於: 所有5個信號都在信號處理函數執行之前產生, 而信號處理函數只執行一次, 因爲Unix信號一般是不排隊的。( SIGCHLD是不可靠信號)
正確的解決辦法是調用waitpid而不是wait。我們在一個循環內調用waitpid, 以獲取所有已終止子進程的狀態。我們必須指定WNOHANG選項, 它告知waitpid在有尚未終止的子進程在運行時不要阻塞。
本節的目的是示範我們在網絡編程時可能會遇到的三種情況:
(1) 當fork子進程時, 必須捕獲SIGCHLD信號;
(2) 當捕獲信號時, 必須處理被中斷的系統調用;
(3) SIGCHLD的信號處理函數必須正確編寫, 應使用waitpid函數以免留下僵死進程。
3、邊界條件🌟
目前我們僅考慮有那些邊界,以及處理思路,具體操作後面介紹。
3.1、accept返回前連接終止
3.1.1 邊界分析
這裏, 三路握手完成從而連接建立之後, 客戶TCP卻發送了一個RST(復位) 。 在服務器端看來, 就在該連接已由TCP排隊, 等着服務器進程調用accept的時候RST(終止連接)到達。 (關於TCP排隊的內容,可以參考書上最大連接數部分)。
模擬這種情形的一個簡單方法就是: 啓動服務器, 讓它調用socket、bind和listen, 然後在調用accept之前睡眠一小段時間。 (相當於服務器比較繁忙,部分已三次握手建立連接的在排隊,accept沒有來的急處理。)
3.3.2 處理方法
POSIX指出返回的errno值必須是ECONNABORTED(“software caused connection abort”, 軟件引起的連接中止) 。 服務器就可以忽略它, 再次調用accept就行。
或者
發現待中止的連接仍在監聽套接字的已完成連接隊列中, 於是從該隊列中刪除該連接, 並釋放相應的已連接套接字。
3.2、服務器進程終止
3.2.1 邊界分析
模擬服務器進程崩潰的情形:啓動我們的客戶/服務器對, 然後殺死服務器子進程。
(1) 作爲進程終止處理的部分工作, 子進程中所有打開着的描述符都被關閉。 這就導致向客戶發送一個FIN, 而客戶TCP則響應以一個ACK。 這就是TCP連接終止工作的前半部分。(這個FIN僅僅表示服務器不再發送數據)
(2) SIGCHLD信號被髮送給服務器父進程, 並得到正確處理。
(3) 客戶正阻塞在fgets調用上。客戶僅僅知道服務器(進程)不在發送消息,並不知道它的進程已終止。如果知道,我們提供一個EOF,客戶程序會正常終止。我們stdin輸入。
(4) Writen函數發送。服務器返回一個RST.
(5) 由於之前的FIN.客戶端的Readline函數,返回0.然後產生報錯信息。
(6) (4)和(5)的到達順序不定。所以可能出現不同的報錯。
(沒有再單獨畫圖了。把筆記的圖截取過來)
3.2.2 處理方法
本例子的問題在於: 當FIN到達套接字時, 客戶正阻塞在fgets調用上。 客戶實際上在應對兩個描述符——套接字和用戶輸入, 它不能單純阻塞在這兩個源中某個特定源的輸入上(正如目前編寫的str_cli函數所爲) ,而是應該阻塞在其中任何一個源的輸入上。 事實上這正是select和poll這兩個函數的目的之一, 我們將在第6章中討論它們。 我們在6.4節重新編寫str_cli函數之後, 一旦殺死服務器子進程, 客戶就會立即被告知已收到FIN
關於FIN,ACK等,我們目前都沒有手動處理。(我想它們應該在connect,accept內部吧。)
但是我們的str_cli函數需要這部分來進行邏輯處理。如果在str_cli函數內能看到返回的FIN/RST,這個邊界問題將會得到處理。
3.3、SIGPIPE信號
3.3.1 邊界分析
要是客戶不理會readline函數返回的錯誤, 反而寫入更多的數據到服務器上, 那又會發生什麼呢?
這種情況是可能發生的, 舉例來說, 客戶可能在讀回任何數據之前執行兩次針對服務器的寫操作, 而RST是由其中第一次寫操作引發的。
當一個進程向某個已收到RST的套接字執行寫操作時, 內核向該進程發送一個SIGPIPE信號。 該信號的默認行爲是終止進程,(如果進程不想終止) 因此進程必須捕獲它以免不情願地被終止。
3.3.2 處理方法
如果沒有特殊的事情要做, 那麼將信號處理辦法直接設置爲SIG_IGN, 並假設後續的輸出操作將捕捉EPIPE錯誤並終止。
3.4、服務器主機崩潰
3.4.1 邊界分析
(1) 當服務器主機崩潰時, 已有的網絡連接上不發出任何東西。 這裏我們假設的是主機崩潰, 而不是由操作員執行命令關機.
(2) 我們在客戶上鍵入一行文本, 它由writen寫入內核, 再由客戶TCP作爲一個數據分節送出。 客戶隨後阻塞於readline調用, 等待回射的應答。
(3)客戶TCP持續重傳數據分節, 試圖從服務器上接收一個ACK。(重傳多次,最終放棄,比較費時)
3.4.2 處理方法
當我們主動給服務器發送數據時:給readline調用設置一個超時。
如果我們不主動向它發送數據也想檢測出服務器主機的崩潰, 那麼需要採用另外一個技術, 也就是我們將在7.5節討論的SO_KEEPALIVE套接字選項
3.5、其他邊界條件
3.5.1 服務器主機崩潰後重啓
服務器主機崩潰後重啓(如果在服務器主機崩潰時客戶不主動給服務器發送數據, 那麼客戶將不會知道服務器主機已經崩潰);
它的TCP丟失了崩潰前的所有連接信息, 因此服務器TCP對於所收到的來自客戶的數據分節響應以一個RST。
當客戶TCP收到該RST時, 客戶正阻塞於readline調用, 導致該調用返回ECONNRESET錯誤。
3.5.2 服務器關機
Unix系統關機時, init進程通常先給所有進程發送SIGTERM信號(該信號可被捕獲) , 等待一段固定的時間(往往在5~20秒) , 然後給所有仍在運行的進程發送SIGKILL信號(該信號不能被捕獲) 。 這麼做留給所有運行的進程一小段時間來清除和終止。 如果我們不捕獲SIGTERM信號並終止, 我們的服務器將由SIGKILL信號終止。 當服務器子進程終止時, 它的所有打開着的描述符都被關閉, 隨後發生的步驟與3.2進程終止 中討論過的一樣。
二、代碼
1、客戶端代碼
/***主函數***/
#include "unp.h"
int
main(int argc, char **argv)
{
int i,sockfd[5];
struct sockaddr_in servaddr;
if (argc != 2)
err_quit("usage: tcpcli <IPaddress>");
for(i=0;i<5;i++){
sockfd[i] = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
Connect(sockfd[i], (SA *) &servaddr, sizeof(servaddr));
}
str_cli(stdin, sockfd[0]); /* do it all */
exit(0);
}
/***str_cli函數***/
#include "unp.h"
void
str_cli(FILE *fp, int sockfd)
{
char sendline[MAXLINE], recvline[MAXLINE];
while (Fgets(sendline, MAXLINE, fp) != NULL) {
Writen(sockfd, sendline, strlen(sendline));
if (Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli: server terminated prematurely");
Fputs(recvline, stdout);
}
}
2、服務端代碼
/*主函數*/
#include "unp.h"
int
main(int argc, char **argv)
{
int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
void sig_chld(int);
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(SERV_PORT);
Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
Listen(listenfd, LISTENQ);
Signal(SIGCHLD, sig_chld);
for ( ; ; ) {
clilen = sizeof(cliaddr);
//connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);
if( (connfd = accept(listenfd,(SA*)&cliaddr,&clilen)) < 0 ){
if( errno == EINTR )
continue; //如果內核不能重啓系統調用
else
err_sys("accept error");
}
if ( (childpid = Fork()) == 0) { /* child process */
Close(listenfd); /* close listening socket */
str_echo(connfd); /* process the request */
exit(0);
}
Close(connfd); /* parent closes connected socket */
}
}
/***********************************************/
/*
*在 tcpcliserv/tcpserv01.c 的基礎上不斷修改
*修改下makefile文件就好
*/
/***信號處理函數***/
#include "unp.h"
void
sig_chld(int signo)
{
pid_t pid;
int stat;
int i = 0;
//pid = wait(&stat);
while ( (pid = waitpid(-1,&stat,WNOHANG)) > 0 ){
printf("i=%d \t child %d terminated\n", ++i,pid);
}
return;
}
/***str_echo函數***/
#include "unp.h"
void
str_echo(int sockfd)
{
ssize_t n;
char buf[MAXLINE];
again:
while ( (n = read(sockfd, buf, MAXLINE)) > 0)
Writen(sockfd, buf, n);
if (n < 0 && errno == EINTR)
goto again;
else if (n < 0)
err_sys("str_echo: read error");
}
附錄
1、信號函數
Sigfunc *
signal(int signo, Sigfunc *func)
{
struct sigaction act, oact;
act.sa_handler = func;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
if (signo == SIGALRM) {
#ifdef SA_INTERRUPT
act.sa_flags |= SA_INTERRUPT; /* SunOS 4.x */
#endif
} else {
#ifdef SA_RESTART
act.sa_flags |= SA_RESTART; /* SVR4, 44BSD */
#endif
}
if (sigaction(signo, &act, &oact) < 0)
return(SIG_ERR);
return(oact.sa_handler);
}
/* end signal */
typedef void Sigfunc(int); /* for signal handlers */
上面,signal函數的原型:void (*signal(int signo, void (*func)(int)))(int);
默認,我們已經知道函數的指針。不清楚的,可以參考先《c語言程序 現代方法》。
signal函數的返回:指向函數的指針。且該指針對應的函數有一個int參數,返回void 類型。
signal函數的參數:int 參數和一個指向函數的指針。且該指針對應的函數有一個int參數,返回void 類型。
但是,我了簡化函數原型,我們使用了typedef 。參考:typedef函數指針用法
typedef void Sigfunc(int);
定義了一種Sisfunc類型,就像int類型一樣。這種是(函數)類型,要求返回值是void ,有一個int參數。
關於這個函數的內容,我們參看:linux信號機制之sigaction結構體淺析,signal 函數,信號捕捉
關於信號這裏,我並非特別理解。但是先知道這樣使用就好。
2、慢系統調用
我們用術語慢系統調用(slow system call) 描述過accept函數, 該術語也適用於那些可能永遠阻塞的系統調用。
永遠阻塞的系統調用是指調用有可能永遠無法返回, 多數網絡支持函數都屬於這一類。 舉例來說, 如果沒有客戶連接到服務器上, 那麼服務器的accept調用就沒有返回的保證。 類似地, 在圖5-3中, 如果客戶從未發送過一行要求服務器回射的文本, 那麼服務器的read調用將永不返回。 其他慢系統調用的例子是對管道和終端設備的讀和寫。 一個值得注意的例外是磁盤I/O, 它們一般都會返回到調用者(假設沒有災難性的硬件故障) 。
適用於慢系統調用的基本規則是:當阻塞於某個慢系統調用的一個進程捕獲某個信號且相應信號處理函數返回時, 該系統調用可能返回一個EINTR錯誤。 有些內核自動重啓某些被中斷的系統調用。 不過爲了便於移植, 當我們編寫捕獲信號的程序時(多數併發服務器捕獲SIGCHLD) , 我們必須對慢系統調用返回EINTR有所準備。 移植性問題是由早期使用的修飾詞“可能”、 “有些”和對POSIX的SA_RESTART標誌的支持是可選的這一事實造成的。 即使某個實現支持SA_RESTART標誌, 也並非所有被中斷系統調用都可以自動重啓。 舉例來說, 大多數源自Berkeley的實現從不自動重啓select, 其中有些實現從不重啓accept和recvfrom。
3、抓包神器 tcpdump 使用
這部分,暫時跳過。