Nginx 是如何解決驚群效應的?

發表於2023-09-19

前置知識

  • linux 網路處理的基本方法:bindlistenaccept
  • epoll 的基本方法:epoll_createepoll_ctlepoll_wait

什麼是驚群效應?

第一次聽到的這個名詞的時候覺得很是有趣,不知道是個什麼意思,總覺得又是奇怪的中文翻譯導致的。

複雜的說(來源於網路)TLDR;

驚群效應(thundering herd)是指多程式(多執行緒)在同時阻塞等待同一個事件的時候(休眠狀態),如果等待的這個事件發生,那麼他就會喚醒等待的所有程式(或者執行緒),但是最終卻只能有一個程式(執行緒)獲得這個時間的“控制權”,對該事件進行處理,而其他程式(執行緒)獲取“控制權”失敗,只能重新進入休眠狀態,這種現象和效能浪費就叫做驚群效應。

簡單的講(我的大白話)

有一道雷打下來,把很多人都吵醒了,但只有其中一個人去收衣服了。
也就是:
有一個請求過來了,把很多程式都喚醒了,但只有其中一個能最終處理。

原因&問題

說起來其實也簡單,多數時候為了提高應用的請求處理能力,會使用多程式(多執行緒)去監聽請求,當請求來時,因為都有能力處理,所以就都被喚醒了。

而問題就是,最終還是隻能有一個程式能來處理。當請求多了,不停地喚醒、休眠、喚醒、休眠,做了很多的無用功,上下文切換又累,對吧。那怎麼解決這個問題呢?下面就是今天要看的重點,我們看看 nginx 是如何解決這個問題的。

nginx 架構

第一點我們需要了解 nginx 大致的架構是怎麼樣的。nginx 將程式分為 masterworker 兩類,非常常見的一種 M-S 策略,也就是 master 負責統籌管理 worker,當然它也負責如:啟動、讀取配置檔案,監聽處理各種訊號等工作。

https://blog.linkinstars.com/blog/nginx-struct.png
圖片來自: https://aosabook.org/en/v2/nginx.html

但是,第一個要注意的問題就出現了,master 的工作有且只有這些,對於請求來說它是不管的,就如同圖中所示,請求是直接被 worker 處理的。如此一來,請求應該被哪個 worker 處理呢?worker 內部又是如何處理請求的呢?

nginx 使用 epoll

接下來我們就要知道 nginx 是如何使用 epoll 來處理請求的。下面可能會涉及到一些原始碼的內容,但不用擔心,你不需要全部理解,只需要知道它們的作用就可以了。順便我會簡單描述一下我是如何去找到這些原始碼的位置的。

master 的工作

其實 master 並不是毫無作為,至少埠是它來佔的。
https://github.com/nginx/nginx/blob/b489ba83e9be446923facfe1a2fe392be3095d1f/src/core/ngx_connection.c#L407C13-L407C13

