關於非阻塞I/O、多路複用、epoll的雜談
本文主要是想解答一下這樣幾個問題:
- 什麼是非阻塞I/O
- 非阻塞I/O和非同步I/O的區別
- epoll的工作原理
檔案描述符
檔案描述符在本文有多次出現,難免有的朋友不太熟悉,有必要簡單說明一下。
檔案描述符是一個非負整數,用於標識一個開啟的檔案。
這裡“檔案”一詞是更寬泛的概念,可以是程序中使用的任何型別的I\O資源,例如常規檔案,管道,Socket等。
通常,開啟I\O流的系統呼叫都會返回一個int型別的檔案描述符。
例如下面分別是開啟一個檔案和建立一個Socket的系統呼叫。
int open(const char *pathname, int flags); int socket(int domain, int type, int protocol);
檔案描述符File descriptor
,在程式中一般簡寫為fd
。
阻塞模式(Blocking I/O)
解釋非阻塞I/O之前先,先聊下阻塞I/O 。
預設的,Unix系統的所有檔案描述符都是阻塞模式。這意味著有關I/O的操作(如read,write,open)都是阻塞的。
以從Linux終端視窗讀取資料為例,如果你在程式中呼叫了read(),程式會一直阻塞,直到你確實敲鍵盤輸入了字元。
這裡的程式阻塞,更準確地講,是系統核心把該程序設定成了睡眠狀態,直到有資料輸入才被喚醒。
TCP Socket通訊也是一樣的道理,如果你嘗試著從socket中讀取資料,read()會被阻塞,直到socket的另一端傳送了資料。
可見,在阻塞模式下程式是不能併發操作的,因為程序\執行緒都被睡眠了。
對於併發I/O,我們將討論三個解決辦法:
- 非阻塞模式
- I/O多路複用系統呼叫,select和epoll
- 多執行緒/多程序
下面詳細聊聊這三個方法。
非阻塞模式(Nonblocking I/O)
在開啟一個檔案時,設定flag引數為O_NONBLOCK
,便告訴了作業系統對這個檔案的操作應該是非阻塞的,或者說該檔案描述符處於非阻塞模式。
這意味著兩點:
- 如果這個檔案不能立即開啟,open()系統呼叫會返回一個錯誤碼,而不是阻塞open()。
- 檔案開啟成功後,後續的I/O操作應該也是非阻塞的,例如如果不能立即完成read或write,返回錯誤碼。
不能立即完成讀寫的原因可能是沒有資料可讀,或者快取已滿沒有更多空間可以寫。
(注意,非阻塞模式對常規檔案無效,因為系統核心總能保證有足夠的快取讓常規檔案I/O不阻塞)
非阻塞模式僅僅是Unix系統中一個原始特性,只有它還不能讓程式併發執行。還需要我們在應用程式中有一個死迴圈,不停的檢測檔案描述符的狀態。
下面以併發訪問兩個檔案描述符為例寫一段虛擬碼。
先解釋一下read()系統呼叫的幾個引數:
int read(int fd, void *buffer, size_t count);
fd
即讀取的檔案描述符buffer
用於接收本次讀取的資料count
本次讀取的最大位元組數返回值
實際讀取的位元組數,發生錯誤返回-1
ssize_t nbytes;
for (;;) {
//檔案描述符1
if ((nbytes = read(fd1, buf, sizeof(buf))) < 0) {
if (errno != EWOULDBLOCK) {
//發生錯誤,且錯誤碼為EWOULDBLOCK
//說明檔案描述符fd1沒有準備好read
}
} else {
handle_data(buf); //處理資料
}
//檔案描述符2
if ((nbytes = read(fd2, buf, sizeof(buf))) < 0) {
if (errno != EWOULDBLOCK) {
//fd2不能read
}
} else {
handle_data(buf);
}
nanosleep(sleep_interval, NULL); //再次檢測前睡眠片刻
}
以上實現了對兩個檔案併發I\O,但有很明顯的缺點:
- 迴圈的頻次太低,會導致I/O的響應延遲。
- 迴圈的頻次太高,當需要併發處理的檔案很多時,每次迴圈都要檢測所有檔案,效能會變得很差(read()是系統呼叫)。
多程序/執行緒方式
併發處理多個I/O流還有一種更原始的方法,使用多程序或多執行緒,每個I/O流獨佔一個程序或執行緒,很容易理解。
也有很多缺點:
- 在任何時刻,都可能有大量的執行緒處於空閒狀態,造成資源浪費。
- 執行緒是佔記憶體的,不可能建立太多的工作執行緒。
- 執行緒/程序的上下文切換也會帶來很大的效能開銷。
在網際網路早期流量比較小,很多服務採用的這種方式,但是它只適用於低併發的伺服器。
上面討論了非阻塞模式和多程序兩個方式,在高併發場景都不太好用。
這時,我們就需要Unix的I/O多路複用了。
I/O多路複用(I/O Multiplexing)
類Unix系統有多個實現了I/O多路複用的系統呼叫,Unix系統的select
和poll
,Linux的epoll
,以及BSD的kqueue
。
他們的底層工作原理相似:首先告訴系統核心你想監控哪些檔案描述符的哪些事件(典型的read和write事件),然後使用者程式被阻塞,直到你感興趣的事件發生。
例如你可能告訴系統核心,“當檔案描述符X可以read時,通知我。”
Unix的I/O多路複用機制不關心檔案描述符是否處於非阻塞模式
,你可以把所有檔案描述符設定為阻塞模式,epoll和select不會受影響。
這一點很重要!非阻塞模式和I/O多路複用,這兩個方法都可以實現併發I/O,但是本質上他們是相互獨立的兩個解決問題思路,互不依賴。
多路複用實現的併發I/O,有時被稱為非同步I/O(asynchronous I/O)
。但是有人也把這種方式稱為非阻塞I/O,這是錯誤的,應該是對非阻塞模式有什麼誤解。
epoll的工作原理
epoll是event poll
的簡寫,是Linux核心提供的一種由事件驅動的I/O通知機制。(注意epoll是Linux特有的,其他類Unix系統可能沒有實現。)
另外,epoll本身並不是一個系統呼叫,而是一組系統呼叫的統稱。
這組系統呼叫包括:
- epoll_create()系統呼叫
- epoll_ctl()系統呼叫
- epoll_wait()系統呼叫
epoll_create()
方法簽名:int epoll_create(int size)
該系統呼叫用於建立一個epoll例項,該例項存在於系統核心空間。
epoll例項會初始化一個列表,用於儲存使用者程式感興趣的檔案描述符,下文簡稱interest list
,引數size即為這個列表的初始大小(可以動態擴充套件)。返回值是epoll例項的檔案描述符。
epoll_ctl()
使用epoll_ctl()可以對interest list
增刪改(ctl應該是control的縮寫)
方法簽名:int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev)
引數說明:
epfd
: 即epoll_create()建立的epoll例項的檔案描述符op
: 列舉值,指定操作型別,例如EPOLL_CTL_ADD新增一個檔案描述符ev
: 結構體型別,是對該檔案描述符的設定。
ev引數最重要的是ev.events
欄位可以新增感興趣的事件,例如添加了read事件,表示該檔案可以read的時候,通知應用程式。epoll_wait()
使用者程式呼叫該方法獲取ready的檔案描述符。
繼續之前先解釋一下檔案描述符什麼時候ready
?
即使在檔案描述符處於阻塞模式(沒有設定O_NONBLOCK
)的情況下,對該檔案描述符的read\write等操作扔不會阻塞,就說該檔案描述符ready。
方法簽名:int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
引數說明:
- epfd: epoll例項的檔案描述符
- evlist: 用於接收ready的檔案描述符,該陣列由使用者程式分配。
- maxevents: 一次呼叫返回的最大事件數量
timeout: epoll_wait系統呼叫的超時時間,0表示不阻塞,無事件發生立即返回。-1一直阻塞,直到有事件發生。大於0表示超時返回。
為了對epoll的併發I/O程式設計有個感性的認識,我們來寫一段虛擬碼
epfd = epoll_create(EPOLL_QUEUE_LEN); //建立epoll例項
static struct epoll_event ev;
int client_sock;
ev.events = EPOLLIN | EPOLLPRI | EPOLLERR | EPOLLHUP; //宣告感興趣的事件
ev.data.fd = client_sock; //檔案描述符指向一個Socket連線
//新增要監控的檔案描述符和事件型別到interest list
//真實環境中,可能需要新增成百上千個這種事件。
int res = epoll_ctl(epfd, EPOLL_CTL_ADD, client_sock, &ev);
while (1) {
int nfds = epoll_wait(epfd, events, MAX_EVENTS_PER_RUN, TIMEOUT);//獲取ready的事件,可能有多個
if (nfds < 0) die("Error in epoll_wait!"); //發生錯誤,退出程式
for(int i = 0; i < nfds; i++) { //遍歷處理每一個已ready的socket
int fd = events[i].data.fd;
handle_io_on_socket(fd);
}
}
從虛擬碼中可以看到,基於epoll的I/O多路複用的時間複雜度只有O(n)
,其中n是ready的事件數量。
時間複雜度不會隨著interest list的增加而線性增長,這使得epoll有很好的擴充套件性。
試想一個高併發伺服器同一時刻可能需要監控成千上萬甚至更多的socket連線,
如果使用文章開始介紹的非阻塞模式,每一次迴圈都要對全部的socket進行測試,這是很恐怖的。
但是epoll的壓力只與socket通訊的繁忙程度有關。
另外提一下,Unix中的poll()和select()有著更差的時間複雜度和空間複雜度,篇幅受限這裡不展開說了。
最後,結合圖片瞭解一下epoll的內部結構(圖片出處見文章最後)
1.程序483通過epoll_create()建立一個epoll例項,該例項存在於核心空間。
2.程序483通過epoll_ctl()系統呼叫,新增五個感興趣的檔案描述符到interest list。
3.當有檔案描述符ready時,系統核心會把該檔案描述符新增到ready list,ready list是interest list的子集。
事件發生後新增到ready list,這個過程是核心完成的。
4. 使用者程式呼叫epoll_wait()時,核心會把ready list返回給使用者程式。
寫在最後
博主的主力語言是Java,對C一知半解,如文章有理解錯誤的地方,感謝指正。
我在學習Java NIO的原理時,遇到了很多Jdk原始碼解決不了的困惑。於是我決定從Linux程式設計介面著手,想了解一下Linux系統怎麼做到的併發I/O。
途中翻閱了很多資料,其中對我幫助最大的是《The Linux Programming Interface》,作者是Linux man-pages的維護者,具有一定的權威性。
這本書並沒有像書名一樣停留在Linux API層面,而是穿插著講解了很多原理性的知識,有幾個知識點講的可謂醍醐灌頂。
其他資料就比較雜亂了,像維基百科、man手冊、個人部落格都有。
這篇文章,更多得是對這幾天學習的知識梳理和總結,沒什麼原創內容。
希望對你有所幫助吧。
參考內容
- 《The Linux Programming Interface》英文版 4.3章節、5.9章節、 56.2章節、63.1章節、63.2.3章節 (核心參考)
- man7關於O_NONBLOCK的權威說明:http://man7.org/linux/man-pages/man2/open.2.html (
裡面有提到,設定O_NONBLOCK與否對select和epoll沒有影響。
)- 很清晰的解釋了非阻塞模式和多路複用的區別和聯絡:https://eklitzke.org/blocking-io-nonblocking-io-and-epoll
- 介紹了epoll的工作原理和內部資料結構(本文中的圖片來源):https://medium.com/@copyconstruct/the-method-to-epolls-madness-d9d2d6378642
- 對《The Linux Programming Interface》,epoll章節的讀書筆記:https://jvns.ca/blog/2017/06/03/async-io-on-linux--select--poll--and-epoll/
- 一個epoll程式設計的小demo : https://kovyrin.net/2006/04/13/epoll-asynchronous-network-programming/
- 關於epoll和poll效能的討論: https://www.win.tue.nl/~aeb/linux/lk/lk-12.html