內(nèi)容目錄
1. 引言2. 脈絡(luò)3 epoll 多線程擴(kuò)展性3.1特定TCP listen fd的accept(2) 的問題3.1.1 水平觸發(fā)的問題:不必要的喚醒3.1.2 邊緣觸發(fā)的問題:不必要的喚醒以及饑餓3.1.3 怎樣才是正確的做法?3.1.4 其他方案3.2 大量TCP連接的read(2)的問題3.2.1 水平觸發(fā)的問題:數(shù)據(jù)亂序3.2.2 邊緣觸發(fā)的問題:數(shù)據(jù)亂序3.2.3 怎樣才是正確的做法?3.3 epoll load balance 總結(jié)4. epoll之file descriptor與file description4.1 總結(jié)5 引用
1引言
本文來自 Marek’s 博客中 I/O multiplexing part 系列之三和四,原文一共有四篇,主要講 Linux 上 IO 多路復(fù)用的一些問題,本文加入了我的一些個人理解,如有不對之處敬請指出。原文鏈接如下:
ThehistoryoftheSelect(2)syscall[1] Select(2)isfundamentallybroken[2] Epollisfundamentallybroken1/2[3] Epollisfundamentallybroken2/2[4]2脈絡(luò)
系列三和系列四分別講 epoll(2) 存在的兩個不同的問題:
-
系列三主要講 epoll 的多線程擴(kuò)展性的問題
-
系列四主要講 epoll 所注冊的 fd (file descriptor) 和實際內(nèi)核中控制的結(jié)構(gòu) file description 擁有不同的生命周期
我們在此也按照該順序進(jìn)行闡述。
3 epoll 多線程擴(kuò)展性
epoll 的多線程擴(kuò)展性的問題主要體現(xiàn)在做多核之間負(fù)載均衡上,有兩個典型的場景:
-
一個 TCP 服務(wù)器,對同一個 listen fd 在多個 CPU 上調(diào)用
accept(2)
系統(tǒng)調(diào)用 -
大量 TCP 連接調(diào)用
read(2)
系統(tǒng)調(diào)用上
3.1 特定 TCP listen fd 的 accept(2) 的問題
一個典型的場景是一個需要處理大量短連接的 HTTP 1.0 服務(wù)器,由于需要 accept() 大量的 TCP 建連請求,所以希望把這些 accept() 分發(fā)到不同的 CPU 上來處理,以充分利用多 CPU 的能力。
這在實際生產(chǎn)環(huán)境是存在的, Tom Herbert 報告有應(yīng)用需要處理每秒 4 萬個建連請求;當(dāng)有這么多請求的時候,很顯然,將其分散到不同的 CPU 上是合理的。
然后實際上,事情并沒有這么簡單,直到 Linux 4.5 內(nèi)核,都無法通過 epoll(2) 把這些請求水平擴(kuò)展到其他 CPU 上。下面我們來看看 epoll 的兩種模式 LT(level trigger, 水平觸發(fā)) 和 ET(edge trigger, 邊緣觸發(fā)) 在處理這種情況下的問題。
3.1.1 水平觸發(fā)的問題:不必要的喚醒
一個愚蠢的做法是是將同一個 epoll fd 放到不同的線程上來 epoll_wait(),這樣做顯然行不通,同樣,將同一個用于 accept 的 fd 加到不同的線程中的 epoll fd 中也行不通。
這是因為 epoll 的水平觸發(fā)模式和 select(2)
一樣存在 “驚群效應(yīng)”,在不加特殊標(biāo)志的水平觸發(fā)模式下,當(dāng)一個新建連接請求過來時,所有的 worker 線程都都會被喚醒,下面是一個這種 case 的例子:
11. 內(nèi)核:收到一個新建連接的請求
22. 內(nèi)核:由于"驚群效應(yīng)",喚醒兩個正在 epoll_wait()的線程 A 和線程 B
33. 線程A:epoll_wait()返回
44. 線程B:epoll_wait()返回
55. 線程A:執(zhí)行 accept()并且成功
66. 線程B:執(zhí)行 accept()失敗,accept()返回 EAGAIN
其中,線程 B 的喚醒完全沒有必要,僅僅只是浪費寶貴的 CPU 資源而已,水平觸發(fā)模式的 epoll 的擴(kuò)展性很差。
3.1.2 邊緣觸發(fā)的問題:不必要的喚醒以及饑餓
既然水平觸發(fā)模式不行,那是不是邊緣觸發(fā)模式會更好呢?實際上并沒有。我們來看看下面這個例子:
11. 內(nèi)核:收到第一個連接請求。線程 A 和線程 B 兩個線程都在 epoll_wait()上等待。由于采用邊緣觸發(fā)模式,所以只有一個線程會收到通知。這里假定線程 A 收到通知
22. 線程A:epoll_wait()返回
33. 線程A:調(diào)用 accpet()并且成功
44. 內(nèi)核:此時 accept queue 為空,所以將邊緣觸發(fā)的 socket 的狀態(tài)從可讀置成不可讀
55. 內(nèi)核:收到第二個建連請求
66. 內(nèi)核:此時,由于線程 A 還在執(zhí)行 accept()處理,只剩下線程 B 在等待 epoll_wait(),于是喚醒線程 B
77. 線程A:繼續(xù)執(zhí)行 accept()直到返回 EAGAIN
88. 線程B:執(zhí)行 accept(),并返回 EAGAIN,此時線程 B 可能有點困惑("明明通知我有事件,結(jié)果卻返回 EAGAIN")
99. 線程A:再次執(zhí)行 accept(),這次終于返回 EAGAIN
可以看到在上面的例子中,線程 B 的喚醒是完全沒有必要的。另外,事實上邊緣觸發(fā)模式還存在饑餓的問題,我們來看下面這個例子:
11. 內(nèi)核:接收到兩個建連請求。線程 A 和線程 B 兩個線程都在等在 epoll_wait()。由于采用邊緣觸發(fā)模式,只有一個線程會被喚醒,我們這里假定線程 A 先被喚醒
22. 線程A:epoll_wait()返回
33. 線程A:調(diào)用 accpet()并且成功
44. 內(nèi)核:收到第三個建連請求。由于線程 A 還沒有處理完(沒有返回 EAGAIN),當(dāng)前 socket 還處于可讀的狀態(tài),由于是邊緣觸發(fā)模式,所有不會產(chǎn)生新的事件
55. 線程A:繼續(xù)執(zhí)行 accept()希望返回 EAGAIN 再進(jìn)入 epoll_wait()等待,然而它又 accept()成功并處理了一個新連接
66. 內(nèi)核:又收到了第四個建連請求
77. 線程A:又繼續(xù)執(zhí)行 accept(),結(jié)果又返回成功
在這個例子中個,這個 socket 只有一次從不可讀狀態(tài)變成可讀狀態(tài),由于 socket 處于邊緣觸發(fā)模式,內(nèi)核只會喚醒 epoll_wait() 一次。在這個例子中個,所有的建連請求全都會給線程 A,導(dǎo)致這個負(fù)載均衡根本沒有生效,線程 A 很忙而線程 B 沒有活干。
3.1.3 怎樣才是正確的做法?
既然水平觸發(fā)和邊緣觸發(fā)都不行,那怎樣才是正確的做法呢?有兩種 workaround 的方式:
-
最好的也是唯一支持可擴(kuò)展的方式是使用從 Linux 4.5+ 開始出現(xiàn)的水平觸發(fā)模式新增的
EPOLLEXCLUSIVE
標(biāo)志,這個標(biāo)志會保證一個事件只有一個 epoll_wait() 會被喚醒,避免了 “驚群效應(yīng)”,并且可以在多個 CPU 之間很好的水平擴(kuò)展。 -
當(dāng)內(nèi)核不支持
EPOLLEXCLUSIVE
時,可以通過 ET 模式下的EPOLLONESHOT
來模擬 LT +EPOLLEXCLUSIVE
的效果,當(dāng)然這樣是有代價的,需要在每個事件處理完之后額外多調(diào)用一次 epoll_ctl(EPOLL_CTL_MOD) 重置這個 fd。這樣做可以將負(fù)載均分到不同的 CPU 上,但是同一時刻,只能有一個 worker 調(diào)用 accept(2)。顯然,這樣又限制了處理 accept(2) 的吞吐。下面是這樣做的例子: -
內(nèi)核:接收到兩個建連請求。線程 A 和 線程 B 兩個線程都在等在 epoll_wait()。由于采用邊緣觸發(fā)模式,只有一個線程會被喚醒,我們這里假定線程 A 先被喚醒
-
線程A:epoll_wait() 返回
-
線程A:調(diào)用 accpet() 并且成功
-
線程A:調(diào)用 epoll_ctl(EPOLL_CTL_MOD),這樣會重置 EPOLLONESHOT 狀態(tài)并將這個 socket fd 重新準(zhǔn)備好 “
3.1.4 其他方案
當(dāng)然,如果不依賴于 epoll() 的話,也還有其他方案。一種方案是使用 SO_REUSEPORT
這個 socket option,創(chuàng)建多個 listen socket 共用一個端口號,不過這種方案其實也存在問題: 當(dāng)一個 listen socket fd 被關(guān)了,已經(jīng)被分到這個 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_CBPF
和 SO_ATTACH_REUSEPORT_EBPF
這兩個 BPF 相關(guān)的 socket option。通過巧妙的設(shè)計,應(yīng)該可以避免掉建連請求被丟掉的情況。
3.2 大量 TCP 連接的 read(2) 的問題
除了 3.1 中說的 accept(2) 的問題之外, 普通的 read(2) 在多核系統(tǒng)上也會有擴(kuò)展性的問題。設(shè)想以下場景:一個 HTTP 服務(wù)器,需要跟大量的 HTTP client 通信,你希望盡快的處理每個客戶端的請求。而每個客戶端連接的請求的處理時間可能并不一樣,有些快有些慢,并且不可預(yù)測,因此簡單的將這些連接切分到不同的 CPU 上,可能導(dǎo)致平均響應(yīng)時間變長。一種更好的排隊策略可能是:用一個 epoll fd 來管理這些連接并設(shè)置 EPOLLEXCLUSIVE
,然后多個 worker 線程來 epoll_wait(),取出就緒的連接并處理[注1]。油管上有個視頻介紹這種稱之為 “combined queue” 的模型。
下面我們來看看 epoll 處理這種模型下的問題:
3.2.1 水平觸發(fā)的問題:數(shù)據(jù)亂序
實際上,由于水平觸發(fā)存在的 “驚群效應(yīng)”,我們并不想用該模型。另外,即使加上 EPOLLEXCLUSIVE
標(biāo)志,仍然存在數(shù)據(jù)競爭的情況,我們來看看下面這個例子:
11. 內(nèi)核:收到 2047 字節(jié)的數(shù)據(jù)
22. 內(nèi)核:線程 A 和線程 B 兩個線程都在 epoll_wait(),由于設(shè)置了 EPOLLEXCLUSIVE,內(nèi)核只會喚醒一個線程,假設(shè)這里先喚醒線程 A
33. 線程A:epoll_wait()返回
44. 內(nèi)核:內(nèi)核又收到 2 個字節(jié)的數(shù)據(jù)
55. 內(nèi)核:線程 A 還在干活,當(dāng)前只有線程 B 在 epoll_wait(),內(nèi)核喚醒線程 B
66. 線程A:調(diào)用 read(2048)并讀走 2048 字節(jié)數(shù)據(jù)
77. 線程B:調(diào)用 read(2048)并讀走剩下的 1 字節(jié)數(shù)據(jù)
這上述場景中,數(shù)據(jù)會被分片到兩個不同的線程,如果沒有鎖保護(hù)的話,數(shù)據(jù)可能會存在亂序。
3.2.2 邊緣觸發(fā)的問題:數(shù)據(jù)亂序
既然水平觸發(fā)模型不行,那么邊緣觸發(fā)呢?實際上也存在相同的競爭,我們看看下面這個例子:
11. 內(nèi)核:收到 2048 字節(jié)的數(shù)據(jù)
22. 內(nèi)核:線程 A 和線程 B 兩個線程都在 epoll_wait(),由于設(shè)置了 EPOLLEXCLUSIVE,內(nèi)核只會喚醒一個線程,假設(shè)這里先喚醒線程 A
33. 線程A:epoll_wait()返回
44. 線程A:調(diào)用 read(2048)并返回 2048 字節(jié)數(shù)據(jù)
55. 內(nèi)核:緩沖區(qū)數(shù)據(jù)全部已經(jīng)讀完,又重新將該 fd 掛到 epoll 隊列上
66. 內(nèi)核:收到 1 字節(jié)的數(shù)據(jù)
77. 內(nèi)核:線程 A 還在干活,當(dāng)前只有線程 B 在 epoll_wait(),內(nèi)核喚醒線程 B
88. 線程B:epoll_wait()返回
99. 線程B:調(diào)用 read(2048)并且只讀到了 1 字節(jié)數(shù)據(jù)
1010. 線程A:再次調(diào)用 read(2048),此時由于內(nèi)核緩沖區(qū)已經(jīng)沒有數(shù)據(jù),返回 EAGAIN
3.2.3 怎樣才是正確的做法?
實際上,要保證同一個連接的數(shù)據(jù)始終落到同一個線程上,在上述 epoll 模型下,唯一的方法就是 epoll_ctl 的時候加上 EPOLLONESHOT
標(biāo)志,然后在每次處理完重新把這個 socket fd 加到 epoll 里面去。
3.3 epoll load balance 總結(jié)
要正確的用好 epoll(2) 并不容易,要用 epoll 實現(xiàn)負(fù)載均衡并且避免數(shù)據(jù)競爭,必須掌握好 EPOLLONESHOT
和 EPOLLEXCLUSIVE
這兩個標(biāo)志。而 EPOLLEXCLUSIVE
又是個 epoll 后來新加的標(biāo)志,所以我們可以說 epoll 最初設(shè)計時,并沒有想著支持這種多線程負(fù)載均衡的場景。
4. epoll 之 file descriptor 與 file description
這一章我們主要討論 epoll 的另一個大問題:file descriptor 與 file description 生命周期不一致的問題。
Foom 在 LWN[6] 上說道:
1顯然 epoll 存在巨大的設(shè)計缺陷,任何懂得 file descriptor 的人應(yīng)該都能看得出來。事實上當(dāng)你回望 epoll 的歷史,你會發(fā)現(xiàn)當(dāng)時實現(xiàn) epoll 的人們顯然并不怎么了解 file descriptor 和 file description 的區(qū)別。:(
實際上,epoll() 的這個問題主要在于它混淆了用戶態(tài)的 file descriptor (我們平常說的數(shù)字 fd) 和內(nèi)核態(tài)中真正用于實現(xiàn)的 file description。當(dāng)進(jìn)程調(diào)用 close(2) 關(guān)閉一個 fd 時,這個問題就會體現(xiàn)出來。
epoll_ctl(EPOLL_CTL_ADD)
實際上并不是注冊一個 file descriptor (fd),而是將 fd 和 一個指向內(nèi)核 file description 的指針的對 (tuple) 一塊注冊給了 epoll,導(dǎo)致問題的根源在于,epoll 里管理的 fd 的生命周期,并不是 fd 本身的,而是內(nèi)核中相應(yīng)的 file description 的。
當(dāng)使用 close(2) 這個系統(tǒng)調(diào)用關(guān)掉一個 fd 時,如果這個 fd 是內(nèi)核中 file description 的唯一引用時,內(nèi)核中的 file description 也會跟著一并被刪除,這樣是 OK 的;但是當(dāng)內(nèi)核中的 file description 還有其他引用時,close 并不會刪除這個 file descrption。這樣會導(dǎo)致當(dāng)這個 fd 還沒有從 epoll 中挪出就被直接 close 時,epoll() 還會在這個已經(jīng) close() 掉了的 fd 上上報事件。
這里以 dup(2) 系統(tǒng)調(diào)用為例來展示這個問題:
1rfd,wfd=pipe()
2write(wfd,"a")#Makethe"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)#Whatwillhappen?
由于 close(rfd) 關(guān)掉了這個 rfd,你可能會認(rèn)為這個 epoll_wait() 會一直阻塞不返回,而實際上并不是這樣。由于調(diào)用了 dup(),內(nèi)核中相應(yīng)的 file description 仍然還有一個引用計數(shù)而沒有被刪除,所以這個 file descption 的事件仍然會上報給 epoll。因此 epoll_wait()
會給一個已經(jīng)不存在的 fd 上報事件。更糟糕的是,一旦你 close() 了這個 fd,再也沒有機(jī)會把這個死掉的 fd 從 epoll 上摘除了,下面的做法都不行:
1epoll_ctl(efpd,EPOLL_CTL_DEL,rfd)
2epoll_ctl(efpd,EPOLL_CTL_DEL,rfd2)
Marc Lehmann 也提到這個問題:
1因此,存在 close 掉了一個 fd,卻還一直從這個 fd 上收到 epoll 事件的可能性。并且這種情況一旦發(fā)生,不管你做什么都無法恢復(fù)了。
因此,并不能依賴于 close()
來做清理工作,一旦調(diào)用了 close(),而正好內(nèi)核里面的 file description 還有引用,這個 epoll fd 就再也修不好了,唯一的做法是把的 epoll fd 給干掉,然后創(chuàng)建一個新的并將之前那些 fd 全部再加到這個新的 epoll fd 上。所以記住這條忠告:
1永遠(yuǎn)記著先在調(diào)用close()之前,顯示的調(diào)用epoll_ctl(EPOLL_CTL_DEL)
4.1 總結(jié)
顯式的將 fd 從 epoll 上面刪掉在調(diào)用 close()
的話可以工作的很好,前提是你對所有的代碼都有掌控力。然后在一些場景里并不一直是這樣,譬如當(dāng)寫一個封裝 epoll 的庫,有時你并不能禁止用戶調(diào)用 close(2) 系統(tǒng)調(diào)用。因此,要寫一個基于 epoll 的輕量級的抽象層并不是一個輕松的事情。
另外,Illumos 也實現(xiàn)了一套 epoll() 機(jī)制,在他們的手冊上,明確提到 Linux 上這個 epoll()/close() 的奇怪語義,并且拒絕支持。
希望本所提到的問題對于使用 Linux 上這個糟糕的 epoll() 設(shè)計的人有所幫助。
注1:筆者認(rèn)為該場景下或許直接用一個 master 線程來做分發(fā),多個 worker 線程做處理 或者采用每個 worker 線程一個自己獨立的 epoll fd 可能是更好的方案。
-
Linux
+關(guān)注
關(guān)注
87文章
11215瀏覽量
208755 -
多線程
+關(guān)注
關(guān)注
0文章
277瀏覽量
19905 -
epoll
+關(guān)注
關(guān)注
0文章
28瀏覽量
2944
原文標(biāo)題:盤點Linux Epoll那些致命弱點
文章出處:【微信號:yikoulinux,微信公眾號:一口Linux】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
相關(guān)推薦
評論