1. 程式人生 > 其它 >網路程式設計筆記(二)-TCP客戶/伺服器示例

網路程式設計筆記(二)-TCP客戶/伺服器示例

網路程式設計筆記(二)-TCP客戶/伺服器示例

參考《UNIX網路程式設計》第 5 章,《TCP/IP 網路程式設計》 第 10 章。

回射(echo)客戶/伺服器原理概述

併發伺服器端實現模型和方法:

  1. 多程序伺服器:通過建立多個程序提供服務。
  2. 多路複用伺服器:通過捆綁並統一管理 I/O 物件提供服務(select 和 epoll)。
  3. 多執行緒伺服器:通過生成與客戶端等量的執行緒提供服務。

這裡學習第一種——多程序伺服器。

需要用到的 linux 命令:

  1. ps au :檢視程序 ID 和狀態。
  2. ./可執行檔案 &:後臺執行某個程序。

原始併發伺服器的實現:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/socket.h>

#define BUF_SIZE 100
#define LISTENQ 5
void error_handling(char *message);
void str_echo(int sockfd);

int main(int argc, char **argv)
{
	int listenfd, connfd;
	pid_t childpid;
	socklen_t clilen;
	struct sockaddr_in cliaddr, servaddr;

	if (argc != 2)
	{
		printf("Usage : %s <port>\n", argv[0]);
		exit(1);
	}
    
	listenfd = socket(AF_INET, SOCK_STREAM, 0);
	if (listenfd == -1)
	{
		error_handling("socket error");
	}

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
	servaddr.sin_port = htons(atoi(argv[1]));

	if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1)
		error_handling("bind() error");

	if (listen(listenfd, LISTENQ) == -1)
		error_handling("listen() error");

	for (;;)
	{
		clilen = sizeof(cliaddr);
		connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
		if (connfd == -1)
			continue;
		else
			printf("new client %d... \n", connfd);

		if ((childpid = fork()) == 0)
		{					  /* child process */
			close(listenfd);  /* close listening socket */
			str_echo(connfd); /* process the request */
			// close(connfd);
			printf("%d client disconnected...", connfd);
			exit(0);
		}
		else if (childpid == -1)
		{
			close(connfd);
			puts("fail to fork");
	    	continue;
		}

		close(connfd); /* parent closes connected socket */
	}
}

void str_echo(int sockfd)
{
	ssize_t n;
	char buf[BUF_SIZE];

	while ((n = read(sockfd, buf, BUF_SIZE)) > 0)
		write(sockfd, buf, n);
}

void error_handling(char *message){  
  
    fputs(message,stderr);  
    fputs("\n",stderr);  
    exit(1);  
}

客戶端:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/socket.h>

#define BUF_SIZE 100
void error_handling(char *sendline);
void str_cli(FILE *fp, int sockfd);

