盤點Linux Epoll那些致命弱點
微信公眾號: 小梁程式設計匯
點選: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) 存在的兩個不同的問題:
-
系列三主要講 epoll 的多執行緒擴充套件性的問題
-
系列四主要講 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 的方式:
-
最好的也是唯一支援可擴充套件的方式是使用從 Linux 4.5+ 開始出現的水平觸發模式新增的
EPOLLEXCLUSIVE
標誌,這個標誌會保證一個事件只有一個 epoll_wait() 會被喚醒,避免了 “驚群效應”,並且可以在多個 CPU 之間很好的水平擴充套件。 -
當核心不支援
EPOLLEXCLUSIVE
時,可以通過 ET 模式下的EPOLLONESHOT
來模擬 LT +EPOLLEXCLUSIVE
的效果,當然這樣是有代價的,需要在每個事件處理完之後額外多呼叫一次 epoll_ctl(EPOLL_CTL_MOD) 重置這個 fd。這樣做可以將負載均分到不同的 CPU 上,但是同一時刻,只能有一個 worker 呼叫 accept(2)。顯然,這樣又限制了處理 accept(2) 的吞吐。下面是這樣做的例子: -
核心:接收到兩個建連請求。執行緒 A 和 執行緒 B 兩個執行緒都在等在 epoll_wait()。由於採用邊緣觸發模式,只有一個執行緒會被喚醒,我們這裡假定執行緒 A 先被喚醒
-
執行緒A:epoll_wait() 返回
-
執行緒A:呼叫 accpet() 並且成功
-
執行緒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_CBPF
和 SO_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 實現負載均衡並且避免資料競爭,必須掌握好 EPOLLONESHOT
和 EPOLLEXCLUSIVE
這兩個標誌。而 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