該博客內(nèi)容大部分參考借鑒了以下博客,寫(xiě)該文章的主要目的是幫助自己梳理知識(shí)。希望也能對(duì)你有幫助
epoll原理詳解及epoll反應(yīng)堆模型
Go netpoller 網(wǎng)絡(luò)模型之源碼全面解析
設(shè)想一個(gè)場(chǎng)景:有100萬(wàn)用戶同時(shí)與一個(gè)進(jìn)程保持著TCP連接孟害,而每一時(shí)刻只有幾十個(gè)或幾百個(gè)TCP連接是活躍的(接收TCP包)袋倔,也就是說(shuō)在每一時(shí)刻進(jìn)程只需要處理這100萬(wàn)連接中的一小部分連接祠饺。那么,如何才能高效的處理這種場(chǎng)景呢邪锌?
進(jìn)程是否在每次詢問(wèn)操作系統(tǒng)收集有事件發(fā)生的TCP連接時(shí),把這100萬(wàn)個(gè)連接告訴操作系統(tǒng)癌瘾,然后由操作系統(tǒng)找出其中有事件發(fā)生的幾百個(gè)連接呢觅丰?
使用select或者poll事件驅(qū)動(dòng)方式解決
#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
// 和 select 緊密結(jié)合的四個(gè)宏:
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
理解 select 的關(guān)鍵在于理解 fd_set,為說(shuō)明方便妨退,取 fd_set 長(zhǎng)度為 1 字節(jié)舶胀,fd_set 中的每一 bit 可以對(duì)應(yīng)一個(gè)文件描述符 fd,則 1 字節(jié)長(zhǎng)的 fd_set 最大可以對(duì)應(yīng) 8 個(gè) fd碧注。select 的調(diào)用過(guò)程如下:
- 執(zhí)行 FD_ZERO(&set), 則 set 用位表示是
0000,0000
- 若 fd=5, 執(zhí)行 FD_SET(fd, &set); 后 set 變?yōu)?0001,0000(第 5 位置為 1)
- 再加入 fd=2, fd=1嚣伐,則 set 變?yōu)?
0001,0011
- 執(zhí)行 select(6, &set, 0, 0, 0) 阻塞等待
- 若 fd=1, fd=2 上都發(fā)生可讀事件,則 select 返回萍丐,此時(shí) set 變?yōu)?
0000,0011
(注意:沒(méi)有事件發(fā)生的 fd=5 被清空)
基于上面的調(diào)用過(guò)程轩端,可以得出 select 的特點(diǎn):
可監(jiān)控的文件描述符個(gè)數(shù)取決于 sizeof(fd_set) 的值。假設(shè)服務(wù)器上 sizeof(fd_set)=512逝变,每 bit 表示一個(gè)文件描述符基茵,則服務(wù)器上支持的最大文件描述符是 512*8=4096。fd_set 的大小調(diào)整可參考 【原創(chuàng)】技術(shù)系列之 網(wǎng)絡(luò)模型(二) 中的模型 2壳影,可以有效突破 select 可監(jiān)控的文件描述符上限
將 fd 加入 select 監(jiān)控集的同時(shí)拱层,還要再使用一個(gè)數(shù)據(jù)結(jié)構(gòu) array 保存放到 select 監(jiān)控集中的 fd,一是用于在 select 返回后宴咧,array 作為源數(shù)據(jù)和 fd_set 進(jìn)行 FD_ISSET 判斷根灯。二是 select 返回后會(huì)把以前加入的但并無(wú)事件發(fā)生的 fd 清空,則每次開(kāi)始 select 前都要重新從 array 取得 fd 逐一加入(FD_ZERO 最先),掃描 array 的同時(shí)取得 fd 最大值 maxfd烙肺,用于 select 的第一個(gè)參數(shù)
可見(jiàn) select 模型必須在 select 前循環(huán) array(加 fd纳猪,取 maxfd),select 返回后循環(huán) array(FD_ISSET 判斷是否有事件發(fā)生)
所以桃笙,select 有如下的缺點(diǎn):
最大并發(fā)數(shù)限制:使用 32 個(gè)整數(shù)的 32 位氏堤,即 32*32=1024 來(lái)標(biāo)識(shí) fd,雖然可修改搏明,但是有以下第 2, 3 點(diǎn)的瓶頸
每次調(diào)用 select鼠锈,都需要把 fd 集合從用戶態(tài)拷貝到內(nèi)核態(tài),這個(gè)開(kāi)銷在 fd 很多時(shí)會(huì)很大
性能衰減嚴(yán)重:每次 kernel 都需要線性掃描整個(gè) fd_set星著,所以隨著監(jiān)控的描述符 fd 數(shù)量增長(zhǎng)购笆,其 I/O 性能會(huì)線性下降
poll 的實(shí)現(xiàn)和 select 非常相似,只是描述 fd 集合的方式不同强饮,poll 使用 pollfd 結(jié)構(gòu)而不是 select 的 fd_set 結(jié)構(gòu)由桌,poll 解決了最大文件描述符數(shù)量限制的問(wèn)題,但是同樣需要從用戶態(tài)拷貝所有的 fd 到內(nèi)核態(tài)邮丰,也需要線性遍歷所有的 fd 集合行您,所以它和 select 只是實(shí)現(xiàn)細(xì)節(jié)上的區(qū)分,并沒(méi)有本質(zhì)上的區(qū)別
epoll
epoll 是 Linux kernel 2.6 之后引入的新 I/O 事件驅(qū)動(dòng)技術(shù)剪廉,I/O 多路復(fù)用的核心設(shè)計(jì)是 1 個(gè)線程處理所有連接的I/O 事件娃循,這一點(diǎn)上 epoll 和 select&poll 是大同小異的。但 select&poll 錯(cuò)誤預(yù)估了一件事斗蒋,當(dāng)數(shù)十萬(wàn)并發(fā)連接存在時(shí)捌斧,可能每一毫秒只有數(shù)百個(gè)活躍的連接,同時(shí)其余數(shù)十萬(wàn)連接在這一毫秒是非活躍的泉沾。select&poll 的使用方法是這樣的:返回的活躍連接 == select(全部待監(jiān)控的連接)
什么時(shí)候會(huì)調(diào)用 select&poll 呢捞蚂?在你認(rèn)為需要找出有報(bào)文到達(dá)的活躍連接時(shí),就應(yīng)該調(diào)用跷究。所以姓迅,select&poll 在高并發(fā)時(shí)是會(huì)被頻繁調(diào)用的。這樣俊马,這個(gè)頻繁調(diào)用的方法就很有必要看看它是否有效率丁存,因?yàn)椋妮p微效率損失都會(huì)被 高頻 二字所放大柴我。它有效率損失嗎解寝?顯而易見(jiàn),全部待監(jiān)控連接是數(shù)以十萬(wàn)計(jì)的艘儒,返回的只是數(shù)百個(gè)活躍連接聋伦,這本身就是無(wú)效率的表現(xiàn)夫偶。被放大后就會(huì)發(fā)現(xiàn),處理并發(fā)上萬(wàn)個(gè)連接時(shí)嘉抓,select&poll 就完全力不從心了索守。這個(gè)時(shí)候就該 epoll 上場(chǎng)了晕窑,epoll 通過(guò)一些新的設(shè)計(jì)和優(yōu)化抑片,基本上解決了 select&poll 的問(wèn)題
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
- 調(diào)用epoll_create建立一個(gè)epoll對(duì)象(在epoll文件系統(tǒng)中給這個(gè)句柄分配資源);
- 調(diào)用epoll_ctl向epoll對(duì)象中添加這100萬(wàn)個(gè)連接的套接字杨赤;
- 調(diào)用epoll_wait收集發(fā)生事件的連接敞斋。
其中,epoll_create 創(chuàng)建一個(gè) epoll 實(shí)例并返回 epollfd疾牲;epoll_ctl 注冊(cè) file descriptor 等待的 I/O 事件(比如 EPOLLIN植捎、EPOLLOUT 等) 到 epoll 實(shí)例上;epoll_wait 則是阻塞監(jiān)聽(tīng) epoll 實(shí)例上所有的 file descriptor 的 I/O 事件阳柔,它接收一個(gè)用戶空間上的一塊內(nèi)存地址 (events 數(shù)組)焰枢,kernel 會(huì)在有 I/O 事件發(fā)生的時(shí)候把文件描述符列表復(fù)制到這塊內(nèi)存地址上,然后 epoll_wait 解除阻塞并返回舌剂,最后用戶空間上的程序就可以對(duì)相應(yīng)的 fd 進(jìn)行讀寫(xiě)了济锄。
epoll原理詳解
當(dāng)某一進(jìn)程調(diào)用epoll_create方法時(shí),Linux內(nèi)核會(huì)創(chuàng)建一個(gè)eventpoll結(jié)構(gòu)體霍转,這個(gè)結(jié)構(gòu)體中有兩個(gè)成員與epoll的使用方式密切相關(guān)荐绝,如下所示:
struct eventpoll {
...
/*紅黑樹(shù)的根節(jié)點(diǎn),這棵樹(shù)中存儲(chǔ)著所有添加到epoll中的事件避消,
也就是這個(gè)epoll監(jiān)控的事件*/
struct rb_root rbr;
/*雙向鏈表rdllist保存著將要通過(guò)epoll_wait返回給用戶的低滩、滿足條件的事件*/
struct list_head rdllist;
...
};
我們?cè)谡{(diào)用epoll_create時(shí),內(nèi)核除了幫我們?cè)趀poll文件系統(tǒng)里建了個(gè)file結(jié)點(diǎn)岩喷,在內(nèi)核cache里建了個(gè)紅黑樹(shù)用于存儲(chǔ)以后epoll_ctl傳來(lái)的socket外恕沫,還會(huì)再建立一個(gè)rdllist雙向鏈表,用于存儲(chǔ)準(zhǔn)備就緒的事件纱意。當(dāng)epoll_wait調(diào)用時(shí)婶溯,僅僅觀察這個(gè)rdllist雙向鏈表里有沒(méi)有數(shù)據(jù)即可。有數(shù)據(jù)就返回妇穴,沒(méi)有數(shù)據(jù)就sleep爬虱,等到timeout時(shí)間到后即使鏈表沒(méi)數(shù)據(jù)也返回。所以腾它,epoll_wait非常高效跑筝。
那么rdllist雙向鏈表數(shù)據(jù)哪里來(lái)呢?所有添加到epoll中的等待事件都會(huì)與設(shè)備(如網(wǎng)卡)驅(qū)動(dòng)程序建立回調(diào)關(guān)系瞒滴,每當(dāng)相應(yīng)事件的發(fā)生時(shí)曲梗,就會(huì)調(diào)用回調(diào)方法赞警。這個(gè)回調(diào)方法在內(nèi)核中叫做ep_poll_callback,它會(huì)把該事件放到上面的rdllist雙向鏈表中虏两。
struct epitem {
...
//紅黑樹(shù)節(jié)點(diǎn)
struct rb_node rbn;
//雙向鏈表節(jié)點(diǎn)
struct list_head rdllink;
//事件句柄等信息
struct epoll_filefd ffd;
//指向其所屬的eventepoll對(duì)象
struct eventpoll *ep;
//期待的事件類型
struct epoll_event event;
...
}; // 這里包含每一個(gè)事件對(duì)應(yīng)著的信息愧旦。
當(dāng)調(diào)用epoll_wait檢查是否有發(fā)生事件的連接時(shí),只是檢查eventpoll對(duì)象中的rdllist雙向鏈表是否有epitem元素而已定罢,如果rdllist鏈表不為空笤虫,則這里的事件復(fù)制到用戶態(tài)內(nèi)存(使用共享內(nèi)存提高效率)中,同時(shí)將事件數(shù)量返回給用戶祖凫。因此epoll_wait效率非常高琼蚯。epoll_ctl在向epoll對(duì)象中添加、修改惠况、刪除事件時(shí)遭庶,從rbr紅黑樹(shù)中查找事件也非常快稠屠,也就是說(shuō)epoll是非常高效的峦睡,它可以輕易地處理百萬(wàn)級(jí)別的并發(fā)連接。
【總結(jié)】
- 執(zhí)行epoll_create()時(shí)权埠,創(chuàng)建了紅黑樹(shù)和就緒鏈表榨了;
- 執(zhí)行epoll_ctl()時(shí),如果增加socket句柄弊知,則檢查在紅黑樹(shù)中是否存在阻逮,存在立即返回,不存在則添加到紅黑樹(shù)干上秩彤,然后向內(nèi)核注冊(cè)回調(diào)函數(shù)叔扼,用于當(dāng)中斷事件來(lái)臨時(shí)向準(zhǔn)備就緒鏈表中插入數(shù)據(jù);
- 執(zhí)行epoll_wait()時(shí)立刻返回準(zhǔn)備就緒鏈表里的數(shù)據(jù)即可
epoll的兩種觸發(fā)模式
epoll有EPOLLLT和EPOLLET兩種觸發(fā)模式漫雷,LT(水平觸發(fā))是默認(rèn)的模式瓜富,ET(邊緣觸發(fā))是“高速”模式。
LT模式下降盹,內(nèi)核告訴你一個(gè)文件描述符是否就緒了与柑,然后你可以對(duì)這個(gè)就緒的fd進(jìn)行IO操作。如果你不作任何操作蓄坏,內(nèi)核還是會(huì)繼續(xù)通知你的价捧,所以,這種模式編程出錯(cuò)誤可能性要小一點(diǎn)涡戳。傳統(tǒng)的select/poll都是這種模型的代表结蟋。
ET模式下,在它檢測(cè)到有 I/O 事件時(shí)渔彰,通過(guò) epoll_wait 調(diào)用會(huì)得到有事件通知的文件描述符嵌屎,對(duì)于每一個(gè)被通知的文件描述符推正,如可讀,則必須將該文件描述符一直讀到空宝惰,讓 errno 返回 EAGAIN 為止植榕,否則下次的 epoll_wait 不會(huì)返回余下的數(shù)據(jù),會(huì)丟掉事件尼夺。如果ET模式不是非阻塞的尊残,那這個(gè)一直讀或一直寫(xiě)勢(shì)必會(huì)在最后一次阻塞。
還有一個(gè)特點(diǎn)是汞斧,epoll使用“事件”的就緒通知方式夜郁,通過(guò)epoll_ctl注冊(cè)fd什燕,一旦該fd就緒粘勒,內(nèi)核就會(huì)采用類似callback的回調(diào)機(jī)制來(lái)激活該fd,epoll_wait便可以收到通知屎即。
為什么ET模式更高效
如果采用LT模式的話庙睡,系統(tǒng)中一旦有大量你不需要讀寫(xiě)的就緒文件描述符,它們每次調(diào)用epoll_wait都會(huì)返回技俐,這樣會(huì)大大降低處理程序檢索自己關(guān)心的就緒文件描述符的效率.乘陪。而采用ET這種邊緣觸發(fā)模式的話,當(dāng)被監(jiān)控的文件描述符上有可讀寫(xiě)事件發(fā)生時(shí)雕擂,epoll_wait()會(huì)通知處理程序去讀寫(xiě)啡邑。如果這次沒(méi)有把數(shù)據(jù)全部讀寫(xiě)完(如讀寫(xiě)緩沖區(qū)太小),那么下次調(diào)用epoll_wait()時(shí)井赌,它不會(huì)通知你谤逼,也就是它只會(huì)通知你一次,直到該文件描述符上出現(xiàn)第二次可讀寫(xiě)事件才會(huì)通知你3鹚搿A鞑俊!這種模式比水平觸發(fā)效率高纹坐,系統(tǒng)不會(huì)充斥大量你不關(guān)心的就緒文件描述符枝冀。
ET模式為什么需要非阻塞socket
ET模式(邊緣觸發(fā))存在的注意點(diǎn):
1、sockfd 的邊緣觸發(fā):高并發(fā)時(shí)耘子,有多個(gè)連接同時(shí)到達(dá)果漾,服務(wù)器的 TCP 就緒隊(duì)列瞬間積累多個(gè)就緒連接,由于是邊緣觸發(fā)模式谷誓,epoll 只會(huì)通知一次绒障,accept 只處理一個(gè)連接,導(dǎo)致 TCP 就緒隊(duì)列中剩下的連接都得不到處理片林。如果沒(méi)有一次處理全部請(qǐng)求端盆,則會(huì)出現(xiàn)客戶端連接不上的問(wèn)題怀骤。不需要討論 sockfd 是否阻塞,因?yàn)閑poll_wait() 返回的必定是已經(jīng)就緒的連接焕妙,所以不管是阻塞還是非阻塞蒋伦,accept() 都會(huì)立即返回。
2焚鹊、阻塞 connfd 的邊緣觸發(fā):如果不一次性讀取一個(gè)事件上的數(shù)據(jù)痕届,會(huì)干擾下一個(gè)事件,所以必須在讀取數(shù)據(jù)的外部套一層循環(huán)末患,這樣才能完整的處理數(shù)據(jù)研叫。但是外層套循環(huán)之后會(huì)導(dǎo)致另外一個(gè)問(wèn)題:處理完數(shù)據(jù)之后,程序會(huì)一直卡在 recv() 函數(shù)上璧针,因?yàn)槭亲枞?IO嚷炉,如果沒(méi)數(shù)據(jù)可讀,它會(huì)一直等在那里探橱,直到有數(shù)據(jù)可讀申屹。但是這個(gè)時(shí)候,如果用另一個(gè)客戶端去連接服務(wù)器隧膏,服務(wù)器就不能受理這個(gè)新的客戶端了哗讥。
3、非阻塞 connfd 的邊緣觸發(fā):和阻塞版本一樣胞枕,必須在讀取數(shù)據(jù)的外部套一層循環(huán)杆煞,這樣才能完整的處理數(shù)據(jù)。因?yàn)榉亲枞?IO 如果沒(méi)有數(shù)據(jù)可讀時(shí)腐泻,會(huì)立即返回决乎,并設(shè)置 errno。這里我們根據(jù) EAGAIN 和 EWOULDBLOCK 來(lái)判斷數(shù)據(jù)是否全部讀取完畢了贫悄,如果讀取完畢瑞驱,就會(huì)正常退出循環(huán)了。
總結(jié)一下:
1窄坦、對(duì)于監(jiān)聽(tīng)的 sockfd唤反,最好使用水平觸發(fā)模式,邊緣觸發(fā)模式會(huì)導(dǎo)致高并發(fā)情況下鸭津,有的客戶端會(huì)連接不上彤侍。如果非要使用邊緣觸發(fā),可以用 while 來(lái)循環(huán) accept()逆趋。
2盏阶、對(duì)于讀寫(xiě)的 connfd,水平觸發(fā)模式下闻书,阻塞和非阻塞效果都一樣名斟,建議設(shè)置非阻塞脑慧。
3、對(duì)于讀寫(xiě)的 connfd砰盐,邊緣觸發(fā)模式下闷袒,必須使用非阻塞 IO,并要求一次性地完整讀寫(xiě)全部數(shù)據(jù)岩梳。
所以在用EPOLL的時(shí)候囊骤,我們都用fcntl將描述符置為非阻塞吧,皆大歡喜冀值。