天天看點

Java I/O 模型的演進

什麼是同步?什麼是異步?阻塞和非阻塞又有什麼差別?本文先從 unix 的 i/o 模型講起,介紹了5種常見的 i/o 模型。而後再引出 java 的 i/o 模型的演進過程,并用執行個體說明如何選擇合适的 java i/o 模型來提高系統的并發量和可用性。

由于,java 的 i/o 依賴于作業系統的實作,是以先了解 unix 的 i/o 模型有助于了解 java 的 i/o。

描述的是使用者線程與核心的互動方式:

同步是指使用者線程發起 i/o 請求後需要等待或者輪詢核心 i/o 操作完成後才能繼續執行;

異步是指使用者線程發起 i/o 請求後仍繼續執行,當核心 i/o 操作完成後會通知使用者線程,或者調用使用者線程注冊的回調函數。

描述的是使用者線程調用核心 i/o 操作的方式:

阻塞是指 i/o 操作需要徹底完成後才傳回到使用者空間;

非阻塞是指 i/o 操作被調用後立即傳回給使用者一個狀态值,無需等到 i/o 操作徹底完成。

一個 i/o 操作其實分成了兩個步驟:發起 i/o 請求和實際的 i/o 操作。 阻塞 i/o 和非阻塞 i/o 的差別在于第一步,發起 i/o 請求是否會被阻塞,如果阻塞直到完成那麼就是傳統的阻塞 i/o ,如果不阻塞,那麼就是非阻塞 i/o 。 同步 i/o 和異步 i/o 的差別就在于第二個步驟是否阻塞,如果實際的 i/o 讀寫阻塞請求程序,那麼就是同步 i/o 。

unix 下共有五種 i/o 模型:

阻塞 i/o

非阻塞 i/o

i/o 多路複用(select 和 poll)

信号驅動 i/o(sigio)

異步 i/o(posix 的 aio_系列函數)

請求無法立即完成則保持阻塞。

階段1:等待資料就緒。網絡 i/o 的情況就是等待遠端資料陸續抵達;磁盤i/o的情況就是等待磁盤資料從磁盤上讀取到核心态記憶體中。

階段2:資料從核心拷貝到程序。出于系統安全,使用者态的程式沒有權限直接讀取核心态記憶體,是以核心負責把核心态記憶體中的資料拷貝一份到使用者态記憶體中。

Java I/O 模型的演進

socket 設定為 nonblock(非阻塞)就是告訴核心,當所請求的 i/o 操作無法完成時,不要将程序睡眠,而是傳回一個錯誤碼(ewouldblock) ,這樣請求就不會阻塞

i/o 操作函數将不斷的測試資料是否已經準備好,如果沒有準備好,繼續測試,直到資料準備好為止。整個 i/o 請求的過程中,雖然使用者線程每次發起 i/o 請求後可以立即傳回,但是為了等到資料,仍需要不斷地輪詢、重複請求,消耗了大量的 cpu 的資源

資料準備好了,從核心拷貝到使用者空間。

Java I/O 模型的演進

一般很少直接使用這種模型,而是在其他 i/o 模型中使用非阻塞 i/o 這一特性。這種方式對單個 i/o 請求意義不大,但給 i/o 多路複用鋪平了道路.

i/o 多路複用會用到 select 或者 poll 函數,這兩個函數也會使程序阻塞,但是和阻塞 i/o 所不同的的,這兩個函數可以同時阻塞多個 i/o 操作。而且可以同時對多個讀操作,多個寫操作的 i/o 函數進行檢測,直到有資料可讀或可寫時,才真正調用 i/o 操作函數。

Java I/O 模型的演進

從流程上來看,使用 select 函數進行 i/o 請求和同步阻塞模型沒有太大的差別,甚至還多了添加監視 socket,以及調用 select 函數的額外操作,效率更差。但是,使用 select 以後最大的優勢是使用者可以在一個線程内同時處理多個 socket 的 i/o 請求。使用者可以注冊多個 socket,然後不斷地調用 select 讀取被激活的 socket,即可達到在同一個線程内同時處理多個 i/o 請求的目的。而在同步阻塞模型中,必須通過多線程的方式才能達到這個目的。

i/o 多路複用模型使用了 reactor 設計模式實作了這一機制。

調用 select / poll 該方法由一個使用者态線程負責輪詢多個 socket,直到某個階段1的資料就緒,再通知實際的使用者線程執行階段2的拷貝。 通過一個專職的使用者态線程執行非阻塞i/o輪詢,模拟實作了階段一的異步化

