《Unix 網路程式設計》05:TCP C/S 程式範例

2022-05-28 12:03:46

TCP客戶/伺服器程式範例

系列文章導航:《Unix 網路程式設計》筆記

目標

ECHO-Application 結構如下:

graph LR; A[標準輸入/輸出] --fgets--> B[TCP-Client] --writen/read--> C[TCP-Server] C --readline/writen--> B --fputs--> A

除此之外,還有:

  • Client 和 Server 啟動時發生什麼
  • Client 正常終止時發生什麼
  • Server 先意外終止會發生什麼

程式程式碼

伺服器端

#include "unp.h"

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

    // 建立 Socket
    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 "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");
}

使用者端

#include	"unp.h"

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

	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);
}
#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);
	}
}

正常情況

當我們把伺服器和使用者端都啟動後,可以通過命令檢視網路的情況:

[root@centos-5610 Unix_Network]# netstat -a | grep 9877
tcp        0      0 0.0.0.0:9877            0.0.0.0:*               LISTEN     
tcp        0      0 localhost:9877          localhost:38160         ESTABLISHED
tcp        0      0 localhost:38160         localhost:9877          ESTABLISHED
  • 第一個是伺服器的父程序,狀態為 LISTEN,監聽範圍和可接受範圍如上所示
  • 第二個是使用者端程序
  • 第三個是伺服器的子程序,為使用者端提供具體的服務

連線的正常斷開

我們在使用者端輸入 EOF (Ctrl + D),之後會發生一系列事情:

sequenceDiagram autonumber participant cs as cli_str participant cm as cli_main participant sm as serv_main_child participant ss as serv_str cs ->> cm: fgets獲得EOF,函數返回 cm ->> cm: 執行完畢, 呼叫 exit 結束 cm ->> ss: 關閉 cli 開啟的所有描述符,並行送 FIN 給客戶 note over cm: FIN_WAIT_1 ss ->> sm: readline 接受到 FIN,返回0,函數返回 sm ->> sm: 執行完畢,呼叫 exit 結束子程序 note over sm: CLOSE_WAIT sm ->> cm: 關閉所有開啟的描述符,傳送ACK note over sm: LAST_ACK note over cm: FIN_WAIT_2 sm ->> cm: FIN note over cm: TIME_WAIT cm ->> sm: ACK note over sm: CLOSED

(上述如通訊端的操作其實是在核心完成的,這裡為了簡便所以標在了對應的執行緒上)

如下,可以看到使用者端的 TIME_WAIT 狀態持續了一段時間

[root@centos-5610 Unix_Network]# netstat -a | grep 9877
tcp        0      0 0.0.0.0:9877            0.0.0.0:*               LISTEN     
tcp        0      0 localhost:38160         localhost:9877          TIME_WAIT  
[root@centos-5610 Unix_Network]# netstat -a | grep 9877
tcp        0      0 0.0.0.0:9877            0.0.0.0:*               LISTEN   

POSIX訊號處理

僵死程序

背景

在上述程式中,其實子程序結束後,會向父程序傳送一個 SIGCHLD 訊號,我們這裡沒有捕捉,預設行為為被忽略。

既然父程序未加處理,子程序於是進入僵死狀態,如下狀態 Z 所示:

[root@centos-5610 Unix_Network]# ps -t pts/0 -o pid,ppid,stat,tty,args,wchan
  PID  PPID STAT TT       COMMAND                     WCHAN
 2008  1771 S    pts/0    ./tcpserv01                 inet_csk_accept
 2382  2008 Z    pts/0    [tcpserv01] <defunct>       do_exit

或如下所示:

[root@centos-5610 tcpcliserv]# ps
  PID TTY          TIME CMD
 1771 pts/0    00:00:00 bash
 2008 pts/0    00:00:00 tcpserv01
 2382 pts/0    00:00:00 tcpserv01 <defunct>
 2555 pts/0    00:00:00 tcpserv01 <defunct>
 2654 pts/0    00:00:00 tcpserv01 <defunct>
 2886 pts/0    00:00:00 tcpserv01 <defunct>
 3238 pts/0    00:00:00 tcpserv01 <defunct>
 6685 pts/0    00:00:00 ps

為什麼會有僵死程序

