天天看點

驚群問題|複現|解決

驚群問題又稱驚群效應,當多個程序等待同一個事件,事件發生後核心會喚醒所有等待中的程序,但是隻有一個程序能夠獲得 CPU 執行權對事件進行處理

前言

我們知道,像 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;
    }
      

總結

通過本文我們了解到什麼是驚群問題,以及對應的解決方式。在編寫類似的多程序的應用時就可以避免這個問題,進而提高應用的性能。

作者:她和她的貓_her-cat

繼續閱讀