劫起 - 再談 Linux epoll 驚羣問題的原因和解決方案

原作者:dog250,授權發佈

重新整理: 極客重生


**文章有點長,可以三連收藏慢慢看
**

緣起

近期排查了一個問題,epoll 驚羣的問題,起初我並不認爲這是驚羣導致,因爲從現象上看,只是體現了 CPU 不均衡。一共 fork 了 20 個 Server 進程,在請求負載中等的時候,有三四個 Server 進程呈現出比較高的 CPU 利用率,其餘的 Server 進程的 CPU 利用率都是非常低。

中斷,軟中斷都是均衡的,網卡 RSS 和 CPU 之間進行了 bind 之後依然如故,既然系統層面查不出個所以然,只能從服務的角度來查了。

自上而下的排查首先就想到了 strace,沒想到一下子就暴露了原形:

accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)

如果僅僅 strace accept,即加上 “-e trace=accept” 參數的話,偶爾會有 accept 成功的現象:

accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, {sa_family=AF_INET, sin_port=htons(39306), sin_addr=inet_addr("172.16.1.202")}, [16]) = 19
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)
accept(4, 0x9ecd930, [16])              = -1 EAGAIN (Resource temporarily unavailable)

大量的 CPU 空轉,進一步加大請求負載,CPU 空轉明顯降低,這說明在預期的空轉期間,新來的請求降低了空轉率… 現象明顯偏向於這就是驚羣導致的之判斷!

本文將詳細說一下關於 epoll 的細節。現在開始!

題目中爲什麼是 “再談”,因爲這個話題別人已經聊過很多了,我順勢繼續下去而已。

簡單介紹驚羣和事件模型

關於什麼是驚羣,這裏不再做概念上的解釋,能搜到這篇文章的想必已經有所瞭解,如果仍有概念上的疑惑,自行百度或者谷歌。

驚羣問題一般出現在那些 web 服務器上,曾經 Linux 系統有個經典的 accept 驚羣問題困擾了大家非常久的時間,這個問題現在已經在內核曾經得以解決,具體來講就是當有新的連接進入到 accept 隊列的時候,內核喚醒且僅喚醒一個進程來處理,這是通過以下的代碼來實現的:

list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
    unsigned flags = curr->flags;
    if (curr->func(curr, mode, wake_flags, key) &&
        (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
            break;
}

是的,添加了一個 WQ_FLAG_EXCLUSIVE 標記,告訴內核進行排他性的喚醒,即喚醒一個進程後即退出喚醒的過程,問題得以解決。

然而,沒有哪個 web 服務器會傻到多個進程直接阻塞在 accept 上準備接收請求,在更高層次上,多路複用的需求讓 select,poll,epoll 等事件模型更爲受到歡迎,所謂的事件模型即阻塞在事件上而不是阻塞在事務上。內核僅僅通知發生了某件事,具體發生了什麼事,則有處理進程或者線程自己來 poll。如此一來,這個事件模型 (無論其實現是 select,poll,還是 epoll) 便可以一次蒐集多個事件,從而滿足多路複用的需求。

好了,基本原理就介紹到這裏,下面我將來詳細談一下 Linux epoll 中的驚羣問題,我們知道 epoll 在實際中要比直接 accept 實用性強很多,據我所知,除非編程學習或者驗證性小 demo,幾乎沒有直接 accept 的代碼,所有的線上代碼幾乎都使用了事件模型。然而由於 select,poll 沒有可擴展性,存在 O(n)O(n) 問題,因此在帶寬越來越高,服務器性能越來越強的趨勢下,越來越多的代碼將收斂到使用 epoll 的情形,所以有必要對其進行深入的討論。

Linux epoll 驚羣問題

知乎上有一個問題:

https://www.zhihu.com/question/24169490/answers/created

建議先看一下,但不要看回答,因爲知乎上上的很多回答往往會讓事情變得更加混亂,除非你自己對這個問題已經有了自己的答案或者觀點,否則還是不要去指望在諸多的答案中選一個自己滿意的來用,還是要自己先思考。

下面我來就這個問題給一個答案,這也是我自己思考的答案:

在 ep_poll 的睡眠中加入 WQ_FLAG_EXCLUSIVE 標記,確實實實在在解決了 epoll 的驚羣問題

epoll_wait 返回後確實也還有多個進程被喚醒只有一個進程能正確處理其他進程無事可做的情況發生,但這不是因爲驚羣,而是你的使用方法不對。


What?使用方法不對?

是的,使用方法不對。若想了解 Why,則必須對 epoll 的實現細節以及其對外提供的 API 的語義有充分的理解,接下來我們就循着這個思路來擼個所以然。請繼續閱讀。

Linux epoll 的實現機制

說起實現原理,很多人喜歡擼源碼分析,我並不喜歡,我認爲源碼是自己看看就行了,搞這個行業的能看懂代碼是一個最最基本的能力,我比較在意的是對某種機制內在邏輯的深入理解,而這個通過代碼是體現不出來的,我一般會做下面幾件事:

不多說。

下面是我總結的一張關於 Linux epoll 的原理圖

要說代碼實現上,其實也比較簡單,大致有以下的幾個邏輯:

  1. 創建 epoll 句柄,初始化相關數據結構

  2. 爲 epoll 句柄添加文件句柄,註冊睡眠 entry 的回調

  3. 事件發生,喚醒相關文件句柄睡眠隊列的 entry,調用其回調

  4. 喚醒 epoll 睡眠隊列的 task,蒐集並上報數據

來,一個一個說

1. 創建 epoll 句柄,初始化相關數據結構

這裏主要就是創建一個 epoll 文件描述符,注意,後面操作 epoll 的時候,就是用這個 epoll 的文件描述符來操作的,所以這就是 epoll 的句柄,精簡過後的 epoll 結構如下:

 struct eventpoll {
    // 阻塞在epoll_wait的task的睡眠隊列
    wait_queue_head_t wq;
    // 存在就緒文件句柄的list,該list上的文件句柄事件將會全部上報給應用
    struct list_head rdllist;
    // 存放加入到此epoll句柄的文件句柄的紅黑樹容器
    struct rb_root rbr;
    // 該epoll結構對應的文件句柄,應用通過它來操作該epoll結構
    struct file *file;
};

2. 爲 epoll 句柄添加文件句柄,註冊睡眠 entry 的回調

這個步驟中其實有兩個子步驟:

1). 添加文件句柄

