作者:后端技術(shù)指南針 來自:后端技術(shù)指南針
0.概述
通過本篇文章將了解到以下內(nèi)容:
- I/O復(fù)用的定義和產(chǎn)生背景
- linux系統(tǒng)的I/O復(fù)用工具
- epoll設(shè)計的基本構(gòu)成
- epoll高性能的底層實現(xiàn)
- epoll的ET模式和LT模式
1.復(fù)用技術(shù)和I/O復(fù)用
- 復(fù)用的概念
復(fù)用技術(shù)(multiplexing)并不是新技術(shù)而是一種設(shè)計思想,在通信和硬件設(shè)計中存在頻分復(fù)用、時分復(fù)用、波分復(fù)用、碼分復(fù)用等,在日常生活中復(fù)用的場景也非常多,因此不要被專業(yè)術(shù)語所迷惑。
從本質(zhì)上來說,復(fù)用就是為了解決有限資源和過多使用者的不平衡問題,且此技術(shù)的理論基礎(chǔ)是資源的可釋放性。
- 資源的可釋放性
舉個實際生活的例子:
不可釋放場景:ICU病房的呼吸機作為有限資源,病人一旦占用且在未脫離危險之前是無法放棄占用的,因此不可能幾個情況一樣的病人輪流使用。
可釋放場景:對于一些其他資源比如醫(yī)護人員就可以實現(xiàn)對多個病人的同時監(jiān)護,理論上不存在一個病人占用醫(yī)護人員資源不釋放的場景。
- 理解IO復(fù)用
I/O的含義:在計算機領(lǐng)域常說的IO包括磁盤IO和網(wǎng)絡(luò)IO,我們所說的IO復(fù)用主要是指網(wǎng)絡(luò)IO,在Linux中一切皆文件,因此網(wǎng)絡(luò)IO也經(jīng)常用文件描述符FD來表示。
復(fù)用的含義:那么這些文件描述符FD要復(fù)用什么呢?在網(wǎng)絡(luò)場景中復(fù)用的就是任務(wù)處理線程,所以簡單理解就是多個IO共用1個線程。
IO復(fù)用的可行性:IO請求的基本操作包括read和write,由于網(wǎng)絡(luò)交互的本質(zhì)性,必然存在等待,換言之就是整個網(wǎng)絡(luò)連接中FD的讀寫是交替出現(xiàn)的,時而可讀可寫,時而空閑,所以IO復(fù)用是可用實現(xiàn)的。
綜上認為,IO復(fù)用技術(shù)就是協(xié)調(diào)多個可釋放資源的FD交替共享任務(wù)處理線程完成通信任務(wù),實現(xiàn)多個fd對應(yīng)1個任務(wù)處理線程。
現(xiàn)實生活中IO復(fù)用就像一只邊牧管理幾百只綿羊一樣:

- IO復(fù)用的設(shè)計原則和產(chǎn)生背景
高效IO復(fù)用機制要滿足:協(xié)調(diào)者消耗最少的系統(tǒng)資源、最小化FD的等待時間、最大化FD的數(shù)量、任務(wù)處理線程最少的空閑、多快好省完成任務(wù)等。
在網(wǎng)絡(luò)并發(fā)量非常小的原始時期,即使per req per process地處理網(wǎng)絡(luò)請求也可以滿足要求,但是隨著網(wǎng)絡(luò)并發(fā)量的提高,原始方式必將阻礙進步,所以就刺激了IO復(fù)用機制的實現(xiàn)和推廣。
2.Linux中IO復(fù)用工具
在Linux中先后出現(xiàn)了select、poll、epoll等,F(xiàn)reeBSD的kqueue也是非常優(yōu)秀的IO復(fù)用工具,kqueue的原理和epoll很類似,本文以Linux環(huán)境為例,并且不討論過多select和poll的實現(xiàn)機制和細節(jié)。
- 開拓者select
select大約是2000年初出現(xiàn)的,其對外的接口定義:

