在迭代服務器中,服務器只能處理一個客戶端的請求,如何同時服務多個客戶端呢?在未講到select/poll/epoll等高級IO之前,比較老土的辦法是使用fork來實現。
網絡服務器通常用fork來同時服務多個客戶端,父進程專門負責監聽端口,每次accept一個新的客戶端連接就fork出一個子進程專門服務這個客戶端。但是子進程退出時會產生僵屍進程,父進程要注意處理SIGCHLD信號和調用wait清理僵屍進程,最簡單的辦法就是直接忽略SIGCHLD信號。
當一個連接建立時,accept返回,服務器接著調用fork,然後由子進程服務客戶(通過已連接套接字connfd),父進程則等待另一個連接(通過監聽套接字listenfd)。既然新的客戶由子進程提供服務,父進程就關閉已連接套接字。
首先下圖給出了在服務器阻塞於accept調用且來自客戶的連接請求到達時客戶和服務器的狀態。
從accept返回後,我們立即就有下面的狀態。連接被內核接受,新的套接字connfd被創建。這是一個已連接套接字,可由此跨連接讀寫數據。
並發服務器的下一步是調用fork,下面是從fork返回後的狀態。
查看本欄目更多精彩內容:http://www.bianceng.cn/OS/unix/
注意,此時listenfd和connfd這兩個描述符都在父進程和子進程之間共享(被復制),再下一步是由父進程關閉已連接套接字,由子進程關閉監聽套接字。如下圖:
在編寫TCP並發服務器的時可能會遇到三種情況:
當fork子進程時,必須捕獲SIGCHLD信號;
當捕獲信號時,必須處理被中斷的慢系統調用;
SIGCHLD的信號處理函數必須正確編寫,應使用waitpid函數,以免留下僵死進程。
我們用術語慢系統調用描述accept,該術語也適用於那些可能永遠阻塞的系統調用。
適用於慢系統調用的基本規則是:當阻塞於某個慢系統調用的一個進程捕獲某個信號且相應信號處理函數返回時,該系統調用可能返回一個EINTR錯誤。有些內核自動重啟某些被中斷的系統調用。不過為了便於移植,當我們編寫捕獲信號的程序時(多數並發服務器捕獲SIGCHLD),我們必須對慢系統調用返回EINTR有所准備。
服務器程序serv.c:
#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> #include<signal.h> #define ERR_EXIT(m) \ do { \ perror(m); \ exit(EXIT_FAILURE); \ } while (0) void do_service(int); int main(void) { signal(SIGCHLD, SIG_IGN); 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) pid_t pid; while (1) { if ((conn = accept(listenfd, (struct sockaddr *)&peeraddr, &peerlen)) < 0) //3次握手完成的序列 { if( errno == EINTR ) ///////////////////////////////////////////////////////////////////必須處理被中斷的系統調用 continue; else ERR_EXIT("accept error"); } printf("recv connect ip=%s port=%d\n", inet_ntoa(peeraddr.sin_addr), ntohs(peeraddr.sin_port)); pid = fork(); if (pid == -1) ERR_EXIT("fork error"); if (pid == 0) { // 子進程 close(listenfd); do_service(conn); exit(EXIT_SUCCESS); } else close(conn); //父進程 } return 0; } void do_service(int conn) { char recvbuf[1024]; while (1) { memset(recvbuf, 0, sizeof(recvbuf)); int ret = read(conn, recvbuf, sizeof(recvbuf)); if (ret == 0) //客戶端關閉了 { printf("client close\n"); break; } else if (ret == -1) ERR_EXIT("read error"); fputs(recvbuf, stdout); write(conn, recvbuf, ret); } }
查看本欄目更多精彩內容:http://www.bianceng.cn/OS/unix/
上述程序利用了一點,就是父子進程共享打開的文件描述符,因為在子進程已經用不到監聽描述符,故將其關閉,而連接描述符對父進程也沒價值,將其關閉。當某個客戶端關閉,則read 返回0,退出循環,子進程順便exit,但如果沒有設置對SIGCHLD信號的忽略,則因為父進程還沒退出,故子進程會變成僵屍進程。
現在先運行server,再打開另外兩個終端,運行client(直接用<<UNIX網絡編程——TCP回射服務器/客戶端程序>>中的客戶端程序),可以看到server輸出如下:
huangcheng@ubuntu:~$ ./serv recv connect ip=127.0.0.1 port=42114 recv connect ip=127.0.0.1 port=42115
在另一個終端ps一下:
huangcheng@ubuntu:~$ ps -aux | grep serv /usr/lib/system-service/system-service-d 1000 3813 0.0 0.0 1640 404 pts/1 S+ 11:27 0:00 ./serv 1000 3815 0.0 0.0 1640 168 pts/1 S+ 11:27 0:00 ./serv 1000 3817 0.0 0.0 1640 156 pts/1 S+ 11:27 0:00 ./serv 1000 3824 0.0 0.0 3572 904 pts/3 S+ 11:28 0:00 grep --color=auto serv
發現共有3個進程,其中一個是父進程處於監聽中,另外兩個是子進程處於對客戶端服務中,現在ctrl+c 掉其中一個client,由上面的分析可知對應服務的子進程也會退出,而因為我們設置了父進程對SIGCHLD信號進行忽略,故不會產生僵屍進程,輸出如下:
huangcheng@ubuntu:~$ ps -aux | grep serv 1000 3813 0.0 0.0 1640 404 pts/1 S+ 11:27 0:00 ./serv 1000 3815 0.0 0.0 1640 168 pts/1 S+ 11:27 0:00 ./serv 1000 3831 0.0 0.0 3572 904 pts/3 S+ 11:29 0:00 grep --color=auto serv
如果把第22行代碼注釋掉,上述的情景輸出為:
1000 3876 0.0 0.0 1640 408 pts/1 S+ 11:32 0:00 ./serv 1000 3878 0.0 0.0 1640 172 pts/1 S+ 11:32 0:00 ./serv 1000 3880 0.0 0.0 0 0 pts/1 Z+ 11:32 0:00 [serv] <defunct> 1000 3885 0.0 0.0 3572 900 pts/3 S+ 11:33 0:00 grep --color=auto serv
即子進程退出後變成了僵屍進程。
如果不想忽略SIGCHLD信號,則必須在信號處理函數中調用wait處理,但這裡需要注意的是wait只能等待第一個退出的子進程,所以這裡需要使用waitpid函數,如下所示:
signal(SIGCHLD, handler); ..................... void handler(int sig) { pid_t pid; int stat; /* wait(NULL); //只能等待第一個退出的子進程 */ /* 即使因為幾個連接同時斷開,信號因不能排隊而父進程只收到一個信號 * 直到已經waitpid到所有子進程,返回0,才退出循環 */ while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0) printf("child %d terminated\n", pid); return; }
1. 必須編寫SIGCHLD信號的信號處理函數,原因為:防止出現僵死進程
2. 當捕獲信號時,必須處理被中斷的系統調用,原因為:
(1)我們鍵入EOF字符來終止客戶。客戶TCP發送一個FIN給服務器,服務器響應以一個ACK。
(2)收到客戶的FIN導致服務器TCP遞送一個EOF給子進程阻塞中的read,從而子進程終止。
(3)當SIGCHLD信號遞交時,父進程阻塞於accept調用。handler函數(信號處理函數)執行,其wait調用取到子進程的PID和終止狀態,隨後是printf調用,最後返回。
(4)既然該信號是在父進程阻塞於慢系統調用(accept)時由父進程捕獲的,內核就會使accept返回一個EINTR錯誤(被中斷的系統調用)。父進程不處理該錯誤,於是終止。
3. SIGCHLD的信號處理函數必須正確編寫,應使用waitpid函數,以免留下僵死進程,原因為:
客戶建立於服務器5個連接
查看本欄目更多精彩內容:http://www.bianceng.cn/OS/unix/
修改過後的客戶端程序如下:
#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) void do_echocli(int sock) { char sendbuf[1024] = {0}; char recvbuf[1024] = {0}; while (fgets(sendbuf, sizeof(sendbuf), stdin) != NULL) { write(sock, sendbuf, strlen(sendbuf)); int ret = read(sock, recvbuf, sizeof(recvbuf)); if (ret == -1) ERR_EXIT("read error"); else if (ret == 0) //服務器關閉 { printf("server close\n"); break; } fputs(recvbuf, stdout); memset(sendbuf, 0, sizeof(sendbuf)); memset(recvbuf, 0, sizeof(recvbuf)); } close(sock); } int main(void) { int sock[5]; int i; for (i = 0; i < 5; i++) { if ((sock[i] = 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[i], (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) ERR_EXIT("connect error"); struct sockaddr_in localaddr; socklen_t addrlen = sizeof(localaddr); if (getsockname(sock[i], (struct sockaddr *)&localaddr, &addrlen) < 0) ERR_EXIT("getsockname error"); /* getpeername()獲取對等方的地址 */ printf("local ip=%s port=%d\n", inet_ntoa(localaddr.sin_addr), ntohs(localaddr.sin_port)); } /* 一個進程也可以發起多個socket連接,因為每次的端口號都不同 */ do_echocli(sock[0]); //發起5個套接字連接,但只借助第一個套接口通信 return 0; }
在上述程序中,我們發起5個sock連接,但只是使用sock0通信,且利用getsockname 打印5個連接的信息。
查看本欄目更多精彩內容:http://www.bianceng.cn/OS/unix/
先運行服務器程序,再運行客戶端,客戶端輸出如下:
huangcheng@ubuntu:~$ ./cli local ip=127.0.0.1 port=33867 local ip=127.0.0.1 port=33868 local ip=127.0.0.1 port=33869 local ip=127.0.0.1 port=33870 local ip=127.0.0.1 port=33871 huangcheng huangcheng
即每個連接的ip地址是一樣的,但端口號不同,服務器方面通過accept返回的信息也打印出連接信息,如下:
huangcheng@ubuntu:~$ ./serv recv connect ip=127.0.0.1 port=33867 recv connect ip=127.0.0.1 port=33868 recv connect ip=127.0.0.1 port=33869 recv connect ip=127.0.0.1 port=33870 recv connect ip=127.0.0.1 port=33871 huangcheng
當客戶終止時,所有打開的描述符由內核自動關閉(我們不調用close,僅調用exit),且所有的5個連接基本在同一時刻終止。這就引發了5個FIN,每個連接一個,他們反過來使服務器的5個子進程基本在同一時刻終止。這又導致差不多在同一時刻有5個SIGCHLD信號遞交給父進程:
我們預期所有的5個子進程都終止了。但是運行PS,我們發現其他4個子進程仍然作為僵死進程存在著。
正確的解決辦法是調用waitpid而不是wait。我們必須指定WNOHANG選項,它告知waitpid在有尚未終止的子進程在運行時不要阻塞。我們不能再循環內調用wait,因為沒有辦法防止wait在運行的子進程尚未終止時阻塞。
注意前面的代碼:
while (1) { if ((conn = accept(listenfd, (struct sockaddr *)&peeraddr, &peerlen)) < 0) //3次握手完成的序列 { if( errno == EINTR ) ///////////////////////////////////////////////////////////////////必須處理被中斷的系統調用 continue; else ERR_EXIT("accept error"); }
這段代碼所做的事情就是自己重啟被中斷的系統調用。對於accept以及諸如read、write、select和open之類函數來說,這是合適的。不過有一個函數我們不能重啟:connect。如果該函數返回EINTR,我們就不能再次調用它,否則將立即返回一個錯誤。當connect被一個捕獲的信號中斷而且不能重啟時,我們必須調用select來等待連接完成。