天天看點

Linux IO模型和網絡程式設計模型

術語概念描述:

IO有記憶體IO、網絡IO和磁盤IO三種,通常我們說的IO指的是後兩者。

阻塞和非阻塞,是函數/方法的實作方式,即在資料就緒之前是立刻傳回還是等待。

以檔案IO為例,一個IO讀過程是檔案資料從磁盤→核心緩沖區→使用者記憶體的過程。同步與異步的差別主要在于資料從核心緩沖區→使用者記憶體這個過程需不需要使用者程序等待。有個資料拷貝的過程,是拷貝完再通知還是在核心緩沖區就通知。(網絡IO把磁盤換做網卡即可)

同步阻塞

同步非阻塞

IO複用

信号驅動

異步非阻塞

去餐館吃飯,點一個自己最愛吃的蓋澆飯,然後在原地等着一直到蓋澆飯做好,自己端到餐桌就餐。這就是典型的同步阻塞。當廚師給你做飯的時候,你需要一直在那裡等着。

網絡程式設計中,讀取用戶端的資料需要調用recvfrom。在預設情況下,這個調用會一直阻塞直到資料接收完畢,就是一個同步阻塞的IO方式。這也是最簡單的IO模型,在通常fd(檔案描述句柄)較少、就緒很快的情況下使用是沒有問題的。

Linux IO模型和網絡程式設計模型

你每次點完飯就在那裡等着,突然有一天你發現自己真傻。于是,你點完之後,就回桌子那裡坐着,然後估計差不多了,就問老闆飯好了沒,如果好了就去端,沒好的話就等一會再去問,依次循環直到飯做好。這就是同步非阻塞。

這種方式在程式設計中對socket設定O_NONBLOCK即可。但此方式僅僅針對網絡IO有效,對磁盤IO并沒有作用。因為本地檔案IO就沒有被認為是阻塞,我們所說的網絡IO的阻塞是因為網路IO有無限阻塞的可能,而本地檔案除非是被鎖住,否則是不可能無限阻塞的,是以隻有鎖這種情況下,O_NONBLOCK才會有作用。而且,磁盤IO時要麼資料在核心緩沖區中直接可以傳回,要麼需要調用實體裝置去讀取,這時候程序的其他工作都需要等待。是以,後續的IO複用和信号驅動IO對檔案IO也是沒有意義的。

Linux IO模型和網絡程式設計模型

你點一份飯然後循環的去問好沒好顯然有點得不償失,還不如就等在那裡直到準備好,但是當你點了好幾樣飯菜的時候,你每次都去問一下所有飯菜的狀态(未做好/已做好)肯定比你每次阻塞在那裡等着好多了。當然,你問的時候是需要阻塞的,一直到有準備好的飯菜或者你等的不耐煩(逾時)。這就引出了IO複用,也叫多路IO就緒通知。這是一種程序預先告知核心的能力,讓核心發現程序指定的一個或多個IO條件就緒了,就通知程序。使得一個程序能在一連串的事件上等待。

IO複用的實作方式目前主要有select、poll和epoll。

select和poll的原理基本相同:

注冊待偵聽的fd(這裡的fd建立時最好使用非阻塞)

每次調用都去檢查這些fd的狀态,當有一個或者多個fd就緒的時候傳回

傳回結果中包括已就緒和未就緒的fd

相比select,poll解決了單個程序能夠打開的檔案描述符數量有限制這個問題:select受限于FD_SIZE的限制,如果修改則需要修改這個宏重新編譯核心;而poll通過一個pollfd數組向核心傳遞需要關注的事件,避開了檔案描述符數量限制。此外,select和poll共同具有的一個很大的缺點就是包含大量fd的數組被整體複制于使用者态和核心态位址空間之間,開銷會随着fd數量增多而線性增大。

select和poll就類似于上面說的就餐方式。但當你每次都去詢問時,老闆會把所有你點的飯菜都輪詢一遍再告訴你情況,當大量飯菜很長時間都不能準備好的情況下是很低效的。于是,老闆有些不耐煩了,就讓廚師每做好一個菜就記下來他。這樣每次你再去問的時候,他會直接把已經準備好的菜告訴你,你再去端。這就是事件驅動IO就緒通知的方式epoll。

