驚群問題|復現|解決

cat發表於2021-07-27

前言

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

總結

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

相關文章