將一個文件句柄,比如 socket 添加到 epoll 的 rbr 紅黑樹容器中,注意,這裏的文件句柄最終也是一個包裝結構,和 epoll 的結構體類似:

struct epitem {
    // 該字段鏈接入epoll句柄的紅黑樹容器
    struct rb_node rbn;
    // 當該文件句柄有事件發生時,該字段鏈接入“就緒鏈表”,準備上報給用戶態
    struct list_head rdllink;
    // 該字段封裝實際的文件,我已經將其展開
    struct epoll_filefd {
        struct file *file;
        int fd;
    } ffd;
    // 反向指向其所屬的epoll句柄
    struct eventpoll *ep;
};

以上結構實例就是 epi,將被添加到 epoll 的 rbr 容器中的邏輯如下:

struct eventpoll *ep = 待加入文件句柄所屬的epoll句柄;
struct file *tfile = 待加入的文件句柄file結構體;
int fd = 待加入的文件描述符ID;
struct epitem *epi = kmem_cache_alloc(epi_cache, GFP_KERNEL);
INIT_LIST_HEAD(&epi->rdllink);
INIT_LIST_HEAD(&epi->fllink);
INIT_LIST_HEAD(&epi->pwqlist);
epi->ep = ep;
ep_set_ffd(&epi->ffd, tfile, fd);
...
ep_rbtree_insert(ep, epi);

2). 註冊睡眠 entry 回調並 poll 文件句柄

在第一個子步驟的代碼邏輯中,我有一段 “…” 省略掉了,這部分比較關鍵,所以我單獨抽取了出來作爲第二個子步驟。

我們知道,Linux 內核的 sleep/wakeup 機制非常重要,幾乎貫穿了所有的內核子系統,值得注意的是,這裏的 sleep/wakeup 依然採用了 OO 的思想,並沒有限制睡眠的 entry 一定要是一個 task,而是將睡眠的 entry 做了一層抽象,即:

struct __wait_queue {
    unsigned int flags;
    // 至於這個private到底是什麼,內核並不限制,顯然,它可以是task,也可以是別的。
    void *private;
    wait_queue_func_t func;
    struct list_head task_list;
};

以上的這個 entry,最終要睡眠在下面的數據結構實例化的一個鏈表上:

struct __wait_queue_head {
    spinlock_t lock;
    struct list_head task_list;
};

顯然,在這裏,一個文件句柄均有自己睡眠隊列用於等待自己發生事件的 entry 在沒有發生事件時來歇息,對於 TCP socket 而言,該睡眠隊列就是其 sk_wq,通過以下方式取到:

static inline wait_queue_head_t *sk_sleep(struct sock *sk)
{
    return &rcu_dereference_raw(sk->sk_wq)->wait;
}

我們需要一個 entry 將來在發生事件的時候從上述 wait_queue_head_t 中被喚醒,執行特定的操作,即將自己放入到 epoll 句柄的 “就緒鏈表” 中。下面的函數可以完成該邏輯的框架:

// 此處的whead就是上面例子中的sk_sleep返回的wait_queue_head_t實例。
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead,
                 poll_table *pt)
{
    struct epitem *epi = ep_item_from_epqueue(pt);
    struct eppoll_entry *pwq;
    if (pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL)) {
        // 發生事件即調用ep_poll_callback回調函數,該回調函數會將自己這個epitem加入到epoll的“就緒鏈表”中去。
        init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
        // 是否排他喚醒取決於用戶的配置,有些IO是希望喚醒所有entry來處理,有些則不必。注意,這裏是針對文件句柄IO而言的,並不是針對epoll句柄的。
        if (epi->event.events & EPOLLEXCLUSIVE)
            add_wait_queue_exclusive(whead, &pwq->wait);
        else
            add_wait_queue(whead, &pwq->wait);
    } 
}

至於說什麼時候調用上面的函數,Linux 的 poll 機制仍然是採用了分層抽象的思想,即上述函數會作爲另一個回調在相關文件句柄的 poll 函數中被調用。即:

static inline unsigned int ep_item_poll(struct epitem *epi, poll_table *pt)
{
    pt->_key = epi->event.events;
    return epi->ffd.file->f_op->poll(epi->ffd.file, pt) & epi->event.events;
}

對於 TCP socket 而言,其 file_operations 的 poll 回調即:

unsigned int tcp_poll(struct file *file, struct socket *sock, poll_table *wait)
{
    unsigned int mask;
    struct sock *sk = sock->sk;
    const struct tcp_sock *tp = tcp_sk(sk);
    // 此函數會調用poll_wait->wait._qproc
    // 而wait._qproc就是ep_ptable_queue_proc
    sock_poll_wait(file, sk_sleep(sk), wait);
    ...
}

現在,我們可以把子步驟 1 中的邏輯補全了:

struct eventpoll *ep = 待加入文件句柄所屬的epoll句柄;
struct file *tfile = 待加入的文件句柄file結構體;
int fd = 待加入的文件描述符ID;
struct epitem *epi = kmem_cache_alloc(epi_cache, GFP_KERNEL);
INIT_LIST_HEAD(&epi->rdllink);
INIT_LIST_HEAD(&epi->fllink);
INIT_LIST_HEAD(&epi->pwqlist);
epi->ep = ep;
ep_set_ffd(&epi->ffd, tfile, fd);
// 這裏會將wait._qproc初始化成ep_ptable_queue_proc
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
// 這裏會調用wait._qproc即ep_ptable_queue_proc,安排entry的回調函數ep_poll_callback,並將entry“睡眠”在socket的sk_wq這個睡眠隊列上。
revents = ep_item_poll(epi, &epq.pt);
ep_rbtree_insert(ep, epi);
// 如果剛纔的ep_item_poll取出了事件,隨即將該item掛入“就緒隊列”中,並且wakeup阻塞在epoll_wait系統調用中的task!
if ((revents & event->events) && !ep_is_linked(&epi->rdllink)) {
    list_add_tail(&epi->rdllink, &ep->rdllist);
    if (waitqueue_active(&ep->wq))
        wake_up_locked(&ep->wq);
}

3. 事件發生,喚醒相關文件句柄睡眠隊列的 entry,調用其回調

上面已經很詳細地描述了 epoll 的基礎設施了,現在我們假設一個 TCP Listen socket 上來了一個連接請求,已經完成了三次握手,內核希望通知 epoll_wait 返回,然後去取 accept。

內核在 wakeup 這個 socket 的 sk_wq 時,最終會調用到 ep_poll_callback 回調,這個函數我們說了好幾次了,現在看看它的真面目:

static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key)
{
    unsigned long flags;
    struct epitem *epi = ep_item_from_wait(wait);
    struct eventpoll *ep = epi->ep;
    // 這個lock比較關鍵,操作“就緒鏈表”相關的,均需要這個lock,以防丟失事件。
    spin_lock_irqsave(&ep->lock, flags);
    // 如果發生的事件我們並不關注,則不處理直接返回即可。
    if (key && !((unsigned long) key & epi->event.events))
        goto out_unlock;
    // 實際將發生事件的epitem加入到“就緒鏈表”中。
    if (!ep_is_linked(&epi->rdllink)) {
        list_add_tail(&epi->rdllink, &ep->rdllist);
    }
    // 既然“就緒鏈表”中有了新成員,則喚醒阻塞在epoll_wait系統調用的task去處理。注意,如果本來epi已經在“就緒隊列”了,這裏依然會喚醒並處理的。
    if (waitqueue_active(&ep->wq)) {
        wake_up_locked(&ep->wq);
    }
out_unlock:
    spin_unlock_irqrestore(&ep->lock, flags);
    ...
}