作為第一個IO復(fù)用系統(tǒng)調(diào)用,select使用一個宏定義函數(shù)按照bitmap原理填充fd,默認大小是1024個,因此對于fd的數(shù)值大于1024都可能出現(xiàn)問題,看下官方預(yù)警:

也就是說當(dāng)fd的數(shù)值大于1024時在將不可控,官方不建議超過1024,但是我們也無法控制fd的絕對數(shù)值大小,之前針對這個問題做過一些調(diào)研,結(jié)論是系統(tǒng)對于fd的分配有自己的策略,會大概率分配到1024以內(nèi),對此我并沒有充分理解,只是提及一下這個坑。
存在的問題:
- 可協(xié)調(diào)fd數(shù)量和數(shù)值都不超過1024 無法實現(xiàn)高并發(fā)
- 使用O(n)復(fù)雜度遍歷fd數(shù)組查看fd的可讀寫性 效率低
- 涉及大量kernel和用戶態(tài)拷貝 消耗大
- 每次完成監(jiān)控需要再次重新傳入并且分事件傳入 操作冗余
綜上可知,select以樸素的方式實現(xiàn)了IO復(fù)用,將并發(fā)量提高的最大K級,但是對于完成這個任務(wù)的代價和靈活性都有待提高。無論怎么樣select作為先驅(qū)對IO復(fù)用有巨大的推動,并且指明了后續(xù)的優(yōu)化方向,不要無知地指責(zé)select。
- 繼承者epoll
epoll最初在2.5.44內(nèi)核版本出現(xiàn),后續(xù)在2.6.x版本中對代碼進行了優(yōu)化使其更加簡潔,先后面對外界的質(zhì)疑在后續(xù)增加了一些設(shè)置來解決隱藏的問題,所以epoll也已經(jīng)有十幾年的歷史了。
在《Unix網(wǎng)絡(luò)編程》第三版(2003年)還沒有介紹epoll,因為那個時代epoll還沒有出現(xiàn),書中只介紹了select和poll,epoll對select中存在的問題都逐一解決,簡單來說epoll的優(yōu)勢包括:
- 對fd數(shù)量沒有限制(當(dāng)然這個在poll也被解決了)
- 拋棄了bitmap數(shù)組實現(xiàn)了新的結(jié)構(gòu)來存儲多種事件類型
- 無需重復(fù)拷貝fd 隨用隨加 隨棄隨刪
- 采用事件驅(qū)動避免輪詢查看可讀寫事件
綜上可知,epoll出現(xiàn)之后大大提高了并發(fā)量對于C10K問題輕松應(yīng)對,即使后續(xù)出現(xiàn)了真正的異步IO,也并沒有(暫時沒有)撼動epoll的江湖地位,主要是因為epoll可以解決數(shù)萬數(shù)十萬的并發(fā)量,已經(jīng)可以解決現(xiàn)在大部分的場景了,異步IO固然優(yōu)異,但是編程難度比epoll更大,權(quán)衡之下epoll仍然富有生命力。
3.epoll的基本實現(xiàn)
- epoll的api定義:

- epoll_create是在內(nèi)核區(qū)創(chuàng)建一個epoll相關(guān)的一些列結(jié)構(gòu),并且將一個句柄fd返回給用戶態(tài),后續(xù)的操作都是基于此fd的,參數(shù)size是告訴內(nèi)核這個結(jié)構(gòu)的元素的大小,類似于stl的vector動態(tài)數(shù)組,如果size不合適會涉及復(fù)制擴容,不過貌似4.1.2內(nèi)核之后size已經(jīng)沒有太大用途了;
- epoll_ctl是將fd添加/刪除于epoll_create返回的epfd中,其中epoll_event是用戶態(tài)和內(nèi)核態(tài)交互的結(jié)構(gòu),定義了用戶態(tài)關(guān)心的事件類型和觸發(fā)時數(shù)據(jù)的載體epoll_data;
- epoll_wait是阻塞等待內(nèi)核返回的可讀寫事件,epfd還是epoll_create的返回值,events是個結(jié)構(gòu)體數(shù)組指針存儲epoll_event,也就是將內(nèi)核返回的待處理epoll_event結(jié)構(gòu)都存儲下來,maxevents告訴內(nèi)核本次返回的最大fd數(shù)量,這個和events指向的數(shù)組是相關(guān)的;
- epoll_event是用戶態(tài)需監(jiān)控fd的代言人,后續(xù)用戶程序?qū)d的操作都是基于此結(jié)構(gòu)的;
- 通俗描述:
可能上面的描述有些抽象,不過其實很好理解,舉個現(xiàn)實中的例子:
- epoll_create場景:
- 大學(xué)開學(xué)第一周,你作為班長需要幫全班同學(xué)領(lǐng)取相關(guān)物品,你在學(xué)生處告訴工作人員,我是xx學(xué)院xx專業(yè)xx班的班長,這時工作人員確定你的身份并且給了你憑證,后面辦的事情都需要用到(也就是調(diào)用epoll_create向內(nèi)核申請了epfd結(jié)構(gòu),內(nèi)核返回了epfd句柄給你使用);
- epoll_ctl場景:
- 你拿著憑證在辦事大廳開始辦事,分揀辦公室工作人員說班長你把所有需要辦理事情的同學(xué)的學(xué)生冊和需要辦理的事情都記錄下來吧,于是班長開始在每個學(xué)生手冊單獨寫對應(yīng)需要辦的事情:
- 李明需要開實驗室權(quán)限、孫大熊需要辦游泳卡......就這樣班長一股腦寫完并交給了工作人員(也就是告訴內(nèi)核哪些fd需要做哪些操作);
- epoll_wait場景:
- 你拿著憑證在領(lǐng)取辦公室門前等著,這時候廣播喊xx班長你們班孫大熊的游泳卡辦好了速來領(lǐng)取、李明實驗室權(quán)限卡辦好了速來取....還有同學(xué)的事情沒辦好,所以班長只能繼續(xù)(也就是調(diào)用epoll_wait等待內(nèi)核反饋的可讀寫事件發(fā)生并處理);
- 官方DEMO
通過man epoll可以看到官方的demo:



在epoll_wait時需要區(qū)分是主監(jiān)聽線程fd的新連接事件還是已連接事件的讀寫請求,進而單獨處理。
4.epoll的底層實現(xiàn)
epoll底層實現(xiàn)最重要的兩個數(shù)據(jù)結(jié)構(gòu):epitem和eventpoll。
可以簡單的認為epitem是和每個用戶態(tài)監(jiān)控IO的fd對應(yīng)的,eventpoll是用戶態(tài)創(chuàng)建的管理所有被監(jiān)控fd的結(jié)構(gòu),詳細的定義如下:


- 底層調(diào)用過程
epoll_create會創(chuàng)建一個類型為struct eventpoll的對象,并返回一個與之對應(yīng)文件描述符,之后應(yīng)用程序在用戶態(tài)使用epoll的時候都將依靠這個文件描述符,而在epoll內(nèi)部也是通過該文件描述符進一步獲取到eventpoll類型對象,再進行對應(yīng)的操作,完成了用戶態(tài)和內(nèi)核態(tài)的貫穿。
epoll_ctl底層主要調(diào)用epoll_insert實現(xiàn)操作:
- 創(chuàng)建并初始化一個strut epitem類型的對象,完成該對象和被監(jiān)控事件以及epoll對象eventpoll的關(guān)聯(lián);
- 將struct epitem類型的對象加入到epoll對象eventpoll的紅黑樹中管理起來;
- 將struct epitem類型的對象加入到被監(jiān)控事件對應(yīng)的目標(biāo)文件的等待列表中,并注冊事件就緒時會調(diào)用的回調(diào)函數(shù),在epoll中該回調(diào)函數(shù)就是ep_poll_callback();
- ovflist主要是暫態(tài)處理,比如調(diào)用ep_poll_callback()回調(diào)函數(shù)的時候發(fā)現(xiàn)eventpoll的ovflist成員不等于EP_UNACTIVE_PTR,說明正在掃描rdllist鏈表,這時將就緒事件對應(yīng)的epitem加入到ovflist鏈表暫存起來,等rdllist鏈表掃描完再將ovflist鏈表中的元素移動到rdllist鏈表中;
- 如圖展示了紅黑樹、雙鏈表、epitem之間的關(guān)系:

