盤點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 隊列上的請求會被丟掉,具體可以參考 http://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]http://idea.popcount.org/2016-11-01-a-brief-history-of-select2/

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

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

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

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

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

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

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

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

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