沒什麼好多說的。現在 “就緒鏈表” 已經有 epi 了,接下來就要喚醒 epoll_wait 進程去處理了。

4. 喚醒 epoll 睡眠隊列的 task,蒐集並上報數據

這個邏輯主要集中在 ep_poll 函數,精簡版如下:

static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
           int maxevents, long timeout)
{
    unsigned long flags;
    wait_queue_t wait;
    // 當前沒有事件才睡眠
    if (!ep_events_available(ep)) {
        init_waitqueue_entry(&wait, current);
        __add_wait_queue_exclusive(&ep->wq, &wait);
        for (;;) {
            set_current_state(TASK_INTERRUPTIBLE);
            ...// 例行的schedule timeout
        }
        __remove_wait_queue(&ep->wq, &wait);
        set_current_state(TASK_RUNNING);
    }
    // 往用戶態上報事件,即那些epoll_wait返回後能獲取的事件。
    ep_send_events(ep, events, maxevents);
}

其中關鍵在 ep_send_events,這個函數實現了非常重要的邏輯,包括 LT 和 ET 的邏輯,我不打算深入去解析這個函數,只是大致說下流程:

ep_scan_ready_list()
{
    // 遍歷“就緒鏈表”
    ready_list_for_each() {
        // 將epi從“就緒鏈表”刪除
        list_del_init(&epi->rdllink);
        // 實際獲取具體的事件。
        // 注意,睡眠entry的回調函數只是通知有“事件”,具體需要每一個文件句柄的特定poll回調來獲取。
        revents = ep_item_poll(epi, &pt);
        if (revents) {
            if (__put_user(revents, &uevent->events) ||
                __put_user(epi->event.data, &uevent->data)) {
                // 如果沒有完成,則將epi重新加回“就緒鏈表”等待下次。
                list_add(&epi->rdllink, head);
                return eventcnt ? eventcnt : -EFAULT;
            }
            // 如果是LT模式,則無論如何都會將epi重新加回到“就緒鏈表”,等待下次重新再poll以確認是否仍然有未處理的事件。這也符合“水平觸發”的邏輯,即“只要你不處理,我就會一直通知你”。
            if (!(epi->event.events & EPOLLET)) {
                list_add_tail(&epi->rdllink, &ep->rdllist);
            }
        }
    }
    // 如果“就緒鏈表”上仍有未處理的epi,且有進程阻塞在epoll句柄的睡眠隊列,則喚醒它!(這將是LT驚羣的根源)
    if (!list_empty(&ep->rdllist)) {
        if (waitqueue_active(&ep->wq))
            wake_up_locked(&ep->wq);
    }
}

這裏的代碼邏輯的分析過程就到此爲止了。以對這個代碼邏輯的充分理解爲基礎,接下來我們就可以看具體的問題細節了。

下面一小節先從 LT(水平觸發模式) 以及 ET(即邊沿觸發模式) 開始。

epoll 的 LT 和 ET 以及相關細節問題

簡單點解釋:

LT 水平觸發

如果事件來了,不管來了幾個,只要仍然有未處理的事件,epoll 都會通知你。

ET 邊沿觸發

如果事件來了,不管來了幾個,你若不處理或者沒有處理完,除非下一個事件到來,否則 epoll 將不會再通知你。

理解了上面說的兩個模式,便可以很明確地展示可能會遇到的問題以及解決方案了,這將非常簡單。

LT 水平觸發模式的問題以及解決

下面是 epoll 使用中非常常見的代碼框架,我將問題註釋於其中:

// 否則會阻塞在IO系統調用,導致沒有機會再epoll
set_socket_nonblocking(sd);
epfd = epoll_create(64);
event.data.fd = sd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sd, &event);
while (1) {
    epoll_wait(epfd, events, 64, xx);
    ... // 危險區域!如果有共享同一個epfd的進程/線程調用epoll_wait,它們也將會被喚醒!
// 這個accept將會有多個進程/線程調用,如果併發請求數很少,那麼將僅有幾個進程會成功:
// 1. 假設accept隊列中有n個請求,則僅有n個進程能成功,其它將全部返回EAGAIN (Resource temporarily unavailable)
// 2. 如果n很大(即增加請求負載),雖然返回EAGAIN的比率會降低,但這些進程也並不一定取到了epoll_wait返回當下的那個預期的請求。
    csd = accept(sd, &in_addr, &in_len); 
    ...
}

