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