2. 基于TCP協議的網絡程序

下圖是基于TCP協議的客戶端/伺服器程序的一般流程:

圖 37.2. TCP協議通訊流程

TCP協議通訊流程

伺服器調用socket()、bind()、listen()完成初始化後,調用accept()阻塞等待,處于監聽連接埠的狀態,客戶端調用socket()初始化後,調用connect()發出SYN段並阻塞等待伺服器應答,伺服器應答一個SYN-ACK段,客戶端收到後從connect()返回,同時應答一個ACK段,伺服器收到後從accept()返回。

數據傳輸的過程:

建立連接後,TCP協議提供全雙工的通信服務,但是一般的客戶端/伺服器程序的流程是由客戶端主動發起請求,伺服器被動處理請求,一問一答的方式。因此,伺服器從accept()返回後立刻調用read(),讀socket就像讀管道一樣,如果沒有數據到達就阻塞等待,這時客戶端調用write()發送請求給伺服器,伺服器收到後從read()返回,對客戶端的請求進行處理,在此期間客戶端調用read()阻塞等待伺服器的應答,伺服器調用write()將處理結果發回給客戶端,再次調用read()阻塞等待下一條請求,客戶端收到後從read()返回,發送下一條請求,如此循環下去。

如果客戶端沒有更多的請求了,就調用close()關閉連接,就像寫端關閉的管道一樣,伺服器的read()返回0,這樣伺服器就知道客戶端關閉了連接,也調用close()關閉連接。注意,任何一方調用close()後,連接的兩個傳輸方向都關閉,不能再發送數據了。如果一方調用shutdown()則連接處于半關閉狀態,仍可接收對方發來的數據。

在學習socket API時要注意應用程序和TCP協議層是如何交互的: *應用程序調用某個socket函數時TCP協議層完成什麼動作,比如調用connect()會發出SYN段 *應用程序如何知道TCP協議層的狀態變化,比如從某個阻塞的socket函數返回就表明TCP協議收到了某些段,再比如read()返回0就表明收到了FIN段

2.1. 最簡單的TCP網絡程序

下面通過最簡單的客戶端/伺服器程序的實例來學習socket API。

server.c的作用是從客戶端讀字元,然後將每個字元轉換為大寫並回送給客戶端。