這一切爲什麼會發生?

我們結合理論和代碼一起來分析。

再看一遍 LT 的描述 “如果事件來了,不管來了幾個,只要仍然有未處理的事件,epoll 都會通知你。”,顯然,epoll_wait 剛剛取到事件的時候的時候,不可能馬上就調用 accept 去處理,事實上,邏輯在 epoll_wait 函數調用的 ep_poll 中還沒返回的,這個時候,顯然符合“仍然有未處理的事件” 這個條件,顯然這個時候爲了實現這個語義,需要做的就是通知別的同樣阻塞在同一個 epoll 句柄睡眠隊列上的進程!在實現上,這個語義由兩點來保證:

保證 1:在 LT 模式下,“就緒鏈表” 上取出的 epi 上報完事件後會重新加回 “就緒鏈表”;

保證 2:如果 “就緒鏈表” 不爲空,且此時有進程阻塞在同一個 epoll 句柄的睡眠隊列上,則喚醒它。

ep_scan_ready_list()
{
    // 遍歷“就緒鏈表”
    ready_list_for_each() {
        list_del_init(&epi->rdllink);
        revents = ep_item_poll(epi, &pt);
        // 保證1
        if (revents) {
            __put_user(revents, &uevent->events);
            if (!(epi->event.events & EPOLLET)) {
                list_add_tail(&epi->rdllink, &ep->rdllist);
            }
        }
    }
    // 保證2
    if (!list_empty(&ep->rdllist)) {
        if (waitqueue_active(&ep->wq))
            wake_up_locked(&ep->wq);
    }
}

我們來看一個情景分析。

假設 LT 模式下有 10 個進程共享同一個 epoll 句柄,此時來了一個請求 client 進入到 accept 隊列,我們發現上述的 1 和 2 是一個循環喚醒的過程:

1). 假設進程 a 的 epoll_wait 首先被 ep_poll_callback 喚醒,那麼滿足 1 和 2,則喚醒了進程 B;

2). 進程 B 在處理 ep_scan_ready_list 的時候,發現依然滿足 1 和 2,於是喚醒了進程 C….

3). 上面 1)和 2)的過程一直到之前某個進程將 client 取出,此時下一個被喚醒的進程在 ep_scan_ready_list 中的 ep_item_poll 調用中將得不到任何事件,此時便不會再將該 epi 加回 “就緒鏈表” 了,LT 水平觸發結束,結束了這場悲傷的夢!

問題非常明確了,但是怎麼解決呢?也非常簡單,讓不同進程的 epoll_waitI 調用互斥即可。

但是且慢!

上面的情景分析所展示的是一個 “驚羣效應” 嗎?其實並不是!對於 Listen socket,當然要避免這種情景,但是對於很多其它的 I/O 文件句柄,說不定還指望着大家一起來 read 數據呢… 所以說,要說互斥也僅僅要針對 Listen socket 的 epoll_wait 調用而言。

換句話說,這裏 epoll LT 模式下有進程被不必要喚醒,這一點並不是內核無意而爲之的,內核肯定是知道這件事的,這個並不像之前 accept 驚羣那樣算是內核的一個缺陷。epoll LT 模式只是提供了一種模式,誤用這種模式將會造成類似驚羣那樣的效應。但是不管怎麼說,爲了討論上的方便,後面我們姑且將這種效應稱作 epoll LT 驚羣吧。

除了 epoll_wait 互斥之外,還有一種解決問題的方案,即使用 ET 邊沿觸發模式,但是會遇到新的問題,我們接下來來描述。

ET 邊沿觸發模式的問題以及解決

ET 模式不滿足上述的 “保證 1”,所以不會將已經上報事件的 epi 重新鏈接回 “就緒鏈表”,也就是說,只要一個 “就緒隊列” 上的 epi 上的事件被上報了,它就會被刪除出 “就緒隊列”。

由於 epi entry 的 callback 即 ep_poll_callback 所做的事情僅僅是將該 epi 自身加入到 epoll 句柄的 “就緒鏈表”,同時喚醒在 epoll 句柄睡眠隊列上的 task,所以這裏並不對事件的細節進行計數,比如說,如果 ep_poll_callback 在將一個 epi 加入 “就緒鏈表” 之前發現它已經在 “就緒鏈表” 了,那麼就不會再次添加,因此可以說,一個 epi 可能 pending 了多個事件,注意到這點非常重要!