int main(int argc, char **argv)
{
    int sockfd;
    struct sockaddr_in serv_addr;

    if (argc != 3)
    {
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1)
    {
        error_handling("socket() error");
    }

    bzero(&serv_addr, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = inet_addr(argv[1]);
    serv_addr.sin_port = htons(atoi(argv[2]));

    if (connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1)
        error_handling("connect() error\r\n");
    else
        printf("Connected....\n");

    str_cli(stdin, sockfd); /* do it all */

    close(sockfd);
    exit(0);
}

void str_cli(FILE *fp, int sockfd)
{
    int str_len = 0;
    while (1)
    {
        char sendline[BUF_SIZE], recvline[BUF_SIZE];
        printf("Input sendline(Q to quit):\n");
        fgets(sendline, BUF_SIZE, stdin);
        if (!strcmp(sendline, "q\n") || !strcmp(sendline, "Q\n"))
            break;

        write(sockfd, sendline, strlen(sendline));
        str_len = read(sockfd, sendline, BUF_SIZE - 1);
        sendline[str_len] = 0;

        printf("sendline from server : %s \n", sendline);
    }
}

void error_handling(char *sendline)
{
    fputs(sendline, stderr);
    fputs("\n", stderr);
    exit(1);
}

POSIX 訊號處理

訊號的定義

訊號(signal)就是告知某個程序發生了某個事件的通知;訊號通常是非同步發生的,也就是說接受訊號的程序不知道訊號的準確發生時刻。

訊號可以:

  1. 一個程序發給另一個程序;
  2. 核心發給某個程序。

訊號的處置

每個訊號都有一個與之關聯的處置,即收到特定訊號時的處理方法;可以通過呼叫 sigaction 函式來設定一個訊號的處置。

處置方法有三種選擇:

  1. 提供一個函式,只要有特定訊號發生它就被呼叫。這樣的函式稱為訊號處理函式(signal handler),這種行為稱為捕獲(catching)訊號。有兩個訊號 SIGKILL 和 SIGSTOP 不能被捕獲。訊號處理函式由訊號值這個單一的整數引數

    來呼叫,且沒有返回值,其函式原型如下:

    void handler(int signo);
    
  2. 可以把某個訊號的處置方法設定為 SIG_IGN 來忽略(ignore)它。SIDKILL 和 SIDSTOP 這兩個訊號不能被忽略;

  3. 可以把某個訊號的處置方法設定為 SIG_DEF 來啟用它的預設(default)處置,預設初值通常是收到訊號後終止程序。另有個別訊號的預設處置為忽略,如 SIGCHLD 和 SIGURG。

第一種處置方法

建立訊號處置的 POSIX 方法就是呼叫 sigaction 函式,但比較複雜(簡單方法是呼叫自帶的 signal 函式)。POSIX 明確規定了呼叫 sigaction 時的語義定義。解決方法是定義自己 signal——只是呼叫 sigaction 函式,以所期望的 POSIX 語義提供一個簡單的介面。

UNIX 系統自帶的 signal 函式,歷史悠久,不太穩定,也叫訊號註冊函式

#include <signal.h>  

// 功能:返回之前註冊的函式指標。
// 引數:int signo,void (*func)(int)
// 返回型別:引數為int型,返回為void型函式指標
void (*signal(int signo, void (*func)(int)))(int);  

一些常見的訊號值:

  • SIGALARM:已到通過 alarm 函式註冊的時間
  • SIGINT:輸入 CTRL + C
  • SIGCHILID:子程序終止

利用 sigaction 函式進行訊號處理,可以代替 signal,也更加穩定(POSIX 明確規定了呼叫 sigaction 時的訊號語義)。signal 函式在 UNIX 的不同系列作業系統中可能存在區別,但是 sigaction 完全相同

#include <signal.h>  

/*
引數:
	signo:傳遞的訊號  
	act:對應於第一個引數的訊號處理函式  
	oldact:獲取之前註冊的訊號處理函式指標,若不需要則傳遞0
返回值:成功返回0,失敗返回-1
*/  
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);  

宣告並初始化結構體以呼叫上述函式:

struct sigaction {  
	void (* sa_handler)(int);		// 儲存訊號處理函式的指標
	sigset_t sa_mask;  				// 可初始化為0
	int sa_flags;  					// 可初始化為0
};

第二種處置方法

把某個訊號的處置方法設定為 SIG_IGN 來忽略(ignore)它。SIDKILL 和 SIDSTOP 這兩個訊號不能被忽略。

第三種處置方法

可以把某個訊號的處置方法設定為 SIG_DEF 來啟用它的預設(default)處置,預設處置通常是收到訊號後終止程序。有個別訊號的預設處置為忽略,如 SIGCHLD 和 SIGURG。

處理 SIGCHLD 訊號(僵死程序)

僵死程序的概念

程序 ID:建立時程序都會從作業系統獲得程序 ID,其值為大於 2 的整數(1 為分配給作業系統啟動後的首個程序)。

通過 fork 函式建立程序:複製正在執行的、呼叫 fork 函式的程序,父子程序擁有完全獨立的記憶體結構。兩個程序都執行 fork 函式以後的語句,共享同一程式碼

初始伺服器的程式碼存在僵死程序問題。

