自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

結(jié)合代碼詳細(xì)聊聊 Java 網(wǎng)絡(luò)編程中的 BIO、NIO 和 AIO

開發(fā) 后端
本文從操作系統(tǒng)的角度來解釋BIO,NIO,AIO的概念,含義和背后的那些事。本文主要分為3篇。
  •   到底什么是“IO Block”
  •  BIO
  •  NIO
  •  IO多路復(fù)用
    •  select
    •  poll
  •  用epoll實(shí)現(xiàn)的IO多路復(fù)用
  •  epoll的優(yōu)勢
  •  水平觸發(fā)和邊沿觸發(fā)
  •  再來思考一下什么是“Block”
  •  總結(jié)

本文從操作系統(tǒng)的角度來解釋BIO,NIO,AIO的概念,含義和背后的那些事。本文主要分為3篇。

  •  第一篇 講解BIO和NIO以及IO多路復(fù)用
  •  第二篇 講解磁盤IO和AIO
  •  第三篇 講解在這些機(jī)制上的一些應(yīng)用的實(shí)現(xiàn)方式,比如nginx,nodejs,Java NIO等

到底什么是“IO Block”

很多人說BIO不好,會(huì)“block”,但到底什么是IO的Block呢?考慮下面兩種情況:

  •  用系統(tǒng)調(diào)用read從socket里讀取一段數(shù)據(jù)
  •  用系統(tǒng)調(diào)用read從一個(gè)磁盤文件讀取一段數(shù)據(jù)到內(nèi)存

如果你的直覺告訴你,這兩種都算“Block”,那么很遺憾,你的理解與Linux不同。Linux認(rèn)為:

  •  對于第一種情況,算作block,因?yàn)長inux無法知道網(wǎng)絡(luò)上對方是否會(huì)發(fā)數(shù)據(jù)。如果沒數(shù)據(jù)發(fā)過來,對于調(diào)用read的程序來說,就只能“等”。
  •  對于第二種情況,不算做block。

是的,對于磁盤文件IO,Linux總是不視作Block。

你可能會(huì)說,這不科學(xué)啊,磁盤讀寫偶爾也會(huì)因?yàn)橛布ぐ?,怎么能不算Block呢?但實(shí)際就是不算。

    “    一個(gè)解釋是,所謂“Block”是指操作系統(tǒng)可以預(yù)見這個(gè)Block會(huì)發(fā)生才會(huì)主動(dòng)Block。例如當(dāng)讀取TCP連接的數(shù)據(jù)時(shí),如果發(fā)現(xiàn)Socket buffer里沒有數(shù)據(jù)就可以確定定對方還沒有發(fā)過來,于是Block;而對于普通磁盤文件的讀寫,也許磁盤運(yùn)作期間會(huì)抖動(dòng),會(huì)短暫暫停,但是操作系統(tǒng)無法預(yù)見這種情況,只能視作不會(huì)Block,照樣執(zhí)行。

基于這個(gè)基本的設(shè)定,在討論IO時(shí),一定要嚴(yán)格區(qū)分網(wǎng)絡(luò)IO和磁盤文件IO。NIO和后文講到的IO多路復(fù)用只對網(wǎng)絡(luò)IO有意義。

    “    嚴(yán)格的說,O_NONBLOCK和IO多路復(fù)用,對標(biāo)準(zhǔn)輸入輸出描述符、管道和FIFO也都是有效的。但本文側(cè)重于討論高性能網(wǎng)絡(luò)服務(wù)器下各種IO的含義和關(guān)系,所以本文做了簡化,只提及網(wǎng)絡(luò)IO和磁盤文件IO兩種情況。

本文先著重講一下網(wǎng)絡(luò)IO。

BIO

有了Block的定義,就可以討論BIO和NIO了。BIO是Blocking IO的意思。在類似于網(wǎng)絡(luò)中進(jìn)行read, write, connect一類的系統(tǒng)調(diào)用時(shí)會(huì)被卡住。

舉個(gè)例子,當(dāng)用read去讀取網(wǎng)絡(luò)的數(shù)據(jù)時(shí),是無法預(yù)知對方是否已經(jīng)發(fā)送數(shù)據(jù)的。因此在收到數(shù)據(jù)之前,能做的只有等待,直到對方把數(shù)據(jù)發(fā)過來,或者等到網(wǎng)絡(luò)超時(shí)。