一個 epi 上 pending 多個事件,這個在 LT 模式下沒有任何問題,因爲獲取事件的 epi 總是會被重新添加回 “就緒鏈表”,那麼如果還有事件,在下次 check 的時候總會取到。然而對於 ET 模式,僅僅將 epi 從 “就緒鏈表” 刪除並將事件本身上報後就返回了,因此如果該 epi 裏還有事件,則只能等待再次發生事件,進而調用 ep_poll_callback 時將該 epi 加入“就緒隊列”。這意味着什麼?

這意味着,應用程序,即 epoll_wait 的調用進程必須自己在獲取事件後將其處理乾淨後方可再次調用 epoll_wait,否則 epoll_wait 不會返回,而是必須等到下次產生事件的時候方可返回。即,依然以 accept 爲例,必須這樣做:

// 否則會阻塞在IO系統調用,導致沒有機會再epoll
set_socket_nonblocking(sd);
epfd = epoll_create(64);
event.data.fd = sd;
// 添加ET標記
event.events |= EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_ADD, sd, &event);
while (1) {
    epoll_wait(epfd, events, 64, xx);
    while ((csd = accept(sd, &in_addr, &in_len)) > 0) {
        do_something(...);
    } 
    ...
}

好了,解釋完了。

以上就是 epoll 的 LT,ET 相關的兩個問題和解決方案。接下來的一節,我將用一個小小的簡單 Demo 來重現上面描述的理論和代碼。

測試 demo

是時候給出一個實際能 run 的代碼了:

#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netdb.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <time.h>
#include <signal.h>
#define COUNT 1
int mode = 0;
int slp = 0;
int pid[COUNT] = {0};
int count = 0;
void server(int epfd)
{
struct epoll_event *events;
int num, i;
struct timespec ts;
events = calloc(64, sizeof(struct epoll_event));
while (1) {
int sd, csd;
struct sockaddr in_addr;
  num = epoll_wait(epfd, events, 64, -1);
if (num <= 0) {
continue;
        }
/*
        ts.tv_sec = 0;
        ts.tv_nsec = 1;
        if(nanosleep(&ts, NULL) != 0) {
            perror("nanosleep");
            exit(1);
        }
        */
// 用於測試ET模式下丟事件的情況
if (slp) {
            sleep(slp);
        }
        sd = events[0].data.fd;
socklen_t in_len = sizeof(in_addr);
        csd = accept(sd, &in_addr, &in_len);
if (csd == -1) {
// 打印這個說明中了epoll LT驚羣的招了。
printf("xxxxxxxxxxxxxxxxxxxxxxxxxx:%d\n", getpid()); 
continue;
        }
// 本進程一共成功處理了多少個請求。
        count ++;
printf("get client:%d\n", getpid()); 
        close(csd);
    }
}
static void siguser_handler(int sig)
{
// 在主進程被Ctrl-C退出的時候,每一個子進程均要打印自己處理了多少個請求。
printf("pid:%d  count:%d\n", getpid(), count);
exit(0);
}
static void sigint_handler(int sig)
{
int i = 0;
// 給每一個子進程發信號,要求其打印自己處理了多少個請求。
for (i = 0; i < COUNT; i++) {
        kill(pid[i], SIGUSR1);
    }
}
int main (int argc, char *argv[])
{
int ret = 0;
int listener;
int c = 0;
struct sockaddr_in saddr;
int port;
int status;
int flags;
int epfd;
    struct epoll_event event;
if (argc < 4) {
exit(1);
    }
// 0爲LT模式,1爲ET模式
    mode = atoi(argv[1]);
    port = atoi(argv[2]);
// 是否在處理accept之前耽擱一會兒,這個參數更容易重現問題
    slp = atoi(argv[3]);
    signal(SIGINT, sigint_handler);
    listener = socket(PF_INET, SOCK_STREAM, 0);
    saddr.sin_family = AF_INET;
    saddr.sin_port = htons(port);
    saddr.sin_addr.s_addr = INADDR_ANY;
    bind(listener, (struct sockaddr*)&saddr, sizeof(saddr));
    listen(listener, SOMAXCONN);
    flags = fcntl (listener, F_GETFL, 0);
    flags |= O_NONBLOCK;
    fcntl (listener, F_SETFL, flags);
    epfd = epoll_create(64);
if (epfd == -1) {
        perror("epoll_create");
abort();
    }
    event.data.fd = listener;
    event.events = EPOLLIN;
if (mode == 1) {
        event.events |= EPOLLET;
    } else if (mode == 2) {
        event.events |= EPOLLONESHOT;
    } 
    ret = epoll_ctl(epfd, EPOLL_CTL_ADD, listener, &event);
if (ret == -1) {
        perror("epoll_ctl");
abort();
    }
for(c = 0; c < COUNT; c++) {
int child;
            child = fork();
if(child == 0) {
// 安裝打印count值的信號處理函數
                    signal(SIGUSR1, siguser_handler);
                    server(epfd);
            }
        pid[c] = child;
printf("server:%d  pid:%d\n", c+1, child);
        }
    wait(&status);
    sleep(1000000);
    close (listener);
}

