今天給大家聊聊I/O復(fù)用,對于大部分公司面試來說,這塊肯定是必問內(nèi)容,它不僅能側(cè)面反映面試這對基礎(chǔ)掌握的是否扎實,還能反映出求職者的知識廣度。
1 從阻塞 I/O 到 I/O 多路復(fù)用
阻塞 I/O,是指進(jìn)程發(fā)起調(diào)用后,會被掛起(阻塞),直到收到數(shù)據(jù)再返回。如果調(diào)用一直不返回,進(jìn)程就會一直被掛起。因此,當(dāng)使用阻塞 I/O 時,需要使用多線程來處理多個文件描述符。
多線程切換有一定的開銷,因此引入非阻塞 I/O。非阻塞 I/O 不會將進(jìn)程掛起,調(diào)用時會立即返回成功或錯誤,因此可以在一個線程里輪詢多個文件描述符是否就緒。
但是非阻塞 I/O 的缺點是:每次發(fā)起系統(tǒng)調(diào)用,只能檢查一個文件描述符是否就緒。當(dāng)文件描述符很多時,系統(tǒng)調(diào)用的成本很高。
因此引入了 I/O 多路復(fù)用,可以 通過一次系統(tǒng)調(diào)用,檢查多個文件描述符的狀態(tài) 。這是 I/O 多路復(fù)用的主要優(yōu)點,相比于非阻塞 I/O,在文件描述符較多的場景下,避免了頻繁的用戶態(tài)和內(nèi)核態(tài)的切換,減少了系統(tǒng)調(diào)用的開銷。
I/O 多路復(fù)用相當(dāng)于將「遍歷所有文件描述符、通過非阻塞 I/O 查看其是否就緒」的過程從用戶線程移到了內(nèi)核中,由內(nèi)核來負(fù)責(zé)輪詢。
進(jìn)程可以通過 select、poll、epoll 發(fā)起 I/O 多路復(fù)用的系統(tǒng)調(diào)用,這些系統(tǒng)調(diào)用都是同步阻塞的: 如果傳入的多個文件描述符中,有描述符就緒,則返回就緒的描述符;否則如果所有文件描述符都未就緒,就阻塞調(diào)用進(jìn)程,直到某個描述符就緒,或者阻塞時長超過設(shè)置的 timeout 后,再返回 。I/O 多路復(fù)用內(nèi)部使用非阻塞 I/O 檢查每個描述符的就緒狀態(tài)。
如果 timeout參數(shù)設(shè)為 NULL,會無限阻塞直到某個描述符就緒;如果timeout參數(shù)設(shè)為 0,會立即返回,不阻塞。
I/O 多路復(fù)用引入了一些額外的操作和開銷,性能更差。但是好處是用戶可以在一個線程內(nèi)同時處理多個 I/O 請求。如果不采用 I/O 多路復(fù)用,則必須通過多線程的方式,每個線程處理一個 I/O 請求。后者線程切換也是有一定的開銷的。
2 為什么 I/O 多路復(fù)用內(nèi)部需要使用非阻塞 I/O?
I/O 多路復(fù)用內(nèi)部會遍歷集合中的每個文件描述符,判斷其是否就緒:
for fd in read_set
if (readable(fd)) // 判斷fd是否就緒
count++;
FDSET(fd, &res_rset) // 將fd添加到就緒隊列中
break;
return count;
這里的 readable(fd) 就是一個非阻塞 I/O 調(diào)用。試想,如果這里使用阻塞 I/O,那么fd未就緒時,select會阻塞在這個文件描述符上,無法檢查下個文件描述符。
注意:這里說的是 I/O 多路復(fù)用的內(nèi)部實現(xiàn),而不是說,使用 I/O 多路復(fù)用就必須使用非阻塞 I/O。
3 select
函數(shù)簽名與參數(shù)
int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict errorfds. struct timeval *restrict timeout);
readfds、writefds、errorfds 是三個文件描述符集合。select 會遍歷每個集合的前 nfds個描述符,分別找到可以讀取、可以寫入、發(fā)生錯誤的描述符,統(tǒng)稱為“就緒”的描述符。然后用找到的子集替換參數(shù)中的對應(yīng)集合,返回所有就緒描述符的總數(shù)。
timeout
參數(shù)表示調(diào)用 select
時的阻塞時長。如果所有文件描述符都未就緒,就阻塞調(diào)用進(jìn)程,直到某個描述符就緒,或者阻塞超過設(shè)置的 timeout 后,返回。如果 timeout
參數(shù)設(shè)為 NULL,會無限阻塞直到某個描述符就緒;如果 timeout
參數(shù)設(shè)為 0,會立即返回,不阻塞。
3.1 什么是文件描述符 fd
文件描述符(file descriptor)是一個非負(fù)整數(shù),從 0 開始。進(jìn)程使用文件描述符來標(biāo)識一個打開的文件。
系統(tǒng)為每一個進(jìn)程維護(hù)了一個文件描述符表,表示該進(jìn)程打開文件的記錄表,而 文件描述符實際上就是這張表的索引 。當(dāng)進(jìn)程打開(open
)或者新建(create
)文件時,內(nèi)核會在該進(jìn)程的文件列表中新增一個表項,同時返回一個文件描述符 —— 也就是新增表項的下標(biāo)。
一般來說,每個進(jìn)程最多可以打開 64 個文件,fd ∈ 0~63
。在不同系統(tǒng)上,最多允許打開的文件個數(shù)不同,Linux 2.4.22 強(qiáng)制規(guī)定最多不能超過 1,048,576。
每個進(jìn)程默認(rèn)都有 3 個文件描述符:0 (stdin)、1 (stdout)、2 (stderr)。
3.2 socket 與 fd 的關(guān)系
socket 是 Unix 中的術(shù)語。socket 可以用于同一臺主機(jī)的不同進(jìn)程間的通信,也可以用于不同主機(jī)間的通信。一個 socket 包含地址、類型和通信協(xié)議等信息,通過 **socket()
**函數(shù)創(chuàng)建:
int socket(int domain, int type, int protocol)
返回的就是這個 socket 對應(yīng)的文件描述符 fd
。操作系統(tǒng)將 socket 映射到進(jìn)程的一個文件描述符上,進(jìn)程就可以通過讀寫這個文件描述符來和遠(yuǎn)程主機(jī)通信。
可以這樣理解:socket 是進(jìn)程間通信規(guī)則的高層抽象,而 fd 提供的是底層的具體實現(xiàn)。socket 與 fd 是一一對應(yīng)的。通過 socket 通信,實際上就是通過文件描述符 fd
讀寫文件。這也符合 Unix“一切皆文件”的哲學(xué)。
3.3 fd_set 文件描述符集合
參數(shù)中的 **fd_set
**類型表示文件描述符的集合。
由于文件描述符 fd
是一個從 0 開始的無符號整數(shù),所以可以使用 fd_set
的二進(jìn)制每一位來表示一個文件描述符。某一位為 1,表示對應(yīng)的文件描述符已就緒。比如比如設(shè) fd_set
長度為 1 字節(jié),則一個 fd_set
變量最大可以表示 8 個文件描述符。當(dāng) **select
**返回 **fd_set = 00010011
**時,表示文件描述符 **1
、2
、5
**已經(jīng)就緒。
3.4 select 使用示例
下圖的代碼說明:
(1)先聲明一個 fd_set
類型的變量 readFDs
(2)調(diào)用 FD_ZERO
,將 readFDs
所有位 置 0
(3)調(diào)用 FD_SET
,將 readFDs
感興趣的位置 1,表示要監(jiān)聽這幾個文件描述符
(4)將 readFDs
傳給 select
,調(diào)用 select
(5)select會將 readFDs
中就緒的位置 1,未就緒的位置 0,返回就緒的文件描述符的數(shù)量
(6)當(dāng) select
返回后,調(diào)用 FD_ISSET
檢測給定位是否為 1,表示對應(yīng)文件描述符是否就緒
比如進(jìn)程想監(jiān)聽 1、2、5 這三個文件描述符,就將 readFDs
設(shè)置為 00010011
,然后調(diào)用 select
。
如果 fd=1
、fd=2
就緒,而 fd=5
未就緒,select
會將 readFDs
設(shè)置為 00000011
并返回 2。
如果每個文件描述符都未就緒,select
會阻塞 timeout
時長,再返回。這期間,如果 readFDs
監(jiān)聽的某個文件描述符上發(fā)生可讀事件,則 select
會將對應(yīng)位置 1,并立即返回。
**3.5 **select 的缺點
- 性能開銷大
- 調(diào)用
select
時會陷入內(nèi)核,這時需要將參數(shù)中的fd_set
從用戶空間拷貝到內(nèi)核空間 - 內(nèi)核需要遍歷傳遞進(jìn)來的所有
fd_set
的每一位,不管它們是否就緒
- 調(diào)用
- 同時能夠監(jiān)聽的文件描述符數(shù)量太少。受限于
sizeof(fd_set)
的大小,在編譯內(nèi)核時就確定了且無法更改。一般是 1024,不同的操作系統(tǒng)不相同。
4 poll
poll 和 select 幾乎沒有區(qū)別。poll 在用戶態(tài)通過數(shù)組方式傳遞文件描述符,在內(nèi)核會轉(zhuǎn)為鏈表方式 存儲 ,沒有最大數(shù)量的限制 。
poll 的函數(shù)簽名如下:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
其中 fds
是一個 pollfd
結(jié)構(gòu)體類型的數(shù)組,調(diào)用 poll()
時必須通過 nfds
指出數(shù)組 fds
的大小,即文件描述符的數(shù)量。
從性能開銷上看,poll 和 select 的差別不大。
5 epoll
epoll 是對 select 和 poll 的改進(jìn),避免了“性能開銷大”和“文件描述符數(shù)量少”兩個缺點。
簡而言之,epoll 有以下幾個特點:
- 使用紅黑樹存儲文件描述符集合
- 使用隊列存儲就緒的文件描述符
- 每個文件描述符只需在添加時傳入一次;通過事件更改文件描述符狀態(tài)
select、poll 模型都只使用一個函數(shù),而 epoll 模型使用三個函數(shù):epoll_create
、epoll_ctl
和 epoll_wait
。
5.1 epoll_create
int epoll_create(int size);
epoll_create
會創(chuàng)建一個 epoll
實例,同時返回一個引用該實例的文件描述符。
返回的文件描述符僅僅指向?qū)?yīng)的 epoll
實例,并不表示真實的磁盤文件節(jié)點。其他 API 如 epoll_ctl
、epoll_wait
會使用這個文件描述符來操作相應(yīng)的 epoll
實例。
當(dāng)創(chuàng)建好 epoll 句柄后,它會占用一個 fd 值,在 linux 下查看 /proc/進(jìn)程id/fd/
,就能夠看到這個 fd。所以在使用完 epoll 后,必須調(diào)用 close(epfd)
關(guān)閉對應(yīng)的文件描述符,否則可能導(dǎo)致 fd 被耗盡。當(dāng)指向同一個 epoll
實例的所有文件描述符都被關(guān)閉后,操作系統(tǒng)會銷毀這個 epoll
實例。
epoll
實例內(nèi)部存儲:
- 監(jiān)聽列表:所有要監(jiān)聽的文件描述符,使用紅黑樹
- 就緒列表:所有就緒的文件描述符,使用鏈表
5.2 epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctl
會監(jiān)聽文件描述符 fd
上發(fā)生的 event
事件。
參數(shù)說明:
epfd
即epoll_create
返回的文件描述符,指向一個epoll
實例fd
表示要監(jiān)聽的目標(biāo)文件描述符event
表示要監(jiān)聽的事件(可讀、可寫、發(fā)送錯誤…)op
表示要對fd
執(zhí)行的操作,有以下幾種:EPOLL_CTL_ADD
:為fd
添加一個監(jiān)聽事件event
EPOLL_CTL_MOD
:Change the event event associated with the target file descriptor fd(event
是一個結(jié)構(gòu)體變量,這相當(dāng)于變量event
本身沒變,但是更改了其內(nèi)部字段的值)EPOLL_CTL_DEL
:刪除fd
的所有監(jiān)聽事件,這種情況下event
參數(shù)沒用
返回值 0 或 -1,表示上述操作成功與否。
epoll_ctl
會將文件描述符 fd
添加到 epoll
實例的監(jiān)聽列表里,同時為 fd
設(shè)置一個回調(diào)函數(shù),并監(jiān)聽事件 event
。當(dāng) fd
上發(fā)生相應(yīng)事件時,會調(diào)用回調(diào)函數(shù),將 fd
添加到 epoll
實例的就緒隊列上。
5.3 epoll_wait
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
這是 epoll 模型的主要函數(shù),功能相當(dāng)于 select
。
參數(shù)說明:
epfd
即epoll_create
返回的文件描述符,指向一個epoll
實例events
是一個數(shù)組,保存就緒狀態(tài)的文件描述符,其空間由調(diào)用者負(fù)責(zé)申請maxevents
指定events
的大小timeout
類似于select
中的 timeout。如果沒有文件描述符就緒,即就緒隊列為空,則epoll_wait
會阻塞 timeout 毫秒。如果 timeout 設(shè)為 -1,則epoll_wait
會一直阻塞,直到有文件描述符就緒;如果 timeout 設(shè)為 0,則epoll_wait
會立即返回
返回值表示 events
中存儲的就緒描述符個數(shù),最大不超過 maxevents
。
5.4 epoll 的優(yōu)點
一開始說,epoll 是對 select 和 poll 的改進(jìn),避免了“性能開銷大”和“文件描述符數(shù)量少”兩個缺點。
對于“文件描述符數(shù)量少”,select 使用整型數(shù)組存儲文件描述符集合,而 epoll 使用紅黑樹存儲,數(shù)量較大。
對于“性能開銷大”,epoll_ctl
中為每個文件描述符指定了回調(diào)函數(shù),并在就緒時將其加入到就緒列表,因此 epoll 不需要像 select
那樣遍歷檢測每個文件描述符,只需要判斷就緒列表是否為空即可。這樣,在沒有描述符就緒時,epoll 能更早地讓出系統(tǒng)資源。
相當(dāng)于時間復(fù)雜度從 O(n) 降為 O(1)
此外,每次調(diào)用 select
時都需要向內(nèi)核拷貝所有要監(jiān)聽的描述符集合,而 epoll 對于每個描述符,只需要在 epoll_ctl
傳遞一次,之后 epoll_wait
不需要再次傳遞。這也大大提高了效率。
5.5 水平觸發(fā)、邊緣觸發(fā)
select
只支持水平觸發(fā),epoll
支持水平觸發(fā)和邊緣觸發(fā)。
水平觸發(fā) (LT,Level Trigger):當(dāng)文件描述符就緒時,會觸發(fā)通知,如果用戶程序沒有一次性把數(shù)據(jù)讀/寫完,下次還會發(fā)出可讀/可寫信號進(jìn)行通知。
邊緣觸發(fā) (ET,Edge Trigger):僅當(dāng)描述符從未就緒變?yōu)榫途w時,通知一次,之后不會再通知。
區(qū)別:邊緣觸發(fā)效率更高, 減少了事件被重復(fù)觸發(fā)的次數(shù) ,函數(shù)不會返回大量用戶程序可能不需要的文件描述符。
水平觸發(fā)、邊緣觸發(fā)的名稱來源:數(shù)字電路當(dāng)中的電位水平,高低電平切換瞬間的觸發(fā)動作叫邊緣觸發(fā),而處于高電平的觸發(fā)動作叫做水平觸發(fā)。
5.6 為什么邊緣觸發(fā)必須使用非阻塞 I/O?
關(guān)于這個問題的解答,強(qiáng)烈建議閱讀這篇文章。下面是一些關(guān)鍵摘要:
- 每次通過
read
系統(tǒng)調(diào)用讀取數(shù)據(jù)時,最多只能讀取緩沖區(qū)大小的字節(jié)數(shù);如果某個文件描述符一次性收到的數(shù)據(jù)超過了緩沖區(qū)的大小,那么需要對其read
多次才能全部讀取完畢 select
可以使用阻塞 I/O 。通過select
獲取到所有可讀的文件描述符后,遍歷每個文件描述符,read
一次數(shù)據(jù)(見上文 select 示例)- 這些文件描述符都是可讀的,因此即使
read
是阻塞 I/O,也一定可以讀到數(shù)據(jù),不會一直阻塞下去 select
采用水平觸發(fā)模式,因此如果第一次read
沒有讀取完全部數(shù)據(jù),那么下次調(diào)用select
時依然會返回這個文件描述符,可以再次read
select
也可以使用非阻塞 I/O 。當(dāng)遍歷某個可讀文件描述符時,使用for
循環(huán)調(diào)用read
多次 ,直到讀取完所有數(shù)據(jù)為止(返回EWOULDBLOCK
)。這樣做會多一次read
調(diào)用,但可以減少調(diào)用select
的次數(shù)
- 這些文件描述符都是可讀的,因此即使
- 在
epoll
的邊緣觸發(fā)模式下,只會在文件描述符的可讀/可寫狀態(tài)發(fā)生切換時,才會收到操作系統(tǒng)的通知- 因此,如果使用
epoll
的 邊緣觸發(fā)模式 ,在收到通知時,**必須使用非阻塞 I/O,并且必須循環(huán)調(diào)用 **read
或write
多次,直到返回EWOULDBLOCK
為止 ,然后再調(diào)用epoll_wait
等待操作系統(tǒng)的下一次通知 - 如果沒有一次性讀/寫完所有數(shù)據(jù),那么在操作系統(tǒng)看來這個文件描述符的狀態(tài)沒有發(fā)生改變,將不會再發(fā)起通知,調(diào)用
epoll_wait
會使得該文件描述符一直等待下去,服務(wù)端也會一直等待客戶端的響應(yīng),業(yè)務(wù)流程無法走完 - 這樣做的好處是每次調(diào)用
epoll_wait
都是有效的——保證數(shù)據(jù)全部讀寫完畢了,等待下次通知。在水平觸發(fā)模式下,如果調(diào)用epoll_wait
時數(shù)據(jù)沒有讀/寫完畢,會直接返回,再次通知。因此邊緣觸發(fā)能顯著減少事件被觸發(fā)的次數(shù) - 為什么
epoll
的 邊緣觸發(fā)模式不能使用阻塞 I/O ?很顯然,邊緣觸發(fā)模式需要循環(huán)讀/寫一個文件描述符的所有數(shù)據(jù)。如果使用阻塞 I/O,那么一定會在最后一次調(diào)用(沒有數(shù)據(jù)可讀/寫)時阻塞,導(dǎo)致無法正常結(jié)束
- 因此,如果使用
6 三者對比
select
:調(diào)用開銷大(需要復(fù)制集合);集合大小有限制;需要遍歷整個集合找到就緒的描述符poll
:poll 采用數(shù)組的方式存儲文件描述符,沒有最大存儲數(shù)量的限制,其他方面和 select 沒有區(qū)別epoll
:調(diào)用開銷?。ú恍枰獜?fù)制);集合大小無限制;采用回調(diào)機(jī)制,不需要遍歷整個集合
select
、poll
都是在用戶態(tài)維護(hù)文件描述符集合,因此每次需要將完整集合傳給內(nèi)核;epoll
由操作系統(tǒng)在內(nèi)核中維護(hù)文件描述符集合,因此只需要在創(chuàng)建的時候傳入文件描述符。
此外 select
只支持水平觸發(fā),epoll
支持邊緣觸發(fā)。
7 適用場景
當(dāng)連接數(shù)較多并且有很多的不活躍連接時,epoll 的效率比其它兩者高很多。當(dāng)連接數(shù)較少并且都十分活躍的情況下,由于 epoll 需要很多回調(diào),因此性能可能低于其它兩者。
-
編程
+關(guān)注
關(guān)注
88文章
3565瀏覽量
93536 -
i/o
+關(guān)注
關(guān)注
0文章
33瀏覽量
4562
發(fā)布評論請先 登錄
相關(guān)推薦
評論