對于單線程的網(wǎng)絡(luò)服務(wù),這樣做就會(huì)有卡死的問題。因?yàn)楫?dāng)?shù)却龝r(shí),整個(gè)線程會(huì)被掛起,無法執(zhí)行,也無法做其他的工作。

    “    順便說一句,這種Block是不會(huì)影響同時(shí)運(yùn)行的其他程序(進(jìn)程)的,因?yàn)楝F(xiàn)代操作系統(tǒng)都是多任務(wù)的,任務(wù)之間的切換是搶占式的。這里Block只是指Block當(dāng)前的進(jìn)程。

于是,網(wǎng)絡(luò)服務(wù)為了同時(shí)響應(yīng)多個(gè)并發(fā)的網(wǎng)絡(luò)請求,必須實(shí)現(xiàn)為多線程的。每個(gè)線程處理一個(gè)網(wǎng)絡(luò)請求。線程數(shù)隨著并發(fā)連接數(shù)線性增長。這的確能奏效。實(shí)際上2000年之前很多網(wǎng)絡(luò)服務(wù)器就是這么實(shí)現(xiàn)的。但這帶來兩個(gè)問題:

  •  線程越多,Context Switch就越多,而Context Switch是一個(gè)比較重的操作,會(huì)無謂浪費(fèi)大量的CPU。
  •  每個(gè)線程會(huì)占用一定的內(nèi)存作為線程的棧。比如有1000個(gè)線程同時(shí)運(yùn)行,每個(gè)占用1MB內(nèi)存,就占用了1個(gè)G的內(nèi)存。

    “    也許現(xiàn)在看來1GB內(nèi)存不算什么,現(xiàn)在服務(wù)器上百G內(nèi)存的配置現(xiàn)在司空見慣了。但是倒退20年,1G內(nèi)存是很金貴的。并且,盡管現(xiàn)在通過使用大內(nèi)存,可以輕易實(shí)現(xiàn)并發(fā)1萬甚至10萬的連接。但是水漲船高,如果是要單機(jī)撐1千萬的連接呢?

問題的關(guān)鍵在于,當(dāng)調(diào)用read接受網(wǎng)絡(luò)請求時(shí),有數(shù)據(jù)到了就用,沒數(shù)據(jù)到時(shí),實(shí)際上是可以干別的。使用大量線程,僅僅是因?yàn)锽lock發(fā)生,沒有其他辦法。

當(dāng)然你可能會(huì)說,是不是可以弄個(gè)線程池呢?這樣既能并發(fā)的處理請求,又不會(huì)產(chǎn)生大量線程。但這樣會(huì)限制最大并發(fā)的連接數(shù)。比如你弄4個(gè)線程,那么最大4個(gè)線程都Block了就沒法響應(yīng)更多請求了。

要是操作IO接口時(shí),操作系統(tǒng)能夠總是直接告訴有沒有數(shù)據(jù),而不是Block去等就好了。于是,NIO登場。

NIO