編譯之,爲 a.out。

測試客戶端選用了簡單 webbench,首先我們看一下 LT 水平觸發模式下的問題:

[zhaoya@~/test]$ sudo ./a.out 0 112 0
server:1  pid:9688
server:2  pid:9689
server:3  pid:9690
server:4  pid:9691
server:5  pid:9692
server:6  pid:9693
server:7  pid:9694
server:8  pid:9695
server:9  pid:9696
server:10  pid:9697

另起一個終端運行 webbench,併發 10,測試 5 秒:

[zhaoya@~/test]$ webbench -c 10 -t 5 http://127.0.0.1:112/        
Webbench - Simple Web Benchmark 1.5
Copyright (c) Radim Kolar 1997-2004, GPL Open Source Software.
Benchmarking: GET http://127.0.0.1:112/
10 clients, running 5 sec.

而 a.out 的終端有以下輸出:

...
get client:9690
get client:9688
get client:9691
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:9693
get client:9692
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:9689
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:9697
get client:9691
...

所有的 “xxxxxxxxxxx:” 的行均是被 epoll LT 驚羣不必要喚醒的進程打印的。

接下來用 ET 模式運行:

[zhaoya@~/test]$ sudo ./a.out 1 112 0
1
對應的輸出如下:
...
get client:14462
get client:14462
get client:14464
get client:14464
get client:14462
...
get client:14466
get client:14469
get client:14469
...

沒有任何一行是 xxx,即沒有被不必要喚醒的驚羣現象發生。

以上兩個 case 確認了 epoll LT 模式的驚羣效應是可以通過改用 ET 模式來解決的,接下來我們確認 ET 模式非循環處理會丟失事件。

用 ET 模式運行 a.out,這時將 slp 參數設置爲 1,即在 epoll_wait 返回和實際 accept 之間耽擱 1 秒,這樣可以讓一個 epi 在被加入到 “就緒鏈表” 中之後,在其被實際 accept 處理之前,積累更多的未決事件,即未處理的請求,而我們實驗的目的則是,epoll ET 會丟失這些事件。

webbench 的參數依然如故,a.out 的輸出如下:

[zhaoya@~/test]$ sudo ./a.out 1 114 1   
server:1  pid:31161
server:2  pid:31162
server:3  pid:31163
server:4  pid:31164
server:5  pid:31165
server:6  pid:31166
server:7  pid:31167
server:8  pid:31168
server:9  pid:31169
server:10  pid:31170
get client:31170
get client:31170
get client:31167
...
get client:31167
get client:31169
get client:31170
get client:31167
get client:31169
^Cpid:31170  count:6
pid:31169  count:5
pid:31163  count:0
pid:31168  count:1
pid:31167  count:5
pid:31165  count:3
pid:31166  count:1
pid:31161  count:0
pid:31162  count:0
pid:31164  count:0
User defined signal 1

同樣的 webbench 參數,僅僅處理了十幾個請求,可見大多數都丟掉了。如果我們用 LT 模式,同樣在 sleep 1 秒導致事件擠壓的情況下,是不是會多處理一些呢?我們的預期應該是肯定的,因爲 LT 模式在事件被處理完之前,會一直促使 epoll_wait 返回繼續處理,那麼讓我們試一下:

[zhaoya@~/test]$ sudo ./a.out 0 115 1  
server:1  pid:363
server:2  pid:364
server:3  pid:365
server:4  pid:366
server:5  pid:367
server:6  pid:368
server:7  pid:369
server:8  pid:370
server:9  pid:371
server:10  pid:372
get client:372
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:371
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:365
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:366
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:363
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:367
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:369
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:364
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:368
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:370
get client:370
get client:364
...
get client:363
get client:368
get client:372
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:371
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:370
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:364
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:367
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:366
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:369
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:365
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:363
^Cpid:363  count:5
pid:368  count:5
pid:372  count:6
pid:371  count:5
pid:365  count:5
pid:364  count:5
User defined signal 1

