TCP客戶/伺服器程式示例
系列文章導航:《Unix 網路程式設計》筆記
目標
ECHO-Application 結構如下:
除此之外,還有:
- 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),之後會發生一系列事情:
(上述如套接字的操作其實是在核心完成的,這裡為了簡便所以標在了對應的執行緒上)
如下,可以看到客戶端的 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 個僵死程式,如果是在不同的機器上執行的,則更為不確定。
用 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
,如果被路由器判定不可達,則返回EHOSTUNREACH
或ENETUNREACH
改進
- 對於上述第一種問題,可以採用後文的
SO-KEEPALIVE
套接字選項 - 第二個人問題可以對
readline
設定一個超時
如果伺服器重啟
- 儘管重啟了,但是 TCP 套接字的資訊都丟失了
- 只能對發過來的請求說:我認識你嗎(RST)
- 客戶端 readline 接收到 RST 後,返回
ECONNRESET
錯誤
伺服器主機關機
Unix 系統關機時,會“先禮後兵”:
- 先傳送
SIGTERM
訊號給所有程式,在一段時間後再傳送SIGKILL
訊號 - 接收到
SIGTERM
程式一般會進行一些善後操作,如果程式不捕獲這個訊號,那他的預設行為就是終止程式 SIGKILL
會讓所有程式終止,自然也會釋放套接字等資訊
資料格式
由於如下的問題:
- 不同的實現以不同的方式儲存二進位制,如大小端位元組序
- 不同的實現在儲存相同的 C 資料型別上的差異
- 不同的實現給結構打包的方式存在差異
所以通過套接字傳輸二進位制資料是不明智的。
解決方法有:
- 把所有的數值資料作為文字串來傳遞
- 顯示定義所支援資料型別的二進位制格式,並傳輸此格式的資料,如 RPC 通常包括這種技術