NIO是指將IO模式設(shè)為“Non-Blocking”模式。在Linux下,一般是這樣: 

  1. void setnonblocking(int fd) {  
  2.     int flags = fcntl(fd, F_GETFL, 0);  
  3.     fcntl(fd, F_SETFL, flags | O_NONBLOCK);  

    “    再強(qiáng)調(diào)一下,以上操作只對socket對應(yīng)的文件描述符有意義;對磁盤文件的文件描述符做此設(shè)置總會(huì)成功,但是會(huì)直接被忽略。

這時(shí),BIO和NIO的區(qū)別是什么呢?

在BIO模式下,調(diào)用read,如果發(fā)現(xiàn)沒數(shù)據(jù)已經(jīng)到達(dá),就會(huì)Block住。

在NIO模式下,調(diào)用read,如果發(fā)現(xiàn)沒數(shù)據(jù)已經(jīng)到達(dá),就會(huì)立刻返回-1, 并且errno被設(shè)為EAGAIN。

    “    在有些文檔中寫的是會(huì)返回EWOULDBLOCK。實(shí)際上,在Linux下EAGAIN和EWOULDBLOCK是一樣的,即#define EWOULDBLOCK EAGAIN

于是,一段NIO的代碼,大概就可以寫成這個(gè)樣子。 

  1. struct timespec sleep_interval{.tv_sec = 0.tv_nsec = 1000};  
  2. ssize_t nbytes;  
  3. while (1) {  
  4.     /* 嘗試讀取 */  
  5.     if ((nbytes = read(fd, buf, sizeof(buf))) < 0) {  
  6.         if (errno == EAGAIN) { // 沒數(shù)據(jù)到  
  7.             perror("nothing can be read");  
  8.         } else {  
  9.             perror("fatal error");  
  10.             exit(EXIT_FAILURE);  
  11.         }  
  12.     } else { // 有數(shù)據(jù)  
  13.         process_data(buf, nbytes);  
  14.     }  
  15.     // 處理其他事情,做完了就等一會(huì),再嘗試  
  16.     nanosleep(sleep_interval, NULL);  

這段代碼很容易理解,就是輪詢,不斷的嘗試有沒有數(shù)據(jù)到達(dá),有了就處理,沒有(得到EWOULDBLOCK或者EAGAIN)就等一小會(huì)再試。這比之前BIO好多了,起碼程序不會(huì)被卡死了。

但這樣會(huì)帶來兩個(gè)新問題:

  •  如果有大量文件描述符都要等,那么就得一個(gè)一個(gè)的read。這會(huì)帶來大量的Context Switch(read是系統(tǒng)調(diào)用,每調(diào)用一次就得在用戶態(tài)和核心態(tài)切換一次)
  •  休息一會(huì)的時(shí)間不好把握。這里是要猜多久之后數(shù)據(jù)才能到。等待時(shí)間設(shè)的太長,程序響應(yīng)延遲就過大;設(shè)的太短,就會(huì)造成過于頻繁的重試,干耗CPU而已。

要是操作系統(tǒng)能一口氣告訴程序,哪些數(shù)據(jù)到了就好了。

于是IO多路復(fù)用被搞出來解決這個(gè)問題。

IO多路復(fù)用

IO多路復(fù)用(IO Multiplexing) 是這么一種機(jī)制:程序注冊一組socket文件描述符給操作系統(tǒng),表示“我要監(jiān)視這些fd是否有IO事件發(fā)生,有了就告訴程序處理”。

IO多路復(fù)用是要和NIO一起使用的。盡管在操作系統(tǒng)級別,NIO和IO多路復(fù)用是兩個(gè)相對獨(dú)立的事情。NIO僅僅是指IO API總是能立刻返回,不會(huì)被Blocking;而IO多路復(fù)用僅僅是操作系統(tǒng)提供的一種便利的通知機(jī)制。操作系統(tǒng)并不會(huì)強(qiáng)制這倆必須得一起用——你可以用NIO,但不用IO多路復(fù)用,就像上一節(jié)中的代碼;也可以只用IO多路復(fù)用 + BIO,這時(shí)效果還是當(dāng)前線程被卡住。但是,IO多路復(fù)用和NIO是要配合一起使用才有實(shí)際意義。因此,在使用IO多路復(fù)用之前,請總是先把fd設(shè)為O_NONBLOCK。

對IO多路復(fù)用,還存在一些常見的誤解,比如:

  •  ❌IO多路復(fù)用是指多個(gè)數(shù)據(jù)流共享同一個(gè)Socket。其實(shí)IO多路復(fù)用說的是多個(gè)Socket,只不過操作系統(tǒng)是一起監(jiān)聽他們的事件而已。

        “        多個(gè)數(shù)據(jù)流共享同一個(gè)TCP連接的場景的確是有,比如Http2 Multiplexing就是指Http2通訊中中多個(gè)邏輯的數(shù)據(jù)流共享同一個(gè)TCP連接。但這與IO多路復(fù)用是完全不同的問題。

  •  ❌IO多路復(fù)用是NIO,所以總是不Block的。其實(shí)IO多路復(fù)用的關(guān)鍵API調(diào)用(select,poll,epoll_wait)總是Block的,正如下文的例子所講。
  •  ❌IO多路復(fù)用和NIO一起減少了IO。實(shí)際上,IO本身(網(wǎng)絡(luò)數(shù)據(jù)的收發(fā))無論用不用IO多路復(fù)用和NIO,都沒有變化。請求的數(shù)據(jù)該是多少還是多少;網(wǎng)絡(luò)上該傳輸多少數(shù)據(jù)還是多少數(shù)據(jù)。IO多路復(fù)用和NIO一起僅僅是解決了調(diào)度的問題,避免CPU在這個(gè)過程中的浪費(fèi),使系統(tǒng)的瓶頸更容易觸達(dá)到網(wǎng)絡(luò)帶寬,而非CPU或者內(nèi)存。要提高IO吞吐,還是提高硬件的容量(例如,用支持更大帶寬的網(wǎng)線、網(wǎng)卡和交換機(jī))和依靠并發(fā)傳輸(例如HDFS的數(shù)據(jù)多副本并發(fā)傳輸)。

操作系統(tǒng)級別提供了一些接口來支持IO多路復(fù)用,最老掉牙的是select和poll。

select

select長這樣: 

  1. int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); 

它接受3個(gè)文件描述符的數(shù)組,分別監(jiān)聽讀取(readfds),寫入(writefds)和異常(expectfds)事件。那么一個(gè) IO多路復(fù)用的代碼大概是這樣: 

  1. struct timeval tv = {.tv_sec = 1.tv_usec = 0};  
  2. ssize_t nbytes;  
  3. while(1) {  
  4.     FD_ZERO(&read_fds);  
  5.     setnonblocking(fd1); 
  6.     setnonblocking(fd2);  
  7.     FD_SET(fd1, &read_fds);  
  8.     FD_SET(fd2, &read_fds);  
  9.     // 把要監(jiān)聽的fd拼到一個(gè)數(shù)組里,而且每次循環(huán)都得重來一次...  
  10.     if (select(FD_SETSIZE, &read_fds, NULL, NULL, &tv) < 0) { // block住,直到有事件到達(dá)  
  11.         perror("select出錯(cuò)了");  
  12.         exit(EXIT_FAILURE);  
  13.     }  
  14.     for (int i = 0; i < FD_SETSIZE; i++) {  
  15.         if (FD_ISSET(i, &read_fds)) {  
  16.             /* 檢測到第[i]個(gè)讀取fd已經(jīng)收到了,這里假設(shè)buf總是大于到達(dá)的數(shù)據(jù),所以可以一次read完 */  
  17.             if ((nbytes = read(i, buf, sizeof(buf))) >= 0) {  
  18.                 process_data(nbytes, buf);  
  19.             } else {  
  20.                 perror("讀取出錯(cuò)了");  
  21.                 exit(EXIT_FAILURE);  
  22.             }  
  23.         }  
  24.     }  

首先,為了select需要構(gòu)造一個(gè)fd數(shù)組(這里為了簡化,沒有構(gòu)造要監(jiān)聽寫入和異常事件的fd數(shù)組)。之后,用select監(jiān)聽了read_fds中的多個(gè)socket的讀取時(shí)間。調(diào)用select后,程序會(huì)Block住,直到一個(gè)事件發(fā)生了,或者等到最大1秒鐘(tv定義了這個(gè)時(shí)間長度)就返回。之后,需要遍歷所有注冊的fd,挨個(gè)檢查哪個(gè)fd有事件到達(dá)(FD_ISSET返回true)。如果是,就說明數(shù)據(jù)已經(jīng)到達(dá)了,可以讀取fd了。讀取后就可以進(jìn)行數(shù)據(jù)的處理。

select有一些發(fā)指的缺點(diǎn):

  •  select能夠支持的最大的fd數(shù)組的長度是1024。這對要處理高并發(fā)的web服務(wù)器是不可接受的。
  •  fd數(shù)組按照監(jiān)聽的事件分為了3個(gè)數(shù)組,為了這3個(gè)數(shù)組要分配3段內(nèi)存去構(gòu)造,而且每次調(diào)用select前都要重設(shè)它們(因?yàn)閟elect會(huì)改這3個(gè)數(shù)組);調(diào)用select后,這3數(shù)組要從用戶態(tài)復(fù)制一份到內(nèi)核態(tài);事件到達(dá)后,要遍歷這3數(shù)組。很不爽。
  •  select返回后要挨個(gè)遍歷fd,找到被“SET”的那些進(jìn)行處理。這樣比較低效。
  •  select是無狀態(tài)的,即每次調(diào)用select,內(nèi)核都要重新檢查所有被注冊的fd的狀態(tài)。select返回后,這些狀態(tài)就被返回了,內(nèi)核不會(huì)記住它們;到了下一次調(diào)用,內(nèi)核依然要重新檢查一遍。于是查詢的效率很低。

poll

poll與select類似于。它大概長這樣:

  1. int poll(struct pollfd *fds, nfds_t nfds, int timeout); 

poll的代碼例子和select差不多,因此也就不贅述了。有意思的是poll這個(gè)單詞的意思是“輪詢”,所以很多中文資料都會(huì)提到對IO進(jìn)行“輪詢”。

    “    上面說的select和下文說的epoll本質(zhì)上都是輪詢。

poll優(yōu)化了select的一些問題。比如不再有3個(gè)數(shù)組,而是1個(gè)polldfd結(jié)構(gòu)的數(shù)組了,并且也不需要每次重設(shè)了。數(shù)組的個(gè)數(shù)也沒有了1024的限制。但其他的問題依舊:

  •  依然是無狀態(tài)的,性能的問題與select差不多一樣;
  •  應(yīng)用程序仍然無法很方便的拿到那些“有事件發(fā)生的fd“,還是需要遍歷所有注冊的fd。

目前來看,高性能的web服務(wù)器都不會(huì)使用select和poll。他們倆存在的意義僅僅是“兼容性”,因?yàn)楹芏嗖僮飨到y(tǒng)都實(shí)現(xiàn)了這兩個(gè)系統(tǒng)調(diào)用。