epoll的出現,解決了select、poll的缺點:

基于事件驅動的方式,避免了每次都要把所有fd都掃描一遍。

epoll_wait隻傳回就緒的fd。

epoll使用nmap記憶體映射技術避免了記憶體複制的開銷。

epoll的fd數量上限是作業系統的最大檔案句柄數目,這個數目一般和記憶體有關,通常遠大于1024。

總結:

select:支援注冊 FD_SETSIZE(1024) 個 socket。

poll: poll 作為 select 的替代者,最大的差別就是,poll 不再限制 socket 數量。

epoll:epoll 能直接傳回具體的準備好的通道,時間複雜度 O(1)。

ps:select 和 poll 都有一個共同的問題,那就是它們都隻會傳回所有通道(channel),但是不會告訴你具體是哪幾個通道已經就緒。一旦知道有通道準備好以後,需要進行一次掃描,通道少的時候還行,一旦通道的數量是幾十萬個以上的時候,掃描一次的時間複雜度 O(n)。後來才催生了epoll實作。

此外,對于IO複用還有一個水準觸發和邊緣觸發的概念:

水準觸發:當就緒的fd未被使用者程序處理後,下一次查詢依舊會傳回,這是select和poll的觸發方式。

邊緣觸發:無論就緒的fd是否被處理,下一次不再傳回。理論上性能更高,但是實作相當複雜,并且任何意外的丢失事件都會造成請求處理錯誤。epoll預設使用水準觸發,通過相應選項可以使用邊緣觸發。

Linux IO模型和網絡程式設計模型

上文的就餐方式還是需要你每次都去問一下飯菜狀況。于是,你再次不耐煩了,就跟老闆說,哪個飯菜好了就通知我一聲吧。然後就自己坐在桌子那裡幹自己的事情。更甚者,你可以把手機号留給老闆,自己出門,等飯菜好了直接發條短信給你。這就類似信号驅動的IO模型。

流程如下:

開啟套接字信号驅動IO功能

系統調用sigaction執行信号處理函數(非阻塞,立刻傳回)

資料就緒(在核心緩沖區),生成sigio信号,通過信号回調通知應用來讀取資料。

Linux IO模型和網絡程式設計模型

之前的就餐方式,到最後總是需要你自己去把飯菜端到餐桌。這下你也不耐煩了,于是就告訴老闆,能不能飯好了直接端到你的面前或者送到你的家裡(資料在使用者記憶體就緒)。這就是異步非阻塞IO了。

對比信号驅動IO,異步IO的主要差別在于:信号驅動由核心告訴我們何時可以開始一個IO操作(資料在核心緩沖區中),而異步IO則由核心通知IO操作何時已經完成(資料已經在使用者空間中)。異步IO又叫做事件驅動IO,在Unix中,POSIX1003.1标準為異步方式通路檔案定義了一套庫函數,定義了AIO的一系列接口。使用aio_read或者aio_write發起異步IO操作。使用aio_error檢查正在運作的IO操作的狀态。

Linux IO模型和網絡程式設計模型

Java的I/O發展簡史:

從JDK1.0到JDK1.3,Java的I/O類庫都非常原始,很多UNIX網絡程式設計中的概念或者接口在I/O類庫中都沒有展現,例如Pipe、Channel、Buffer和Selector等。2002年釋出JDK1.4時,NIO以JSR-51的身份正式随JDK釋出。它新增了個java.nio包,提供了很多進行異步I/O開發的API和類庫,主要的類和接口如下。

進行異步I/O操作的緩沖區ByteBuffer等;

進行異步I/O操作的管道Pipe;

進行各種I/O操作(異步或者同步)的Channel,包括ServerSocketChannel和SocketChannel;

多種字元集的編碼能力和解碼能力;

實作非阻塞I/O操作的多路複用器selector;

基于流行的Perl實作的正規表達式類庫;

檔案通道FileChannel。

新的NIO類庫的提供,極大地促進了基于Java的異步非阻塞程式設計的發展和應用,但是,它依然有不完善的地方,特别是對檔案系統的處理能力仍顯不足,主要問題如下。