/* server.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define MAXLINE 80
#define SERV_PORT 8000

int main(void)
{
	struct sockaddr_in servaddr, cliaddr;
	socklen_t cliaddr_len;
	int listenfd, connfd;
	char buf[MAXLINE];
	char str[INET_ADDRSTRLEN];
	int i, n;

	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, (struct sockaddr *)&servaddr, sizeof(servaddr));

	listen(listenfd, 20);

	printf("Accepting connections ...\n");
	while (1) {
		cliaddr_len = sizeof(cliaddr);
		connfd = accept(listenfd, 
				(struct sockaddr *)&cliaddr, &cliaddr_len);
	  
		n = read(connfd, buf, MAXLINE);
		printf("received from %s at PORT %d\n",
		       inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
		       ntohs(cliaddr.sin_port));
    
		for (i = 0; i < n; i++)
			buf[i] = toupper(buf[i]);
		write(connfd, buf, n);
		close(connfd);
	}
}

下面介紹程序中用到的socket API,這些函數都在sys/socket.h中。

int socket(int family, int type, int protocol);

socket()打開一個網絡通訊連接埠,如果成功的話,就像open()一樣返回一個檔案描述符,應用程序可以像讀寫檔案一樣用read/write在網絡上收發數據,如果socket()調用出錯則返回-1。對於IPv4,family參數指定為AF_INET。對於TCP協議,type參數指定為SOCK_STREAM,表示面向流的傳輸協議。如果是UDP協議,則type參數指定為SOCK_DGRAM,表示面向數據報的傳輸協議。protocol參數的介紹從略,指定為0即可。

int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);

伺服器程序所監聽的網絡地址和連接埠號通常是固定不變的,客戶端程序得知伺服器程序的地址和連接埠號後就可以向伺服器發起連接,因此伺服器需要調用bind綁定一個固定的網絡地址和連接埠號。bind()成功返回0,失敗返回-1。

bind()的作用是將參數sockfd和myaddr綁定在一起,使sockfd這個用於網絡通訊的檔案描述符監聽myaddr所描述的地址和連接埠號。前面講過,struct sockaddr *是一個通用指針類型,myaddr參數實際上可以接受多種協議的sockaddr結構體,而它們的長度各不相同,所以需要第三個參數addrlen指定結構體的長度。我們的程序中對myaddr參數是這樣初始化的:

bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);

首先將整個結構體清零,然後設置地址類型為AF_INET,網絡地址為INADDR_ANY,這個宏表示本地的任意IP地址,因為伺服器可能有多個網卡,每個網卡也可能綁定多個IP地址,這樣設置可以在所有的IP地址上監聽,直到與某個客戶端建立了連接時才確定下來到底用哪個IP地址,連接埠號為SERV_PORT,我們定義為8000。

int listen(int sockfd, int backlog);

典型的伺服器程序可以同時服務于多個客戶端,當有客戶端發起連接時,伺服器調用的accept()返回並接受這個連接,如果有大量的客戶端發起連接而伺服器來不及處理,尚未accept的客戶端就處于連接等待狀態,listen()聲明sockfd處于監聽狀態,並且最多允許有backlog個客戶端處于連接待狀態,如果接收到更多的連接請求就忽略。listen()成功返回0,失敗返回-1。

int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);

三方握手完成後,伺服器調用accept()接受連接,如果伺服器調用accept()時還沒有客戶端的連接請求,就阻塞等待直到有客戶端連接上來。cliaddr是一個傳出參數,accept()返回時傳出客戶端的地址和連接埠號。addrlen參數是一個傳入傳出參數(value-result argument),傳入的是調用者提供的緩衝區cliaddr的長度以避免緩衝區溢出問題,傳出的是客戶端地址結構體的實際長度(有可能沒有占滿調用者提供的緩衝區)。如果給cliaddr參數傳NULL,表示不關心客戶端的地址。

我們的伺服器程序結構是這樣的:

while (1) {
	cliaddr_len = sizeof(cliaddr);
	connfd = accept(listenfd, 
			(struct sockaddr *)&cliaddr, &cliaddr_len);
	n = read(connfd, buf, MAXLINE);
	...
	close(connfd);
}

整個是一個while死循環,每次循環處理一個客戶端連接。由於cliaddr_len是傳入傳出參數,每次調用accept()之前應該重新賦初值。accept()的參數listenfd是先前的監聽檔案描述符,而accept()的返回值是另外一個檔案描述符connfd,之後與客戶端之間就通過這個connfd通訊,最後關閉connfd斷開連接,而不關閉listenfd,再次回到循環開頭listenfd仍然用作accept的參數。accept()成功返回一個檔案描述符,出錯返回-1。

client.c的作用是從命令行參數中獲得一個字元串發給伺服器,然後接收伺服器返回的字元串並打印。

/* client.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define MAXLINE 80
#define SERV_PORT 8000

int main(int argc, char *argv[])
{
	struct sockaddr_in servaddr;
	char buf[MAXLINE];
	int sockfd, n;
	char *str;
    
	if (argc != 2) {
		fputs("usage: ./client message\n", stderr);
		exit(1);
	}
	str = argv[1];
    
	sockfd = socket(AF_INET, SOCK_STREAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
	servaddr.sin_port = htons(SERV_PORT);
    
	connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

	write(sockfd, str, strlen(str));

	n = read(sockfd, buf, MAXLINE);
	printf("Response from server:\n");
	write(STDOUT_FILENO, buf, n);

	close(sockfd);
	return 0;
}

由於客戶端不需要固定的連接埠號,因此不必調用bind(),客戶端的連接埠號由內核自動分配。注意,客戶端不是不允許調用bind(),只是沒有必要調用bind()固定一個連接埠號,伺服器也不是必須調用bind(),但如果伺服器不調用bind(),內核會自動給伺服器分配監聽連接埠,每次啟動伺服器時連接埠號都不一樣,客戶端要連接伺服器就會遇到麻煩。

int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);

客戶端需要調用connect()連接伺服器,connect和bind的參數形式一致,區別在於bind的參數是自己的地址,而connect的參數是對方的地址。connect()成功返回0,出錯返回-1。

先編譯運行伺服器:

$ ./server
 Accepting connections ...

然後在另一個終端裡用netstat命令查看:

$ netstat -apn|grep 8000
 tcp        0      0 0.0.0.0:8000            0.0.0.0:*               LISTEN     8148/server

可以看到server程序監聽8000連接埠,IP地址還沒確定下來。現在編譯運行客戶端:

$ ./client abcd
Response from server:
ABCD

回到server所在的終端,看看server的輸出:

$ ./server
 Accepting connections ...
 received from 127.0.0.1 at PORT 59757

可見客戶端的連接埠號是自動分配的。現在把客戶端所連接的伺服器IP改為其它主機的IP,試試兩台主機的通訊。

再做一個小實驗,在客戶端的connect()代碼之後插一個while(1);死循環,使客戶端和伺服器都處于連接中的狀態,用netstat命令查看:

$ ./server &
[1] 8343
$ Accepting connections ...
./client abcd &
[2] 8344
$ netstat -apn|grep 8000
tcp        0      0 0.0.0.0:8000            0.0.0.0:*               LISTEN     8343/server         
tcp        0      0 127.0.0.1:44406         127.0.0.1:8000          ESTABLISHED8344/client         
tcp        0      0 127.0.0.1:8000          127.0.0.1:44406         ESTABLISHED8343/server

應用程序中的一個socket檔案描述符對應一個socket pair,也就是源地址:源連接埠號和目的地址:目的連接埠號,也對應一個TCP連接。

表 37.1. client和server的socket狀態

socket檔案描述符源地址:源連接埠號目的地址:目的連接埠號狀態
server.c中的listenfd0.0.0.0:80000.0.0.0:*LISTEN
server.c中的connfd127.0.0.1:8000127.0.0.1:44406ESTABLISHED
client.c中的sockfd127.0.0.1:44406127.0.0.1:8000ESTABLISHED

2.2. 錯誤處理與讀寫控制

上面的例子不僅功能簡單,而且簡單到几乎沒有什麼錯誤處理,我們知道,系統調用不能保證每次都成功,必須進行出錯處理,這樣一方面可以保證程序邏輯正常,另一方面可以迅速得到故障信息。

為使錯誤處理的代碼不影響主程序的可讀性,我們把與socket相關的一些系統函數加上錯誤處理代碼包裝成新的函數,做成一個模組wrap.c:

#include <stdlib.h>
#include <errno.h>
#include <sys/socket.h>

void perr_exit(const char *s)
{
	perror(s);
	exit(1);
}

int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr)
{
	int n;

again:
	if ( (n = accept(fd, sa, salenptr)) < 0) {
		if ((errno == ECONNABORTED) || (errno == EINTR))
			goto again;
		else
			perr_exit("accept error");
	}
	return n;
}

void Bind(int fd, const struct sockaddr *sa, socklen_t salen)
{
	if (bind(fd, sa, salen) < 0)
		perr_exit("bind error");
}

void Connect(int fd, const struct sockaddr *sa, socklen_t salen)
{
	if (connect(fd, sa, salen) < 0)
		perr_exit("connect error");
}

void Listen(int fd, int backlog)
{
	if (listen(fd, backlog) < 0)
		perr_exit("listen error");
}

int Socket(int family, int type, int protocol)
{
	int n;

	if ( (n = socket(family, type, protocol)) < 0)
		perr_exit("socket error");
	return n;
}

ssize_t Read(int fd, void *ptr, size_t nbytes)
{
	ssize_t n;

again:
	if ( (n = read(fd, ptr, nbytes)) == -1) {
		if (errno == EINTR)
			goto again;
		else
			return -1;
	}
	return n;
}

ssize_t Write(int fd, const void *ptr, size_t nbytes)
{
	ssize_t n;

again:
	if ( (n = write(fd, ptr, nbytes)) == -1) {
		if (errno == EINTR)
			goto again;
		else
			return -1;
	}
	return n;
}

void Close(int fd)
{
	if (close(fd) == -1)
		perr_exit("close error");
}

慢系統調用accept、read和write被信號中斷時應該重試。connect雖然也會阻塞,但是被信號中斷時不能立刻重試。對於accept,如果errno是ECONNABORTED,也應該重試。詳細解釋見參考資料。

TCP協議是面向流的,read和write調用的返回值往往小於參數指定的位元組數。對於read調用,如果接收緩衝區中有20位元組,請求讀100個位元組,就會返回20。對於write調用,如果請求寫100個位元組,而發送緩衝區中只有20個位元組的空閒位置,那麼write會阻塞,直到把100個位元組全部交給發送緩衝區才返回,但如果socket檔案描述符有O_NONBLOCK標誌,則write不阻塞,直接返回20。為避免這些情況干擾主程序的邏輯,確保讀寫我們所請求的位元組數,我們實現了兩個包裝函數readn和writen,也放在wrap.c中:

ssize_t Readn(int fd, void *vptr, size_t n)
{
	size_t  nleft;
	ssize_t nread;
	char   *ptr;

	ptr = vptr;
	nleft = n;
	while (nleft > 0) {
		if ( (nread = read(fd, ptr, nleft)) < 0) {
			if (errno == EINTR)
				nread = 0;
			else
				return -1;
		} else if (nread == 0)
			break;

		nleft -= nread;
		ptr += nread;
	}
	return n - nleft;
}

ssize_t Writen(int fd, const void *vptr, size_t n)
{
	size_t nleft;
	ssize_t nwritten;
	const char *ptr;

	ptr = vptr;
	nleft = n;
	while (nleft > 0) {
		if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
			if (nwritten < 0 && errno == EINTR)
				nwritten = 0;
			else
				return -1;
		}

		nleft -= nwritten;
		ptr += nwritten;
	}
	return n;
}

如果應用層協議的各欄位長度固定,用readn來讀是非常方便的。例如設計一種客戶端上傳檔案的協議,規定前12位元組表示檔案名,超過12位元組的檔案名截斷,不足12位元組的檔案名用'\0'補齊,從第13位元組開始是檔案內容,上傳完所有檔案內容後關閉連接,伺服器可以先調用readn讀12個位元組,根據檔案名創建檔案,然後在一個循環中調用read讀檔案內容並存檔,循環結束的條件是read返回0。

欄位長度固定的協議往往不夠靈活,難以適應新的變化。比如,以前DOS的檔案名是8位元組主檔案名加“.”加3位元組副檔名,不超過12位元組,但是現代操作系統的檔案名可以長得多,12位元組就不夠用了。那麼制定一個新版本的協議規定檔案名欄位為256位元組怎麼樣?這樣又造成很大的浪費,因為大多數檔案名都很短,需要用大量的'\0'補齊256位元組,而且新版本的協議和老版本的程序無法兼容,如果已經有很多人在用老版本的程序了,會造成遵循新協議的程序與老版本程序的互操作性(Interoperability)問題。如果新版本的協議要添加新的欄位,比如規定前12位元組是檔案名,從13到16位元組是檔案類型說明,從第17位元組開始才是檔案內容,同樣會造成和老版本的程序無法兼容的問題。

現在重新看看上一節的TFTP協議是如何避免上述問題的:TFTP協議的各欄位是可變長的,以'\0'為分隔符,檔案名可以任意長,再看blksize等幾個選項欄位,TFTP協議並沒有規定從第m位元組到第n位元組是blksize的值,而是把選項的描述信息“blksize”與它的值“512”一起做成一個可變長的欄位,這樣,以後添加新的選項仍然可以和老版本的程序兼容(老版本的程序只要忽略不認識的選項就行了)。

因此,常見的應用層協議都是帶有可變長欄位的,欄位之間的分隔符用換行的比用'\0'的更常見,例如本節後面要介紹的HTTP協議。可變長欄位的協議用readn來讀就很不方便了,為此我們實現一個類似於fgets的readline函數,也放在wrap.c中:

static ssize_t my_read(int fd, char *ptr)
{
	static int read_cnt;
	static char *read_ptr;
	static char read_buf[100];

	if (read_cnt <= 0) {
	again:
		if ( (read_cnt = read(fd, read_buf, sizeof(read_buf))) < 0) {
			if (errno == EINTR)
				goto again;
			return -1;
		} else if (read_cnt == 0)
			return 0;
		read_ptr = read_buf;
	}
	read_cnt--;
	*ptr = *read_ptr++;
	return 1;
}

ssize_t Readline(int fd, void *vptr, size_t maxlen)
{
	ssize_t n, rc;
	char    c, *ptr;

	ptr = vptr;
	for (n = 1; n < maxlen; n++) {
		if ( (rc = my_read(fd, &c)) == 1) {
			*ptr++ = c;
			if (c  == '\n')
				break;
		} else if (rc == 0) {
			*ptr = 0;
			return n - 1;
		} else
			return -1;
	}
	*ptr  = 0;
	return n;
}

習題

1、請讀者自己寫出wrap.c的標頭檔wrap.h,後面的網絡程式碼都要用到這個標頭檔。

2、修改server.c和client.c,添加錯誤處理。

2.3. 把client改為互動式輸入

目前實現的client每次運行只能從命令行讀取一個字元串發給伺服器,再從伺服器收回來,現在我們把它改成互動式的,不斷從終端接受用戶輸入並和server交互。

/* client.c */
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#include "wrap.h"