如果是追求性能的話,在BSD/macOS上提供了kqueue api;在Salorias中提供了/dev/poll(可惜該操作系統(tǒng)已經(jīng)涼涼);而在Linux上提供了epoll api。它們的出現(xiàn)徹底解決了select和poll的問題。Java NIO,nginx等在對應(yīng)的平臺(tái)的上都是使用這些api實(shí)現(xiàn)。

因?yàn)榇蟛糠智闆r下我會(huì)用Linux做服務(wù)器,所以下文以Linux epoll為例子來解釋多路復(fù)用是怎么工作的。

用epoll實(shí)現(xiàn)的IO多路復(fù)用

epoll是Linux下的IO多路復(fù)用的實(shí)現(xiàn)。這里單開一章是因?yàn)樗浅S写硇?,并且Linux也是目前最廣泛被作為服務(wù)器的操作系統(tǒng)。細(xì)致的了解epoll對整個(gè)IO多路復(fù)用的工作原理非常有幫助。

與select和poll不同,要使用epoll是需要先創(chuàng)建一下的。

int epfd = epoll_create(10);

epoll_create在內(nèi)核層創(chuàng)建了一個(gè)數(shù)據(jù)表,接口會(huì)返回一個(gè)“epoll的文

  1. int epfd = epoll_create(10); 