ngx_open_listening_sockets(ngx_cycle_t *cycle)
{
    .....
    for (i = 0; i < cycle->listening.nelts; i++) {
        .....
        if (bind(s, ls[i].sockaddr, ls[i].socklen) == -1) {

        if (listen(s, ls[i].backlog) == -1) {
}

那麼,根據我們 nginx.conf 的配置檔案,看需要監聽哪個埠,於是就去 bind 的了,這裡沒問題。

【發現原始碼】這裡我是直接在程式碼裡面搜 bind 方法去找的,因為我知道,不管你怎麼樣,你總是要繫結埠的

然後是建立 worker 的,雖不起眼,但很關鍵。 https://github.com/nginx/nginx/blob/b489ba83e9be446923facfe1a2fe392be3095d1f/src/os/unix/ngx_process.c#L186

ngx_spawn_process(ngx_cycle_t *cycle, ngx_spawn_proc_pt proc, void *data,
    char *name, ngx_int_t respawn)
{
    ....
    pid = fork();
【發現原始碼】這裡我直接搜 fork,整個專案裡面需要 fork 的情況只有兩個地方,很快就找到了 worker

由於是 fork 建立的,也就是複製了一份 task_struct 結構。所以 master 的幾乎全部它都有。

worker 的工作

nginx 有一個分模組的思想,它將不同功能分成了不同的模組,而 epoll 自然就是在 ngx_epoll_module.c 中了

https://github.com/nginx/nginx/blob/b489ba83e9be446923facfe1a2fe392be3095d1f/src/event/modules/ngx_epoll_module.c#L330C23-L330C23

ngx_epoll_init(ngx_cycle_t *cycle, ngx_msec_t timer)
{
    ngx_epoll_conf_t  *epcf;

    epcf = ngx_event_get_conf(cycle->conf_ctx, ngx_epoll_module);

    if (ep == -1) {
        ep = epoll_create(cycle->connection_n / 2);

其他不重要,就連 epoll_ctlepoll_wait 也不重要了,這裡你需要知道的就是,從呼叫鏈路來看,是 worker 建立的 epoll 物件,也就是每個 worker 都有自己的 epoll 物件,而監聽的sokcet 是一樣的!

【發現原始碼】這裡更加直接,搜尋 epoll_create 肯定就能找到

問題的關鍵

此時問題的關鍵基本就能瞭解了,每個 worker 都有處理能力,請求來了此時應該喚醒誰呢?講道理那不是所有 epoll 都會有事件,所有 worker 都 accept 請求?顯然這樣是不行的。那麼 nginx 是如何解決的呢?

如何解決

解決方式一共有三種,下面我們一個個來看:

  1. accept_mutex(應用層的解決方案)
  2. EPOLLEXCLUSIVE(核心層的解決方案)
  3. SO_REUSEPORT(核心層的解決方案)

accept_mutex

看到 mutex 可能你就知道了,鎖嘛!這也是對於高併發處理的 ”基操“ 遇事不決加鎖,沒錯,加鎖肯定能解決問題。 https://github.com/nginx/nginx/blob/b489ba83e9be446923facfe1a2fe392be3095d1f/src/event/ngx_event_accept.c#L328

具體程式碼就不展示了,其中細節很多,但本質很容易理解,就是當請求來了,誰拿到了這個鎖,誰就去處理。沒拿到的就不管了。鎖的問題很直接,除了慢沒啥不好的,但至少很公平。

EPOLLEXCLUSIVE

EPOLLEXCLUSIVE 是 2016 年 4.5+ 核心新新增的一個 epoll 的標識。它降低了多個程式/執行緒透過 epoll_ctl 新增共享 fd 引發的驚群機率,使得一個事件發生時,只喚醒一個正在 epoll_wait 阻塞等待喚醒的程式(而不是全部喚醒)。

關鍵是:每次核心只喚醒一個睡眠的程式處理資源

但,這個方案不是完美的解決了,它僅是降低了機率哦。為什麼這樣說呢?相比於原來全部喚醒,那肯定是好了不少,降低了衝突。但由於本質來說 socket 是共享的,當前程式處理完成的時間不確定,在後面被喚醒的程式可能會發現當前的 socket 已經被之前喚醒的程式處理掉了。

SO_REUSEPORT

nginx 在 1.9.1 版本加入了這個功能 https://www.nginx.com/blog/socket-sharding-nginx-release-1-9-1/
其本質是利用了 Linux 的 reuseport 的特性,使用 reuseport 核心允許多個程式 listening socket 到同一個埠上,而從核心層面做了負載均衡,每次喚醒其中一個程式。

反應到 nginx 上就是,每個 worker 程式都建立獨立的 listening socket,監聽相同的埠,accept 時只有一個程式會獲得連線。效果就和下圖所示一樣。

https://blog.linkinstars.com/blog/nginx-epoll-reuseport.png

而使用方式則是:

http {
     server {
          listen 80 reuseport;
          server_name  localhost;
          # ...
     }
}

從官方的測試情況來看確實是厲害
https://blog.linkinstars.com/blog/nginx-epoll-reuseport-benchmarking.png

當然,正所謂:完事無絕對,技術無銀彈。這個方案的問題在於核心是不知道你忙還是不忙的。只會無腦的丟給你。與之前的搶鎖對比,搶鎖的程式一定是不忙的,現在手上的工作都已經忙不過來了,沒機會去搶鎖了;而這個方案可能導致,如果當前程式忙不過來了,還是會只要根據 reuseport 的負載規則輪到你了就會傳送給你,所以會導致有的請求被前面慢的請求卡住了。

總結

本文,從瞭解什麼 ”驚群效應“ 到 nginx 架構和 epoll 處理的原理,最終分析三種不同的處理 “驚群效應” 的方案。分析到這裡,我想你應該明白其實 nginx 這個多佇列服務模型是所存在的一些問題,只不過絕大多數場景已經完完全全夠用了。

參考連結

相關文章