下面通過最簡單的客戶端/服務器程序的實例來學習socket API。
echoser.c 程序的功能是從客戶端讀取字符 然後直接回射回去。
/************************************************************************* > File Name: echoser.c > Author: Simba > Mail: [email protected] > Created Time: Fri 01 Mar 2013 06:15:27 PM CST ************************************************************************/ #include<stdio.h> #include<sys/types.h> #include<sys/socket.h> #include<unistd.h> #include<stdlib.h> #include<errno.h> #include<arpa/inet.h> #include<netinet/in.h> #include<string.h> #define ERR_EXIT(m) \ do { \ perror(m); \ exit(EXIT_FAILURE); \ } while (0) int main(void) { int listenfd; //被動套接字(文件描述符),即只可以accept if ((listenfd = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0) // listenfd = socket(AF_INET, SOCK_STREAM, 0) ERR_EXIT("socket error"); struct sockaddr_in servaddr; memset(&servaddr, 0, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_port = htons(5188); servaddr.sin_addr.s_addr = htonl(INADDR_ANY); /* servaddr.sin_addr.s_addr = inet_addr("127.0.0.1"); */ /* inet_aton("127.0.0.1", &servaddr.sin_addr); */ int on = 1; if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0) ERR_EXIT("setsockopt error"); if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) ERR_EXIT("bind error"); if (listen(listenfd, SOMAXCONN) < 0) //listen應在socket和bind之後,而在accept之前 ERR_EXIT("listen error"); struct sockaddr_in peeraddr; //傳出參數 socklen_t peerlen = sizeof(peeraddr); //傳入傳出參數,必須有初始值 int conn; // 已連接套接字(變為主動套接字,即可以主動connect) if ((conn = accept(listenfd, (struct sockaddr *)&peeraddr, &peerlen)) < 0) ERR_EXIT("accept error"); printf("recv connect ip=%s port=%d\n", inet_ntoa(peeraddr.sin_addr), ntohs(peeraddr.sin_port)); char recvbuf[1024]; while (1) { memset(recvbuf, 0, sizeof(recvbuf)); int ret = read(conn, recvbuf, sizeof(recvbuf)); fputs(recvbuf, stdout); write(conn, recvbuf, ret); } close(conn); close(listenfd); return 0; }
下面介紹程序中用到的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參數是這樣初始 化的:
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(5188);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
首先將整個結構體清零(也可以用bzero函數),然後設置地址類型為AF_INET,網絡地址為INADDR_ANY,這個宏表 示本地的任意IP地址,因為服務器可能有多個網卡,每個網卡也可能綁定多個IP地址,這樣設置可以在所有的IP地址上監聽 ,直到與某個客戶端建立了連接時才確定下來到底用哪個IP地址,端口號為5188。
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,表示不關心客戶端的地址。
在上面的程序中我們通過peeraddr打印連接上來的客戶端ip和端口號。
在while循環中從accept返回的文件描述符 conn讀取客戶端的請求,然後直接回射回去。
echocli.c 的作用是從標准輸入得到一行字符,然後發送給服務器後 從服務器接收,再打印在標准輸出。
/************************************************************************* > File Name: echoser.c > Author: Simba > Mail: [email protected] > Created Time: Fri 01 Mar 2013 06:15:27 PM CST ************************************************************************/ #include<stdio.h> #include<sys/types.h> #include<sys/socket.h> #include<unistd.h> #include<stdlib.h> #include<errno.h> #include<arpa/inet.h> #include<netinet/in.h> #include<string.h> #define ERR_EXIT(m) \ do { \ perror(m); \ exit(EXIT_FAILURE); \ } while (0) int main(void) { int sock; if ((sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0) // listenfd = socket(AF_INET, SOCK_STREAM, 0) ERR_EXIT("socket error"); struct sockaddr_in servaddr; memset(&servaddr, 0, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_port = htons(5188); servaddr.sin_addr.s_addr = inet_addr("127.0.0.1"); /* inet_aton("127.0.0.1", &servaddr.sin_addr); */ if (connect(sock, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) ERR_EXIT("connect error"); char sendbuf[1024] = {0}; char recvbuf[1024] = {0}; while (fgets(sendbuf, sizeof(sendbuf), stdin) != NULL) { write(sock, sendbuf, strlen(sendbuf)); read(sock, recvbuf, sizeof(recvbuf)); fputs(recvbuf, stdout); memset(sendbuf, 0, sizeof(sendbuf)); memset(recvbuf, 0, sizeof(recvbuf)); } close(sock); 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。
先編譯運行服務器:
simba@ubuntu:~/Documents/code/linux_programming/UNP/socket$ ./echoser
然後在另一個終端裡用netstat命令查看:
simba@ubuntu:~$ netstat -anp | grep 5188
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
tcp 0 0 0.0.0.0:5188 0.0.0.0:* LISTEN 4425/echoser
可以看到server程序監聽5188端口,IP地址還沒確定下來。現在編譯運行客戶端:
simba@ubuntu:~/Documents/code/linux_programming/UNP/socket$ ./echocli
回到server所在的終端,看 看server的輸出:
simba@ubuntu:~/Documents/code/linux_programming/UNP/socket$ ./echoser
recv connect ip=127.0.0.1 port=59431
可見客戶端的端口號是自動分配的。
再次netstat 一下
simba@ubuntu:~$ netstat -anp | grep 5188
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
tcp 0 0 0.0.0.0:5188 0.0.0.0:* LISTEN 4425/echoser
tcp 0 0 127.0.0.1:59431 127.0.0.1:5188 ESTABLISHED 4852/echocli
tcp 0 0 127.0.0.1:5188 127.0.0.1:59431 ESTABLISHED 4425/echoser
應用程序中的一個socket文件描述符對應一個socket pair,也就是源地址:源端口號和目的地 址:目的端口號,也對應一個TCP連接。
上面第一行即echoser.c 中的listenfd;第二行即echocli 中的conn; 第三 行即echoser.c 中的sock。4425和4852分別是進程id。
現在來做個測試,先把40~42行的代碼注釋起來。
首 先啟動server,然後啟動client,然後用Ctrl-C使server終止,這時馬上再運行server,結果是:
simba@ubuntu:~/Documents/code/linux_programming/UNP/socket$ ./echoser
bind error: Address already in use
這是因為,雖然server的應用程序終止了,但TCP協議層的連接並沒有完全斷開,因此不能再次監聽同樣的 server端口。我們用netstat命令查看一下:
simba@ubuntu:~/Documents/code/linux_programming/UNP/socket$ netstat -anp | grep 5188
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
tcp 0 0 127.0.0.1:5188 127.0.0.1:37381 FIN_WAIT2 -
tcp 1 0 127.0.0.1:37381 127.0.0.1:5188 CLOSE_WAIT 2302/echocli
server終止時,socket描述符會自動關閉並發FIN段給client,client收到 FIN後處於CLOSE_WAIT狀態,但是client並沒有終止,也沒有關閉socket描述符,因此不會發FIN給server,因此server的 TCP連接處於FIN_WAIT2狀態。
現在用Ctrl-C把client也終止掉,再觀察現象:
simba@ubuntu:~/Documents/code/linux_programming/UNP/socket$ netstat -anp | grep 5188
(No info could be read for "-p": geteuid()=1000 but you should be root.)
tcp 0 0 127.0.0.1:5188 127.0.0.1:37382 TIME_WAIT -
simba@ubuntu:~/Documents/code/linux_programming/UNP/socket$ ./echoser
bind error: Address already in use
client終止時自動關閉socket描述符,server的TCP連接收到client發的FIN段後處於TIME_WAIT狀態。TCP協議規 定,主動關閉連接的一方要處於TIME_WAIT狀態,等待兩個MSL(maximumsegment lifetime)的時間後才能回到CLOSED狀態 ,需要有MSL 時間的主要原因是在這段時間內如果最後一個ack段沒有發送給對方,則可以重新發送。因為我們先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描述符。將原來注釋的40~42行代碼打開,問題解決。