設定僵死的目的是維護子程序的資訊,以便父程序在以後某個時候獲取這些資訊(包括程序 ID、終止狀態、資源利用情況)

父程序終止了,還有人管這些僵死程序嗎

如果父程序也終止了,而其有處於僵死狀態的子程序,那麼子程序的父程序會被設定為 1(init 程序的 ID),init 程序會清理他們(wait,後續講解)

僵死程序的壞處

他們佔用核心的空間,最終可能導致我們耗盡處理資源,所以我們必須處理僵死程序。

訊號基礎

訊號就是告知某個程序發生了某個事件的通知,有時也稱為軟體中斷。

訊號的來源

  • 一個程序傳送給另一個程序(或自身)
  • 由核心發給某個程序

訊號的處理

通過呼叫 sigaction 函數設定一個訊號的處理,並有三種選擇:

  • 設定一個訊號處理常式。SIGKILL 和 SIGSTOP 不能被捕獲
  • 設定為 SIG_IGN 來忽略它。同樣,上述兩個訊號不能被忽略
  • 設定為 SIG_DFL 來啟用他的預設處置。預設處置通常是終止程序

signal

sigaction 函數太過於複雜,所以一般我們會呼叫 signal 函數。

但是 signal 函數由於歷史和標準的原因在不同的系統上實現不一致,所以我們實現自己的 signal 方法。其簽名如下:

void (*signal(int signo, void (*func)(int)))(int);

我們會做一些處理,簡化其表示:

typedef void Sigfunc(int);

Sigfunc *signal(int signo, Sigfunc * func);

signal 函數如下:

#include "unp.h"

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);
}

處理 SIGCHLD 訊號

建立一個俘獲 SIGCHLD 訊號的訊號處理常式,在函數體中呼叫 wait(後面會提到):

void sig_chld(int signo)
{
	pid_t	pid;
	int		stat;

	pid = wait(&stat);
	printf("child %d terminated\n", pid);
	return;
}

在 Listen 方法後呼叫:(必須在 fork 前呼叫,且只能執行一次)

Listen(listenfd, LISTENQ);

Signal(SIGCHLD, sig_chld);

此時就不會再出現僵死程序了。

被中斷的系統呼叫

慢系統呼叫

如 accept 等函數,如果沒有使用者連線,將一直阻塞下去,把這樣的系統呼叫稱為慢系統呼叫。

滿系統呼叫的中斷

如前一節我們處理 SIGCHLD 訊號時,當系統阻塞於一個慢系統呼叫時,而該程序又捕獲了一個訊號,且相應的訊號處理常式返回時,該系統呼叫可能會返回一個 IENTER 錯誤。

有些系統可能會自動重啟某些被中斷的系統呼叫,但是出於對程式的可移植性考慮,我們應該對此有所準備。

for (;;) {
  clilen = sizeof(cliaddr);
  if ((connfd = accept(listenfd, (SA *)&cliaddr, &clilen)) < 0) {
    if (errno == EINTER) {
      continue;
    }
    else {
      err_sys("XXX");
    }
  }
}

這種方式對 accept 以及諸如 read、write、select、open 之類的函數來說都是合適的,但是如前面所說,connect 函數不能重啟。

wait 和 waitpid

#include <sys/wait.h>

pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);

相同之處

均返回已終止子程序的 ID,以及通過 statloc 指標返回的子程序終止狀態

一些宏 WIFEXIST、WEXITSTATUS 可以用來檢視其資訊

不同之處

如果呼叫 wait 的程序沒有已終止的子程序,則阻塞至第一個現有子程序終止為止

而 waitpid 可以通過 pid 和 options 引數來進行更多的控制

wait 的問題

如果我們用多臺使用者端傳送請求,然後同時終止,如下:

多個 SIGCHLD 訊號會到達,但是 wait 只會被執行一次,導致會留下 4 個僵死程序,如果是在不同的機器上執行的,則更為不確定。

具體原因可以參考 問題:Linux 訊號處理,當連續給一個程序同時傳送多個訊號時,部分訊號丟失而未得到處理

用 waitpid 可以解決這個問題:

#include	"unp.h"

void sig_chld(int signo) {
	pid_t	pid;
	int		stat;

	while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0)
		printf("child %d terminated\n", pid);
	return;
}
  • WNOHANG 表示如果沒有終止的子程序就阻塞(因此我們可以用 while 迴圈)

