【UNIX網路編程】TCP客戶/伺服器程式樣本,unix網路編程
做一個簡單的回射伺服器:
客戶從標準輸入讀入一行文本,寫給伺服器 -> 伺服器從網路輸入讀入這行文本,並回射給客戶 -> 客戶從網路輸入讀入這行回射文本,並顯示在標準輸出上
以下是My Code(部分.h檔案是由unpv13e檔案夾中的.c檔案改名得到)
#include "../unpv13e/unp.h"#include "../unpv13e/apueerror.h"#include "../unpv13e/wrapsock.h"#include "../unpv13e/wrapunix.h"#include "../unpv13e/wraplib.h"#include "../unpv13e/writen.h"#include "../unpv13e/str_echo.h"intmain(int argc, char **argv){intlistenfd, connfd;pid_tchildpid;socklen_tclilen;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(SERV_PORT);Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));Listen(listenfd, LISTENQ);for ( ; ; ) {clilen = sizeof(cliaddr);connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);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 */}}
#include "../unpv13e/unp.h"#include "../unpv13e/apueerror.h"#include "../unpv13e/wrapsock.h"#include "../unpv13e/wrapunix.h"#include "../unpv13e/wraplib.h"#include "../unpv13e/writen.h"#include "../unpv13e/str_cli.h"#include "../unpv13e/readline.h"#include "../unpv13e/wrapstdio.h"intmain(int argc, char **argv){intsockfd;struct sockaddr_inservaddr;if (argc != 2)err_quit("usage: tcpcli <IPaddress>");sockfd = 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, (SA *) &servaddr, sizeof(servaddr));str_cli(stdin, sockfd);/* do it all */exit(0);}
// 伺服器代碼和用戶端代碼如上所示。
其中,str_echo是從客戶讀入資料,並把他們回射給客戶;str_cli是從標準輸入讀入一行文本,寫到伺服器上,讀回伺服器對該行的回射,並把回射行寫到標準輸出上。
正常啟動伺服器程式,然後可以使用netstat -a來查看伺服器監聽通訊端的狀態:
可以看到*:9877那一行就是上面的程式,調用了socket,bind,listen之後,由於backlog的隊列為空白,阻塞於accept調用。
當客戶程式運行(./tcpcli01 127.0.0.1),再次使用命令netstat -a查看:
可以看到子進程與用戶端已經串連成功,處於ESTABLISHED狀態。
使用ps命令來檢查進程的狀態:
可以看到最後一行的進程的父進程是第一行。STAT中的S都為睡眠狀態,WCHAN表示他們處於STAT的條件。(此處顯示和書上不同,不過意思應該是一樣的)
正常終止:按下ctrl+D然後立即執行netstat -a,顯示如下(由於測試,臨時連接埠跟不同):
正常終止客戶和伺服器的步驟是:
1) 鍵入EOF字元,fgets返回一個null 指標,於是str_cli函數返回
2) 當str_cli返回到客戶的main函數,main通過調用exit終止
3) 進程終止處理的部分工作是關閉所有開啟的描述符,因此客戶開啟的通訊端由核心關閉。這導致客戶TCP發送一個FIN給伺服器,伺服器TCP則以ACK響應,這就是TCP串連終止序列的前半部分。
4) 當伺服器TCP接收FIN時,伺服器子進程阻塞於readline調用,於是readline返回0,導致str_echo函數返回伺服器子進程的main函數
5) 伺服器子進程通過調用exit來終止
6) 伺服器子進程中開啟的所有描述符隨之關閉。有子進程來關閉已串連通訊端會引發TCP串連終止序列的最後兩個位元組:一個從伺服器到客戶的FIN和一個從客戶到伺服器的ACK。至此,串連完全終止,客戶通訊端進入TIME_WAIT狀態
7) 進程終止處理的另一部分內容是:在伺服器子進程終止時,給父進程發送一個SIGCHLD訊號。這一點在本例中發生了,但是我們沒有在代碼中捕獲該訊號,而該訊號的預設行為是被忽略。既然父進程未加處理,子進程進入僵死狀態。
僵死進程:子進程退出時,父進程並未對其發出的SIGCHLD訊號進行適當處理,導致子進程停留在僵死狀態等待父進程為其收屍。
POSIX訊號處理
POSIX 表示可移植作業系統介面(Portable Operating System Interface ,縮寫為 POSIX ),POSIX標準定義了作業系統應該為應用程式提供的介面標準
訊號:告知某個進程發生了某個事件的通知,也稱為軟體中斷(software interrupt)。訊號通常是非同步發生的,也就是說進程預先不知道訊號的準確發生時刻。
非同步雙方不需要共同的時鐘,也就是接收方不知道發送方什麼時候發送,所以在發送的資訊中就要有提示接收方開始接收的資訊,如開始位,同時在結束時有停止位。非同步另外一種含義是電腦多線程的非同步處理。與同步處理相對,非同步處理不用阻塞當前線程來等待處理完成,而是允許後續操作,直至其它線程將處理完成,並回調通知此線程。
訊號可以由一個進程發給另一個進程(或自身),也可以由核心發給某個進程。
上面提到的SIGCHLD訊號就是核心在任何一個進程終止時發給它的父進程的一個訊號。
每隔訊號都有一個與之關聯的處置(disposition),也叫行為(action),通過調用sigaction函數來設定一個訊號的處置,三種選擇:
1) 提供一個函數,只要有特定訊號發生就被調用,這樣的函數成為訊號處理函數(signal handler),這種行為成為捕獲(catching)訊號。其中有兩個訊號不能被捕獲(SIGKILL 和 SIGSTOP)。訊號處理函數由訊號值這個整數參數調用,且沒有傳回值,函數原型為 void handler(int signo); 。對於大多數訊號來說,調用sigaction函數並指定訊號發生時所調用的函數就是捕獲訊號所需做的全部工作,但是像SIGIO,SIGPOLL,SIGURG這些個別訊號還要求捕獲它們的進程做些額外工作。
2) 將訊號設定為SIG_IGN來忽略它。但是SIGKILL和SIGSTOP這兩個訊號不能被忽略。
3) 將訊號設定為SIG_DFL來啟用它的預設處置。預設處置通常是在收到訊號後終止進程,其中某些訊號還在當前工作目錄產生一個進程的核心映像(core image),另有個別訊號預設是忽略,SIGCHLD和SIGURG就是這樣。
signal函數:用於建立訊號處置的函數。這個函數需要自己封裝(因為,POSIX提供的方法sigaction函數太複雜,而系統提供的簡單的signal函數是在POSIX出現之前,所以不相容不同的訊號。 => 定義自己的signal函數,其中在裡面調用sigaction函數)
以下函數是書上通用(最終版本)的:
/* include signal */#include"unp.h"Sigfunc *signal(int signo, Sigfunc *func){struct sigactionact, oact;act.sa_handler = func;sigemptyset(&act.sa_mask);act.sa_flags = 0;if (signo == SIGALRM) {#ifdefSA_INTERRUPTact.sa_flags |= SA_INTERRUPT;/* SunOS 4.x */#endif} else {#ifdefSA_RESTARTact.sa_flags |= SA_RESTART;/* SVR4, 44BSD */#endif}if (sigaction(signo, &act, &oact) < 0)return(SIG_ERR);return(oact.sa_handler);}/* end signal */Sigfunc *Signal(int signo, Sigfunc *func)/* for our signal() function */{Sigfunc*sigfunc;if ( (sigfunc = signal(signo, func)) == SIG_ERR)err_sys("signal error");return(sigfunc);}
系統提供的signal函數的原型是這樣的: void (*signal(int signo, void (*func) (int)))(int);
要理解這個函數原型需要Crowdsourced Security Testing道 函數指標
函數指標是指向函數的指標變數。 因而“函數指標”本身首先應是指標變數,只不過該指標變數指向函數。函數指標有兩個用途:調用函數和做函數的參數。
拆解的來理解的話,這是形如 void (*) (int) 的一個函數,而signal(int signo, void (*func) (int))這樣的一個函數返回了一個函數指標,這個函數指標指向的函數正是 void (*) (int)
於是,函數原型的含義是:聲明一個signal函數,參數是訊號量(int型)和訊號處理函數(只有1個int參數,無傳回值),傳回值是函數指標(指向一個有1個int參數,無傳回值)
簡化的理解方式:
先定義一個Sigfunc類型, typedef void Sigfunc(int);
如何理解typedef? 找到一篇參考文章。
typedef 在語句中所起的作用只不過是把語句原先定義變數的功能變成了定義類型的功能。例如:typedef int *apple,先別看typedef。int *apple是聲明指向整型變數的指標,所以apple就是指向整型變數指標的類型。
所以,Sigfunc是一個函數類型,有一個整數參數且不傳回值。原型此時變成了 Sigfunc *signal(int signo, Sigfunc *func); // 該函數的第二個參數和傳回值都是指向訊號處理函數的指標
處理SIGCHLD訊號
設定僵死(zoombie)狀態的目的是維護子進程的資訊,以便父進程在以後某個時候擷取這些資訊包括子進程的進程ID,終止狀態以及資源利用資訊等。
如果父進程終止,僵死的子進程的父進程ID會被重設為1(init 進程),init進程會清理它們(wait)
無論何時fork子進程都得wait它們,以防它們變成僵死進程。
所以,要建立一個俘獲SIGCHLD訊號的訊號處理函數,並在函數體內調用wait。
在調用listen之後,增加signal(SIGCHLD, sig_chld); // 要在fork第一個子進程之前完成,且只做一次。sig_chld需要自己定義
(注意:以下函數不是最終的版本,後面是需要改進的)
#include"unp.h"voidsig_chld(int signo){pid_tpid;intstat;pid = wait(&stat);printf("child %d terminated\n", pid);return;}
處理僵死進程的可移植方法就是捕獲SIGCHLD,並調用wait或waitpid。
如果signal函數是來自系統內建的函數庫,而不是自訂的signal函數,會出現以下的情況:
即使處理了SIGCHLD訊號,仍然會造成慢系統調用(accept)被中斷(返回一個EINTR錯誤),而父進程不處理該錯誤,所以父進程中止。雖然有些核心會自動重啟某些被中斷的系統調用,但是我們必須對慢系統調用返回EINTR有所準備來相容。標準C函數庫中提供的signal函數不會使核心自動重啟被中斷的系統調用。所以需要設定SA_RESTART標誌。但是即使設定了,有些核心或者一些系統調用不能重啟,於是需要在accept之後處理。
該術語適用於那些可能永遠阻塞的系統調用。永遠阻塞的系統調用是指調用永遠無法返回,多數網路支援函數都屬於這一類。如:若沒有客戶串連到伺服器上,那麼伺服器的accept調用就會一直阻塞。
在我的機器中測試的時候,代碼和相應顯示如下:
#include "../unpv13e/unp.h"#include "../unpv13e/apueerror.h"#include "../unpv13e/wrapsock.h"#include "../unpv13e/wrapunix.h"#include "../unpv13e/wraplib.h"#include "../unpv13e/writen.h"#include "../unpv13e/str_echo.h"// #include "../unpv13e/signal.h" #include "../unpv13e/sigchldwait.h"// 使用wait來處理僵死進程intmain(int argc, char **argv){intlistenfd, connfd;pid_tchildpid;socklen_tclilen;struct sockaddr_incliaddr, servaddr;voidsig_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 ( (childpid = Fork()) == 0) {/* child process */Close(listenfd);/* close listening socket */str_echo(connfd);/* process the request */exit(0);}Close(connfd);/* parent closes connected socket */}}
沒有出現accept error: Interrupted system call,屬於核心自動重啟某些被中斷的系統調用的類型。但是為了便於移植,還是要設定SA_RESTART標誌,並且在accept函數中忽略EINTR錯誤,即:
1) 使用自訂的Signal函數(最終版本見上面的signal.h)
2) 在accept中忽略錯誤
for ( ; ; ) {clilen = sizeof(cliaddr);if ( (connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {if (errno == EINTR)continue;/* back to for() */elseerr_sys("accept error");}// ... ...}
既然如此,為什麼Accept這個包裹函數中不直接處理EINTR錯誤呢?因為connect函數在返回EINTR的時候,不能再次調用它,否則立即返回一個錯誤!
修改了EINTR的處理並且使用自訂Signal函數之後,伺服器程式的最終版就實現了。(訊號處理函數不在tcpserv程式中,所以書上的tcpserv03和tcpserv04程式相同)
wait和waitpid函數:處理已終止的子進程
#include <sys/wait.h>pid_t wait(int *statloc);pid_t waitpid(pid_t pid, int *statloc, int options); // 成功返回進程ID,出錯則為0或-1
以上兩個函數返回進程ID號(pid),和子進程終止狀態(statloc)
如果調用wait的進程沒有已終止的子進程,不過有一個或多個子進程仍在執行,那麼wait將阻塞到現有子進程第一個終止為止。
waitpid函數有更多的控制選擇,pid參數允許指定等待的進程ID,-1表示等待第一個終止的子進程。options中最常用的選項是WNOHANG,它告知核心在沒有已終止子進程時不要阻塞。
這兩個函數的區別在於:
建立一個訊號處理函數並在其中調用wait並不足以防止出現僵死進程。當有多個訊號同時終止,引發多個FIN併產生SIGCHLD訊號的時候,由於Unix訊號是不排隊的,所以訊號處理函數不保證執行幾次,很有可能會留下僵死進程。
測試使用wait的情況,以下是相應顯示:
(訊號處理函數見上面,主要是 pid = wait(&stat); )
做了兩次測試,嚴重的問題不僅是會留下僵死進程,並且不確定會有怎樣的結果。上面的兩個例子,一個是留下了4個僵死進程,一個是留下了一個。
正確的方法是調用waitpid:在一個迴圈內調用waitpid,以擷取所有已終止子進程的狀態,而且必須指定WNOHANG選項告知waitpid在有尚未終止的子進程在運行時不要阻塞。
最終版本的sig_chld函數:
#include"unp.h"voidsig_chld(int signo){pid_tpid;intstat;while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0)printf("child %d terminated\n", pid);return;}
伺服器程式的最終版本如下(可以正確處理accept返回的EINTR,並建立一個給所有已終止子程式調用waitpid的訊號處理函數)
#include"unp.h"intmain(int argc, char **argv){intlistenfd, connfd;pid_tchildpid;socklen_tclilen;struct sockaddr_incliaddr, servaddr;voidsig_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);/* must call waitpid() */for ( ; ; ) {clilen = sizeof(cliaddr);if ( (connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {if (errno == EINTR)continue;/* back to for() */elseerr_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 */}}
改用waitpid之後的圖示:
最終版本能夠處理的情況:
1) 當fork子進程時,必須捕獲SIGCHLD訊號
2) 當捕獲訊號時,必須處理被中斷的系統調用
3) SIGCHLD的訊號處理函數必須正確編寫,應使用waitpid函數以免留下的僵死進程
一些異常情況:
1) accept返回前串連中止:三路握手完成從而建立串連之後,客戶TCP發送了一個RST。即,該串連已經排隊,等著伺服器處理序調用accept的時候RST到達。
如何處理這種中止的串連依賴於不同的實現。有些實現完全在核心中處理中止的串連,伺服器處理序根本看不到;有些返回EPROTO的errno值,POSIX返回ECONNABORTED(因為有些致命的協議相關事件也會返回EPROTO,所以換成ECONNABORTED錯誤,使得伺服器可以忽略它)。一般伺服器處理的時候只需要再次調用accept就行。
2) 伺服器處理序終止:處理客戶的子進程意外終止,該子進程所有開啟著的描述符都關閉,這就導致向客戶發送一個FIN,而客戶TCP則響應一個ACK,完成了四次揮手的前半部分,同時,SIGCHLD訊號被發送給伺服器父進程,並得到正確處理。然而此時,客戶進程阻塞在fgets調用上,等待從終端接收一行文本。
其中在開啟tcpcli01之後,在另一個終端中kill掉了子進程,所以此時伺服器返回了一個FIN給客戶,客戶響應ACK,然後當客戶再次發送一行資料的時候(此時可以發送資料給伺服器,因為在四次揮手的前半部分結束之後,客戶需要告知伺服器自身已經關閉),伺服器處理序已經關閉了,所以返回一個RST。此時,在客戶進程中可能出現兩種情況: 1) 調用readline發生在收到RST之前(本例所示),接收到的FIN使readline立即返回0(EOF),而客戶此時並未預期收到EOF,於是以出錯資訊退出。 2) 如果readline發生在收到RST之後,則返回一個ECONNRESET,表示對方複位串連錯誤。
本例的問題在於,當FIN到達通訊端時,客戶正阻塞在fgets調用上,而客戶實際上在應對兩個描述符——通訊端和使用者輸入,它不能單純阻塞其中某個輸入,而是應該阻塞其中任何一個源的輸入上,這就是 select 和 poll 這兩個函數的目的之一。
後面會重新編寫str_cli函數,一旦殺死伺服器子進程,客戶就會立即被告知已收到FIN。
3) SIGPIPE訊號:不理會readline函數返回的錯誤,寫入更多的資料到伺服器。(如,客戶在讀回任何資料(調用readline)之前執行兩次寫(Writen)操作,而RST是第一次寫的時候引發的)
寫一個已接收了FIN的通訊端沒問題,但是寫一個已接收了RST的通訊端則是一個錯誤。所以根據伺服器處理序終止的例子來看,殺死子進程之後,客戶第一次寫入資料,會引發RST,而第二次寫則會引發SIGPIPE訊號,該訊號預設行為是終止進程,並且不論該進程是捕獲了該訊號並從其訊號處理函數返回,還是忽略這個訊號,寫操作都會返回EPIPE錯誤。
-----以上未完工-----