注:rbr表示rb_root,rbn表示rb_node 上文給出了其在內(nèi)核中的定義
- epoll_wait的數(shù)據(jù)拷貝
常見錯誤觀點:epoll_wait返回時,對于就緒的事件,epoll使用的是共享內(nèi)存的方式,即用戶態(tài)和內(nèi)核態(tài)都指向了就緒鏈表,所以就避免了內(nèi)存拷貝消耗網(wǎng)上抄來抄去的觀點
關(guān)于epoll_wait使用共享內(nèi)存的方式來加速用戶態(tài)和內(nèi)核態(tài)的數(shù)據(jù)交互,避免內(nèi)存拷貝的觀點,并沒有得到2.6內(nèi)核版本代碼的證實,并且關(guān)于這次拷貝的實現(xiàn)是這樣的:

5.ET模式和LT模式
- 簡單理解
默認采用LT模式,LT支持阻塞和非阻塞套,ET模式只支持非阻塞套接字,其效率要高于LT模式,并且LT模式更加安全。
LT和ET模式下都可以通過epoll_wait方法來獲取事件,LT模式下將事件拷貝給用戶程序之后,如果沒有被處理或者未處理完,那么在下次調(diào)用時還會反饋給用戶程序,可以認為數(shù)據(jù)不會丟失會反復(fù)提醒;
ET模式下如果沒有被處理或者未處理完,那么下次將不再通知到用戶程序,因此避免了反復(fù)被提醒,卻加強了對用戶程序讀寫的要求;
- 深入理解
上面的簡單理解在網(wǎng)上隨便找一篇都會講到,但是LT和ET真正使用起來,還是存在一定難度的。
- LT的讀寫操作
LT對于read操作比較簡單,有read事件就讀,讀多讀少都沒有問題,但是write就不那么容易了,一般來說socket在空閑狀態(tài)時發(fā)送緩沖區(qū)一定是不滿的,假如fd一直在監(jiān)控中,那么會一直通知寫事件,不勝其煩。
所以必須保證沒有數(shù)據(jù)要發(fā)送的時候,要把fd的寫事件監(jiān)控從epoll列表中刪除,需要的時候再加入回去,如此反復(fù)。
天下沒有免費的午餐,總是無代價地提醒是不可能的,對應(yīng)write的過度提醒,需要使用者隨用隨加,否則將一直被提醒可寫事件。
- ET的讀寫操作
fd可讀則返回可讀事件,若開發(fā)者沒有把所有數(shù)據(jù)讀取完畢,epoll不會再次通知read事件,也就是說如果沒有全部讀取所有數(shù)據(jù),那么導(dǎo)致epoll不會再通知該socket的read事件,事實上一直讀完很容易做到。
若發(fā)送緩沖區(qū)未滿,epoll通知write事件,直到開發(fā)者填滿發(fā)送緩沖區(qū),epoll才會在下次發(fā)送緩沖區(qū)由滿變成未滿時通知write事件。
ET模式下只有socket的狀態(tài)發(fā)生變化時才會通知,也就是讀取緩沖區(qū)由無數(shù)據(jù)到有數(shù)據(jù)時通知read事件,發(fā)送緩沖區(qū)由滿變成未滿通知write事件。
- 一道面試題
使用Linux epoll模型的LT水平觸發(fā)模式,當(dāng)socket可寫時,會不停的觸發(fā)socket可寫的事件,如何處理?網(wǎng)絡(luò)流傳的騰訊面試題
這道題目對LT和ET考察比較深入,驗證了前文說的LT模式write問題。
普通做法:
當(dāng)需要向socket寫數(shù)據(jù)時,將該socket加入到epoll等待可寫事件。接收到socket可寫事件后,調(diào)用write()或send()發(fā)送數(shù)據(jù),當(dāng)數(shù)據(jù)全部寫完后, 將socket描述符移出epoll列表,這種做法需要反復(fù)添加和刪除。
改進做法:
向socket寫數(shù)據(jù)時直接調(diào)用send()發(fā)送,當(dāng)send()返回錯誤碼EAGAIN,才將socket加入到epoll,等待可寫事件后再發(fā)送數(shù)據(jù),全部數(shù)據(jù)發(fā)送完畢,再移出epoll模型,改進的做法相當(dāng)于認為socket在大部分時候是可寫的,不能寫了再讓epoll幫忙監(jiān)控。
上面兩種做法是對LT模式下write事件頻繁通知的修復(fù),本質(zhì)上ET模式就可以直接搞定,并不需要用戶層程序的補丁操作。
- ET模式的線程饑餓問題
如果某個socket源源不斷地收到非常多的數(shù)據(jù),在試圖讀取完所有數(shù)據(jù)的過程中,有可能會造成其他的socket得不到處理,從而造成饑餓問題。
解決辦法:為每個已經(jīng)準(zhǔn)備好的描述符維護一個隊列,這樣程序就可以知道哪些描述符已經(jīng)準(zhǔn)備好了但是并沒有被讀取完,然后程序定時或定量的讀取,如果讀完則移除,直到隊列為空,這樣就保證了每個fd都被讀到并且不會丟失數(shù)據(jù),流程如圖:

- EPOLLONESHOT設(shè)置
A線程讀完某socket上數(shù)據(jù)后開始處理這些數(shù)據(jù),此時該socket上又有新數(shù)據(jù)可讀,B線程被喚醒讀新的數(shù)據(jù),造成2個線程同時操作一個socket的局面 ,EPOLLONESHOT保證一個socket連接在任一時刻只被一個線程處理。
- 兩種模式的選擇
通過前面的對比可以看到LT模式比較安全并且代碼編寫也更清晰,但是ET模式屬于高速模式,在處理大高并發(fā)場景使用得當(dāng)效果更好,具體選擇什么根據(jù)自己實際需要和團隊代碼能力來選擇,如果并發(fā)很高且團隊水平較高可以選擇ET模式,否則建議LT模式。
6.epoll的驚群問題
在2.6.18內(nèi)核中accept的驚群問題已經(jīng)被解決了,但是在epoll中仍然存在驚群問題,表現(xiàn)起來就是當(dāng)多個進程/線程調(diào)用epoll_wait時會阻塞等待,當(dāng)內(nèi)核觸發(fā)可讀寫事件,所有進程/線程都會進行響應(yīng),但是實際上只有一個進程/線程真實處理這些事件。
在epoll官方?jīng)]有正式修復(fù)這個問題之前,Nginx作為知名使用者采用全局鎖來限制每次可監(jiān)聽fd的進程數(shù)量,每次只有1個可監(jiān)聽的進程,后來在Linux 3.9內(nèi)核中增加了SO_REUSEPORT選項實現(xiàn)了內(nèi)核級的負載均衡,Nginx1.9.1版本支持了reuseport這個新特性,從而解決驚群問題。
EPOLLEXCLUSIVE是在2016年Linux 4.5內(nèi)核新添加的一個 epoll 的標(biāo)識,Ngnix 在 1.11.3 之后添加了NGX_EXCLUSIVE_EVENT選項對該特性進行支持。EPOLLEXCLUSIVE標(biāo)識會保證一個事件發(fā)生時候只有一個線程會被喚醒,以避免多偵聽下的驚群問題。