首先我們允許 socket 進行信号驅動 i/o,并安裝一個信号處理函數,程序繼續運作并不阻塞。當資料準備好時,程序會收到一個 sigio 信号,可以在信号處理函數中調用 i/o 操作函數處理資料。

Java I/O 模型的演進

調用 aio_read 函數,告訴核心描述字,緩沖區指針,緩沖區大小,檔案偏移以及通知的方式,然後立即傳回。當核心将資料拷貝到緩沖區後,再通知應用程式。

Java I/O 模型的演進

異步 i/o 模型使用了 proactor 設計模式實作了這一機制。

告知核心,當整個過程(包括階段1和階段2)全部完成時,通知應用程式來讀資料.

前四種模型的差別是階段1不相同,階段2基本相同,都是将資料從核心拷貝到調用者的緩沖區。而異步 i/o 的兩個階段都不同于前四個模型。

同步 i/o 操作引起請求程序阻塞,直到 i/o 操作完成。異步 i/o 操作不引起請求程序阻塞。

Java I/O 模型的演進

在了解了 unix 的 i/o 模型之後,其實 java 的 i/o 模型也是類似。

在上一節 socket 章節中的 echoserver 就是一個簡單的阻塞 i/o 例子,伺服器啟動後,等待用戶端連接配接。在用戶端連接配接伺服器後,伺服器就阻塞讀寫取資料流。

echoserver 代碼:

使用多線程來支援多個用戶端來通路伺服器。

主線程 multithreadechoserver.java

處理器類 echoserverhandler.java

存在問題:每次接收到新的連接配接都要建立一個線程,處理完成後銷毀線程,代價大。當有大量地短連接配接出現時,性能比較低。

針對上面多線程的模型中,出現的線程重複建立、銷毀帶來的開銷,可以采用線程池來優化。每次接收到新連接配接後從池中取一個空閑線程進行處理,處理完成後再放回池中,重用線程避免了頻率地建立和銷毀線程帶來的開銷。

主線程 threadpoolechoserver.java

存在問題:在大量短連接配接的場景中性能會有提升,因為不用每次都建立和銷毀線程,而是重用連接配接池中的線程。但在大量長連接配接的場景中,因為線程被連接配接長期占用,不需要頻繁地建立和銷毀線程,因而沒有什麼優勢。

雖然這種方法可以适用于小到中度規模的用戶端的并發數,如果連接配接數超過 100,000或更多,那麼性能将很不理想。

"阻塞i/o+線程池"網絡模型雖然比"阻塞i/o+多線程"網絡模型在性能方面有提升,但這兩種模型都存在一個共同的問題:讀和寫操作都是同步阻塞的,面對大并發(持續大量連接配接同時請求)的場景,需要消耗大量的線程來維持連接配接。cpu 在大量的線程之間頻繁切換,性能損耗很大。一旦單機的連接配接超過1萬,甚至達到幾萬的時候,伺服器的性能會急劇下降。

而 nio 的 selector 卻很好地解決了這個問題,用主線程(一個線程或者是 cpu 個數的線程)保持住所有的連接配接,管理和讀取用戶端連接配接的資料,将讀取的資料交給後面的線程池處理,線程池處理完業務邏輯後,将結果交給主線程發送響應給用戶端,少量的線程就可以處理大量連接配接的請求。

java nio 由以下幾個核心部分組成:

channel

buffer

selector

要使用 selector,得向 selector 注冊 channel,然後調用它的 select()方法。這個方法會一直阻塞到某個注冊的通道有事件就緒。一旦這個方法傳回,線程就可以處理這些事件,事件的例子有如新連接配接進來,資料接收等。

主線程 nonblokingechoserver.java

java se 7 版本之後,引入了異步 i/o (nio.2) 的支援,為建構高性能的網絡應用提供了一個利器。

主線程 asyncechoserver.java

<a href="http://my.safaribooksonline.com/book/programming/java/9781449365936">java network programming, 4th edition</a>

<a href="http://www.amazon.com/pro-java-nio-2-experts-voice/dp/1430240113">pro java 7 nio.2</a>

<a href="http://www.amazon.com/unix-network-programming-sockets-networking/dp/0131411551/ref=sr_1_1?ie=utf8&amp;qid=1456823747&amp;sr=8-1&amp;keywords=unix+network+programming">unix network programming, volume 1: the sockets networking api (3rd edition)</a>

<a href="https://github.com/waylau/essential-java">java 程式設計要點</a>