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

使用引腳作為普通的I/O時(shí)一定要進(jìn)行引腳的功能復(fù)用嗎
數(shù)字I/O介紹
Java I/O 的相關(guān)方法分析

Linux中如何使用信號(hào)驅(qū)動(dòng)式I/O?

關(guān)于STM32通用和復(fù)用I/O口

評(píng)論