件描述符”指向這個(gè)表。注意,接口參數(shù)是一個(gè)表達(dá)要監(jiān)聽事件列表的長度的數(shù)值。但不用太在意,因?yàn)閑poll內(nèi)部隨后會(huì)根據(jù)事件注冊和事件注銷動(dòng)態(tài)調(diào)整epoll中表格的大小。

img

epoll創(chuàng)建

為什么epoll要?jiǎng)?chuàng)建一個(gè)用文件描述符來指向的表呢?這里有兩個(gè)好處:

  •  epoll是有狀態(tài)的,不像select和poll那樣每次都要重新傳入所有要監(jiān)聽的fd,這避免了很多無謂的數(shù)據(jù)復(fù)制。epoll的數(shù)據(jù)是用接口epoll_ctl來管理的(增、刪、改)。
  •  epoll文件描述符在進(jìn)程被fork時(shí),子進(jìn)程是可以繼承的。這可以給對多進(jìn)程共享一份epoll數(shù)據(jù),實(shí)現(xiàn)并行監(jiān)聽網(wǎng)絡(luò)請求帶來便利。但這超過了本文的討論范圍,就此打住。

epoll創(chuàng)建后,第二步是使用epoll_ctl接口來注冊要監(jiān)聽的事件。 

  1. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 

其中第一個(gè)參數(shù)就是上面創(chuàng)建的epfd。第二個(gè)參數(shù)op表示如何對文件名進(jìn)行操作,共有3種。

  •  EPOLL_CTL_ADD - 注冊一個(gè)事件
  •  EPOLL_CTL_DEL - 取消一個(gè)事件的注冊
  •  EPOLL_CTL_MOD - 修改一個(gè)事件的注冊

第三個(gè)參數(shù)是要操作的fd,這里必須是支持NIO的fd(比如socket)。

第四個(gè)參數(shù)是一個(gè)epoll_event的類型的數(shù)據(jù),表達(dá)了注冊的事件的具體信息。 

  1. typedef union epoll_data {  
  2.     void    *ptr;  
  3.     int      fd;  
  4.     uint32_t u32;  
  5.     uint64_t u64;  
  6. } epoll_data_t;  
  7. struct epoll_event {  
  8.     uint32_t     events;    /* Epoll events */  
  9.     epoll_data_t data;      /* User data variable */  
  10. }; 