僵死程序:目的是為了維護子程序的資訊(程序ID,終止狀態,資源利用資訊),以便父程序在以後某個時候存取。如果父程序未主動要求獲得子程序的結束狀態值,作業系統將讓子程序長時間處於僵死狀態。僵死程序佔用記憶體中的空間,最終可能導致耗盡核心資源。

啟動初始伺服器:

啟動初始客戶端並連線伺服器,可以看到,斷開連線後出現僵死程序(Z):

銷燬僵死程序的方法

wait 函式

利用 wait 函式銷燬僵死程序的原理:父程序主動請求獲取子程序的返回值。

#include <sys/wait.h>  

// 返回值:成功時返回終止的子程序ID,失敗返回-1 
pid_t wait(int * statloc);

wait 和 waitpid 均返回兩個值:已終止子程序的程序 ID 號,以及通過 statloc 指標返回的子程序終止狀態(一個整數)。子程序終止狀態需要通過下列巨集分離:

  • WIFEXITED:子程序正常終止時返回 TRUE。

  • WEXITSTATUS:返回子程序的返回值。

wait(&status);
if (WIFEXITED(status)){  
	printf("Child pass num : %d", WEXITSTATUS(status));  
}

waitpid 函式

wait 的侷限性:呼叫 wait 函式時,如果沒有已終止的子程序,那麼程式將阻塞(Blocking)直到有子程序終止。wait 函式不能處理客戶端與伺服器同時建立多個連線的情況(《UNIX 網路程式設計》P109-111)

wait 函式會引起程式阻塞,但 waitpid 函式不會阻塞,而且可以指定等待的目標子程序,options 指定為 WNOHANG 時沒有終止子程序也不會阻塞。

#include <sys/wait.h>  

/*
引數:
	pid:等待終止的目標子程序ID,若傳遞-1,則與wait函式相同,等待任意子程序  
	statloc:與wait函式的statloc引數一致
	options:傳遞標頭檔案sys/wait.h中宣告的常量 WNOHANG,即使沒有終止子程序也不會阻塞,而是返回0並退出函式
*/
// 返回值:成功時返回終止子程序ID,失敗返回-1 
pid_t waitpid(pid_t pid, int * statloc, int options);

// Example:
while (!waitpid(-1, &status, WNOHANG)){  
	sleep(1);  
	puts("sleep 1sec.");  
}  
if (WIFEXITED(status)){  
	printf("child send %d \n",WEXITSTATUS(status));
}

使用 signal 消除僵死程序

  1. 在伺服器程式中呼叫 listen 之後新增訊號註冊函式 signal:

    signal(SIGCHLD, sig_chld);
    
  2. 編寫訊號處理函式 sig_chld:

    // 版本1:使用 wait
    void sig_chld(int signo){
        pid_t pid;
        int   stat;
        pid = wait(&stat);
        printf("child %d terminated\n",pid);
        return;
    }
    
    // 版本2,使用 waitpid
    void sig_chld(int signo){
        pid_t pid;
        int   stat;
        while( (pid = waitpid(-1, &stat, WHOHANG)) > 0 )
        	printf("child %d terminated\n",pid);
        return;
    }
    

可以看到,沒有僵死程序:

使用 sigaction 消除僵死程序

類似 signal,在伺服器程式中呼叫 listen 之後新增以下程式碼,使用同樣的訊號處理函式 sig_chlid。

// 處理僵死程序
	struct sigaction act;
	int state;
	act.sa_handler = sig_chld;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    state = sigaction(SIGCHLD, &act, 0);

使用 waitpid 而不是 wait 的原因——UNIX 訊號不排隊

一個客戶與併發伺服器建立 5 個連線時,建立一個訊號處理函式並在其中呼叫的 wait 不足以防止出現僵死程序(只能終止一個程序)。原因:所有 5 個訊號都在訊號處理函式執行之前產生,而訊號處理函式只執行了一次,因為 Unix 訊號一般是不排隊的。正確的解決方法是呼叫 waitpid 而不是 wait:在一個迴圈內呼叫 waitpid,以獲取所有已終止子程序的狀態。WHOHANG 告知 waitpid 沒有已終止子程序時也不要阻塞。

