下面通過最簡單的用戶端/伺服器程式的執行個體來學習socket API。
echoser.c 程式的功能是從用戶端讀取字元然後直接回射回去。
C++ Code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
|
|
/************************************************************************* > File Name: echoser.c > Author: Simba > Mail: dameng34@163.com > 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 的作用是從標準輸入得到一行字元,然後發送給伺服器後從伺服器接收,再列印在標準輸出。
C++ Code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
|
|
/************************************************************************* > File Name: echoser.c > Author: Simba > Mail: dameng34@163.com > 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行代碼開啟,問題解決。
參考:
《Linux C 編程一站式學習》
《TCP/IP詳解 卷一》
《UNP》