多路復用技術是把多個低信道組合成一個高速信道的技術,它可以有效的提高數據鏈路的利用率,從而使得一條高速的主干鏈路同時為多條低速的接入鏈路提供服務.
Linux 對于 accept(2) 的驚群(thundering herd)問題,早已解決,目前許多人也把這種現象稱為新的驚群:用多路復用模型時,不同的進程監控的文件描述符集合的交集不為空,等這個交集的某個文件IO事件觸發后,內核將的多個監控了這個io且阻塞在 select(2),poll(2) 或 epoll_wait(2) 的進程喚醒,但嚴格來說,這種現象不叫驚群(thundering herd),而是沖突(collision).對于內核來說,喚醒所有監控這一IO事件的進程是合理的,這是因為:select/poll/epoll 不同與 accept,它們監控的文件描述符是可以被多個進程同時處理的,比如一個進程只讀取這個文件句柄一小部分數據,另一進程讀剩余部分,而 accept 處理的套接字是互斥的,一個套接字不能被兩個進程 accept.
我注意到,對這種 select/poll/epoll 沖突的理解存在許多誤區,比如有人都用如下類似的代碼模擬select沖突(網上搜 select 驚群或 epoll 驚群有真相):
- #include <stdio.h>
- #include <unistd.h>
- #include <fcntl.h>
- #include <stdlib.h>
- #include <strings.h>
- #include <arpa/inet.h>
- void worker_hander(int listenfd)
- {
- fd_set rset;
- int connfd, ret;
- printf("worker pid#%d is waiting for connection...n", getpid());
- for (;;) {
- FD_ZERO(&rset);
- FD_SET(listenfd,&rset);
- ret = select(listenfd+1,&rset,NULL,NULL,NULL);
- if(ret < 0)
- perror("select");
- else if(ret > 0 && FD_ISSET(listenfd, &rset)) {
- printf("worker pid#%d 's listenfd is readablen",
- getpid());
- connfd = accept(listenfd, NULL, 0);
- if(connfd < 0) {
- perror("accept error");
- continue;
- }
- printf("worker pid#%d create a new connection...n",
- getpid());
- sleep(1);
- close(connfd);
- }
- }
- }
- static int fd_set_noblock(int fd)
- {
- int flags;
- flags = fcntl(fd, F_GETFL);
- if (flags == -1)
- return -1;
- flags |= O_NONBLOCK;
- flags = fcntl(fd, F_SETFL, flags);
- return flags;
- }
- int main(int argc,char*argv[])
- {
- int listenfd;
- struct sockaddr_in servaddr;
- int sock_opt = 1;
- listenfd = socket(AF_INET,SOCK_STREAM,0);
- if (listenfd < 0) {
- perror("socket");
- exit(1);
- }
- fd_set_noblock(listenfd);
- if ((setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (void *)&sock_opt,
- sizeof(sock_opt))) < 0) {
- perror("setsockopt");
- exit(1);
- }
- bzero(&servaddr, sizeof servaddr);
- servaddr.sin_family = AF_INET;
- servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
- servaddr.sin_port = htons(1234);
- bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr));
- listen(listenfd, 10);
- //Vevb.com
- pid_t pid;
- pid = fork();
- if (pid < 0) {
- perror("fork");
- exit(1);
- } else if (pid == 0)
- worker_hander(listenfd);
- worker_hander(listenfd);
- return 0;
- }
編譯后用先運行以上的服務端,客戶端可以用 netcat 模擬連接:nc 127.0.0.1 1234
以上代碼是兩個進程同時監控同一個文件描述符,返回的結果基本是只有一個select返回,于是試驗人認為"并不是將所有工作進程全部喚醒,而只是喚醒了一部分".
這個錯誤的認識在于沒有理解喚醒的含義,并不是要從 select(2) 返回才叫喚醒.
一個進程在等待的io事件發生之前,內核會為這個進程描述符的state字段設置 TASK_INTERRUPTIBLE 狀態,此時進程描述符位于等待隊列中,一旦等待的事件發生后,進程就會被喚醒,進程描述符就會被移到運行隊列中,發生進程切換時,內核進程調度器會根據調度策略從運行隊列選擇一個進程執行.
因此,上述程序實際上喚醒了所有的兩個進程,只不過先被調度的那個進程 select(2) 返回后,如果執行到accept(2) 也沒有發生進程切換,把IO事件處理掉了,而等到后調度的那個進程執行時,select(2) 里面已經沒有這個IO事件了,內核檢測這個進程沒有監控的事件發生,會把這個進程繼續放到等待隊列里面去,select(2) 并沒有返回,這種情況的概率是非常大的,另一種概率很小的情況是:先被調度的進程執行到 accept(2) 就發生了進程切換,而在下一次運行前,調度器啟動了后一個進程,這樣的話,后一個進程也將會從select(2)返回.
后一種情況很不容易發生,在 accetp(2) 之前插入 usleep(3) 或 sleep(3) 就可以提高發生的概率了.
內核喚醒進程又不能讓這個進程執行,再次把它移動到等待隊列,造成了一定的開銷浪費,nginx 是這樣處理的:用一個管理進程管理多個工作進程的多路復用,工作進程在epoll_wait(2)前向管理進程申請鎖,確保同一時刻,多個進程在epoll監聽的文件描述符集合的交集為空.
新聞熱點
疑難解答