void sig_chld(int signo){
    pid_t pid;
    int   stat;
    while( (pid = waitpid(-1, &stat, WHOHANG)) > 0 )
    	printf("child %d terminated\n",pid);
    return;
}

小結

  1. 當 fork 子程序時,必須捕獲 SIGCHLD 訊號。
  2. 當捕獲訊號時,必須處理被中斷的系統呼叫。(P107)
  3. SIGCHLD 的訊號處理函式必須正確編寫,應使用 waitpid 函式以免留下僵死程序。

伺服器程序終止

複習 TCP 四次握手關閉連線的過程:

模擬伺服器程序崩潰時,客戶端會發生什麼:

  1. 找到伺服器子程序的程序 ID,並執行 kill 命令殺死它。此時被殺死的伺服器子程序的所有開啟著的描述符都將關閉。這就導致伺服器向客戶端傳送一個 FIN,而客戶端會向伺服器響應一個 ACK。是四次握手關閉連線的前半部分。

  2. SIGCHLD 訊號被髮送給伺服器父程序,僵死子程序得到正確處理。然而問題是客戶程序此時阻塞在 fgets 函式上,等待從終端接收一行文字。

  3. 此時在另一個視窗執行 netstat 命令,可以看到 TCP 連線終止序列的前半部分已經完成。

    伺服器終端:

    [qhn@Tommy tcpcliserv]$ ps au
    USER        PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
    root       1910  0.1  0.3 341764  7732 tty1     Ssl+ 19:26   0:04 /usr/bin/X :0 -background none -noreset -audit 4 -verbose -auth /run
    qhn        3840  0.0  0.1 117096  2772 pts/0    Ss   19:44   0:00 /usr/bin/bash
    qhn        6772  0.0  0.0   6388   544 pts/0    S    20:30   0:00 ./tcpserv04
    qhn        7240  0.0  0.1 116968  3184 pts/1    Ss   20:32   0:00 /usr/bin/bash
    qhn        8104  0.0  0.0   6396   396 pts/1    S+   20:39   0:00 ./tcpcli01 127.0.0.1
    qhn        8105  0.0  0.0   6388   104 pts/0    S    20:39   0:00 ./tcpserv04
    qhn        8159  0.0  0.0 155448  1872 pts/0    R+   20:39   0:00 ps au
    [qhn@Tommy tcpcliserv]$ kill 8105
    [qhn@Tommy tcpcliserv]$ child 8105 terminated
    
    [qhn@Tommy tcpcliserv]$ netstat -a | grep 9877
    tcp        0      0 0.0.0.0:9877            0.0.0.0:*               LISTEN     
    tcp        0      0 localhost:9877          localhost:51616         FIN_WAIT2  
    tcp        1      0 localhost:51616         localhost:9877          CLOSE_WAIT 
    
  4. 此時,在客戶端鍵入一行文字 "another line",str_cli 呼叫 written,客戶 TCP 將資料傳送給伺服器(FIN 的接收並沒有告知客戶 TCP 伺服器程序已經終止,但實際上在本例中伺服器程序已經被殺死了)。由於伺服器先前開啟的連線套接字已經終止,於是響應以一個 RST。

  5. 客戶程序之前阻塞在 fgets 上,看不到這個 RST。客戶傳送 "another line" 後立即呼叫 readline,直接收到終止符 EOF(因為之前客戶端收到了 FIN),這是客戶未預期的,所以客戶端會提示以出錯資訊 "server terminated prematurely"(伺服器過早終止)退出。

    客戶端終端:

    [qhn@Tommy tcpcliserv]$ ./tcpcli01 127.0.0.1
    hello
    hello
    hi
    hi
    another line
    str_cli: server terminated prematurely
    [qhn@Tommy tcpcliserv]$ 
    

    本例的問題在於:當 FIN 到達客戶套接字時,客戶正阻塞在 fgets 呼叫上,不能夠及時處理。客戶端實際上在應對兩個描述符——套接字和使用者輸入。它不能單純阻塞在這兩個源中某個特定源的輸入上,而是應該同時阻塞在這兩個源的輸入上。這正是 selectpoll 這兩個函式的目的之一。

