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);
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監聽的檔案描述符集合的交集為空白。