盤點Linux Epoll那些致命弱點

語言: CN / TW / HK

微信公眾號: 小梁程式設計匯

點選:point_up_2|type_1_2:,關注作者可瞭解更多技術乾貨;

因未開通留言,問題或建議,請私信留言;

內容目錄

1. 引言 2. 脈絡 3 epoll 多執行緒擴充套件性 3.1特定TCP listen fd的accept(2) 的問題 3.1.1 水平觸發的問題:不必要的喚醒 3.1.2 邊緣觸發的問題:不必要的喚醒以及飢餓 3.1.3 怎樣才是正確的做法? 3.1.4 其他方案 3.2 大量TCP連線的read(2)的問題 3.2.1 水平觸發的問題:資料亂序 3.2.2 邊緣觸發的問題:資料亂序 3.2.3 怎樣才是正確的做法? 3.3 epoll load balance 總結 4. epoll之file descriptor與file description 4.1 總結 5 引用

1 引言

本文來自 Marek’s 部落格中 I/O multiplexing part 系列之三和四,原文一共有四篇,主要講 Linux 上 IO 多路複用的一些問題,本文加入了我的一些個人理解,如有不對之處敬請指出。原文連結如下:

The history of the Select(2) syscall  [1]

Select(2) is fundamentally broken  [2]

Epoll is fundamentally broken 1/2  [3]

Epoll is fundamentally broken 2/2  [4]

2 脈絡

系列三和系列四分別講 epoll(2) 存在的兩個不同的問題:

  1. 系列三主要講 epoll 的多執行緒擴充套件性的問題

  2. 系列四主要講 epoll 所註冊的 fd (file descriptor) 和實際核心中控制的結構 file description 擁有不同的生命週期

我們在此也按照該順序進行闡述。

3 epoll 多執行緒擴充套件性

epoll 的多執行緒擴充套件性的問題主要體現在做多核之間負載均衡上,有兩個典型的場景:

accept(2)
read(2)

3.1 特定 TCP listen fd 的 accept(2) 的問題

一個典型的場景是一個需要處理大量短連線的 HTTP 1.0 伺服器,由於需要 accept() 大量的 TCP 建連請求,所以希望把這些 accept() 分發到不同的 CPU 上來處理,以充分利用多 CPU 的能力。

這在實際生產環境是存在的, Tom Herbert 報告有應用需要處理每秒 4 萬個建連請求;當有這麼多請求的時候,很顯然,將其分散到不同的 CPU 上是合理的。

然後實際上,事情並沒有這麼簡單,直到 Linux 4.5 核心,都無法通過 epoll(2) 把這些請求水平擴充套件到其他 CPU 上。下面我們來看看 epoll 的兩種模式 LT(level trigger, 水平觸發) 和 ET(edge trigger, 邊緣觸發) 在處理這種情況下的問題。

3.1.1 水平觸發的問題:不必要的喚醒

一個愚蠢的做法是是將同一個 epoll fd 放到不同的執行緒上來 epoll_wait(),這樣做顯然行不通,同樣,將同一個用於 accept 的 fd 加到不同的執行緒中的 epoll fd 中也行不通。

這是因為 epoll 的水平觸發模式和 select(2) 一樣存在 “驚群效應”,在不加特殊標誌的水平觸發模式下,當一個新建連線請求過來時,所有的 worker 執行緒都都會被喚醒,下面是一個這種 case 的例子:

11. 核心:收到一個新建連線的請求
22. 核心:由於 "驚群效應" ,喚醒兩個正在 epoll_wait() 的執行緒 A 和執行緒 B
33. 執行緒A:epoll_wait() 返回
44. 執行緒B:epoll_wait() 返回
55. 執行緒A:執行 accept() 並且成功
66. 執行緒B:執行 accept() 失敗,accept() 返回 EAGAIN

其中,執行緒 B 的喚醒完全沒有必要,僅僅只是浪費寶貴的 CPU 資源而已,水平觸發模式的 epoll 的擴充套件性很差。

3.1.2 邊緣觸發的問題:不必要的喚醒以及飢餓

既然水平觸發模式不行,那是不是邊緣觸發模式會更好呢?實際上並沒有。我們來看看下面這個例子:

11. 核心:收到第一個連線請求。執行緒 A 和 執行緒 B 兩個執行緒都在 epoll_wait() 上等待。由於採用邊緣觸發模式,所以只有一個執行緒會收到通知。這裡假定執行緒 A 收到通知
22. 執行緒A:epoll_wait() 返回
33. 執行緒A:呼叫 accpet() 並且成功
44. 核心:此時 accept queue 為空,所以將邊緣觸發的 socket 的狀態從可讀置成不可讀
55. 核心:收到第二個建連請求
66. 核心:此時,由於執行緒 A 還在執行 accept() 處理,只剩下執行緒 B 在等待 epoll_wait(),於是喚醒執行緒 B
77. 執行緒A:繼續執行 accept() 直到返回 EAGAIN
88. 執行緒B:執行 accept(),並返回 EAGAIN,此時執行緒 B 可能有點困惑("明明通知我有事件,結果卻返回 EAGAIN")
99. 執行緒A:再次執行 accept(),這次終於返回 EAGAIN

可以看到在上面的例子中,執行緒 B 的喚醒是完全沒有必要的。另外,事實上邊緣觸發模式還存在飢餓的問題,我們來看下面這個例子:

11. 核心:接收到兩個建連請求。執行緒 A 和 執行緒 B 兩個執行緒都在等在 epoll_wait()。由於採用邊緣觸發模式,只有一個執行緒會被喚醒,我們這裡假定執行緒 A 先被喚醒
22. 執行緒A:epoll_wait() 返回
33. 執行緒A:呼叫 accpet() 並且成功
44. 核心:收到第三個建連請求。由於執行緒 A 還沒有處理完(沒有返回 EAGAIN),當前 socket 還處於可讀的狀態,由於是邊緣觸發模式,所有不會產生新的事件
55. 執行緒A:繼續執行 accept() 希望返回 EAGAIN 再進入 epoll_wait() 等待,然而它又 accept() 成功並處理了一個新連線
66. 核心:又收到了第四個建連請求
77. 執行緒A:又繼續執行 accept(),結果又返回成功

在這個例子中個,這個 socket 只有一次從不可讀狀態變成可讀狀態,由於 socket 處於邊緣觸發模式,核心只會喚醒 epoll_wait() 一次。在這個例子中個,所有的建連請求全都會給執行緒 A,導致這個負載均衡根本沒有生效,執行緒 A 很忙而執行緒 B 沒有活幹。

3.1.3 怎樣才是正確的做法?

既然水平觸發和邊緣觸發都不行,那怎樣才是正確的做法呢?有兩種 workaround 的方式:

  1. 最好的也是唯一支援可擴充套件的方式是使用從 Linux 4.5+ 開始出現的水平觸發模式新增的 EPOLLEXCLUSIVE 標誌,這個標誌會保證一個事件只有一個 epoll_wait() 會被喚醒,避免了 “驚群效應”,並且可以在多個 CPU 之間很好的水平擴充套件。

  2. 當核心不支援 EPOLLEXCLUSIVE 時,可以通過 ET 模式下的 EPOLLONESHOT 來模擬 LT + EPOLLEXCLUSIVE 的效果,當然這樣是有代價的,需要在每個事件處理完之後額外多呼叫一次 epoll_ctl(EPOLL_CTL_MOD) 重置這個 fd。這樣做可以將負載均分到不同的 CPU 上,但是同一時刻,只能有一個 worker 呼叫 accept(2)。顯然,這樣又限制了處理 accept(2) 的吞吐。下面是這樣做的例子:

  3. 核心:接收到兩個建連請求。執行緒 A 和 執行緒 B 兩個執行緒都在等在 epoll_wait()。由於採用邊緣觸發模式,只有一個執行緒會被喚醒,我們這裡假定執行緒 A 先被喚醒

  4. 執行緒A:epoll_wait() 返回

  5. 執行緒A:呼叫 accpet() 並且成功

  6. 執行緒A:呼叫 epoll_ctl(EPOLL_CTL_MOD),這樣會重置 EPOLLONESHOT 狀態並將這個 socket fd 重新準備好 “

3.1.4 其他方案

當然,如果不依賴於 epoll() 的話,也還有其他方案。一種方案是使用 SO_REUSEPORT 這個 socket option,建立多個 listen socket 共用一個埠號,不過這種方案其實也存在問題: 當一個 listen socket fd 被關了,已經被分到這個 listen socket fd 的 accept 佇列上的請求會被丟掉,具體可以參考 https://engineeringblog.yelp.com/2015/04/true-zero-downtime-haproxy-reloads.html 和 LWN 上的 comment [5]

從 Linux 4.5 開始引入了 SO_ATTACH_REUSEPORT_CBPFSO_ATTACH_REUSEPORT_EBPF 這兩個 BPF 相關的 socket option。通過巧妙的設計,應該可以避免掉建連請求被丟掉的情況。

