UNP學習筆記2——從一個簡單的ECHO程序分析TCP客戶/服務器之間的通信


1 概述

編寫一個簡單的ECHO(回復)程序來分析TCP客戶和服務器之間的通信流程,要求如下:

  • 客戶從標准輸入讀入一行文本,並發送給服務器
  • 服務器從網絡輸入讀取這個文本,並回復給客戶
  • 客戶從網絡輸入讀取這個回復,並顯示在標准輸出上

通過這樣一個簡單的例子來學習TCP協議的基本流程,同時探討在實際過程中可能發生的意外情況,從而更深層次的理解其工作原理:

  • 客戶和服務器啟動時發生了什么?
  • 客戶正常終止發生了什么?
  • 若服務器進程在客戶之前終止,則客戶會發生什么?
  • 若服務器主機崩潰,則客戶會發生什么?
  • ……

2 基本程序

TCP echo 服務器函數:echo_server.c

#include <stdio.h> #include <stdlib.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <errno.h>

//str_echo函數從套接字上回射數據
void str_echo(int sockfd) { ssize_t n; char buf[1000]; again: while((n=read(sockfd, buf, 1000)) > 0) { write(sockfd, buf, n); } if(n<0 && errno==EINTR) goto again; else if(n<0) perror("read error"); } int main(int argc, char **argv) { int listenfd, connfd;    //監聽描述符,連接描述符
    pid_t childpid;    //子進程pid
    socklen_t clilen;    //客戶IP地址長度
    struct sockaddr_in server_addr, client_addr; /*socket函數*/ listenfd=socket(AF_INET, SOCK_STREAM, 0); /*服務器地址*/ memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family=AF_INET; server_addr.sin_addr.s_addr=htonl(INADDR_ANY); server_addr.sin_port=htons(12345); /*bindt函數*/
    if(bind(listenfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) perror("bind error"); /*listent函數*/
    if(listen(listenfd, 10) < 0) perror("listen error"); while(1) { clilen=sizeof(client_addr); /*父進程調用accpet函數,阻塞直到客戶connect*/  
        if((connfd=accept(listenfd, (struct sockaddr*)&client_addr, &clilen)) < 0) perror("accept error"); if((childpid=fork())==0)    //子進程
 { close(listenfd); //關閉監聽描述符
            str_echo(connfd);    //處理請求
            exit(0); } close(connfd); //父進程關閉連接描述符 
 } return 0; } 

TCP echo 客戶端函數:echo_client.c

#include <stdio.h> #include <stdlib.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h>


int main(int argc, char **argv) { if(argc<2)    //檢查輸入參數
        perror("usage:echo_client <server addr>"); int sockfd;    //網絡套接字
    struct sockaddr_in server_addr;    //服務器地址
    
    /*socket函數*/ sockfd=socket(AF_INET, SOCK_STREAM, 0); /*配置服務器地址*/ memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family=AF_INET; server_addr.sin_port=htons(12345); if((inet_pton(AF_INET, argv[1], &server_addr.sin_addr)) < 0) perror("invaild IP address"); /*connect函數*/
    if(connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) perror("can't connect to server"); /*ECHO處理函數*/
    char send[1000],recv[1000]; /*從標准輸入讀取文本*/
    while(fgets(send, 1000, stdin)!=NULL) { /*發送文本到服務器*/ write(sockfd, send, strlen(send)); /*接收從服務器返回*/
        if(read(sockfd, recv, 1000)==0) perror("server terminated"); /*打印到標准輸出*/ fputs(recv, stdout); } return 0; }

3 正常啟動

首先在Linux上后台啟動服務器程序

# ./echo_server & [1] 2211

服務器啟動之后,它調用socket、bind、listen並阻塞於accept。檢查服務器監聽套接字的狀態。

# netstat -a | grep 12345 tcp  0  0  *:12345  *:*  LISTEN

接着在同一主機上啟動客戶端程序

./echo_client localhost

服務器啟動之后,它調用socket、connect引起TCP三路握手過程。當三路握手完成后,客戶端中的connect和服務器中的accept均返回,於是連接建立。接着發生步驟如下:

  1. 客戶調用echo_str函數,該函數將阻塞於fgets調用
  2. 服務器中accept函數返回,調用fork,再由子進程調用echo_str,該函數將阻塞於read調用
  3. 另一方面,服務器父進程再次調用accept並阻塞,等待下一個客戶連接

至此,有3個正在睡眠(阻塞)的進程:客戶進程、服務器父進程和服務器子進程。

tcp  0  0  *:12345       *:*        LISTEN tcp  0  0  localhost:46636  localhost:12345  ESTABLISHED tcp  0  0  localhost:12345  localhost:46636  ESTABLISHED

4 正常終止

#echo_server localhsot hello hello bye bye ^D

我們鍵入兩行,都能得到回射,接着鍵入EOF字符(Ctrl+D),客戶端進程將終止。如果此時立即執行netstat命令,將會看到如下結果

# netstat -a | grep 12345 tcp  0  0  *:12345      *:*        LISTEN tcp  0  0  localhost:46636  localhost:12345  TIME_WAIT

當前連接的客戶端進入了TIME_WAIT狀態,而監聽服務器仍在等待另外一個客戶連接。因此總結正常的終止客戶和服務器的步驟:

  1. 當我們鍵入EOF后,fgets返回一個空指針,於是str_cli返回,main函數返回,最終客戶進程終止
  2. 進程終止處理需要關閉所有打開的描述符,因此客戶向TCP服務器發送一個FIN,服務器響應ACK,這是TCP連接終止的前半部分。此時,服務器套接字處於CLOSE_WAIT狀態,客戶端套接字處於FIN_WAIT_2狀態
  3. 當服務器接收FIN時,服務器進程阻塞於read調用,於是read返回0,main函數返回,最終服務器子進程終止
  4. 服務器子進程打開的描述符關閉,向客戶發送一個FIN,客戶返回一個ACK。此時客戶套接字處於TIME_WAIT狀態
  5. 進程終止處理的另一部分內容是:當服務器子進程終止時,給父進程發送一個SIGCHLD信號。但是我們沒有在代碼中處理該信號,該信號的默認行為是忽略。因為父進程未加處理,因此子進程處於僵死狀態。

用ps命令驗證:

# ps PID   TTY   TIME    CMD 2194  pts/0  00:00:00  bash 2256  pts/0  00:00:00  echo_server 2258  pts/0  00:00:00  echo_server <defunct>
2260  pts/0  00:00:00  ps

我們必須要處理僵死進程,它們占用內核空間,並且可能導致進程資源耗盡。

5 處理SIGCHLD信號

5.1 處理僵死進程

設置僵死狀態目的是維護子進程信息(包括子進程ID、終止狀態、CPU時間、內存使用量等等資源利用信息),以便父進程在以后的某個時間獲取。如果一個父進程終止,有子進程處於僵死狀態,那么這些僵死進程的父進程會被設置為1(init進程),init進程負責清理它們。

我們顯然不願意保留僵死進程。無論何時我們fork子進程都得wait它們,以防它們變成僵死進程。因此我們建立一個捕獲SIGCHLD信號的信號處理函數,在函數體中調用wait,放在listen調用之后。

signal(SIGCHLD, sig_chld);

定義的sig_chld函數如下所示

void sig_chld(int signo) {   pid_t pid;   int stat;   pid=wait(&stat);   printf("child %d terminated\n", pid);  //通常不建議在信號處理函數中調用標准I/O函數
  return; }

修改后的程序運行結果

#echo_server & [2]  16939 #echo_client localhost hello hello ^D child 16942 terminated accept error: Interrupted system call

當SIGCHLD信號提交時,父進程阻塞於accept調用。sig_chld函數執行,其wait調用取到子進程的PID和終止狀態,隨后是printf調用,最后返回。

既然該信號是父進程阻塞於慢系統調用(accept)時由父進程捕獲的,內核就會使accept返回一個EINTER錯誤(被中斷的系統調用),而父進程不處理該錯誤,於是終止。

因此這個例子說明,在編寫捕獲信號的網絡程序時,必須意識到中斷的系統調用並且正確處理它們。

5.2 處理被中斷的系統調用

適用於慢系統調用的基本規則是:當阻塞於某個慢系統調用的一個進程捕獲某個信號且相應的信號處理函數返回時,該系統調用可能會返回一個TINTR錯誤。有些內核會自動重啟某些被中斷的系統調用,有些則不會,因此我們必須對慢系統調用返回EINTR有所准備。

為了處理被中斷的accept,我們修改如下

if ((connfd = accept(listenfd, (struct sockaddr *)&client_addr, &clilen)) < 0) {   if(errno == EINTR)     continue;   else     perror("accept error"); }

5.3 wait和waitpid

#include <sys/wait.h> 
piad_t wait(
int *statloc); pid_t waitpid(pid_t pid, int *statloc, int options);

函數wait和waitpid均返回兩個值:已終止的子進程的PID號和通過statloc指針返回的子進程終止狀態(一個整數)。

如果調用wait的進程沒有已終止的子進程,但有子進程在運行,那么wait將阻塞到現有第一個子進程終止為止。而waitpid函數的pid參數指定想等待的進程ID,值-1表示等待第一個終止的子進程。其次,options參數允許指定附加選項。最常用的選項是WNOHANG,它告知內核在沒有已終止子進程時不要阻塞。

我們現在修改函數使客戶建立5個與服務器的連接,同時終止客戶端主進程,這樣會5個連接基本上在同一時刻終止,最終會導致在同一時刻有5個SIGCHLD信號遞交給父進程。

正是這一種一個信號多個實例的遞交造成了一些問題,運行新的程序結果:

#echo_server & [1]  1696 #echo_client localhost hello hello ^D child 1697 terminated

結果顯示,5個子進程只有一個調用printf輸出,說明其他4個子進程仍然作為僵死進程存在着。

# ps PID   TTY   TIME    CMD 1600  pts/0  00:00:00  bash 1696  pts/0  00:00:00  echo_server 1698  pts/0  00:00:00  echo_server <defunct>
1699  pts/0  00:00:00  echo_server <defunct>
1700  pts/0  00:00:00  echo_server <defunct>
1701  pts/0  00:00:00  echo_server <defunct>
2260  pts/0  00:00:00  ps

因此,建立一個信號處理函數並且調用wait並不足以防止出現僵死進程。問題在於:5個信號都在信號處理函數執行之前產生,而信號處理函數只執行一次,因為Unix信號一般都是排隊的。更嚴重的是,該問題是不確定的,在服務器和客戶在同一台主機上執行了1次,若不在一台主機上一般出現2次,3次……則依賴於FIN到達主機的時機。

正確的做法是調用waitpid而不是wait,下面給出了正確處理SIG_CHLD信號的sig_chld函數。

void sig_chld(int signo) {   pid_t pid;   int stat;   while ( (pid=waitpid(-1, &stat, WNOHANG)) > 0)     printf("child %d terminated\n", pid);  //通常不建議在信號處理函數中調用標准I/O函數
  return; }

這個版本管用的原因在於:在一個循環內調用waitpid,以獲取所有已終止的子進程的狀態。必須指定WNOHANG選項,它告知waitpid在有尚未終止的子進程在運行時不要阻塞。我們不能再循環內調用wait,因為沒有辦法防止在有子進程在尚未終止時阻塞。

總結之前的結論,我們在網絡編程時可能會遇到的3種情況:

  1. 當fork子進程時,必須捕獲SIGCHLD信號
  2. 當捕獲信號時,必須處理被中斷的系統調用
  3. SIGCHLD的信號處理函數必須正確編寫,應使用waitpid避免留下僵死進程。

 6 accept返回之前終止

除了系統中斷的例子,另外一種情形也會導致accept返回一個非致命的錯誤,在這種情況下,需要再次調用accept。

在三次握手從而連接建立之后,客戶TCP卻發送了一個RST(復位)。在服務端看來,就在該連接已在排隊,等待服務器進程調用accept的時候到達。

大多數系統會返回一個錯誤給服務器進程作為accept的返回結果,不過錯誤本身取決於實現

7 服務器進程終止

現在我們啟動客戶/服務器對,然后殺死服務器子進程,這就導致服務器發送一個FIN,客戶TCP於是回應一個ACK。

客戶端沒有發生任何事,然而客戶端進程阻塞在fgets調用上,等待用戶從標准輸入輸入內容,此時運行netstat命令,觀察套接字的狀態:

# netstat -a | grep 12345 tcp  0  0  *:12345      *:*        LISTEN tcp  0  0  localhost:46636  localhost:12345  TIME_WAIT2 tcp  0  0  localhost:12345  localhost:46636  CLOSE_WAIT

我們還可以在客戶端上再鍵入一行文本,str_cli調用write,客戶TCP接着把數據發送給服務器。TCP允許這么做,因為客戶接受到FIN只是表明服務器進程關閉了自己那邊的連接,不再向客戶發送數據了而已,但仍然可以接收數據。FIN的接收並沒有告知客戶TCP服務器進程已經終止(事實上是終止了)。

當服務器收到客戶發來的數據時,因為連接已經終止了,就發送給客戶一個RST。然而客戶進程看不到這個RST,因為它在調用write之后立即調用read,並且由於之前收到的FIN,read立即返回0(表示EOF)。客戶端進程並未預期收到EOF,於是調用出錯信息“server terminated prematurely”(服務器過早終止)退出。

#echo_client localhost
hello
hello
 
another line
str_cli: server terminated prematurely

 本例子的問題在於:當FIN到達套接字時,客戶正阻塞在fgets調用上。客戶實際上在應對兩個描述符——套接字和用戶輸入,而它不能單純的阻塞在兩者之一上,而是應該阻塞在任何一個源的輸入上。因此,今后采用select和poll兩個函數解決這個問題。

10 SIGPIPE信號

接着上一個問題,如果客戶不理會read返回的錯誤,反而寫入更多的數據到服務器上,那會發生什么呢?這種情況是可能發生的。例如,客戶在讀回任何數據之前向服務器執行了兩次寫操作,而RST是由第一次寫操作引發的。

因此,適用於這種情況的規則是:當一個進程向一個已經收到RST的套接字執行寫操作時,內核將向該進程發送一個SIGPIPE信號,該信號的默認行為是終止該進程,因此進程應該捕獲它防止被意外終止。無論是捕獲還是忽略該信號,寫操作都會返回EPIPE錯誤。

11 服務器意外情況

(1)服務器崩潰

此時客戶TCP持續重傳數據分節,試圖從服務器接收一個ACK。當經過一段相當長的時間之后,TCP放棄,給客戶進程返回一個錯誤。如果服務器崩潰對客戶的數據分節沒有相應,則返回的錯誤是ETIMEOUT,如果某個中間路由器發現主機不可達,則響應一個destination unreachable的ICMP消息,所返回的錯誤是EHOSTUNREACH或ENETUNREACH。

(2) 服務器崩潰后重啟

當服務器崩潰重啟時,他的TCP丟失了崩潰前的所有連接信息,因此服務器TCP對於所收到的分節都響應一個RST。當客戶收到該RST時,客戶正在阻塞於read調用,導致該調用返回ECONNRESET錯誤。

(3) 服務器主機關機

Unix系統關機時,init進程通常給所有進程發送SIGTERM信號(該信號可被捕獲),等待一段固定時間,然后給所有仍在運行的進程發送SIGKILL信號(該信號不能被捕獲)。當所有子進程被終止時,將關閉所有打開的套接字。因此這種情形和服務器關閉的情況一樣。

12 最后改進過的程序

echo_server.c

 

#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>
#include <sys/wait.h>
#include <string.h>

/*ECHO函數*/
void str_echo(int sockfd)
{
    ssize_t n;
    char buf[1000];
    
again:
    while((n=read(sockfd, buf, 1000)) > 0)
    {
        write(sockfd, buf, n);
    }
    if(n<0 && errno==EINTR)
        goto again;
    else if(n<0)
        perror("read error");
    
}

// 信號處理函數
 void sig_chld(int signo)
 {
    pid_t pid;
    int stat;
    while((pid = waitpid(-1, &stat, WNOHANG)>0))
        printf("child %d terminated\n", pid);
 }

int main(int argc, char **argv)
{
    
    int listenfd, connfd;
    pid_t childpid;
    socklen_t clilen;
    struct sockaddr_in server_addr, client_addr;
    
    listenfd=socket(AF_INET, SOCK_STREAM, 0);
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family=AF_INET;
    server_addr.sin_addr.s_addr=htonl(INADDR_ANY);
    server_addr.sin_port=htons(12345);

    if(bind(listenfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
        perror("bind error");    
    
    if(listen(listenfd, 10) < 0)
        perror("listen error");
    
    /*處理SIGCHLD信號*/
    signal(SIGCHLD, sig_chld);
    
    while(1)
    {
        clilen=sizeof(client_addr);
        
        /*處理被中斷的accept調用*/
        if((connfd=accept(listenfd, (struct sockaddr*)&client_addr, &clilen)) < 0)
        {
            if(errno == EINTR)
                continue;
            else
                perror("accept error");
        }
            
        if((childpid=fork())==0) //child process
        {
            close(listenfd);    //close listening socket
            str_echo(connfd);
            exit(0);
        }
        close(connfd);
    }
    return 0;
}    

 

echo_client.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void str_client(FILE *fp, int sockfd)
{
    char send[1000],recv[1000];
    
    /*從標准輸入讀取文本*/
    while(fgets(send, 1000, fp)!=NULL)
    {
        /*發送文本到服務器*/
        write(sockfd, send, strlen(send));
        
        /*接收從服務器返回*/
        if(read(sockfd, recv, 1000)==0)
            perror("server terminated");
        
        /*打印到標准輸出*/
        fputs(recv, stdout);
    }    
}

int main(int argc, char **argv)
{
    if(argc<2)    //檢查輸入參數
        perror("usage:echo_client <server addr>");
    
    int sockfd;    //網絡套接字
    struct sockaddr_in server_addr;    //服務器地址
    
    /*socket函數*/
    sockfd=socket(AF_INET, SOCK_STREAM, 0);
    
    /*配置服務器地址*/
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family=AF_INET;
    server_addr.sin_port=htons(12345);
    if((inet_pton(AF_INET, argv[1], &server_addr.sin_addr)) < 0)
        perror("invaild IP address");
    
    /*connect函數*/
    if(connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
        perror("can't connect to server");
    
    /*ECHO處理函數*/
    str_client(stdin, sockfd);
    
    return 0;
}

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2026 CODEPRJ.COM