比方說,想關(guān)注一個(gè)fd1的讀取事件事件,并采用邊緣觸發(fā)(下文會(huì)解釋什么是邊緣觸發(fā)),大概要這么寫: 

  1. struct epoll_data ev;  
  2. ev.events = EPOLLIN | EPOLLET; // EPOLLIN表示讀事件;EPOLLET表示邊緣觸發(fā)  
  3. ev.data.fd = fd1

通過epoll_ctl就可以靈活的注冊/取消注冊/修改注冊某個(gè)fd的某些事件。

管理fd事件注冊

第三步,使用epoll_wait來等待事件的發(fā)生。 

  1. int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout); 

特別留意,這一步是"block"的。只有當(dāng)注冊的事件至少有一個(gè)發(fā)生,或者timeout達(dá)到時(shí),該調(diào)用才會(huì)返回。這與select和poll幾乎一致。但不一樣的地方是evlist,它是epoll_wait的返回?cái)?shù)組,里面只包含那些被觸發(fā)的事件對應(yīng)的fd,而不是像select和poll那樣返回所有注冊的fd。

監(jiān)聽fd事件

綜合起來,一段比較完整的epoll代碼大概是這樣的。 

  1. #define MAX_EVENTS 10  
  2. struct epoll_event ev, events[MAX_EVENTS];  
  3. int nfds, epfd, fd1, fd2;  
  4. // 假設(shè)這里有兩個(gè)socket,fd1和fd2,被初始化好。  
  5. // 設(shè)置為non blocking  
  6. setnonblocking(fd1);  
  7. setnonblocking(fd2);  
  8. // 創(chuàng)建epoll  
  9. epfd = epoll_create(MAX_EVENTS);  
  10. if (epollfd == -1) {  
  11.     perror("epoll_create1");  
  12.     exit(EXIT_FAILURE);  
  13.  
  14. //注冊事件  
  15. ev.events = EPOLLIN | EPOLLET;  
  16. ev.data.fd = fd1 
  17. if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd1, &ev) == -1) {  
  18.     perror("epoll_ctl: error register fd1");  
  19.     exit(EXIT_FAILURE);  
  20.  
  21. if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd2, &ev) == -1) {  
  22.     perror("epoll_ctl: error register fd2");  
  23.     exit(EXIT_FAILURE);  
  24. // 監(jiān)聽事件  
  25. for (;;) {  
  26.     nfds = epoll_wait(epdf, events, MAX_EVENTS, -1);  
  27.     if (nfds == -1) {  
  28.         perror("epoll_wait");  
  29.         exit(EXIT_FAILURE);  
  30.     }  
  31.     for (n = 0; n < nfds; ++n) { // 處理所有發(fā)生IO事件的fd  
  32.         process_event(events[n].data.fd);  
  33.         // 如果有必要,可以利用epoll_ctl繼續(xù)對本fd注冊下一次監(jiān)聽,然后重新epoll_wait  
  34.     }  

此外,epoll的手冊 中也有一個(gè)簡單的例子。

所有的基于IO多路復(fù)用的代碼都會(huì)遵循這樣的寫法:注冊——監(jiān)聽事件——處理——再注冊,無限循環(huán)下去。

epoll的優(yōu)勢

為什么epoll的性能比select和poll要強(qiáng)呢?select和poll每次都需要把完成的fd列表傳入到內(nèi)核,迫使內(nèi)核每次必須從頭掃描到尾。而epoll完全是反過來的。epoll在內(nèi)核的數(shù)據(jù)被建立好了之后,每次某個(gè)被監(jiān)聽的fd一旦有事件發(fā)生,內(nèi)核就直接標(biāo)記之。epoll_wait調(diào)用時(shí),會(huì)嘗試直接讀取到當(dāng)時(shí)已經(jīng)標(biāo)記好的fd列表,如果沒有就會(huì)進(jìn)入等待狀態(tài)。

同時(shí),epoll_wait直接只返回了被觸發(fā)的fd列表,這樣上層應(yīng)用寫起來也輕松愉快,再也不用從大量注冊的fd中篩選出有事件的fd了。

簡單說就是select和poll的代價(jià)是**"O(所有注冊事件fd的數(shù)量)",而epoll的代價(jià)是"O(發(fā)生事件fd的數(shù)量)"**。于是,高性能網(wǎng)絡(luò)服務(wù)器的場景特別適合用epoll來實(shí)現(xiàn)——因?yàn)榇蠖鄶?shù)網(wǎng)絡(luò)服務(wù)器都有這樣的模式:同時(shí)要監(jiān)聽大量(幾千,幾萬,幾十萬甚至更多)的網(wǎng)絡(luò)連接,但是短時(shí)間內(nèi)發(fā)生的事件非常少。