3.2 大量 TCP 連線的 read(2) 的問題

除了 3.1 中說的 accept(2) 的問題之外, 普通的 read(2) 在多核系統上也會有擴充套件性的問題。設想以下場景:一個 HTTP 伺服器,需要跟大量的 HTTP client 通訊,你希望儘快的處理每個客戶端的請求。而每個客戶端連線的請求的處理時間可能並不一樣,有些快有些慢,並且不可預測,因此簡單的將這些連線切分到不同的 CPU 上,可能導致平均響應時間變長。一種更好的排隊策略可能是:用一個 epoll fd 來管理這些連線並設定 EPOLLEXCLUSIVE ,然後多個 worker 執行緒來 epoll_wait(),取出就緒的連線並處理[注1]。油管上有個影片介紹這種稱之為 “combined queue” 的模型。

下面我們來看看 epoll 處理這種模型下的問題:

3.2.1 水平觸發的問題:資料亂序

實際上,由於水平觸發存在的 “驚群效應”,我們並不想用該模型。另外,即使加上 EPOLLEXCLUSIVE 標誌,仍然存在資料競爭的情況,我們來看看下面這個例子:

11. 核心:收到 2047 位元組的資料
22. 核心:執行緒 A 和執行緒 B 兩個執行緒都在 epoll_wait(),由於設定了 EPOLLEXCLUSIVE,核心只會喚醒一個執行緒,假設這裡先喚醒執行緒 A
33. 執行緒A:epoll_wait() 返回
44. 核心:核心又收到 2 個位元組的資料
55. 核心:執行緒 A 還在幹活,當前只有執行緒 B 在 epoll_wait(),核心喚醒執行緒 B
66. 執行緒A:呼叫 read(2048) 並讀走 2048 位元組資料
77. 執行緒B:呼叫 read(2048) 並讀走剩下的 1 位元組資料

這上述場景中,資料會被分片到兩個不同的執行緒,如果沒有鎖保護的話,資料可能會存在亂序。

3.2.2 邊緣觸發的問題:資料亂序

既然水平觸發模型不行,那麼邊緣觸發呢?實際上也存在相同的競爭,我們看看下面這個例子:

 11. 核心:收到 2048 位元組的資料
 22. 核心:執行緒 A 和執行緒 B 兩個執行緒都在 epoll_wait(),由於設定了 EPOLLEXCLUSIVE,核心只會喚醒一個執行緒,假設這裡先喚醒執行緒 A
 33. 執行緒A:epoll_wait() 返回
 44. 執行緒A:呼叫 read(2048) 並返回 2048 位元組資料
 55. 核心:緩衝區資料全部已經讀完,又重新將該 fd 掛到 epoll 佇列上
 66. 核心:收到 1 位元組的資料
 77. 核心:執行緒 A 還在幹活,當前只有執行緒 B 在 epoll_wait(),核心喚醒執行緒 B
 88. 執行緒B:epoll_wait() 返回
 99. 執行緒B:呼叫 read(2048) 並且只讀到了 1 位元組資料
1010. 執行緒A:再次呼叫 read(2048),此時由於核心緩衝區已經沒有資料,返回 EAGAIN

3.2.3 怎樣才是正確的做法?

實際上,要保證同一個連線的資料始終落到同一個執行緒上,在上述 epoll 模型下,唯一的方法就是 epoll_ctl 的時候加上 EPOLLONESHOT 標誌,然後在每次處理完重新把這個 socket fd 加到 epoll 裡面去。

3.3 epoll load balance 總結

要正確的用好 epoll(2) 並不容易,要用 epoll 實現負載均衡並且避免資料競爭,必須掌握好 EPOLLONESHOTEPOLLEXCLUSIVE 這兩個標誌。而 EPOLLEXCLUSIVE 又是個 epoll 後來新加的標誌,所以我們可以說 epoll 最初設計時,並沒有想著支援這種多執行緒負載均衡的場景。

4. epoll 之 file descriptor 與 file description

這一章我們主要討論 epoll 的另一個大問題:file descriptor 與 file description 生命週期不一致的問題。

Foom 在 LWN [6] 上說道:

1顯然 epoll 存在巨大的設計缺陷,任何懂得 file descriptor 的人應該都能看得出來。事實上當你回望 epoll 的歷史,你會發現當時實現 epoll 的人們顯然並不怎麼了解 file descriptor 和 file description 的區別。:(

實際上,epoll() 的這個問題主要在於它混淆了使用者態的 file descriptor (我們平常說的數字 fd) 和核心態中真正用於實現的 file description。當程序呼叫 close(2) 關閉一個 fd 時,這個問題就會體現出來。

epoll_ctl(EPOLL_CTL_ADD) 實際上並不是註冊一個 file descriptor (fd),而是將 fd 和 一個指向核心 file description 的指標的對 (tuple) 一塊註冊給了 epoll,導致問題的根源在於,epoll 裡管理的 fd 的生命週期,並不是 fd 本身的,而是核心中相應的 file description 的。

當使用 close(2) 這個系統呼叫關掉一個 fd 時,如果這個 fd 是核心中 file description 的唯一引用時,核心中的 file description 也會跟著一併被刪除,這樣是 OK 的;但是當核心中的 file description 還有其他引用時,close 並不會刪除這個 file descrption。這樣會導致當這個 fd 還沒有從 epoll 中挪出就被直接 close 時,epoll() 還會在這個已經 close() 掉了的 fd 上上報事件。

這裡以 dup(2) 系統呼叫為例來展示這個問題:

 1rfd, wfd = pipe()
 2write(wfd, "a")             # Make the "rfd" readable
 3
 4epfd = epoll_create()
 5epoll_ctl(efpd, EPOLL_CTL_ADD, rfd, (EPOLLIN, rfd))
 6
 7rfd2 = dup(rfd)
 8close(rfd)
 9
10r = epoll_wait(epfd, -1ms)  # What will happen?

由於 close(rfd) 關掉了這個 rfd,你可能會認為這個 epoll_wait() 會一直阻塞不返回,而實際上並不是這樣。由於呼叫了 dup(),核心中相應的 file description 仍然還有一個引用計數而沒有被刪除,所以這個 file descption 的事件仍然會上報給 epoll。因此 epoll_wait() 會給一個已經不存在的 fd 上報事件。更糟糕的是,一旦你 close() 了這個 fd,再也沒有機會把這個死掉的 fd 從 epoll 上摘除了,下面的做法都不行:

1epoll_ctl(efpd, EPOLL_CTL_DEL, rfd)
2epoll_ctl(efpd, EPOLL_CTL_DEL, rfd2)

Marc Lehmann 也提到這個問題:

1因此,存在 close 掉了一個 fd,卻還一直從這個 fd 上收到 epoll 事件的可能性。並且這種情況一旦發生,不管你做什麼都無法恢復了。

因此,並不能依賴於 close() 來做清理工作,一旦呼叫了 close(),而正好核心裡面的 file description 還有引用,這個 epoll fd 就再也修不好了,唯一的做法是把的 epoll fd 給幹掉,然後建立一個新的並將之前那些 fd 全部再加到這個新的 epoll fd 上。所以記住這條忠告:

1永遠記著先在呼叫 close() 之前,顯示的呼叫 epoll_ctl(EPOLL_CTL_DEL)

4.1 總結

顯式的將 fd 從 epoll 上面刪掉在呼叫 close() 的話可以工作的很好,前提是你對所有的程式碼都有掌控力。然後在一些場景裡並不一直是這樣,譬如當寫一個封裝 epoll 的庫,有時你並不能禁止使用者呼叫 close(2) 系統呼叫。因此,要寫一個基於 epoll 的輕量級的抽象層並不是一個輕鬆的事情。

另外,Illumos 也實現了一套 epoll() 機制,在他們的手冊上,明確提到 Linux 上這個 epoll()/close() 的奇怪語義,並且拒絕支援。

希望本所提到的問題對於使用 Linux 上這個糟糕的 epoll() 設計的人有所幫助。

注1:筆者認為該場景下或許直接用一個 master 執行緒來做分發,多個 worker 執行緒做處理 或者採用每個 worker 執行緒一個自己獨立的 epoll fd 可能是更好的方案。

5 引用

[1]https://idea.popcount.org/2016-11-01-a-brief-history-of-select2/

[2]https://idea.popcount.org/2017-01-06-select-is-fundamentally-broken/

[3]https://idea.popcount.org/2017-02-20-epoll-is-fundamentally-broken-12/

[4]https://idea.popcount.org/2017-03-20-epoll-is-fundamentally-broken-22/

[5]https://lwn.net/Articles/542866/

[6]https://lwn.net/Articles/542866/

[7]https://kernel.taobao.org/2019/12/epoll-is-fundamentally-broken/

[8]https://zh.wikipedia.org/wiki/Epoll

[9]https://stackoverflow.com/questions/4058368/what-does-eagain-mean

如果覺得本文對你有幫助的話,可以點選:point_down|type_1_2:的 收藏、贊和在看支援一下作者噢