網路程式設計筆記(二)-TCP客戶/伺服器示例
網路程式設計筆記(二)-TCP客戶/伺服器示例
參考《UNIX網路程式設計》第 5 章,《TCP/IP 網路程式設計》 第 10 章。
回射(echo)客戶/伺服器原理概述
併發伺服器端實現模型和方法:
- 多程序伺服器:通過建立多個程序提供服務。
- 多路複用伺服器:通過捆綁並統一管理 I/O 物件提供服務(select 和 epoll)。
- 多執行緒伺服器:通過生成與客戶端等量的執行緒提供服務。
這裡學習第一種——多程序伺服器。
需要用到的 linux 命令:
ps au
:檢視程序 ID 和狀態。./可執行檔案 &
:後臺執行某個程序。
原始併發伺服器的實現:
#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)就是告知某個程序發生了某個事件的通知;訊號通常是非同步發生的,也就是說接受訊號的程序不知道訊號的準確發生時刻。
訊號可以:
- 一個程序發給另一個程序;
- 核心發給某個程序。
訊號的處置
每個訊號都有一個與之關聯的處置,即收到特定訊號時的處理方法;可以通過呼叫 sigaction
函式來設定一個訊號的處置。
處置方法有三種選擇:
-
提供一個函式,只要有特定訊號發生它就被呼叫。這樣的函式稱為訊號處理函式(signal handler),這種行為稱為捕獲(catching)訊號。有兩個訊號 SIGKILL 和 SIGSTOP 不能被捕獲。訊號處理函式由訊號值這個單一的整數引數
void handler(int signo);
-
可以把某個訊號的處置方法設定為 SIG_IGN 來忽略(ignore)它。SIDKILL 和 SIDSTOP 這兩個訊號不能被忽略;
-
可以把某個訊號的處置方法設定為 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 消除僵死程序
-
在伺服器程式中呼叫 listen 之後新增訊號註冊函式 signal:
signal(SIGCHLD, sig_chld);
-
編寫訊號處理函式 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;
}
小結
- 當 fork 子程序時,必須捕獲 SIGCHLD 訊號。
- 當捕獲訊號時,必須處理被中斷的系統呼叫。(P107)
- SIGCHLD 的訊號處理函式必須正確編寫,應使用 waitpid 函式以免留下僵死程序。
伺服器程序終止
複習 TCP 四次握手關閉連線的過程:
模擬伺服器程序崩潰時,客戶端會發生什麼:
-
找到伺服器子程序的程序 ID,並執行 kill 命令殺死它。此時被殺死的伺服器子程序的所有開啟著的描述符都將關閉。這就導致伺服器向客戶端傳送一個 FIN,而客戶端會向伺服器響應一個 ACK。是四次握手關閉連線的前半部分。
-
SIGCHLD 訊號被髮送給伺服器父程序,僵死子程序得到正確處理。然而問題是客戶程序此時阻塞在 fgets 函式上,等待從終端接收一行文字。
-
此時在另一個視窗執行 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
-
此時,在客戶端鍵入一行文字 "another line",str_cli 呼叫 written,客戶 TCP 將資料傳送給伺服器(FIN 的接收並沒有告知客戶 TCP 伺服器程序已經終止,但實際上在本例中伺服器程序已經被殺死了)。由於伺服器先前開啟的連線套接字已經終止,於是響應以一個 RST。
-
客戶程序之前阻塞在 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 呼叫上,不能夠及時處理。客戶端實際上在應對兩個描述符——套接字和使用者輸入。它不能單純阻塞在這兩個源中某個特定源的輸入上,而是應該同時阻塞在這兩個源的輸入上。這正是 select 和 poll 這兩個函式的目的之一。
伺服器主機崩潰
在不同的主機上執行伺服器和客戶端,先啟動伺服器,再啟動客戶端,確定它們正常啟動後,從網路上斷開伺服器主機,並在客戶鍵入一行文字。
- 當伺服器主機崩潰後(不是由操作員執行命令關機),已有的網路連線上不再發出任何東西。
- 此時客戶鍵入一行文字,文字由 writen 寫入核心,再由客戶 TCP 作為一個數據分節發出。然後客戶阻塞在 readline 呼叫,等待伺服器回射應答。
- 此時用 tcpdump 就會發現,客戶 TCP 持續重傳資料分節,試圖從伺服器上接收一個 ACK。
- 既然客戶阻塞在 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 個問題:
- 不同的實現以不同的格式儲存二進位制數——大端位元組序與小端位元組序。
- 不同的實現在儲存相同的 C 資料型別上可能存在差異——大多數 32 位 Unix 系統使用 32 位表示長整數,而 64 位系統一般使用 64 位表示長整數。
- 不同的實現給結構打包的方式存在差異。因此,穿越套接字傳送二進位制結構絕不是明智的。
解決方法:
- 把所有的數值資料作為文字串來傳遞。
- 顯式定義所支援資料型別的二進位制格式(位數、大端或小端位元組序),並以這樣的格式在客戶與伺服器之間傳遞所有資料。遠端過程呼叫(RPC)通常使用這種技術。
總結
從簡單的 echo 伺服器開始,解決了以下問題:
- 處理僵死子程序——採用訊號處理(signal,sigaction)。
- 伺服器程序終止時,客戶程序收到 FIN 但並不知道終止——使用 select、poll。
- 伺服器主機崩潰時,必須通過客戶向伺服器傳送資料才能檢驗—— SO_KEEPALIVE 套接字選項。
- 穿越套接字傳送二進位制結構絕不是明智的——把所有的數值資料作為文字串來傳遞。
參考資料
https://www.cnblogs.com/soldierback/p/10690783.html