BIO
有了Block的定義,就可以討論BIO和NIO了。BIO是Blocking IO的意思。在類似於網絡中進行read
, write
, connect
一類的系統調用時會被卡住。
舉個例子,當用read
去讀取網絡的數據時,是無法預知對方是否已經發送數據的。因此在收到數據之前,能做的只有等待,直到對方把數據發過來,或者等到網絡超時。
對於單線程的網絡服務,這樣做就會有卡死的問題。因為當等待時,整個線程會被掛起,無法執行,也無法做其他的工作。
順便說一句,這種Block是不會影響同時運行的其他程序(進程)的,因為現代操作系統都是多任務的,任務之間的切換是搶占式的。這里Block只是指Block當前的進程。
於是,網絡服務為了同時響應多個並發的網絡請求,必須實現為多線程的。每個線程處理一個網絡請求。線程數隨着並發連接數線性增長。這的確能奏效。實際上2000年之前很多網絡服務器就是這么實現的。但這帶來兩個問題:
- 線程越多,Context Switch就越多,而Context Switch是一個比較重的操作,會無謂浪費大量的CPU。
- 每個線程會占用一定的內存作為線程的棧。比如有1000個線程同時運行,每個占用1MB內存,就占用了1個G的內存。
也許現在看來1GB內存不算什么,現在服務器上百G內存的配置現在司空見慣了。但是倒退20年,1G內存是很金貴的。並且,盡管現在通過使用大內存,可以輕易實現並發1萬甚至10萬的連接。但是水漲船高,如果是要單機撐1千萬的連接呢?
問題的關鍵在於,當調用read
接受網絡請求時,有數據到了就用,沒數據到時,實際上是可以干別的。使用大量線程,僅僅是因為Block發生,沒有其他辦法。
當然你可能會說,是不是可以弄個線程池呢?這樣既能並發的處理請求,又不會產生大量線程。但這樣會限制最大並發的連接數。比如你弄4個線程,那么最大4個線程都Block了就沒法響應更多請求了。
要是操作IO接口時,操作系統能夠總是直接告訴有沒有數據,而不是Block去等就好了。於是,NIO登場。
NIO
NIO是指將IO模式設為“Non-Blocking”模式。在Linux下,一般是這樣:
void setnonblocking(int fd) { int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); }
再強調一下,以上操作只對socket對應的文件描述符有意義;對磁盤文件的文件描述符做此設置總會成功,但是會直接被忽略。
這時,BIO和NIO的區別是什么呢?
在BIO模式下,調用read,如果發現沒數據已經到達,就會Block住。
在NIO模式下,調用read,如果發現沒數據已經到達,就會立刻返回-1, 並且errno被設為EAGAIN
。
在有些文檔中寫的是會返回
EWOULDBLOCK
。實際上,在Linux下EAGAIN
和EWOULDBLOCK
是一樣的,即#define EWOULDBLOCK EAGAIN
於是,一段NIO的代碼,大概就可以寫成這個樣子。
struct timespec sleep_interval{.tv_sec = 0, .tv_nsec = 1000}; ssize_t nbytes; while (1) { /* 嘗試讀取 */ if ((nbytes = read(fd, buf, sizeof(buf))) < 0) { if (errno == EAGAIN) { // 沒數據到 perror("nothing can be read"); } else { perror("fatal error"); exit(EXIT_FAILURE); } } else { // 有數據 process_data(buf, nbytes); } // 處理其他事情,做完了就等一會,再嘗試 nanosleep(sleep_interval, NULL); }
這段代碼很容易理解,就是輪詢,不斷的嘗試有沒有數據到達,有了就處理,沒有(得到EWOULDBLOCK
或者EAGAIN
)就等一小會再試。這比之前BIO好多了,起碼程序不會被卡死了。
但這樣會帶來兩個新問題:
- 如果有大量文件描述符都要等,那么就得一個一個的read。這會帶來大量的Context Switch(
read
是系統調用,每調用一次就得在用戶態和核心態切換一次) - 休息一會的時間不好把握。這里是要猜多久之后數據才能到。等待時間設的太長,程序響應延遲就過大;設的太短,就會造成過於頻繁的重試,干耗CPU而已。
要是操作系統能一口氣告訴程序,哪些數據到了就好了。
於是IO多路復用被搞出來解決這個問題。
IO多路復用
IO多路復用(IO Multiplexing) 是這么一種機制:程序注冊一組socket文件描述符給操作系統,表示“我要監視這些fd是否有IO事件發生,有了就告訴程序處理”。
IO多路復用是要和NIO一起使用的。盡管在操作系統級別,NIO和IO多路復用是兩個相對獨立的事情。NIO僅僅是指IO API總是能立刻返回,不會被Blocking;而IO多路復用僅僅是操作系統提供的一種便利的通知機制。操作系統並不會強制這倆必須得一起用——你可以用NIO,但不用IO多路復用,就像上一節中的代碼;也可以只用IO多路復用 + BIO,這時效果還是當前線程被卡住。但是,IO多路復用和NIO是要配合一起使用才有實際意義。因此,在使用IO多路復用之前,請總是先把fd設為O_NONBLOCK
。
對IO多路復用,還存在一些常見的誤解,比如:
-
❌IO多路復用是指多個數據流共享同一個Socket。其實IO多路復用說的是多個Socket,只不過操作系統是一起監聽他們的事件而已。
多個數據流共享同一個TCP連接的場景的確是有,比如Http2 Multiplexing就是指Http2通訊中中多個邏輯的數據流共享同一個TCP連接。但這與IO多路復用是完全不同的問題。
-
❌IO多路復用是NIO,所以總是不Block的。其實IO多路復用的關鍵API調用(
select
,poll
,epoll_wait
)總是Block的,正如下文的例子所講。 -
❌IO多路復用和NIO一起減少了IO。實際上,IO本身(網絡數據的收發)無論用不用IO多路復用和NIO,都沒有變化。請求的數據該是多少還是多少;網絡上該傳輸多少數據還是多少數據。IO多路復用和NIO一起僅僅是解決了調度的問題,避免CPU在這個過程中的浪費,使系統的瓶頸更容易觸達到網絡帶寬,而非CPU或者內存。要提高IO吞吐,還是提高硬件的容量(例如,用支持更大帶寬的網線、網卡和交換機)和依靠並發傳輸(例如HDFS的數據多副本並發傳輸)。
操作系統級別提供了一些接口來支持IO多路復用,最老掉牙的是select
和poll
。
select
select
長這樣:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
它接受3個文件描述符的數組,分別監聽讀取(readfds
),寫入(writefds
)和異常(expectfds
)事件。那么一個 IO多路復用的代碼大概是這樣:
struct timeval tv = {.tv_sec = 1, .tv_usec = 0}; ssize_t nbytes; while(1) { FD_ZERO(&read_fds); setnonblocking(fd1); setnonblocking(fd2); FD_SET(fd1, &read_fds); FD_SET(fd2, &read_fds); // 把要監聽的fd拼到一個數組里,而且每次循環都得重來一次... if (select(FD_SETSIZE, &read_fds, NULL, NULL, &tv) < 0) { // block住,直到有事件到達 perror("select出錯了"); exit(EXIT_FAILURE); } for (int i = 0; i < FD_SETSIZE; i++) { if (FD_ISSET(i, &read_fds)) { /* 檢測到第[i]個讀取fd已經收到了,這里假設buf總是大於到達的數據,所以可以一次read完 */ if ((nbytes = read(i, buf, sizeof(buf))) >= 0) { process_data(nbytes, buf); } else { perror("讀取出錯了"); exit(EXIT_FAILURE); } } } }
首先,為了select
需要構造一個fd數組(socket文件描述符,表示“我要監視這些fd是否有IO事件發生,有了就告訴程序處理”。這里為了簡化,沒有構造要監聽寫入和異常事件的fd數組)。之后,用select
監聽了read_fds
中的多個socket的讀取時間。調用select
后,程序會Block住,直到一個事件發生了,或者等到最大1秒鍾(tv
定義了這個時間長度)就返回。之后,需要遍歷所有注冊的fd,挨個檢查哪個fd有事件到達(FD_ISSET
返回true)。如果是,就說明數據已經到達了,可以讀取fd了。讀取后就可以進行數據的處理。
select
有一些發指的缺點:
select
能夠支持的最大的fd數組的長度是1024。這對要處理高並發的web服務器是不可接受的。- fd數組按照監聽的事件分為了3個數組,為了這3個數組要分配3段內存去構造,而且每次調用
select
前都要重設它們(因為select
會改這3個數組);調用select
后,這3數組要從用戶態復制一份到內核態;事件到達后,要遍歷這3數組。很不爽。 select
返回后要挨個遍歷fd,找到被“SET”的那些進行處理。這樣比較低效。select
是無狀態的,即每次調用select
,內核都要重新檢查所有被注冊的fd的狀態。select
返回后,這些狀態就被返回了,內核不會記住它們;到了下一次調用,內核依然要重新檢查一遍。於是查詢的效率很低。
poll
poll
與select
類似於。它大概長這樣:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
poll
的代碼例子和select
差不多,因此也就不贅述了。有意思的是poll
這個單詞的意思是“輪詢”,所以很多中文資料都會提到對IO進行“輪詢”。
上面說的select和下文說的epoll本質上都是輪詢。
poll
優化了select
的一些問題。比如不再有3個數組,而是1個polldfd
結構的數組了,並且也不需要每次重設了。數組的個數也沒有了1024的限制。但其他的問題依舊:
- 依然是無狀態的,性能的問題與
select
差不多一樣; - 應用程序仍然無法很方便的拿到那些“有事件發生的fd“,還是需要遍歷所有注冊的fd。
目前來看,高性能的web服務器都不會使用select
和poll
。他們倆存在的意義僅僅是“兼容性”,因為很多操作系統都實現了這兩個系統調用。
如果是追求性能的話,在BSD/macOS上提供了kqueue api;在Salorias中提供了/dev/poll(可惜該操作系統已經涼涼);而在Linux上提供了epoll api。它們的出現徹底解決了select
和poll
的問題。Java NIO,nginx等在對應的平台的上都是使用這些api實現。
因為大部分情況下我會用Linux做服務器,所以下文以Linux epoll為例子來解釋多路復用是怎么工作的。
用epoll實現的IO多路復用
epoll是Linux下的IO多路復用的實現。這里單開一章是因為它非常有代表性,並且Linux也是目前最廣泛被作為服務器的操作系統。細致的了解epoll對整個IO多路復用的工作原理非常有幫助。
與select
和poll
不同,要使用epoll是需要先創建一下的。
int epfd = epoll_create(10);
epoll_create
在內核層創建了一個數據表,記錄要注冊的fd。接口會返回一個“epoll的文件描述符”指向這個表。注意,接口參數是一個表達要監聽事件列表的長度的數值。但不用太在意,因為epoll內部隨后會根據事件注冊和事件注銷動態調整epoll中表格的大小。

為什么epoll要創建一個用文件描述符來指向的表呢?這里有兩個好處:
- epoll是有狀態的,不像
select
和poll
那樣每次都要重新傳入所有要監聽的fd,這避免了很多無謂的數據復制。epoll的數據是用接口epoll_ctl
來管理的(增、刪、改)。 - epoll文件描述符在進程被fork時,子進程是可以繼承的。這可以給對多進程共享一份epoll數據,實現並行監聽網絡請求帶來便利。但這超過了本文的討論范圍,就此打住。
epoll創建后,第二步是使用epoll_ctl
接口來注冊要監聽的事件。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
其中第一個參數就是上面創建的表epfd
。第二個參數op
表示如何對文件名進行操作,共有3種。
EPOLL_CTL_ADD
- 注冊一個事件EPOLL_CTL_DEL
- 取消一個事件的注冊EPOLL_CTL_MOD
- 修改一個事件的注冊
第三個參數是要操作的fd,這里必須是支持NIO的fd(比如socket)。
第四個參數是一個epoll_event
的類型的數據,表達了注冊的事件的具體信息。
typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ };
比方說,想關注一個fd1的讀取事件事件,並采用邊緣觸發(下文會解釋什么是邊緣觸發),大概要這么寫:
struct epoll_data ev; ev.events = EPOLLIN | EPOLLET; // EPOLLIN表示讀事件;EPOLLET表示邊緣觸發 ev.data.fd = fd1;
通過epoll_ctl
就可以靈活的注冊/取消注冊/修改注冊某個fd的某些事件。

第三步,使用epoll_wait
來等待事件的發生。
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
特別留意,這一步是"block"的。只有當注冊的事件至少有一個發生,或者timeout
達到時,該調用才會返回。這與select
和poll
幾乎一致。但不一樣的地方是evlist
,它是epoll_wait
的返回數組,里面只包含那些被觸發的事件對應的fd,而不是像select
和poll
那樣返回所有注冊的fd。

綜合起來,一段比較完整的epoll代碼大概是這樣的。
#define MAX_EVENTS 10 struct epoll_event ev, events[MAX_EVENTS]; int nfds, epfd, fd1, fd2; // 假設這里有兩個socket,fd1和fd2,被初始化好。 // 設置為non blocking setnonblocking(fd1); setnonblocking(fd2); // 創建epoll epfd = epoll_create(MAX_EVENTS); if (epollfd == -1) { perror("epoll_create1"); exit(EXIT_FAILURE); } //注冊事件 ev.events = EPOLLIN | EPOLLET; ev.data.fd = fd1; if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd1, &ev) == -1) { perror("epoll_ctl: error register fd1"); exit(EXIT_FAILURE); } if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd2, &ev) == -1) { perror("epoll_ctl: error register fd2"); exit(EXIT_FAILURE); } // 監聽事件 for (;;) { nfds = epoll_wait(epdf, events, MAX_EVENTS, -1); if (nfds == -1) { perror("epoll_wait"); exit(EXIT_FAILURE); } for (n = 0; n < nfds; ++n) { // 處理所有發生IO事件的fd process_event(events[n].data.fd); // 如果有必要,可以利用epoll_ctl繼續對本fd注冊下一次監聽,然后重新epoll_wait } }
此外,epoll的手冊 中也有一個簡單的例子。
所有的基於IO多路復用的代碼都會遵循這樣的寫法:注冊——監聽事件——處理——再注冊,無限循環下去。
epoll的優勢
為什么epoll的性能比select
和poll
要強呢? select
和poll
每次都需要把完成的fd列表傳入到內核,迫使內核每次必須從頭掃描到尾。而epoll完全是反過來的。epoll在內核的數據被建立好了之后,每次某個被監聽的fd一旦有事件發生,內核就直接標記之。epoll_wait
調用時,會嘗試直接讀取到當時已經標記好的fd列表,如果沒有就會進入等待狀態。
同時,epoll_wait
直接只返回了被觸發的fd列表,這樣上層應用寫起來也輕松愉快,再也不用從大量注冊的fd中篩選出有事件的fd了。
簡單說就是select
和poll
的代價是"O(所有注冊事件fd的數量)",而epoll的代價是"O(發生事件fd的數量)"。於是,高性能網絡服務器的場景特別適合用epoll來實現——因為大多數網絡服務器都有這樣的模式:同時要監聽大量(幾千,幾萬,幾十萬甚至更多)的網絡連接,但是短時間內發生的事件非常少。
但是,假設發生事件的fd的數量接近所有注冊事件fd的數量,那么epoll的優勢就沒有了,其性能表現會和poll
和select
差不多。
epoll除了性能優勢,還有一個優點——同時支持水平觸發(Level Trigger)和邊沿觸發(Edge Trigger)。
AIO
AIO希望的是,你select,poll,epoll都需要用一個函數去監控一大堆fd,那么我AIO不需要了,你把fd告訴內核,你應用程序無需等待,內核會通過信號等軟中斷告訴應用程序,數據來了,你直接讀了,所以,用了AIO可以廢棄select,poll,epoll。
但linux的AIO的實現方式是內核和應用共享一片內存區域,應用通過檢測這個內存區域(避免調用nonblocking的read、write函數來測試是否來數據,因為即便調用nonblocking的read和write由於進程要切換用戶態和內核態,仍舊效率不高)來得知fd是否有數據,可是檢測內存區域畢竟不是實時的,你需要在線程里構造一個監控內存的循環,設置sleep,總的效率不如epoll這樣的實時通知。所以,AIO是渣,適合低並發的IO操作。所以java7引入的NIO.2引入的AIO對高並發的網絡IO設計程序來說,也是渣,只有Netty的epoll+edge-triggered notification最牛,能在linux讓應用和OS取得最高效率的溝通。
水平觸發和邊沿觸發
默認情況下,epoll使用水平觸發,這與select
和poll
的行為完全一致。在水平觸發下,epoll頂多算是一個“跑得更快的poll”。
而一旦在注冊事件時使用了EPOLLET
標記(如上文中的例子),那么將其視為邊沿觸發(或者有地方叫邊緣觸發,一個意思)。那么到底什么水平觸發和邊沿觸發呢?
考慮下圖中的例子。有兩個socket的fd——fd1和fd2。我們設定監聽f1的“水平觸發讀事件“,監聽fd2的”邊沿觸發讀事件“。我們使用在時刻t1,使用epoll_wait
監聽他們的事件。在時刻t2時,兩個fd都到了100bytes數據,於是在時刻t3, epoll_wait
返回了兩個fd進行處理。在t4,我們故意不讀取所有的數據出來,只各自讀50bytes。然后在t5重新注冊兩個事件並監聽。在t6時,只有fd1會返回,因為fd1里的數據沒有讀完,仍然處於“被觸發”狀態;而fd2不會被返回,因為沒有新數據到達。

這個例子很明確的顯示了水平觸發和邊沿觸發的區別。
-
水平觸發只關心文件描述符中是否還有沒完成處理的數據,如果有,不管怎樣
epoll_wait
,總是會被返回。簡單說——水平觸發代表了一種“狀態”。 -
邊沿觸發只關心文件描述符是否有新的事件產生,如果有,則返回;如果返回過一次,不管程序是否處理了,只要沒有新的事件產生,
epoll_wait
不會再認為這個fd被“觸發”了。簡單說——邊沿觸發代表了一個“事件”。那么邊沿觸發怎么才能迫使新事件產生呢?一般需要反復調用
read
/write
這樣的IO接口,直到得到了EAGAIN
錯誤碼,再去嘗試epoll_wait
才有可能得到下次事件。
那么為什么需要邊沿觸發呢?
邊沿觸發把如何處理數據的控制權完全交給了開發者,提供了巨大的靈活性。比如,讀取一個http的請求,開發者可以決定只讀取http中的headers數據就停下來,然后根據業務邏輯判斷是否要繼續讀(比如需要調用另外一個服務來決定是否繼續讀)。而不是次次被socket尚有數據的狀態煩擾;寫入數據時也是如此。比如希望將一個資源A寫入到socket。當socket的buffer充足時,epoll_wait
會返回這個fd是准備好的。但是資源A此時不一定准備好。如果使用水平觸發,每次經過epoll_wait
也總會被打擾。在邊沿觸發下,開發者有機會更精細的定制這里的控制邏輯。
但不好的一面時,邊沿觸發也大大的提高了編程的難度。一不留神,可能就會miss掉處理部分socket數據的機會。如果沒有很好的根據EAGAIN
來“重置”一個fd,就會造成此fd永遠沒有新事件產生,進而導致餓死相關的處理代碼。
再來思考一下什么是“Block”
上面的所有介紹都在圍繞如何讓網絡IO不會被Block。但是網絡IO處理僅僅是整個數據處理中的一部分。如果你留意到上文例子中的“處理事件”代碼,就會發現這里可能是有問題的。
- 處理代碼有可能需要讀寫文件,可能會很慢,從而干擾整個程序的效率;
- 處理代碼有可能是一段復雜的數據計算,計算量很大的話,就會卡住整個執行流程;
- 處理代碼有bug,可能直接進入了一段死循環……
這時你會發現,這里的Block和本文之初講的O_NONBLOCK
是不同的事情。在一個網絡服務中,如果處理程序的延遲遠遠小於網絡IO,那么這完全不成問題。但是如果處理程序的延遲已經大到無法忽略了,就會對整個程序產生很大的影響。這時IO多路復用已經不是問題的關鍵。
試分析和比較下面兩個場景:
- web proxy。程序通過IO多路復用接收到了請求之后,直接轉發給另外一個網絡服務。
- web server。程序通過IO多路復用接收到了請求之后,需要讀取一個文件,並返回其內容。
它們有什么不同?它們的瓶頸可能出在哪里?
總結
小結一下本文:
- 對於socket的文件描述符才有所謂BIO和NIO。
- 多線程+BIO模式會帶來大量的資源浪費,而NIO+IO多路復用可以解決這個問題。
- 在Linux下,基於epoll的IO多路復用是解決這個問題的最佳方案;epoll相比
select
和poll
有很大的性能優勢和功能優勢,適合實現高性能網絡服務。