沒有統一的檔案屬性(例如讀寫權限);

API能力比較弱,例如目錄的級聯建立和遞歸周遊,往往需要自己實作;

底層存儲系統的一些進階API無法使用;

所有的檔案操作都是同步阻塞調用,不支援異步檔案讀寫操作。

2011年7月28日,JDK1.7正式釋出。它的一個比較大的亮點就是将原來的NIO類庫進行了更新,被稱為NIO2.0。

NIO2.0由JSR-203演進而來,它主要提供了如下三個方面的改進。

提供能夠批量擷取檔案屬性的API,這些API具有平台無關性,不與特性的檔案系統相耦合,另外它還提供了标準檔案系統的SPI,供各個服務提供商擴充實作;

提供AIO功能,支援基于檔案的異步I/O操作和針對網絡套接字的異步操作;

完成JSR-51定義的通道功能,包括對配置和多點傳播資料報的支援等。

上文講述了UNIX環境的五種IO模型。基于這五種模型,在Java中,随着NIO和NIO2.0(AIO)的引入,一般具有以下幾種網絡程式設計模型:

BIO

NIO

AIO 

BIO是一個典型的網絡程式設計模型,是通常我們實作一個服務端程式的過程,步驟如下:

主線程accept請求阻塞

請求到達,建立新的線程來處理這個套接字,完成對用戶端的響應。

主線程繼續accept下一個請求

這種模型有一個很大的問題是:當用戶端連接配接增多時,服務端建立的線程也會暴漲,系統性能會急劇下降。是以,在此模型的基礎上,類似于 tomcat的bio connector,采用的是線程池來避免對于每一個用戶端都建立一個線程。有些地方把這種方式叫做僞異步IO(把請求抛到線程池中異步等待處理)。

JDK1.4開始引入了NIO類庫,這裡的NIO指的是Non-blcok IO,主要是使用Selector多路複用器來實作。Selector在Linux等主流作業系統上是通過epoll實作的。

NIO的實作流程,類似于select:

建立ServerSocketChannel監聽用戶端連接配接并綁定監聽端口,設定為非阻塞模式。

建立Reactor線程,建立多路複用器(Selector)并啟動線程。

将ServerSocketChannel注冊到Reactor線程的Selector上。監聽accept事件。

Selector線上程run方法中無線循環輪詢準備就緒的Key。

Selector監聽到新的用戶端接入,處理新的請求,完成tcp三次握手,建立實體連接配接。

将新的用戶端連接配接注冊到Selector上,監聽讀操作。讀取用戶端發送的網絡消息。

用戶端發送的資料就緒則讀取用戶端請求,進行處理。

相比BIO,NIO的程式設計非常複雜。

AIO

JDK1.7引入NIO2.0,提供了異步檔案通道和異步套接字通道的實作,是真正的異步非阻塞IO, 對應于Unix中的異步IO。

通常會有一個線程池用于執行異步任務,送出任務的線程将任務送出到線程池就可以立馬傳回,不必等到任務真正完成。如果想要知道任務的執行結果,通常是通過傳遞一個回調函數任務結束後去調用這個函數(任務結束後去系統調用這個函數)或者Future get(需要用時編碼阻塞擷取)的方式,任務結束後去調用這個函數。同樣的原理,Java 中的異步 IO 也是一樣的,都是由一個線程池來負責執行任務,然後使用回調或自己去查詢結果。異步 IO 主要是為了控制線程數量,減少過多的線程帶來的記憶體消耗和 CPU 線上程排程上的開銷。

建立AsynchronousServerSocketChannel,綁定監聽端口

調用AsynchronousServerSocketChannel的accpet方法,傳入自己實作的CompletionHandler(回調函數)。包括上一步,都是非阻塞的

連接配接傳入,回調CompletionHandler的completed方法,在裡面,調用AsynchronousSocketChannel的read方法,傳入負責處理資料的CompletionHandler。

資料就緒,觸發負責處理資料的CompletionHandler的completed方法。繼續做下一步處理即可。

寫入操作類似,也需要傳入CompletionHandler。

Linux IO模型和網絡程式設計模型