#define MAXLINE 80
#define SERV_PORT 8000

int main(int argc, char *argv[])
{
	struct sockaddr_in servaddr;
	char buf[MAXLINE];
	int sockfd, n;
    
	sockfd = Socket(AF_INET, SOCK_STREAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
	servaddr.sin_port = htons(SERV_PORT);
    
	Connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

	while (fgets(buf, MAXLINE, stdin) != NULL) {
		Write(sockfd, buf, strlen(buf));
		n = Read(sockfd, buf, MAXLINE);
		if (n == 0)
			printf("the other side has been closed.\n");
		else
			Write(STDOUT_FILENO, buf, n);
	}

	Close(sockfd);
	return 0;
}

編譯並運行server和client,看看是否達到了你預想的結果。

$ ./client
haha1
HAHA1 
haha2
the other side has been closed.
haha3
$

這時server仍在運行,但是client的運行結果並不正確。原因是什麼呢?仔細查看server.c可以發現,server對每個請求只處理一次,應答後就關閉連接,client不能繼續使用這個連接發送數據。但是client下次循環時又調用write發數據給server,write調用只負責把數據交給TCP發送緩衝區就可以成功返回了,所以不會出錯,而server收到數據後應答一個RST段,client收到RST段後無法立刻通知應用層,只把這個狀態保存在TCP協議層。client下次循環又調用write發數據給server,由於TCP協議層已經處于RST狀態了,因此不會將數據發出,而是發一個SIGPIPE信號給應用層,SIGPIPE信號的預設處理動作是終止程序,所以看到上面的現象。

為了避免client異常退出,上面的代碼應該在判斷對方關閉了連接後break出循環,而不是繼續write。另外,有時候代碼中需要連續多次調用write,可能還來不及調用read得知對方已關閉了連接就被SIGPIPE信號終止掉了,這就需要在初始化時調用sigaction處理SIGPIPE信號,如果SIGPIPE信號沒有導致進程異常退出,write返回-1並且errno為EPIPE。

另外,我們需要修改server,使它可以多次處理同一客戶端的請求。

/* server.c */
#include <stdio.h>
#include <string.h>
#include <netinet/in.h>
#include "wrap.h"

#define MAXLINE 80
#define SERV_PORT 8000

int main(void)
{
	struct sockaddr_in servaddr, cliaddr;
	socklen_t cliaddr_len;
	int listenfd, connfd;
	char buf[MAXLINE];
	char str[INET_ADDRSTRLEN];
	int i, n;

	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, (struct sockaddr *)&servaddr, sizeof(servaddr));

	Listen(listenfd, 20);

	printf("Accepting connections ...\n");
	while (1) {
		cliaddr_len = sizeof(cliaddr);
		connfd = Accept(listenfd, 
				(struct sockaddr *)&cliaddr, &cliaddr_len);
		while (1) {
			n = Read(connfd, buf, MAXLINE);
			if (n == 0) {
				printf("the other side has been closed.\n");
				break;
			}
			printf("received from %s at PORT %d\n",
			       inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
			       ntohs(cliaddr.sin_port));
    
			for (i = 0; i < n; i++)
				buf[i] = toupper(buf[i]);
			Write(connfd, buf, n);
		}
		Close(connfd);
	}
}