但是,假設(shè)發(fā)生事件的fd的數(shù)量接近所有注冊事件fd的數(shù)量,那么epoll的優(yōu)勢就沒有了,其性能表現(xiàn)會(huì)和poll和select差不多。

epoll除了性能優(yōu)勢,還有一個(gè)優(yōu)點(diǎn)——同時(shí)支持水平觸發(fā)(Level Trigger)和邊沿觸發(fā)(Edge Trigger)。

水平觸發(fā)和邊沿觸發(fā)

默認(rèn)情況下,epoll使用水平觸發(fā),這與select和poll的行為完全一致。在水平觸發(fā)下,epoll頂多算是一個(gè)“跑得更快的poll”。

而一旦在注冊事件時(shí)使用了EPOLLET標(biāo)記(如上文中的例子),那么將其視為邊沿觸發(fā)(或者有地方叫邊緣觸發(fā),一個(gè)意思)。那么到底什么水平觸發(fā)和邊沿觸發(fā)呢?

考慮下圖中的例子。有兩個(gè)socket的fd——fd1和fd2。我們設(shè)定監(jiān)聽f1的“水平觸發(fā)讀事件“,監(jiān)聽fd2的”邊沿觸發(fā)讀事件“。我們使用在時(shí)刻t1,使用epoll_wait監(jiān)聽他們的事件。在時(shí)刻t2時(shí),兩個(gè)fd都到了100bytes數(shù)據(jù),于是在時(shí)刻t3, epoll_wait返回了兩個(gè)fd進(jìn)行處理。在t4,我們故意不讀取所有的數(shù)據(jù)出來,只各自讀50bytes。然后在t5重新注冊兩個(gè)事件并監(jiān)聽。在t6時(shí),只有fd1會(huì)返回,因?yàn)閒d1里的數(shù)據(jù)沒有讀完,仍然處于“被觸發(fā)”狀態(tài);而fd2不會(huì)被返回,因?yàn)闆]有新數(shù)據(jù)到達(dá)。

img

水平觸發(fā)和邊沿觸發(fā)

這個(gè)例子很明確的顯示了水平觸發(fā)和邊沿觸發(fā)的區(qū)別。

  •  水平觸發(fā)只關(guān)心文件描述符中是否還有沒完成處理的數(shù)據(jù),如果有,不管怎樣epoll_wait,總是會(huì)被返回。簡單說——水平觸發(fā)代表了一種“狀態(tài)”。
  •  邊沿觸發(fā)只關(guān)心文件描述符是否有新的事件產(chǎn)生,如果有,則返回;如果返回過一次,不管程序是否處理了,只要沒有新的事件產(chǎn)生,epoll_wait不會(huì)再認(rèn)為這個(gè)fd被“觸發(fā)”了。簡單說——邊沿觸發(fā)代表了一個(gè)“事件”。

        “        那么邊沿觸發(fā)怎么才能迫使新事件產(chǎn)生呢?一般需要反復(fù)調(diào)用read/write這樣的IO接口,直到得到了EAGAIN錯(cuò)誤碼,再去嘗試epoll_wait才有可能得到下次事件。

那么為什么需要邊沿觸發(fā)呢?

邊沿觸發(fā)把如何處理數(shù)據(jù)的控制權(quán)完全交給了開發(fā)者,提供了巨大的靈活性。比如,讀取一個(gè)http的請求,開發(fā)者可以決定只讀取http中的headers數(shù)據(jù)就停下來,然后根據(jù)業(yè)務(wù)邏輯判斷是否要繼續(xù)讀(比如需要調(diào)用另外一個(gè)服務(wù)來決定是否繼續(xù)讀)。而不是次次被socket尚有數(shù)據(jù)的狀態(tài)煩擾;寫入數(shù)據(jù)時(shí)也是如此。比如希望將一個(gè)資源A寫入到socket。當(dāng)socket的buffer充足時(shí),epoll_wait會(huì)返回這個(gè)fd是準(zhǔn)備好的。但是資源A此時(shí)不一定準(zhǔn)備好。如果使用水平觸發(fā),每次經(jīng)過epoll_wait也總會(huì)被打擾。在邊沿觸發(fā)下,開發(fā)者有機(jī)會(huì)更精細(xì)的定制這里的控制邏輯。

