前言
我們知道,像 Nginx、Workerman 都是單 Master 多 Worker 的進程模型。
Master 進程用於創建監聽套接字、創建 Worker 進程及管理 Worker 進程。
Worker 進程是由 Master 進程通過 fork 系統調用派生出來的,所以會自動繼承 Master 進程的監聽套接字,每個 Worker 進程都可以獨立地接收並處理來自客戶端的連接。
由於多個 Worker 進程都在等待同一個套接字上的事件,就會出現標題所說的驚群問題。
轉載請注明來源地址:她和她的貓
什么是驚群問題
驚群問題又稱驚群效應,當多個進程等待同一個事件,事件發生后內核會喚醒所有等待中的進程,但是只有一個進程能夠獲得 CPU 執行權對事件進行處理,其他的進程都是被無效喚醒的,隨后會再次陷入阻塞狀態,等待下一次事件發生時被喚醒。
舉個例子,你們寢室幾個人都在一邊睡覺一邊等外賣,外賣到了的時候,快遞小哥嗷一嗓子把你們幾個人都叫醒了,但是他只送了一個人的外賣,其它人罵罵咧咧的又躺下了,下次外賣來的時候,又會把這幾個人都吵醒。
這里的室友表示進程,外賣小哥表示操作系統,外賣就是等待的事件。
驚群問題帶來的問題
由於每次事件發生會喚醒所有進程,所以操作系統會對多個進程頻繁地做無效的調度,讓 CPU 大部分時間都浪費在了上下文切換上面,而不是讓真正需要工作的進程運行,導致系統性能大打折扣。
發生驚群問題的時機
通過上面的介紹可以知道,驚群問題主要發生在 socket_accept 和 socket_select 兩個函數的調用上。
下面我們通過兩個例子復現這兩個系統調用的驚群。
socket_accept 函數
PHP 中的 socket_accept 函數是 accept 系統調用的一層包裝。函數原型如下:
socket_accept(Socket $socket): Socket|false
該函數接收監聽套接字上的新連接,一旦接收成功,就會返回一個新的套接字(連接套接字)用於與客戶端進行通信。如果沒有待處理的連接,socket_accept 函數將阻塞,直到有新的連接出現。
// 創建 TCP 套接字
$server_socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
// 將套接字綁定到指定的主機地址和端口上
socket_bind($server_socket, "0.0.0.0", 8080);
// 設置為監聽套接字
socket_listen($server_socket);
printf("master[%d] running\n", posix_getpid());
for ($i = 0; $i < 5; $i++) {
$pid = pcntl_fork();
if ($pid < 0) {
exit('fork 失敗');
} else if ($pid == 0) {
// 這里是子進程
$pid = posix_getpid();
printf("worker[%d] running\n", $pid);
// while true 是為了處理完一個連接之后,可以繼續處理下一個連接
while (true) {
// 由於我們剛剛創建的 $server 是阻塞 IO,
// 所以代碼運行到這的時候會阻塞住,會將 CPU 讓出去,
// 直到有客戶端來連接
$conn_socket = socket_accept($server_socket);
if (!$conn_socket) {
printf("worker[%d] 接收新連接失敗,原因:%s\n", $pid, socket_last_error($conn_socket));
continue;
}
// 獲取客戶端地址及端口號
socket_getpeername($conn_socket, $address, $port);
printf("worker[%d] 接收新連接成功:%s:%d\n", $pid, $address, $port);
// 關閉客戶端連接
socket_close($conn_socket);
}
}
// 這里是父進程
}
// 父進程等待子進程退出,回收資源
while (true) {
// 為待處理的信號調用信號處理程序。
\pcntl_signal_dispatch();
// 暫停當前進程的執行,直到一個子進程退出,或者直到一個信號被傳遞。
$pid = \pcntl_wait($status, WUNTRACED);
// 再次調用待處理信號的信號處理程序。
\pcntl_signal_dispatch();
if ($pid > 0) {
printf("worker[%d] 退出\n", $pid);
}
}
上面的代碼先創建了一個監聽套接字 $server_socket,然后通過 pcntl_fork 函數派生出 5 個子進程。
在調用完 pcntl_fork 函數后,如果派生子進程成功,那么該函數會有兩個返回值,在父進程中返回子進程的進程 ID,在子進程中返回 0;派生失敗則返回 -1。
- 父進程:調用 pcntl_wait 函數阻塞等待子進程退出,然后回收進程資源
- 子進程:調用 socket_accept 函數並阻塞,直到有新連接需要處理。
將上面的代碼保存為 accept.php,然后在 CLI 中執行 php accept.php
啟動服務端程序,可以看到 1 個 master 進程和 5 個 worker 進程都已經處於運行狀態:
執行 pstree -acp pid
查看一下進程樹:
進程樹的結構與我們服務啟動的日志是一致的。
接下來我們執行 telnet 0.0.0.0 8080
命令連接到服務端程序上,accept.php 輸出:
咦,怎么回事,跟一開始說的不一樣啊,這明明只有一個進程被喚醒然后處理了新連接!
莫慌,這是在預料之中的,因為在 Linux 2.6 后的版本中,Linux 已經修復了 accept 的驚群問題。
演示這一步主要是為后面的內容做鋪墊。
socket_select 函數
跟 socket_accept 函數一樣,socket_select 函數也是 select 系統調用的一層包裝。
select 是最早的一種多路復用實現方式,性能相對於后面出現的 poll、epoll 要差很多,那么為什么這里要用 select 來做演示呢?
一是因為支持 select 的操作系統比較多,連 Windows 和 MacOS 也都支持 select 系統調用。
二是截止目前 Linux 內核版本 4.4.0 依然沒有解決 select 的驚群問題。
socket_select 接受套接字數組並阻塞等待它們有事件發生。函數原型如下:
socket_select(
array|null &$read,
array|null &$write,
array|null &$except,
int|null $seconds,
int $microseconds = 0
): int|false
- $read 表示需要監聽可讀事件的套接字數組。
- $write 表示需要監聽可寫事件的套接字數組。
- $except 表示需要監聽的異常事件套接字數組。
- $seconds 和 $microseconds 組合起來表示 select 阻塞超時時間,$seconds 為 0 表示不等待,立即返回,設置為 null 表示一直阻塞等待,直到有事件發生。
當在函數超時前有事件發生時,返回值為發生事件的套接字數量,如果是函數超時,返回值為 0 ,有錯誤發生時返回 false。
socket_select 函數的示例程序與上面 socket_accept 函數的差不多,只不過需要將監聽套接字設置為非阻塞,然后在 socket_accept 函數之前調用 socket_select 進行阻塞等待事件。
// 創建 TCP 套接字
$server_socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
// 將套接字綁定到指定的主機地址和端口上
socket_bind($server_socket, "0.0.0.0", 8080);
// 設置為監聽套接字
socket_listen($server_socket);
// 設置為非阻塞
socket_set_nonblock($server_socket);
printf("master[%d] running\n", posix_getpid());
for ($i = 0; $i < 5; $i++) {
$pid = pcntl_fork();
if ($pid < 0) {
exit('fork 失敗');
} else if ($pid == 0) {
// 這里是子進程
$pid = posix_getpid();
printf("worker[%d] running\n", $pid);
// while true 是為了處理完一個連接之后,可以繼續處理下一個連接
while (true) {
// 將監聽套接字放入可讀事件的套接字數組中,
// 表示我們需要等待監聽套接字上的可讀事件,
// 監聽套接字發生可讀事件說明有客戶端連接上來了。
$reads = [$server_socket];
// 可寫事件和異常事件我們不關心,設置為空數組即可。
$writes = $excepts = [];
// 超時時間設置為 NULL,表示一直阻塞等待,直到有事件發生。
$num = socket_select($reads, $writes, $excepts, NULL);
printf("worker[%d] wakeup,num:%d\n", $pid, $num);
$conn_socket = socket_accept($server_socket);
if (!$conn_socket) {
printf("worker[%d] 接收新連接失敗\n", $pid);
continue;
}
// 獲取客戶端地址及端口號
socket_getpeername($conn_socket, $address, $port);
printf("worker[%d] 接收新連接成功:%s:%d\n", $pid, $address, $port);
// 關閉客戶端連接
socket_close($conn_socket);
}
}
// 這里是父進程
}
// 父進程等待子進程退出,回收資源
while (true) {
// 為待處理的信號調用信號處理程序。
\pcntl_signal_dispatch();
// 暫停當前進程的執行,直到一個子進程退出,或者直到一個信號被傳遞。
$pid = \pcntl_wait($status, WUNTRACED);
// 再次調用待處理信號的信號處理程序。
\pcntl_signal_dispatch();
if ($pid > 0) {
printf("worker[%d] 退出\n", $pid);
}
}
我們將上述代碼保存為 select.php
並執行 php select.php
啟動服務,然后使用 telnet 127.0.0.1 8080
連接上去就會發現 5 個子進程都輸出了 wakeup,但是只有一個進程 accept 成功了。
如何解決驚群問題
因為驚群問題主要是出在系統調用上,但是內核系統更新肯定沒那么及時,而且不能保證所有操作系統都會修復這個問題。
所以解決方案可以分為兩類:用戶程序層面和內核程序層面,用戶程序層面就是通過加鎖解決問題,內核程序層面就是讓內核程序提供一些機制,一勞永逸地解決這個問題。
用戶程序:加鎖
通過上面我們可以知道,驚群問題發生的前提是多個進程監聽同一個套接字上的事件,所以我們只讓一個進程去處理監聽套接字就可以了。
Nginx 采用了自己實現的 accept 加鎖機制,避免多個進程同時調用 accept。Nginx 多進程的鎖在底層默認是通過 CPU 自旋鎖實現的,如果操作系統不支持,就會采用文件鎖。
Nginx 事件處理的入口函數使 ngx_process_events_and_timers(),下面是簡化后的加鎖過程:
// 是否開啟 accept 鎖,
// 開啟則需要搶鎖,以防驚群,默認是關閉的。
if (ngx_use_accept_mutex) {
if (ngx_accept_disabled > 0) {
// ngx_accept_disabled 的值是經過算法計算出來的,
// 當值大於 0 時,說明此進程負載過高,不再接收新連接。
ngx_accept_disabled--;
} else {
// 嘗試搶 accept 鎖,發生錯誤直接返回
if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
return;
}
if (ngx_accept_mutex_held) {
// 搶到鎖,設置事件處理標識,后續事件先暫存隊列中。
flags |= NGX_POST_EVENTS;
} else {
// 未搶到鎖,修改阻塞等待時間,使得下一次搶鎖不會等待太久
if (timer == NGX_TIMER_INFINITE
|| timer > ngx_accept_mutex_delay)
{
timer = ngx_accept_mutex_delay;
}
}
}
}
在 ngx_trylock_accept_mutex 函數中,如果搶到了鎖,Nginx 會把監聽套接字的可讀事件放入事件循環中,該進程有新連接進來的時候就可以 accept 了。
內核程序:從根源解決問題
在高本版的 Nginx 中 accept 鎖默認是關閉的,如果開啟了 accept 鎖,那么在多個 worker 進程並行的情況下,對於 accept 函數的調用是串行的,效率不高。
所以最好的方式還是讓內核程序解決驚群的問題,從問題的根源上去解決。
Linux 內核 3.9 及后續版本提供了新的套接字參數 SO_REUSEPORT,該參數允許多個進程綁定到同一個套接字上,內核在收到新的連接時,只會喚醒其中一個進程進行處理,內核中也會做負載均衡,避免某個進程負載過高。
對於 epoll 多路復用機制,Linux 內核 4.5+ 新增 EPOLLEXCLUSIVE 標志,這個標志會保證一個事件只會有一個阻塞在 epoll_wait 函數的進程被喚醒,避免了驚群問題。
在 Nginx 的 ngx_event_process_init 函數中,可以看到 Nginx 是如何使用 SO_REUSEPORT 和 EPOLLEXCLUSIVE 的。
// Nginx 支持端口復用
#if (NGX_HAVE_REUSEPORT)
// 配置 listen 80 resuseport 時,支持多進程共用一個端口,
// 此時可直接把監聽套接字加入事件循環中,並監聽可讀事件。
if (ls[i].reuseport) {
if (ngx_add_event(rev, NGX_READ_EVENT, 0) == NGX_ERROR) {
return NGX_ERROR;
}
continue;
}
#endif
// 打開 accept_mutex 鎖之后,
// 每個 worker 進程不能直接處理監聽套接字,
// 需要在 worker 進程搶到鎖之后才能將監聽套接字放入自己的事件循環中。
if (ngx_use_accept_mutex) {
continue;
}
// Nginx 支持 EPOLLEXCLUSIVE 標志
#if (NGX_HAVE_EPOLLEXCLUSIVE)
// 如果 nginx 使用的是 epoll 多路復用機制,並且 worker 進程大於 1,
// 那么就將監聽套接字加入自己的事件循環中,並且設置 EPOLLEXCLUSIVE 標志。
if ((ngx_event_flags & NGX_USE_EPOLL_EVENT)
&& ccf->worker_processes > 1)
{
if (ngx_add_event(rev, NGX_READ_EVENT, NGX_EXCLUSIVE_EVENT)
== NGX_ERROR)
{
return NGX_ERROR;
}
continue;
}
#endif
// 未開啟 accept_mutex 鎖,未啟動 resuseport 端口復用,不支持 EPOLLEXCLUSIVE 標志,
// 此后監聽套接字發生事件時會引發驚群問題。
if (ngx_add_event(rev, NGX_READ_EVENT, 0) == NGX_ERROR) {
return NGX_ERROR;
}
總結
通過本文我們了解到什么是驚群問題,以及對應的解決方式。在編寫類似的多進程的應用時就可以避免這個問題,從而提高應用的性能。