前言
我們知道,像 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;
}
總結
通過本文我們瞭解到什麼是驚群問題,以及對應的解決方式。在編寫類似的多程式的應用時就可以避免這個問題,從而提高應用的效能。