是的,多處理了很多,但是出現了 LT 驚羣,這也是意料之中的事。

最後,讓我們把這個 Demo 代碼小改一下,改成循環處理,依然採用 ET 模式,sleep 1 秒,看看情況會怎樣。修改後的代碼如下:

void server(int epfd)
{
struct epoll_event *events;
int num, i;
struct timespec ts;
events = calloc(64, sizeof(struct epoll_event));
while (1) {
int sd, csd;
struct sockaddr in_addr;
num = epoll_wait(epfd, events, 64, -1);
if (num <= 0) {
      continue;
  }
if (slp)
                sleep(slp);
   sd = events[0].data.fd;
socklen_t in_len = sizeof(in_addr);
// 這裏循環處理,一直到空。
while ((csd = accept(sd, &in_addr, &in_len)) > 0) {
                        count ++;
printf("get client:%d\n", getpid());
                        close(csd);
                }
        }
}

改完代碼後,再做同樣參數的測試,結果大大不同:

[zhaoya@~/test]$ sudo ./a.out 0 116 1
...
get client:3640
get client:3645
get client:3640
get client:3641
get client:3641
get client:3641
^Cpid:3642  count:14
pid:3647  count:33531
pid:3646  count:21824
pid:3648  count:22
pid:3644  count:32219
pid:3645  count:94449
pid:3641  count:8
pid:3640  count:85385
pid:3643  count:13
pid:3639  count:10
User defined signal 1

可以看到,大多數的請求都得到了處理,同樣的邏輯,epoll_wait 返回後的循環讀和一次讀結果顯然不同。

問題和解決方案都很明確了,可以結單了嗎?我想是的,但是在終結這個話題之前,我還想說一些結論性的東西以供備忘和參考。

結論

曾經,爲了實現併發服務器,出現了很多的所謂範式,比如下面的兩個很常見:

範式 1:設置多個 IP 地址,多個 IP 地址同時偵聽相同的端口,前端用 4 層負載均衡或者反向代理來對這些 IP 地址進行請求分發;

範式 2:Master 進程創建一個 Listen socket,然後 fork 出來 N 個 worker 進程,這 N 個 worker 進程同時偵聽這個 socket。

第一個範式與本文講的 epoll 無關,更多的體現一種 IP 層的技術,這裏不談,這裏僅僅說一下第二個範式。

爲了保證元組的唯一性以及處理的一致性,很長時間以來對於服務器而言,是不允許 bind 同一個 IP 地址和端口對的。然而爲了可以併發處理多個連接請求,則必須採用某種多處理的方式,爲了多個進程可以同時偵聽同一個 IP 地址端口對,便出現了 create listener+fork 這種模型,具體來講就是:

sd = create_listen_socket();
for (i = 0; i < N; i++) {
    if (fork() == 0) {
        // 繼承了父進程的文件描述符
        server(sd);
    }
}

然而這種模式僅僅是做到了進程級的可擴展性,即一個進程在忙時,其它進程可以介入幫忙處理,底層的 socket 句柄其實是同一個!簡單點說,這是一個沙漏模型:

這種模型在處理同一個 socket 的時候,必須互斥,同時內核必須防止潛在的驚羣效應,因爲互斥的要求,有且僅有一個進程可以處理特定的請求。這就對編程造成了極大的干擾。

以本文所描述的 case 爲例,如果不清楚 epoll LT 模式和 ET 模式潛在的問題,那麼就很容易誤用 epoll 導致比較令人頭疼的後果。

非常幸運,reuseport 出現後,模型徹底變成了桶狀:

於是乎,使用了 reuseport,一切都變得明朗了:

爲什麼 reuseport 沒有驚羣? 首先我們要知道驚羣發生的原因,就是同時喚醒了多個進程處理一個事件,導致了不必要的 CPU 空轉。爲什麼會喚醒多個進程,因爲發生事件的文件描述符在多個進程之間是共享的。而 reuseport 呢,偵聽同一個 IP 地址端口對的多個 socket 本身在 socket 層就是相互隔離的,在它們之間的事件分發是 TCP/IP 協議棧完成的,所以不會再有驚羣發生。

所以,結論是什麼?

結論就是全部統一採用 reuseport 的方式,徹底解決驚羣問題。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/O_QVxhyS7C3gJluqaLerWQ