Linux 五種 IO 模型和三種多路復(fù)用技術(shù)大詳解
作者 | symen
在Linux系統(tǒng)中,實(shí)際上所有的I/O設(shè)備都被抽象為文件這個(gè)概念,一切皆文件(Everything is File)。無(wú)論是磁盤(pán)、網(wǎng)絡(luò)數(shù)據(jù)、終端,還是進(jìn)程間通信工具(如:管道pipe)等都被抽象為文件的概念。 這種設(shè)計(jì)使得 I/O 操作可以通過(guò)統(tǒng)一的文件描述符(File Descriptor, FD)來(lái)管理。 在了解多路復(fù)用select、poll、epoll實(shí)現(xiàn)之前,我們先簡(jiǎn)單回憶復(fù)習(xí)以下兩個(gè)概念。
一、什么是多路復(fù)用
多路: 是指多個(gè)網(wǎng)絡(luò)連接(Socket)
復(fù)用: 是指通過(guò)一個(gè)線程同時(shí)監(jiān)控多個(gè)文件描述符的就緒狀態(tài)。這樣,程序可以高效地處理多個(gè) I/O 事件,而不需要為每個(gè)連接創(chuàng)建單獨(dú)的線程,從而節(jié)省系統(tǒng)資源。
多路復(fù)用主要有三種技術(shù):select,poll,epoll。其中,epoll 是最新且性能最優(yōu)的實(shí)現(xiàn),能夠高效地處理大規(guī)模并發(fā)連接。
二、五種IO模型
[1]blockingIO - 阻塞IO
[2]nonblockingIO - 非阻塞IO
[3]signaldrivenIO - 信號(hào)驅(qū)動(dòng)IO
[4]asynchronousIO - 異步IO
[5]IOmultiplexing - IO多路復(fù)用
1. 阻塞式I/O模型
在阻塞式 I/O 模型中,在I/O操作的兩個(gè)階段均會(huì)阻塞線程:
- 數(shù)據(jù)等待階段:當(dāng)進(jìn)程或線程發(fā)起IO請(qǐng)求(如:調(diào)用 recvfrom 系統(tǒng)調(diào)用)時(shí),它會(huì)一直阻塞,直到內(nèi)核確認(rèn)數(shù)據(jù)已準(zhǔn)備好(例:網(wǎng)卡接收數(shù)據(jù)、網(wǎng)絡(luò)數(shù)據(jù)到達(dá)內(nèi)核緩沖區(qū))。
- 數(shù)據(jù)復(fù)制階段:內(nèi)核將數(shù)據(jù)從內(nèi)核空間復(fù)制到用戶(hù)空間時(shí),線程/進(jìn)程仍處于阻塞狀態(tài)。此過(guò)程線程/進(jìn)程在等待I/O完成期間無(wú)法執(zhí)行其他任務(wù)(被掛起),CPU資源可能閑置。
主要特點(diǎn):
- 阻塞掛起: 進(jìn)程/線程在等待數(shù)據(jù)時(shí)會(huì)被掛起,不占用 CPU 資源。
- 及時(shí)響應(yīng): 每個(gè)操作都能得到及時(shí)處理,適合對(duì)實(shí)時(shí)性要求較高的場(chǎng)景。
- 實(shí)現(xiàn)簡(jiǎn)單: 開(kāi)發(fā)難度低,邏輯直觀,代碼按順序執(zhí)行,無(wú)需處理多線程或異步回調(diào)的復(fù)雜性。
- 適用場(chǎng)景: 阻塞式 I/O 模型適合并發(fā)量較小、對(duì)實(shí)時(shí)性要求較高的應(yīng)用。但在高并發(fā)場(chǎng)景中,其系統(tǒng)開(kāi)銷(xiāo)和性能限制使其不再適用。
- 系統(tǒng)開(kāi)銷(xiāo)大:由于每個(gè)請(qǐng)求都會(huì)阻塞進(jìn)程/線程,因此需要為每個(gè)請(qǐng)求分配獨(dú)立的進(jìn)程或線程來(lái)處理。在高并發(fā)場(chǎng)景下,這種模型會(huì)消耗大量系統(tǒng)資源(如內(nèi)存和上下文切換開(kāi)銷(xiāo)),導(dǎo)致性能瓶頸。
2. 非阻塞式I/O模型
在非阻塞式 I/O 模型中,當(dāng)進(jìn)程發(fā)起 I/O 系統(tǒng)調(diào)用(如 recvfrom)時(shí):
- 如果內(nèi)核緩沖區(qū)沒(méi)有數(shù)據(jù),內(nèi)核會(huì)立即返回一個(gè)錯(cuò)誤(如 EWOULDBLOCK 或 EAGAIN),而不會(huì)阻塞進(jìn)程。
- 如果內(nèi)核緩沖區(qū)有數(shù)據(jù),內(nèi)核會(huì)將數(shù)據(jù)復(fù)制到用戶(hù)空間并返回成功。
主要特點(diǎn):
- 非阻塞: 進(jìn)程不會(huì)被掛起,無(wú)論是否有數(shù)據(jù)都會(huì)立即返回。
- 輪詢(xún)機(jī)制: 進(jìn)程需要不斷發(fā)起系統(tǒng)調(diào)用(輪詢(xún))來(lái)檢查數(shù)據(jù)是否就緒,這會(huì)消耗大量 CPU 資源。
- 實(shí)現(xiàn)難度較低: 相比阻塞式 I/O,開(kāi)發(fā)復(fù)雜度稍高,但仍屬于較簡(jiǎn)單的模型。
- 實(shí)時(shí)性差: 輪詢(xún)機(jī)制無(wú)法保證及時(shí)響應(yīng)數(shù)據(jù)到達(dá)事件,可能導(dǎo)致延遲。
- 適用場(chǎng)景: 適合并發(fā)量較小、且對(duì)實(shí)時(shí)性要求不高的網(wǎng)絡(luò)應(yīng)用開(kāi)發(fā)。由于其 CPU 開(kāi)銷(xiāo)較大,通常不適用于高并發(fā)或高性能場(chǎng)景。
3. 信號(hào)驅(qū)動(dòng)IO
在信號(hào)驅(qū)動(dòng) I/O 模型中,進(jìn)程發(fā)起一個(gè) I/O 操作時(shí),會(huì)向內(nèi)核注冊(cè)一個(gè)信號(hào)處理函數(shù)(如 SIGIO),然后立即返回,不會(huì)被阻塞。當(dāng)內(nèi)核數(shù)據(jù)就緒時(shí),會(huì)向進(jìn)程發(fā)送一個(gè)信號(hào),進(jìn)程在信號(hào)處理函數(shù)中調(diào)用 I/O 操作(如 recvfrom)讀取數(shù)據(jù)。
主要特點(diǎn):
- 非阻塞: 進(jìn)程在等待數(shù)據(jù)時(shí)不會(huì)被阻塞,可以繼續(xù)執(zhí)行其他任務(wù)。
- 回調(diào)機(jī)制: 通過(guò)信號(hào)通知的方式實(shí)現(xiàn)異步事件處理,數(shù)據(jù)就緒時(shí)內(nèi)核主動(dòng)通知進(jìn)程。
- 實(shí)現(xiàn)難度大: 信號(hào)處理函數(shù)的編寫(xiě)和調(diào)試較為復(fù)雜,開(kāi)發(fā)難度較高。
- 信號(hào)處理復(fù)雜性: 信號(hào)處理函數(shù)需要處理異步事件,可能引入競(jìng)態(tài)條件和不可預(yù)測(cè)的行為。
適用場(chǎng)景有限: 適合對(duì)實(shí)時(shí)性要求較高、但并發(fā)量較小的網(wǎng)絡(luò)應(yīng)用開(kāi)發(fā)。由于其實(shí)現(xiàn)復(fù)雜性和潛在問(wèn)題,通常不適用于高并發(fā)或高性能場(chǎng)景。
4. 異步IO
在異步 I/O 模型中,當(dāng)進(jìn)程發(fā)起一個(gè) I/O 操作時(shí),會(huì)立即返回,不會(huì)被阻塞,也不會(huì)立即返回結(jié)果。內(nèi)核會(huì)負(fù)責(zé)完成整個(gè) I/O 操作(包括數(shù)據(jù)準(zhǔn)備和復(fù)制到用戶(hù)空間),并在操作完成后通知進(jìn)程。如果 I/O 操作成功,進(jìn)程可以直接獲取到數(shù)據(jù)。
主要特點(diǎn):
- 完全非阻塞: 進(jìn)程在發(fā)起 I/O 操作后不會(huì)被阻塞,可以繼續(xù)執(zhí)行其他任務(wù)。
- Proactor 模式: 內(nèi)核負(fù)責(zé)完成 I/O 操作并通知進(jìn)程,進(jìn)程只需處理最終結(jié)果。
- 高性能: 適合高并發(fā)、高性能場(chǎng)景,能夠充分利用系統(tǒng)資源。
- 操作系統(tǒng)支持: 異步 I/O 需要操作系統(tǒng)的底層支持。在 Linux 中,異步 I/O 從 2.5 版本內(nèi)核開(kāi)始引入,并在 2.6 版本中成為標(biāo)準(zhǔn)特性。
- 實(shí)現(xiàn)難度大: 異步 I/O 的開(kāi)發(fā)復(fù)雜度較高,需要處理回調(diào)、事件通知等機(jī)制。
適用場(chǎng)景: 異步 I/O 模型非常適合高性能、高并發(fā)的網(wǎng)絡(luò)應(yīng)用開(kāi)發(fā),如大規(guī)模 Web 服務(wù)器、數(shù)據(jù)庫(kù)系統(tǒng)等。
5. IO復(fù)用模型
大多數(shù)文件系統(tǒng)的默認(rèn)IO操作都是緩存IO。在Linux的緩存IO機(jī)制中,操作系統(tǒng)會(huì)將IO的數(shù)據(jù)緩存在文件系統(tǒng)的頁(yè)緩存(page cache)。也就是說(shuō),數(shù)據(jù)會(huì)先被拷貝到操作系統(tǒng)內(nèi)核的緩沖區(qū)中,然后才會(huì)從操作系統(tǒng)內(nèi)核的緩存區(qū)拷貝到應(yīng)用程序的地址空間中。這種做法的缺點(diǎn)就是,需要在應(yīng)用程序地址空間和內(nèi)核進(jìn)行多次拷貝,這些拷貝動(dòng)作所帶來(lái)的CPU以及內(nèi)存開(kāi)銷(xiāo)是非常大的。 至于為什么不能直接讓磁盤(pán)控制器把數(shù)據(jù)送到應(yīng)用程序的地址空間中呢?最簡(jiǎn)單的一個(gè)原因就是應(yīng)用程序不能直接操作底層硬件。 總的來(lái)說(shuō),IO分兩階段: 1)數(shù)據(jù)準(zhǔn)備階段 2)內(nèi)核空間復(fù)制回用戶(hù)進(jìn)程緩沖區(qū)階段。如下圖:
三、I/O 多路復(fù)用之select、poll、epoll詳解
目前支持I/O多路復(fù)用的系統(tǒng)調(diào)用有select,pselect,poll,epoll。與多進(jìn)程和多線程技術(shù)相比,I/O多路復(fù)用技術(shù)的最大優(yōu)勢(shì)是系統(tǒng)開(kāi)銷(xiāo)小,系統(tǒng)不必創(chuàng)建進(jìn)程/線程,也不必維護(hù)這些進(jìn)程/線程,從而大大減小了系統(tǒng)的開(kāi)銷(xiāo)。 I/O多路復(fù)用就是通過(guò)一種機(jī)制,一個(gè)進(jìn)程可以監(jiān)視多個(gè)描述符,一旦某個(gè)描述符就緒(一般是讀就緒或者寫(xiě)就緒),能夠通知程序進(jìn)行相應(yīng)的讀寫(xiě)操作。但select,poll,epoll本質(zhì)上都是同步I/O,因?yàn)樗麄兌夹枰谧x寫(xiě)事件就緒后自己負(fù)責(zé)進(jìn)行讀寫(xiě),也就是說(shuō)這個(gè)讀寫(xiě)過(guò)程是阻塞的,而異步I/O則無(wú)需自己負(fù)責(zé)進(jìn)行讀寫(xiě),異步I/O的實(shí)現(xiàn)會(huì)負(fù)責(zé)把數(shù)據(jù)從內(nèi)核拷貝到用戶(hù)空間。
1. select
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- readfds:內(nèi)核檢測(cè)該集合中的IO是否可讀。如果想讓內(nèi)核幫忙檢測(cè)某個(gè)IO是否可讀,需要手動(dòng)把文件描述符加入該集合。
- writefds:內(nèi)核檢測(cè)該集合中的IO是否可寫(xiě)。同readfds,需要手動(dòng)把文件描述符加入該集合。
- exceptfds:內(nèi)核檢測(cè)該集合中的IO是否異常。同readfds,需要手動(dòng)把文件描述符加入該集合。
- nfds:以上三個(gè)集合中最大的文件描述符數(shù)值 + 1,例如集合是{0,1,5,10},那么 maxfd 就是 11
- timeout:用戶(hù)線程調(diào)用select的超時(shí)時(shí)長(zhǎng)。
- 設(shè)置成NULL,表示如果沒(méi)有 I/O 事件發(fā)生,則 select 一直等待下去。
- 設(shè)置為非0的值,這個(gè)表示等待固定的一段時(shí)間后從 select 阻塞調(diào)用中返回。
- 設(shè)置成 0,表示根本不等待,檢測(cè)完畢立即返回。
函數(shù)返回值:
- 大于0:成功,返回集合中已就緒的IO總個(gè)數(shù)
- 等于-1:調(diào)用失敗
- 等于0:沒(méi)有就緒的IO
從上述的select函數(shù)聲明可以看出,fd_set本質(zhì)是一個(gè)數(shù)組,為了方便我們操作該數(shù)組,操作系統(tǒng)提供了以下函數(shù):
// 將文件描述符fd從set集合中刪除
void FD_CLR(int fd, fd_set *set);
// 判斷文件描述符fd是否在set集合中
int FD_ISSET(int fd, fd_set *set);
// 將文件描述符fd添加到set集合中
void FD_SET(int fd, fd_set *set);
// 將set集合中, 所有文件描述符對(duì)應(yīng)的標(biāo)志位設(shè)置為0
void FD_ZERO(fd_set *set);
select 函數(shù)監(jiān)視的文件描述符分3類(lèi),分別是writefds、readfds、和exceptfds,當(dāng)用戶(hù)process調(diào)用select的時(shí)候,select會(huì)將需要監(jiān)控的readfds集合拷貝到內(nèi)核空間(假設(shè)監(jiān)控的僅僅是socket可讀),然后遍歷自己監(jiān)控的skb(SocketBuffer),挨個(gè)調(diào)用skb的poll邏輯以便檢查該socket是否有可讀事件,遍歷完所有的skb后,如果沒(méi)有任何一個(gè)socket可讀,那么select會(huì)調(diào)用schedule_timeout進(jìn)入schedule循環(huán),使得process進(jìn)入睡眠。如果在timeout時(shí)間內(nèi)某個(gè)socket上有數(shù)據(jù)可讀了,或者等待timeout了,則調(diào)用select的process會(huì)被喚醒,接下來(lái)select就是遍歷監(jiān)控的集合,挨個(gè)收集可讀事件并返回給用戶(hù)了,相應(yīng)的偽碼如下:
int select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
// nfds:監(jiān)控的文件描述符集里最大文件描述符加1
// readfds:監(jiān)控有讀數(shù)據(jù)到達(dá)文件描述符集合,傳入傳出參數(shù)
// writefds:監(jiān)控寫(xiě)數(shù)據(jù)到達(dá)文件描述符集合,傳入傳出參數(shù)
// exceptfds:監(jiān)控異常發(fā)生達(dá)文件描述符集合, 傳入傳出參數(shù)
// timeout:定時(shí)阻塞監(jiān)控時(shí)間,3種情況
// 1.NULL,永遠(yuǎn)等下去
// 2.設(shè)置timeval,等待固定時(shí)間
// 3.設(shè)置timeval里時(shí)間均為0,檢查描述字后立即返回,輪詢(xún)
/*
* select服務(wù)端偽碼
* 首先一個(gè)線程不斷接受客戶(hù)端連接,并把socket文件描述符放到一個(gè)list里。
*/
while(1) {
connfd = accept(listenfd);
fcntl(connfd, F_SETFL, O_NONBLOCK);
fdlist.add(connfd);
}
/*
* select函數(shù)還是返回剛剛提交的list,應(yīng)用程序依然list所有的fd,只不過(guò)操作系統(tǒng)會(huì)將準(zhǔn)備就緒的文件描述符做上標(biāo)識(shí),
* 用戶(hù)層將不會(huì)再有無(wú)意義的系統(tǒng)調(diào)用開(kāi)銷(xiāo)。
*/
struct timeval timeout;
int max = 0; // 用于記錄最大的fd,在輪詢(xún)中時(shí)刻更新即可
// 初始化比特位
FD_ZERO(&read_fd);
while (1) {
// 阻塞獲取 每次需要把fd從用戶(hù)態(tài)拷貝到內(nèi)核態(tài)
nfds = select(max + 1, &read_fd, &write_fd, NULL, &timeout);
// 每次需要遍歷所有fd,判斷有無(wú)讀寫(xiě)事件發(fā)生
for (int i = 0; i <= max && nfds; ++i) {
// 只讀已就緒的文件描述符,不用過(guò)多遍歷
if (i == listenfd) {
// 這里處理accept事件
FD_SET(i, &read_fd);//將客戶(hù)端socket加入到集合中
}
if (FD_ISSET(i, &read_fd)) {
// 這里處理read事件
}
}
}
下面的動(dòng)圖能更直觀的讓我們了解select:
通過(guò)上面的select邏輯過(guò)程分析,相信大家都意識(shí)到,select存在三個(gè)問(wèn)題:
(1) 每次調(diào)用select,都需要把被監(jiān)控的fds集合從用戶(hù)態(tài)空間拷貝到內(nèi)核態(tài)空間,高并發(fā)場(chǎng)景下這樣的拷貝會(huì)使得消耗的資源是很大的。
(2) 能監(jiān)聽(tīng)端口的數(shù)量有限,單個(gè)進(jìn)程所能打開(kāi)的最大連接數(shù)由FD_SETSIZE宏定義,監(jiān)聽(tīng)上限就等于fds_bits位數(shù)組中所有元素的二進(jìn)制位總數(shù),其大小是32個(gè)整數(shù)的大小(在32位的機(jī)器上,大小就是3232,同理64位機(jī)器上為3264),當(dāng)然我們可以對(duì)宏FD_SETSIZE進(jìn)行修改,然后重新編譯內(nèi)核,但是性能可能會(huì)受到影響,一般該數(shù)和系統(tǒng)內(nèi)存關(guān)系很大,具體數(shù)目可以cat /proc/sys/fs/file-max察看。32位機(jī)默認(rèn)1024個(gè),64位默認(rèn)2048。
(3) 被監(jiān)控的fds集合中,只要有一個(gè)有數(shù)據(jù)可讀,整個(gè)socket集合就會(huì)被遍歷一次調(diào)用sk的poll函數(shù)收集可讀事件:由于當(dāng)初的需求是樸素,僅僅關(guān)心是否有數(shù)據(jù)可讀這樣一個(gè)事件,當(dāng)事件通知來(lái)的時(shí)候,由于數(shù)據(jù)的到來(lái)是異步的,我們不知道事件來(lái)的時(shí)候,有多少個(gè)被監(jiān)控的socket有數(shù)據(jù)可讀了,于是,只能挨個(gè)遍歷每個(gè)socket來(lái)收集可讀事件了。
2. poll
poll 的實(shí)現(xiàn)與 select 非常相似,都是通過(guò)監(jiān)視多個(gè)文件描述符(fd)來(lái)實(shí)現(xiàn) I/O 多路復(fù)用。兩者的主要區(qū)別在于描述 fd 集合的方式:select 使用 fd_set 結(jié)構(gòu),而 poll 使用 pollfd 結(jié)構(gòu)。select 的 fd_set 結(jié)構(gòu)限制了 fd 集合的大?。ㄍǔ?1024),而 poll 使用 pollfd 結(jié)構(gòu),理論上可以支持更多的 fd,解決了 select 的問(wèn)題 (2)。
與 select 類(lèi)似,poll 也存在性能瓶頸。當(dāng)監(jiān)視的 fd 數(shù)量較多時(shí),poll 需要將整個(gè) pollfd 數(shù)組在用戶(hù)態(tài)和內(nèi)核態(tài)之間復(fù)制,無(wú)論這些 fd 是否就緒。這種復(fù)制的開(kāi)銷(xiāo)會(huì)隨著 fd 數(shù)量的增加而線性增長(zhǎng),導(dǎo)致性能下降。
poll 適合需要監(jiān)視較多 fd 的場(chǎng)景,但在高并發(fā)或 fd 數(shù)量非常大的情況下,性能仍然不如 epoll。
解決 fd 數(shù)量限制問(wèn)題:
struct pollfd {
int fd; /*文件描述符*/
short events; /*監(jiān)控的事件*/
short revents; /*監(jiān)控事件中滿(mǎn)足條件返回的事件*/
};
int poll(struct pollfd *fds, unsigned long nfds, int timeout);
函數(shù)參數(shù):
- fds:struct pollfd類(lèi)型的數(shù)組, 存儲(chǔ)了待檢測(cè)的文件描述符,struct pollfd有三個(gè)成員:
- fd:委托內(nèi)核檢測(cè)的文件描述符
- events:委托內(nèi)核檢測(cè)的fd事件(輸入、輸出、錯(cuò)誤),每一個(gè)事件有多個(gè)取值
- revents:這是一個(gè)傳出參數(shù),數(shù)據(jù)由內(nèi)核寫(xiě)入,存儲(chǔ)內(nèi)核檢測(cè)之后的結(jié)果
- nfds:描述的是數(shù)組 fds 的大小
- timeout: 指定poll函數(shù)的阻塞時(shí)長(zhǎng)
- -1:一直阻塞,直到檢測(cè)的集合中有就緒的IO事件,然后解除阻塞函數(shù)返回
- 0:不阻塞,不管檢測(cè)集合中有沒(méi)有已就緒的IO事件,函數(shù)馬上返回
- 大于0:表示 poll 調(diào)用方等待指定的毫秒數(shù)后返回
函數(shù)返回值:
- -1:失敗
- 大于0:表示檢測(cè)的集合中已就緒的文件描述符的總個(gè)數(shù)
下面是poll的函數(shù)原型,poll改變了fds集合的描述方式,使用了pollfd結(jié)構(gòu)而不是select的fd_set結(jié)構(gòu),使得poll支持的fds集合限制遠(yuǎn)大于select的1024。poll雖然解決了fds集合大小1024的限制問(wèn)題,從實(shí)現(xiàn)來(lái)看。很明顯它并沒(méi)優(yōu)化大量描述符數(shù)組被整體復(fù)制于用戶(hù)態(tài)和內(nèi)核態(tài)的地址空間之間,以及個(gè)別描述符就緒觸發(fā)整體描述符集合的遍歷的低效問(wèn)題。poll隨著監(jiān)控的socket集合的增加性能線性下降,使得poll也并不適合用于大并發(fā)場(chǎng)景。
poll服務(wù)端實(shí)現(xiàn)偽碼:
struct pollfd fds[POLL_LEN];
unsigned int nfds=0;
fds[0].fd=server_sockfd;
fds[0].events=POLLIN|POLLPRI;
nfds++;
while {
res=poll(fds,nfds,-1);
if(fds[0].revents&(POLLIN|POLLPRI)) {
//執(zhí)行accept并加入fds中,nfds++
if(--res<=0) continue
}
//循環(huán)之后的fds
if(fds[i].revents&(POLLIN|POLLERR )) {
//讀操作或處理異常等
if(--res<=0) continue
}
}
3. epoll
在 Linux 網(wǎng)絡(luò)編程中,select 曾長(zhǎng)期被用作事件觸發(fā)的機(jī)制。然而,隨著高并發(fā)場(chǎng)景的需求增加,select 的性能瓶頸逐漸顯現(xiàn)。為了解決這些問(wèn)題,Linux 內(nèi)核引入了 epoll 機(jī)制。相比于 select,epoll 的最大優(yōu)勢(shì)在于其性能不會(huì)隨著監(jiān)聽(tīng)的文件描述符(fd)數(shù)量的增加而顯著下降。如前面我們所說(shuō),在內(nèi)核中的select實(shí)現(xiàn)中,它是采用輪詢(xún)來(lái)處理的,輪詢(xún)的fd數(shù)目越多,自然耗時(shí)越多。并且,在linux/posix_types.h頭文件有這樣的聲明:
#define __FD_SETSIZE 1024 (select 最多只能同時(shí)監(jiān)聽(tīng) 1024 個(gè) fd(由 __FD_SETSIZE 定義)。雖然可以通過(guò)修改內(nèi)核頭文件并重新編譯內(nèi)核來(lái)擴(kuò)大這一限制,但這并不能從根本上解決問(wèn)題。) 而epoll 使用基于事件回調(diào)的機(jī)制,而不是輪詢(xún)。它只會(huì)關(guān)注活躍的 fd,因此性能不會(huì)隨著 fd 數(shù)量的增加而顯著下降。
epoll 的使用: epoll的接口非常簡(jiǎn)單,一共就三個(gè)函數(shù):
- epoll_create創(chuàng)建句柄:使用 epoll_create 創(chuàng)建一個(gè) epoll 句柄。參數(shù) size 用于告訴內(nèi)核監(jiān)聽(tīng) fd 的數(shù)量(在較新的內(nèi)核中,size 參數(shù)已被忽略,但仍需大于 0),這個(gè)參數(shù)不同于select()中的第一個(gè)參數(shù),給出最大監(jiān)聽(tīng)的fd+1的值。需要注意的是,當(dāng)創(chuàng)建好epoll句柄后,它就是會(huì)占用一個(gè)fd值,在linux下如果查看/proc/進(jìn)程id/fd/,是能夠看到這個(gè)fd的,所以在使用完epoll后,必須調(diào)用close()關(guān)閉,否則可能導(dǎo)致fd被耗盡。
- epoll_ctl管理連接:使用 epoll_ctl 向 epoll 對(duì)象中添加、修改或刪除要管理的 fd。
- epoll_wait等待事件:使用 epoll_wait 等待其管理的 fd 上的 I/O 事件。
(1) epoll_create 函數(shù)
int epoll_create(int size);
- 功能:該函數(shù)生成一個(gè) epoll 專(zhuān)用的文件描述符。
- 參數(shù)size: 用來(lái)告訴內(nèi)核這個(gè)監(jiān)聽(tīng)的數(shù)目一共有多大,參數(shù) size 并不是限制了 epoll 所能監(jiān)聽(tīng)的描述符最大個(gè)數(shù),只是對(duì)內(nèi)核初始分配內(nèi)部數(shù)據(jù)結(jié)構(gòu)的一個(gè)建議。自從 linux 2.6.8 之后,size 參數(shù)是被忽略的,也就是說(shuō)可以填只有大于 0 的任意值。
- 返回值:如果成功,返回poll 專(zhuān)用的文件描述符,否者失敗,返回-1。
epoll_create的源碼實(shí)現(xiàn):
SYSCALL_DEFINE1(epoll_create1, int, flags)
{
struct eventpoll *ep = NULL;
//創(chuàng)建一個(gè) eventpoll 對(duì)象
error = ep_alloc(&ep);
}
//struct eventpoll 的定義
// file:fs/eventpoll.c
struct eventpoll {
//sys_epoll_wait用到的等待隊(duì)列
wait_queue_head_t wq;
//接收就緒的描述符都會(huì)放到這里
struct list_head rdllist;
//每個(gè)epoll對(duì)象中都有一顆紅黑樹(shù)
struct rb_root rbr;
......
}
static int ep_alloc(struct eventpoll **pep)
{
struct eventpoll *ep;
//申請(qǐng) epollevent 內(nèi)存
ep = kzalloc(sizeof(*ep), GFP_KERNEL);
//初始化等待隊(duì)列頭
init_waitqueue_head(&ep->wq);
//初始化就緒列表
INIT_LIST_HEAD(&ep->rdllist);
//初始化紅黑樹(shù)指針
ep->rbr = RB_ROOT;
......
}
其中eventpoll 這個(gè)結(jié)構(gòu)體中的幾個(gè)成員的含義如下:
- wq: 等待隊(duì)列鏈表。軟中斷數(shù)據(jù)就緒的時(shí)候會(huì)通過(guò) wq 來(lái)找到阻塞在 epoll 對(duì)象上的用戶(hù)進(jìn)程。
- rbr: 紅黑樹(shù)。為了支持對(duì)海量連接的高效查找、插入和刪除,eventpoll 內(nèi)部使用的就是紅黑樹(shù)。通過(guò)紅黑樹(shù)來(lái)管理用戶(hù)主進(jìn)程accept添加進(jìn)來(lái)的所有 socket 連接。
- rdllist: 就緒的描述符鏈表。當(dāng)有連接就緒的時(shí)候,內(nèi)核會(huì)把就緒的連接放到 rdllist 鏈表里。這樣應(yīng)用進(jìn)程只需要判斷鏈表就能找出就緒進(jìn)程,而不用去遍歷紅黑樹(shù)的所有節(jié)點(diǎn)了。
(2) epoll_ctl 函數(shù)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- 功能:epoll 的事件注冊(cè)函數(shù),它不同于 select() 是在監(jiān)聽(tīng)事件時(shí)告訴內(nèi)核要監(jiān)聽(tīng)什么類(lèi)型的事件,而是在這里先注冊(cè)要監(jiān)聽(tīng)的事件類(lèi)型。
- 參數(shù)epfd: epoll 專(zhuān)用的文件描述符,epoll_create()的返回值
- 參數(shù)op: 表示動(dòng)作,用三個(gè)宏來(lái)表示:
1. EPOLL_CTL_ADD:注冊(cè)新的 fd 到 epfd 中;
2. EPOLL_CTL_MOD:修改已經(jīng)注冊(cè)的fd的監(jiān)聽(tīng)事件;
3. EPOLL_CTL_DEL:從 epfd 中刪除一個(gè) fd;
- 參數(shù)fd: 需要監(jiān)聽(tīng)的文件描述符
- 參數(shù)event: 告訴內(nèi)核要監(jiān)聽(tīng)什么事件,struct epoll_event 結(jié)構(gòu)如:
- events 可以是以下幾個(gè)宏的集合:
- EPOLLIN :表示對(duì)應(yīng)的文件描述符可以讀(包括對(duì)端 SOCKET 正常關(guān)閉);
- EPOLLOUT:表示對(duì)應(yīng)的文件描述符可以寫(xiě);
- EPOLLPRI:表示對(duì)應(yīng)的文件描述符有緊急的數(shù)據(jù)可讀(這里應(yīng)該表示有帶外數(shù)據(jù)到來(lái));
- EPOLLERR:表示對(duì)應(yīng)的文件描述符發(fā)生錯(cuò)誤;
- EPOLLHUP:表示對(duì)應(yīng)的文件描述符被掛斷;
- EPOLLET :將 EPOLL 設(shè)為邊緣觸發(fā)(Edge Trigger)模式,這是相對(duì)于水平觸發(fā)(Level Trigger)來(lái)說(shuō)的。
- EPOLLONESHOT:只監(jiān)聽(tīng)一次事件,當(dāng)監(jiān)聽(tīng)完這次事件之后,如果還需要繼續(xù)監(jiān)聽(tīng)這個(gè) socket 的話(huà),需要再次把這個(gè) socket 加入到 EPOLL 隊(duì)列里
- 返回值:0表示成功,-1表示失敗。
(3) epoll_wait函數(shù)
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
- 功能:等待事件的產(chǎn)生,收集在 epoll 監(jiān)控的事件中已經(jīng)發(fā)送的事件,類(lèi)似于 select() 調(diào)用。
- 參數(shù)epfd: epoll 專(zhuān)用的文件描述符,epoll_create()的返回值
- 參數(shù)events: 分配好的 epoll_event 結(jié)構(gòu)體數(shù)組,epoll 將會(huì)把發(fā)生的事件賦值到events 數(shù)組中(events 不可以是空指針,內(nèi)核只負(fù)責(zé)把數(shù)據(jù)復(fù)制到這個(gè) events 數(shù)組中,不會(huì)去幫助我們?cè)谟脩?hù)態(tài)中分配內(nèi)存)。
- 參數(shù)maxevents: maxevents 告之內(nèi)核這個(gè) events 有多少個(gè) 。
- 參數(shù)timeout: 超時(shí)時(shí)間,單位為毫秒,為 -1 時(shí),函數(shù)為阻塞。
返回值:
- 如果成功,表示返回需要處理的事件數(shù)目
- 如果返回0,表示已超時(shí)
- 如果返回-1,表示失敗
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <stdlib.h>
#include <cassert>
#include <sys/epoll.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include<iostream>
const int MAX_EVENT_NUMBER = 10000; //最大事件數(shù)
// 設(shè)置句柄非阻塞
int setnonblocking(int fd)
{
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
return old_option;
}
int main(){
// 創(chuàng)建套接字
int nRet=0;
int m_listenfd = socket(PF_INET, SOCK_STREAM, 0);
if(m_listenfd<0)
{
printf("fail to socket!");
return -1;
}
//
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_ANY);
address.sin_port = htons(6666);
int flag = 1;
// 設(shè)置ip可重用
setsockopt(m_listenfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
// 綁定端口號(hào)
int ret = bind(m_listenfd, (struct sockaddr *)&address, sizeof(address));
if(ret<0)
{
printf("fail to bind!,errno :%d",errno);
return ret;
}
// 監(jiān)聽(tīng)連接fd
ret = listen(m_listenfd, 200);
if(ret<0)
{
printf("fail to listen!,errno :%d",errno);
return ret;
}
// 初始化紅黑樹(shù)和事件鏈表結(jié)構(gòu)rdlist結(jié)構(gòu)
epoll_event events[MAX_EVENT_NUMBER];
// 創(chuàng)建epoll實(shí)例
int m_epollfd = epoll_create(5);
if(m_epollfd==-1)
{
printf("fail to epoll create!");
return m_epollfd;
}
// 創(chuàng)建節(jié)點(diǎn)結(jié)構(gòu)體將監(jiān)聽(tīng)連接句柄
epoll_event event;
event.data.fd = m_listenfd;
//設(shè)置該句柄為邊緣觸發(fā)(數(shù)據(jù)沒(méi)處理完后續(xù)不會(huì)再觸發(fā)事件,水平觸發(fā)是不管數(shù)據(jù)有沒(méi)有觸發(fā)都返回事件),
event.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
// 添加監(jiān)聽(tīng)連接句柄作為初始節(jié)點(diǎn)進(jìn)入紅黑樹(shù)結(jié)構(gòu)中,該節(jié)點(diǎn)后續(xù)處理連接的句柄
epoll_ctl(m_epollfd, EPOLL_CTL_ADD, m_listenfd, &event);
//進(jìn)入服務(wù)器循環(huán)
while(1)
{
int number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
if (number < 0 && errno != EINTR)
{
printf( "epoll failure");
break;
}
for (int i = 0; i < number; i++)
{
int sockfd = events[i].data.fd;
// 屬于處理新到的客戶(hù)連接
if (sockfd == m_listenfd)
{
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(m_listenfd, (struct sockaddr *)&client_address, &client_addrlength);
if (connfd < 0)
{
printf("errno is:%d accept error", errno);
return false;
}
epoll_event event;
event.data.fd = connfd;
//設(shè)置該句柄為邊緣觸發(fā)(數(shù)據(jù)沒(méi)處理完后續(xù)不會(huì)再觸發(fā)事件,水平觸發(fā)是不管數(shù)據(jù)有沒(méi)有觸發(fā)都返回事件),
event.events = EPOLLIN | EPOLLRDHUP;
// 添加監(jiān)聽(tīng)連接句柄作為初始節(jié)點(diǎn)進(jìn)入紅黑樹(shù)結(jié)構(gòu)中,該節(jié)點(diǎn)后續(xù)處理連接的句柄
epoll_ctl(m_epollfd, EPOLL_CTL_ADD, connfd, &event);
setnonblocking(connfd);
}
else if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR))
{
//服務(wù)器端關(guān)閉連接,
epoll_ctl(m_epollfd, EPOLL_CTL_DEL, sockfd, 0);
close(sockfd);
}
//處理客戶(hù)連接上接收到的數(shù)據(jù)
else if (events[i].events & EPOLLIN)
{
char buf[1024]={0};
read(sockfd,buf,1024);
printf("from client :%s");
// 將事件設(shè)置為寫(xiě)事件返回?cái)?shù)據(jù)給客戶(hù)端
events[i].data.fd = sockfd;
events[i].events = EPOLLOUT | EPOLLET | EPOLLONESHOT | EPOLLRDHUP;
epoll_ctl(m_epollfd, EPOLL_CTL_MOD, sockfd, &events[i]);
}
else if (events[i].events & EPOLLOUT)
{
std::string response = "server response \n";
write(sockfd,response.c_str(),response.length());
// 將事件設(shè)置為讀事件,繼續(xù)監(jiān)聽(tīng)客戶(hù)端
events[i].data.fd = sockfd;
events[i].events = EPOLLIN | EPOLLRDHUP;
epoll_ctl(m_epollfd, EPOLL_CTL_MOD, sockfd, &events[i]);
}
//else if 可以加管道,unix套接字等等數(shù)據(jù)
}
}
}
如下圖,可以幫助我們理解的更加絲滑(/手動(dòng)狗頭):
4. epoll的邊緣觸發(fā)與水平觸發(fā)
(1) 水平觸發(fā)(LT)
關(guān)注點(diǎn)是數(shù)據(jù)是否有無(wú),只要讀緩沖區(qū)不為空,寫(xiě)緩沖區(qū)不滿(mǎn),那么epoll_wait就會(huì)一直返回就緒,水平觸發(fā)是epoll的默認(rèn)工作方式。適合對(duì)事件處理邏輯要求不高的場(chǎng)景。
(2) 邊緣觸發(fā)(ET)
關(guān)注點(diǎn)是數(shù)據(jù)的變化。只有當(dāng)緩沖區(qū)狀態(tài)發(fā)生變化時(shí)(例如從空變?yōu)榉强?,或從非空變?yōu)榭眨?,epoll_wait 才會(huì)返回就緒狀態(tài)。這里的數(shù)據(jù)變化并不單純指緩沖區(qū)從有數(shù)據(jù)變?yōu)闆](méi)有數(shù)據(jù),或者從沒(méi)有數(shù)據(jù)變?yōu)橛袛?shù)據(jù),還包括了數(shù)據(jù)變多或者變少。即當(dāng)buffer長(zhǎng)度有變化時(shí),就會(huì)觸發(fā)。 假設(shè)epoll被設(shè)置為了邊緣觸發(fā),當(dāng)客戶(hù)端寫(xiě)入了100個(gè)字符,由于緩沖區(qū)從0變?yōu)榱?00,于是服務(wù)端epoll_wait觸發(fā)一次就緒,服務(wù)端讀取了2個(gè)字節(jié)后不再讀取。這個(gè)時(shí)候再去調(diào)用epoll_wait會(huì)發(fā)現(xiàn)不會(huì)就緒,只有當(dāng)客戶(hù)端再次寫(xiě)入數(shù)據(jù)后,才會(huì)觸發(fā)就緒。 這就導(dǎo)致如果使用ET模式,那就必須保證要「一次性把數(shù)據(jù)讀取&寫(xiě)入完」,否則會(huì)導(dǎo)致數(shù)據(jù)長(zhǎng)期無(wú)法讀取/寫(xiě)入。適合高性能場(chǎng)景,可以減少事件通知的次數(shù),提高效率。
4. epoll 為什么比select,poll更高效?
從上圖可以看出,epoll使用紅黑樹(shù)管理文件描述符,紅黑樹(shù)插入和刪除的都是時(shí)間復(fù)雜度 O(logN),不會(huì)隨著文件描述符數(shù)量增加而改變。 select、poll采用數(shù)組或者鏈表的形式管理文件描述符,那么在遍歷文件描述符時(shí),時(shí)間復(fù)雜度會(huì)隨著文件描述的增加而增加,我們從以下幾點(diǎn)分析epoll的優(yōu)勢(shì):
(1) 事件驅(qū)動(dòng)機(jī)制(基于回調(diào),而非輪詢(xún))
- select 和 poll 的輪詢(xún)機(jī)制: select 和 poll 采用輪詢(xún)的方式檢查所有被監(jiān)視的文件描述符(fd),無(wú)論這些 fd 是否就緒。每次調(diào)用時(shí),都需要將整個(gè) fd 集合從用戶(hù)態(tài)復(fù)制到內(nèi)核態(tài),并在內(nèi)核中遍歷所有 fd 來(lái)檢查其狀態(tài)。隨著 fd 數(shù)量的增加,輪詢(xún)的開(kāi)銷(xiāo)會(huì)線性增長(zhǎng),導(dǎo)致性能顯著下降。
- epoll 的事件驅(qū)動(dòng)機(jī)制:- epoll 使用基于事件回調(diào)的機(jī)制。內(nèi)核會(huì)維護(hù)一個(gè)就緒隊(duì)列,只關(guān)注那些狀態(tài)發(fā)生變化的 fd(即活躍的 fd)。一旦檢測(cè)到epoll管理的socket描述符就緒時(shí),內(nèi)核會(huì)采用類(lèi)似 callback 的回調(diào)機(jī)制,將其加入就緒隊(duì)列,epoll_wait 只需從隊(duì)列中獲取就緒的 fd,而不需要遍歷所有 fd。這種機(jī)制使得 epoll 的性能不會(huì)隨著 fd 數(shù)量的增加而顯著下降。
(2) 避免頻繁的用戶(hù)態(tài)與內(nèi)核態(tài)數(shù)據(jù)拷貝
- select 和 poll 的數(shù)據(jù)拷貝問(wèn)題: 每次調(diào)用 select 或 poll 時(shí),都需要將整個(gè) fd 集合從用戶(hù)態(tài)復(fù)制到內(nèi)核態(tài),調(diào)用結(jié)束后再將結(jié)果從內(nèi)核態(tài)復(fù)制回用戶(hù)態(tài)。這種頻繁的數(shù)據(jù)拷貝在高并發(fā)場(chǎng)景下會(huì)帶來(lái)較大的性能開(kāi)銷(xiāo)。
- epoll 的優(yōu)化: epoll 使用了內(nèi)存映射( mmap )技術(shù),這樣便徹底省掉了這些socket描述符在系統(tǒng)調(diào)用時(shí)拷貝的開(kāi)銷(xiāo)(因?yàn)閺挠脩?hù)空間到內(nèi)核空間需要拷貝操作)。mmap將用戶(hù)空間的一塊地址和內(nèi)核空間的一塊地址同時(shí)映射到相同的一塊物理內(nèi)存地址(不管是用戶(hù)空間還是內(nèi)核空間都是虛擬地址,最終要通過(guò)地址映射映射到物理地址),使得這塊物理內(nèi)存對(duì)內(nèi)核和對(duì)用戶(hù)均可見(jiàn),減少用戶(hù)態(tài)和內(nèi)核態(tài)之間的數(shù)據(jù)交換,不需要依賴(lài)拷貝,這樣子內(nèi)核可以直接看到epoll監(jiān)聽(tīng)的socket描述符,效率極高。
(3) 支持更大的并發(fā)連接數(shù)
- select 的 fd 數(shù)量限制: select 使用 fd_set 結(jié)構(gòu),其大小通常被限制為 1024(由 __FD_SETSIZE 定義),這意味著它最多只能同時(shí)監(jiān)視 1024 個(gè) fd。雖然可以通過(guò)修改內(nèi)核頭文件并重新編譯內(nèi)核來(lái)擴(kuò)大這一限制,但這并不能從根本上解決問(wèn)題。
- poll 的改進(jìn)與局限: poll 使用 pollfd 結(jié)構(gòu),理論上可以支持更多的 fd,但它仍然需要遍歷所有 fd,性能會(huì)隨著 fd 數(shù)量的增加而下降。
- epoll 的無(wú)限制支持: epoll 沒(méi)有 fd 數(shù)量的硬性限制,適合高并發(fā)場(chǎng)景,能夠輕松支持?jǐn)?shù)萬(wàn)甚至數(shù)十萬(wàn)的并發(fā)連接。
四、總結(jié)
select,poll,epoll都是IO多路復(fù)用機(jī)制,即可以監(jiān)視多個(gè)描述符,一旦某個(gè)描述符就緒(讀或?qū)懢途w),能夠通知程序進(jìn)行相應(yīng)讀寫(xiě)操作。 但select,poll,epoll本質(zhì)上都是同步I/O,因?yàn)樗麄兌夹枰谧x寫(xiě)事件就緒后自己負(fù)責(zé)進(jìn)行讀寫(xiě),也就是說(shuō)這個(gè)讀寫(xiě)過(guò)程是阻塞的,而異步I/O則無(wú)需自己負(fù)責(zé)進(jìn)行讀寫(xiě),異步I/O的實(shí)現(xiàn)會(huì)負(fù)責(zé)把數(shù)據(jù)從內(nèi)核拷貝到用戶(hù)空間。
- select,poll實(shí)現(xiàn)需要自己不斷輪詢(xún)所有fd集合,直到設(shè)備就緒,期間可能要睡眠和喚醒多次交替。而epoll其實(shí)也需要調(diào)用epoll_wait不斷輪詢(xún)就緒鏈表,期間也可能多次睡眠和喚醒交替,但是它是設(shè)備就緒時(shí),調(diào)用回調(diào)函數(shù),把就緒fd放入就緒鏈表中,并喚醒在epoll_wait中進(jìn)入睡眠的進(jìn)程。雖然都要睡眠和交替,但是select和poll在“醒著”的時(shí)候要遍歷整個(gè)fd集合,而epoll在“醒著”的時(shí)候只要判斷一下就緒鏈表是否為空就行了,這節(jié)省了大量的CPU時(shí)間。這就是回調(diào)機(jī)制帶來(lái)的性能提升。
- select,poll每次調(diào)用都要把fd集合從用戶(hù)態(tài)往內(nèi)核態(tài)拷貝一次,并且要把current往設(shè)備等待隊(duì)列中掛一次,而epoll只要一次拷貝,而且把current往等待隊(duì)列上掛也只掛一次(在epoll_wait的開(kāi)始,注意這里的等待隊(duì)列并不是設(shè)備等待隊(duì)列,只是一個(gè)epoll內(nèi)部定義的等待隊(duì)列)。這也能節(jié)省不少的開(kāi)銷(xiāo)。