![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLi0zaHRGcWdUYuVzVa9GczoVdG1mWfVGc5RHLwkzX39GZhh2csATMflHLwEzX4xSZz91ZsADMx8FdsYkRGZkRG9lcvx2bjxSa2EWNhJTW1AlUxEFeVRUUfRHelRHL2EzXlpXazxyayFWbyVGdhd3LcV2Zh1Wa9M3clN2byBXLzN3btg3PnVGcq5iN4IDOwYmY2QWOiNGN0ITNxEDMzMzYzYGNkNjMzMmYw8CX3EzLchDMxIDMy8CXn9Gbi9CXzV2Zh1WavwVbvNmLvR3YxUjL5M3Lc9CX6MHc0RHaiojIsJye.jpeg)
本文從作業系統的角度來解釋BIO,NIO,AIO的概念,含義和背後的那些事。本文主要分為3篇。
第一篇:講解BIO和NIO以及IO多路複用 https://www.jianshu.com/p/ef418ccf2f7d
第二篇:講解磁盤IO和AIO https://www.jianshu.com/p/444646e02ef7
第三篇:講解在這些機制上的一些應用的實作方式,比如nginx,nodejs,Java NIO等
到底什麼是“IO Block”
很多人說BIO不好,會“block”,但到底什麼是IO的Block呢?考慮下面兩種情況:
用系統調用read從socket裡讀取一段資料
用系統調用read從一個磁盤檔案讀取一段資料到記憶體
如果你的直覺告訴你,這兩種都算“Block”,那麼很遺憾,你的了解與Linux不同。Linux認為:
對于第一種情況,算作block,因為Linux無法知道網絡上對方是否會發資料。如果沒資料發過來,對于調用read的程式來說,就隻能“等”。
對于第二種情況,不算做block。
是的,對于磁盤檔案IO,Linux總是不視作Block。
你可能會說,這不科學啊,磁盤讀寫偶爾也會因為硬體而卡殼啊,怎麼能不算Block呢?但實際就是不算。
一個解釋是,所謂“Block”是指作業系統可以預見這個Block會發生才會主動Block。例如當讀取TCP連接配接的資料時,如果發現Socket buffer裡沒有資料就可以确定定對方還沒有發過來,于是Block;而對于普通磁盤檔案的讀寫,也許磁盤運作期間會抖動,會短暫暫停,但是作業系統無法預見這種情況,隻能視作不會Block,照樣執行。
基于這個基本的設定,在讨論IO時,一定要嚴格區分網絡IO和磁盤檔案IO。NIO和後文講到的IO多路複用隻對網絡IO有意義。
嚴格的說,O_NONBLOCK和IO多路複用,對标準輸入輸出描述符、管道和FIFO也都是有效的。但本文側重于讨論高性能網絡伺服器下各種IO的含義和關系,是以本文做了簡化,隻提及網絡IO和磁盤檔案IO兩種情況。
本文先着重講一下網絡IO。
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下,一般是這樣:
再強調一下,以上操作隻對socket對應的檔案描述符有意義;對磁盤檔案的檔案描述符做此設定總會成功,但是會直接被忽略。
這時,BIO和NIO的差別是什麼呢?
在BIO模式下,調用read,如果發現沒資料已經到達,就會Block住。
在NIO模式下,調用read,如果發現沒資料已經到達,就會立刻傳回-1, 并且errno被設為EAGAIN。
在有些文檔中寫的是會傳回EWOULDBLOCK。實際上,在Linux下EAGAIN和EWOULDBLOCK是一樣的,即#define EWOULDBLOCK EAGAIN
于是,一段NIO的代碼,大概就可以寫成這個樣子。
這段代碼很容易了解,就是輪詢,不斷的嘗試有沒有資料到達,有了就處理,沒有(得到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長這樣:
它接受3個檔案描述符的數組,分别監聽讀取(readfds),寫入(writefds)和異常(expectfds)事件。
那麼一個 IO多路複用的代碼大概是這樣:
首先,為了select需要構造一個fd數組(這裡為了簡化,沒有構造要監聽寫入和異常事件的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類似于。它大概長這樣:
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是需要先建立一下的。
epoll_create在核心層建立了一個資料表,接口會傳回一個“epoll的檔案描述符”指向這個表。注意,接口參數是一個表達要監聽事件清單的長度的數值。但不用太在意,因為epoll内部随後會根據事件注冊和事件登出動态調整epoll中表格的大小。
epoll建立
為什麼epoll要建立一個用檔案描述符來指向的表呢?這裡有兩個好處:
epoll是有狀态的,不像select和poll那樣每次都要重新傳入所有要監聽的fd,這避免了很多無謂的資料複制。epoll的資料是用接口epoll_ctl來管理的(增、删、改)。
epoll檔案描述符在程序被fork時,子程序是可以繼承的。這可以給對多程序共享一份epoll資料,實作并行監聽網絡請求帶來便利。但這超過了本文的讨論範圍,就此打住。
epoll建立後,第二步是使用epoll_ctl接口來注冊要監聽的事件。
其中第一個參數就是上面建立的epfd。第二個參數op表示如何對檔案名進行操作,共有3種。
EPOLL_CTL_ADD - 注冊一個事件
EPOLL_CTL_DEL - 取消一個事件的注冊
EPOLL_CTL_MOD - 修改一個事件的注冊
第三個參數是要操作的fd,這裡必須是支援NIO的fd(比如socket)。
第四個參數是一個epoll_event的類型的資料,表達了注冊的事件的具體資訊。
比方說,想關注一個fd1的讀取事件事件,并采用邊緣觸發(下文會解釋什麼是邊緣觸發),大概要
這麼寫:
通過epoll_ctl就可以靈活的注冊/取消注冊/修改注冊某個fd的某些事件。
管理fd事件注冊
第三步,使用epoll_wait來等待事件的發生。
特别留意,這一步是"block"的。隻有當注冊的事件至少有一個發生,或者timeout達到時,該調用才會傳回。這與select和poll幾乎一緻。但不一樣的地方是evlist,它是epoll_wait的傳回數組,裡面隻包含那些被觸發的事件對應的fd,而不是像select和poll那樣傳回所有注冊的fd。
監聽fd事件
綜合起來,一段比較完整的epoll代碼大概是這樣的。
此外,epoll的手冊 http://man7.org/linux/man-pages/man7/epoll.7.html中也有一個簡單的例子。
所有的基于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)。
水準觸發和邊沿觸發
預設情況下,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有很大的性能優勢和功能優勢,适合實作高性能網絡服務。