經過上面的修改後,客戶端和伺服器可以進行多次交互了。我們知道,伺服器通常是要同時服務多個客戶端的,運行上面的server和client之後,再開一個終端運行client試試,新的client能得到服務嗎?想想為什麼。

2.4. 使用fork並發處理多個client的請求

怎麼解決這個問題?網絡伺服器通常用fork來同時服務多個客戶端,父進程專門負責監聽連接埠,每次accept一個新的客戶端連接就fork出一個子進程專門服務這個客戶端。但是子進程退出時會產生殭屍進程,父進程要注意處理SIGCHLD信號和調用wait清理殭屍進程。

以下給出代碼框架,完整的代碼請讀者自己完成。

listenfd = socket(...);
bind(listenfd, ...);
listen(listenfd, ...); 
while (1) {
	connfd = accept(listenfd, ...);
	n = fork();
	if (n == -1) {
		perror("call to fork");
		exit(1);
	} else if (n == 0) {
		close(listenfd);
		while (1) {
			read(connfd, ...);
			...
			write(connfd, ...);
		}
		close(connfd);
		exit(0);
	} else
		close(connfd);
}

2.5. setsockopt

現在做一個測試,首先啟動server,然後啟動client,然後用Ctrl-C使server終止,這時馬上再運行server,結果是:

$ ./server
 bind error: Address already in use

這是因為,雖然server的應用程序終止了,但TCP協議層的連接並沒有完全斷開,因此不能再次監聽同樣的server連接埠。我們用netstat命令查看一下:

$ netstat -apn |grep 8000
 tcp        1      0 127.0.0.1:33498         127.0.0.1:8000          CLOSE_WAIT 10830/client        
 tcp        0      0 127.0.0.1:8000          127.0.0.1:33498         FIN_WAIT2  -

server終止時,socket描述符會自動關閉並發FIN段給client,client收到FIN後處于CLOSE_WAIT狀態,但是client並沒有終止,也沒有關閉socket描述符,因此不會發FIN給server,因此server的TCP連接處于FIN_WAIT2狀態。

現在用Ctrl-C把client也終止掉,再觀察現象:

$ netstat -apn |grep 8000
 tcp        0      0 127.0.0.1:8000          127.0.0.1:44685         TIME_WAIT  -
 $ ./server
 bind error: Address already in use

client終止時自動關閉socket描述符,server的TCP連接收到client發的FIN段後處于TIME_WAIT狀態。TCP協議規定,主動關閉連接的一方要處于TIME_WAIT狀態,等待兩個MSL(maximum segment lifetime)的時間後才能回到CLOSED狀態,因為我們先Ctrl-C終止了server,所以server是主動關閉連接的一方,在TIME_WAIT期間仍然不能再次監聽同樣的server連接埠。MSL在RFC1122中規定為兩分鐘,但是各操作系統的實現不同,在Linux上一般經過半分鐘後就可以再次啟動server了。至于為什麼要規定TIME_WAIT的時間請讀者參考UNP 2.7節。