伺服器主機崩潰

在不同的主機上執行伺服器和客戶端,先啟動伺服器,再啟動客戶端,確定它們正常啟動後,從網路上斷開伺服器主機,並在客戶鍵入一行文字。

  1. 當伺服器主機崩潰後(不是由操作員執行命令關機),已有的網路連線上不再發出任何東西。
  2. 此時客戶鍵入一行文字,文字由 writen 寫入核心,再由客戶 TCP 作為一個數據分節發出。然後客戶阻塞在 readline 呼叫,等待伺服器回射應答。
  3. 此時用 tcpdump 就會發現,客戶 TCP 持續重傳資料分節,試圖從伺服器上接收一個 ACK。
  4. 既然客戶阻塞在 readline 呼叫上,該呼叫會返回一個錯誤:
    • 假設伺服器已經崩潰,對客戶的資料分節根本沒有響應,返回錯誤 ETIMEDOUT
    • 如果某個中間路由器判定伺服器已不可達,則該路由器會響應一個 “destination unreachable” (目的地不可達)ICMP 訊息,返回錯誤為 **EHOSTUNREACH **或 ENETUNREACH

本例的問題在於:想要知道伺服器主機是否崩潰,只能通過客戶向伺服器主機發送資料來檢驗。如果想不傳送資料就檢測出伺服器主機是否崩潰,需要使用 SO_KEEPALIVE 套接字選項。

伺服器主機崩潰並重啟

伺服器主機崩潰並重啟時,在客戶上鍵入一行文字。重啟後,伺服器 TCP 丟失了崩潰前所有連線資訊,因此 TCP 對客戶響應一個 RST(重置連線)。當客戶 TCP 收到該 RST 時,客戶正阻塞於 readline 呼叫,導致該呼叫返回 ECONNRESET 錯誤。

伺服器主機關機

伺服器主機被操作員關機將會發生什麼:Unix 系統關機時,init 程序會給所有程序傳送一個 SIGTERM 訊號(該訊號可被捕獲),等待一段固定時間(5~12s),然後給所有仍在執行的程式傳送給一個 SIGKILL(該訊號不能被捕獲)。這麼做的目的是,留出一小段時間給所有執行的程序來清除與終止。

如果不捕獲 SIGTERM 訊號並終止,伺服器將由 SIGKILL 訊號終止。當伺服器子程序終止時,它的所有開啟著的描述符都被關閉,這樣又回到了伺服器程序終止的問題。

資料格式

在客戶與伺服器之間傳遞二進位制值時,如果位元組序不一樣或所支援的長整數的大小不一致,將會出錯。

3 個問題:

  1. 不同的實現以不同的格式儲存二進位制數——大端位元組序與小端位元組序。
  2. 不同的實現在儲存相同的 C 資料型別上可能存在差異——大多數 32 位 Unix 系統使用 32 位表示長整數,而 64 位系統一般使用 64 位表示長整數。
  3. 不同的實現給結構打包的方式存在差異。因此,穿越套接字傳送二進位制結構絕不是明智的。

解決方法:

  1. 把所有的數值資料作為文字串來傳遞
  2. 顯式定義所支援資料型別的二進位制格式(位數、大端或小端位元組序),並以這樣的格式在客戶與伺服器之間傳遞所有資料。遠端過程呼叫(RPC)通常使用這種技術。

總結

從簡單的 echo 伺服器開始,解決了以下問題:

  • 處理僵死子程序——採用訊號處理(signal,sigaction)。
  • 伺服器程序終止時,客戶程序收到 FIN 但並不知道終止——使用 select、poll。
  • 伺服器主機崩潰時,必須通過客戶向伺服器傳送資料才能檢驗—— SO_KEEPALIVE 套接字選項。
  • 穿越套接字傳送二進位制結構絕不是明智的——把所有的數值資料作為文字串來傳遞。

參考資料

https://www.cnblogs.com/soldierback/p/10690783.html

https://blog.csdn.net/zzxiaozhao/article/details/102662861

https://wuhlan3.gitee.io/wuhlan3/2021/08/03/UNIX網路程式設計(五)