異常情況

accept 返回前連線中止

連線剛剛建立,使用者端就傳送一個 RST。

什麼樣的場景下會發生這種事情?我在網上簡單檢索了一下,但是沒有找到典型的發生場景。

書上給出的例子是 Web 伺服器比較繁忙

如何處理這種情況取決於具體的實現。

伺服器程序終止

這裡指的是伺服器的子程序,也就是提供具體服務的那個程序。

我們先把 server/client 啟動,然後把子程序關閉掉,觀察現象:

[root@centos-5610 tcpcliserv]# ./tcpcli01 127.0.0.1
>>1
str_cli: server terminated prematurely
  • 如果我們什麼也不做,那麼使用者端會一直被 fgets 阻塞,它對外界發生的事情一無所知
  • 如果我們傳送什麼新的資訊,那麼會出現一個報錯資訊

過程解釋

  • 伺服器的相關 socket 關閉後,會傳送一個 FIN 個使用者端
  • 使用者端 socket 雖然接收到了,但是這隻表示伺服器程序關閉了連線的伺服器端,從而不在往其中傳送任何訊息了,但並沒有告知客戶 TCP 伺服器程序已經終止;所以使用者端還是可以傳送 writen 的
  • 當伺服器 TCP 接收到來自客戶的資料時,由於該 TCP 已經被關閉,所以會相應一個 RST
  • 使用者端在呼叫 write 後便進入 readline,於是接收到了 TCP 之前傳送到的 FIN (使用者端沒有接收到 RST),這將使 readline 返回 0,程式結束
  • 使用者端進行關閉資源的各項操作

本例的問題在於:

  • 使用者端同時應對了兩個描述符:通訊端和使用者輸入
  • 使用者端應該阻塞在其中任何一個源的輸入上,而不是單純地阻塞在這兩個源中某個特定源的輸入上

這正是後文 select 和 poll 這兩個函數的目的之一;後文經過修改,即可讓程式立刻對伺服器的 FIN 進行處理

SIGPIPE 訊號

  • 前文:接收到使用者端的 FIN 後,仍然可以 write 寫資料
  • 但是,如果接收到了伺服器的 RST,此時如果再寫資料,就會由核心向程序傳送一個 SIGPIPE 訊號;此訊號的預設行為是終止程序
  • 我們可以捕獲該訊號,不過無論是否捕獲,readline 還是會返回一個 EPIPE 錯誤

伺服器主機崩潰

例如伺服器宕機了,這種情況下伺服器來不及交代「遺言」就掛掉了。

發生的事情

  • 如果使用者端不傳送訊息,則會想上文提到的場景一樣,永遠等下去
  • 如果傳送訊息,則會由於接收不到伺服器的響應而不斷嘗試重新傳送,書中等待了 9 分鐘才放棄傳送,返回 ETIMEDOUT,如果被路由器判定不可達,則返回 EHOSTUNREACHENETUNREACH

改進

  • 對於上述第一種問題,可以採用後文的 SO-KEEPALIVE 通訊端選項
  • 第二個人問題可以對 readline 設定一個超時

如果伺服器重啟

  • 儘管重啟了,但是 TCP 通訊端的資訊都丟失了
  • 只能對發過來的請求說:我認識你嗎(RST)
  • 使用者端 readline 接收到 RST 後,返回 ECONNRESET 錯誤

伺服器主機關機

Unix 系統關機時,會「先禮後兵」:

  • 先傳送 SIGTERM 訊號給所有程序,在一段時間後再傳送 SIGKILL 訊號
  • 接收到 SIGTERM 程序一般會進行一些善後操作,如果程序不捕獲這個訊號,那他的預設行為就是終止程序
  • SIGKILL 會讓所有程序終止,自然也會釋放通訊端等資訊

資料格式

由於如下的問題:

  1. 不同的實現以不同的方式儲存二進位制,如大小端位元組序
  2. 不同的實現在儲存相同的 C 資料型別上的差異
  3. 不同的實現給結構打包的方式存在差異

所以通過通訊端傳輸二進位制資料是不明智的。

解決方法有:

  1. 把所有的數值資料作為文字串來傳遞
  2. 顯示定義所支援資料型別的二進位制格式,並傳輸此格式的資料,如 RPC 通常包括這種技術