在server的TCP連接沒有完全斷開之前不允許重新監聽是不合理的,因為,TCP連接沒有完全斷開指的是connfd(127.0.0.1:8000)沒有完全斷開,而我們重新監聽的是listenfd(0.0.0.0:8000),雖然是占用同一個連接埠,但IP地址不同,connfd對應的是與某個客戶端通訊的一個具體的IP地址,而listenfd對應的是wildcard address。解決這個問題的方法是使用setsockopt()設置socket描述符的選項SO_REUSEADDR為1,表示允許創建連接埠號相同但IP地址不同的多個socket描述符。在server代碼的socket()和bind()調用之間插入如下代碼:

int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

有關setsockopt可以設置的其它選項請參考UNP第7章。

2.6. 使用select

select是網絡程序中很常用的一個系統調用,它可以同時監聽多個阻塞的檔案描述符(例如多個網絡連接),哪個有數據到達就處理哪個,這樣,不需要fork和多進程就可以實現並發服務的server。

/* server.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <netinet/in.h>
#include "wrap.h"

#define MAXLINE 80
#define SERV_PORT 8000

int main(int argc, char **argv)
{
	int i, maxi, maxfd, listenfd, connfd, sockfd;
	int nready, client[FD_SETSIZE];
	ssize_t n;
	fd_set rset, allset;
	char buf[MAXLINE];
	char str[INET_ADDRSTRLEN];
	socklen_t cliaddr_len;
	struct sockaddr_in	cliaddr, servaddr;

	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, (struct sockaddr *)&servaddr, sizeof(servaddr));

	Listen(listenfd, 20);

	maxfd = listenfd;		/* initialize */
	maxi = -1;			/* index into client[] array */
	for (i = 0; i < FD_SETSIZE; i++)
		client[i] = -1;	/* -1 indicates available entry */
	FD_ZERO(&allset);
	FD_SET(listenfd, &allset);

	for ( ; ; ) {
		rset = allset;	/* structure assignment */
		nready = select(maxfd+1, &rset, NULL, NULL, NULL);
		if (nready < 0)
			perr_exit("select error");

		if (FD_ISSET(listenfd, &rset)) { /* new client connection */
			cliaddr_len = sizeof(cliaddr);
			connfd = Accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);

			printf("received from %s at PORT %d\n",
			       inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
			       ntohs(cliaddr.sin_port));

			for (i = 0; i < FD_SETSIZE; i++)
				if (client[i] < 0) {
					client[i] = connfd; /* save descriptor */
					break;
				}
			if (i == FD_SETSIZE) {
				fputs("too many clients\n", stderr);
				exit(1);
			}

			FD_SET(connfd, &allset);	/* add new descriptor to set */
			if (connfd > maxfd)
				maxfd = connfd; /* for select */
			if (i > maxi)
				maxi = i;	/* max index in client[] array */

			if (--nready == 0)
				continue;	/* no more readable descriptors */
		}

		for (i = 0; i <= maxi; i++) {	/* check all clients for data */
			if ( (sockfd = client[i]) < 0)
				continue;
			if (FD_ISSET(sockfd, &rset)) {
				if ( (n = Read(sockfd, buf, MAXLINE)) == 0) {
					/* connection closed by client */
					Close(sockfd);
					FD_CLR(sockfd, &allset);
					client[i] = -1;
				} else {
					int j;
					for (j = 0; j < n; j++)
						buf[j] = toupper(buf[j]);
					Write(sockfd, buf, n);
				}

				if (--nready == 0)
					break;	/* no more readable descriptors */
			}
		}
	}
}