但不好的一面時(shí),邊沿觸發(fā)也大大的提高了編程的難度。一不留神,可能就會(huì)miss掉處理部分socket數(shù)據(jù)的機(jī)會(huì)。如果沒有很好的根據(jù)EAGAIN來“重置”一個(gè)fd,就會(huì)造成此fd永遠(yuǎn)沒有新事件產(chǎn)生,進(jìn)而導(dǎo)致餓死相關(guān)的處理代碼。

再來思考一下什么是“Block”

上面的所有介紹都在圍繞如何讓網(wǎng)絡(luò)IO不會(huì)被Block。但是網(wǎng)絡(luò)IO處理僅僅是整個(gè)數(shù)據(jù)處理中的一部分。如果你留意到上文例子中的“處理事件”代碼,就會(huì)發(fā)現(xiàn)這里可能是有問題的。

  •  處理代碼有可能需要讀寫文件,可能會(huì)很慢,從而干擾整個(gè)程序的效率;
  •  處理代碼有可能是一段復(fù)雜的數(shù)據(jù)計(jì)算,計(jì)算量很大的話,就會(huì)卡住整個(gè)執(zhí)行流程;
  •  處理代碼有bug,可能直接進(jìn)入了一段死循環(huán)……

這時(shí)你會(huì)發(fā)現(xiàn),這里的Block和本文之初講的O_NONBLOCK是不同的事情。在一個(gè)網(wǎng)絡(luò)服務(wù)中,如果處理程序的延遲遠(yuǎn)遠(yuǎn)小于網(wǎng)絡(luò)IO,那么這完全不成問題。但是如果處理程序的延遲已經(jīng)大到無法忽略了,就會(huì)對整個(gè)程序產(chǎn)生很大的影響。這時(shí)IO多路復(fù)用已經(jīng)不是問題的關(guān)鍵。

試分析和比較下面兩個(gè)場景:

  •  web proxy。程序通過IO多路復(fù)用接收到了請求之后,直接轉(zhuǎn)發(fā)給另外一個(gè)網(wǎng)絡(luò)服務(wù)。
  •  web server。程序通過IO多路復(fù)用接收到了請求之后,需要讀取一個(gè)文件,并返回其內(nèi)容。

它們有什么不同?它們的瓶頸可能出在哪里?

總結(jié)

小結(jié)一下本文:

  •  對于socket的文件描述符才有所謂BIO和NIO。
  •  多線程+BIO模式會(huì)帶來大量的資源浪費(fèi),而NIO+IO多路復(fù)用可以解決這個(gè)問題。
  •  在Linux下,基于epoll的IO多路復(fù)用是解決這個(gè)問題的最佳方案;epoll相比select和poll有很大的性能優(yōu)勢和功能優(yōu)勢,適合實(shí)現(xiàn)高性能網(wǎng)絡(luò)服務(wù)。

但是IO多路復(fù)用僅僅是解決了一部分問題,另外一部分問題如何解決呢?且聽下回分解。 

 

責(zé)任編輯:龐桂玉 來源: JAVA高級架構(gòu)
相關(guān)推薦

2020-04-16 15:20:43

PHP前端BIO

2022-04-16 16:52:24

Netty網(wǎng)絡(luò)服務(wù)器客戶端程序

2021-08-12 18:48:31

響應(yīng)式編程Bio

2020-10-10 19:37:27

BIO 、NIO 、A

2023-07-11 08:40:02

IO模型后臺(tái)

2023-06-26 07:39:10

2019-10-18 08:22:43

BIONIOAIO

2011-03-31 10:41:49

BIONIOIO

2018-09-19 14:53:02

NIOBIO運(yùn)行

2022-02-22 08:00:48

JavaNIOBuffer

2021-03-04 08:34:55

同步阻塞非阻塞

2011-12-07 16:50:29

JavaNIO

2011-12-15 11:39:25

JavaNIO

2023-07-28 08:23:05

選擇器Java NIO

2019-05-05 08:50:42

阻塞非阻塞BIO

2024-11-06 16:38:51

IO網(wǎng)絡(luò)

2011-12-08 10:24:53

JavaNIO

2011-12-15 09:40:06

Javanio

2020-11-20 07:51:02

JavaSPI機(jī)制

2023-03-07 08:00:12

netpollGo
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號(hào)