最近一年用NIO寫了很多網絡程序,也研究了一些開源NIO網絡框架netty、mina等,總結了一下NIO的架構特色。java
不管是netty仍是mina它們都在java原生NIO的基礎上進行了完善的封裝,雖然細節有所不一樣,但整體架構思路一致,都大概劃分出了如下幾個組成部分:react
- - transport:傳輸層的抽象程序員
- - protocol: 協議codec的抽象緩存
- - event model:統一事件模型安全
- - buffer:底層buffer封裝網絡
在徹底屏蔽底層API的同時,對上層應用提供了自身的統一API接口。session
框架進行黑盒封裝的同時,再進行通用化的接口開放,帶來的好處是統一化,但壞處是程序的透明度下降,抽象度提升,增長理解難度和實現難度。多線程
下面說說每一個部分的一些設計考量:架構
transport傳輸層抽象都是對java原生NIO API的封裝,在這一層封裝的程度在於框架的實現目標。例如mina立足於通用的網絡框架,所以徹底屏蔽了原生的API,提供了自身的統一接口,由於它不只須要封裝NIO的API還有一系列其餘類型的IO操做的API,提供統一API接口。爲了通用兼顧各種傳輸通道所以可能不得不暴露多餘的API接口,使用方需甄別傳輸通道的不一樣,增長了理解難度。併發
protocol封裝各種經常使用協議的codec操做,但目前這些網絡框架的codec實現都與自身的API緊密綁定,下降了可重用性。
event model 事件模型的設計一般不能徹底獨立,例如java NIO自己的模型是事件驅動的,但傳統阻塞型IO並不是事件驅動,要兼顧兩者一般要付出額外的代價和開銷。
有一種說法是讓異步IO同步化使用(由於同步化使用更簡單,異步致使了業務處理的碎片化)到底對不對值得商榷?模型阻抗致使的代價和開銷屏蔽在了黑盒中,也容易誤導應用程序員對本該採用同步化處理的業務卻濫用了異步化機制,並不會帶來什麼好處。
buffer 一般都用來配合底層IO數據流和協議codec使用,自己是否適合暴露給應用方取決於框架是否整合codec,由於codec自己帶有業務性質,而純粹的IO數據流處理使用的buffer則徹底無需暴露給應用方。
以上簡單說了下NIO框架各部分的設計考量,能夠看出目前流行的NIO框架(netty和mina)都在走一條相似「瑞士軍刀」的路線,集各類功能與一身(多種IO封裝、協議封裝),但你又很難把瑞士軍刀上的某個刀片拆下來單獨使用。
在實踐中感受,考慮從單一性、簡潔性、重用性、組合性、透明性幾個方面去設計原子化的IO組件也許更可取,更像是一種「工具箱」路線。
典型的事件驅動模型NIO框架組件交互圖以下:
Acceptor: 負責監聽鏈接事件負責接入
Processor:負責IO讀寫事件處理
EventDispatcher:負責事件派發
Handler:業務處理器
後面將經過一個系列文章來討論一個原子化的NIO組件實現的細節及設計考量。
注:本文適合對象需對java NIO API的使用及異步事件模型(Reactor模式)有必定程度的瞭解,主要講述使用java原生NIO實現一個TCP監聽綁定的過程及細節設計。
咱們一開始設計了一個TCP接入服務類,這個類提供了一個API方法提供對本地一系列地址(端口)的監聽綁定,類初始化後完成Selector的open操做以下:
提供的綁定API,其方法簽名以下:
參數中能夠傳遞多個本地地址(端口)同時進行監聽綁定。
在NIO的綁定過程當中需進行事件註冊(對OP_ACCEPT感興趣),以下:
因爲註冊過程當中除了涉及鎖競爭還可能產生死鎖,因此通常的作法都是將綁定地址放在隊列中進行異步註冊由reactor線程進行處理,例如:
如上面代碼片斷中的wait0()方法就是等待綁定結果,若出現綁定異常則拋出
至此,完成了TCP服務監聽過程,下文將進一步講述服務接入和數據傳輸相關設計細節。
注:本文適合對象需對java NIO API的使用及異步事件模型(Reactor模式)有必定程度的瞭解,主要講述使用java原生NIO實現一個TCP服務的過程及細節設計。
前文講述了NIO TCP服務綁定過程的實現機制,如今能夠開始講述服務監聽啓動後如何和處理接入和數據傳輸相關的細節設計。
在NIO的接入類中有一個Reactor線程,用於處理OP_ACCEPT事件通知,以下:
當有客戶端接入時selector.select()方法返回大於0的整數,並進入accept()方法進行處理,具體以下:
注意:此時與客戶鏈接的通道還沒有註冊對讀/寫事件感興趣,由於它的註冊與前文綁定過程同樣須要異步進行。
所以將封裝通道的session轉交給一個processor對象(io讀寫處理器,該概念也是來自mina),processor內部維持了一個新建session的隊列,在其內部reactor線程循環中進行註冊處理。
有關processor處理讀寫事件的細節設計見下文。
注:本文適合對象需對java NIO API的使用及異步事件模型(Reactor模式)有必定程度的瞭解,主要講述使用java原生NIO實現一個TCP服務的過程及細節設計。
上文講到當客戶端完成與服務端的鏈接創建後,爲其SocketChannel封裝了一個session對象表明這個鏈接,並交給processor處理。
processor的內部有3個重要的隊列,分別存放新建立的session、須要寫數據的session和準備關閉的session,以下:
1. selector.select(),其中爲了處理鏈接超時的狀況,select方法中傳遞了超時參數以避免其永久阻塞,一般是1秒。該方法即時在沒有事件發生時每秒返回一次,進入循環檢測超時
3. 有讀/寫事件時,進行相關處理,每次讀寫事件發生時更新一次最後的IO時間。
讀取數據時有一個小技巧在於靈活自適應buffer分配(來自mina的一個實現策略),每次判斷讀取到的字節數若乘以2依然小於buffer大小,則收縮buffer爲原來一半,若讀取的字節數已裝滿buffer則擴大一倍。
處理寫操做實際上是異步的,老是放入flushSessions中等待寫出。
4. 如有須要寫數據的session,則進行flush操做。
寫事件通常默認都是不去關注的,由於在TCP緩衝區可寫或遠端斷開或IO錯誤發生時都會觸發該事件,容易誘發服務端忙循環從而CPU100%問題。爲了保證讀寫公平,寫buffer的大小設置爲讀buffer的1.5倍(來自mina的實現策略),每次寫數據前設置爲對寫事件再也不感興趣。限制每次寫出數據大小的緣由除了避免讀寫不公平,也避免某些鏈接有大量數據須要寫出時一次佔用了過多的網絡帶寬而其餘鏈接的數據寫出被延遲從而影響了公平性。
- - buffer一次寫完,則派發消息已經發送事件
關閉session的操做具體來講就是對channel.close()和key.cancel(),這2個操做後其實尚未徹底釋放socket佔用的文件描述符,需等到下次select()操做後,一些NIO框架會主動調用,因爲咱們這裏select(TIMEOUT)帶有超時參數會自動喚醒,所以不存在這個問題。
前文講述了NIO數據讀寫處理,那麼這些數據最終如何被遞交給上層業務程序進行處理的呢?
NIO框架通常都採用了事件派發模型來與業務處理器交互,它與原生NIO的事件機制是模型匹配的,缺點是帶來了業務處理的碎片化。須要業務程序開發者對事件的生命週期有一個清晰的瞭解,不像傳統方式那麼直觀。
事件派發器(EventDispatcher)就成爲了NIO框架中IO處理線程和業務處理回調接口(Handler)之間的橋樑。
因爲業務處理的時間長短是難以肯定的,因此通常事件處理器都會分離IO處理線程,使用新的業務處理線程池來進行事件派發,回調業務接口實現。
下面經過一段示例代碼來講明事件的派發過程:
這是processor從網絡中讀取到一段字節後發起的MESSAGE_RECEIVED事件,調用了eventDispatcher.dispatch(Event e)方法。
dispatch的方法實現有如下關鍵點須要考慮:
1. 事件派發是多線程的,派發線程最終會調用業務回調接口來進行事件處理,回調接口由業務方實現自身去保證線程併發性和安全性。
2. 對於TCP應用來講,由同一session(這裏可表明同一個鏈接)收到的數據必須保證有序派發,不一樣的session可無序。
3. 不一樣session的事件派發要儘量保證公平性,例如:session1有大量事件產生致使派發線程繁忙時,session2產生一個事件不會由於派發線程都在忙於處理session1的事件而被積壓,session2的事件也能儘快獲得及時派發。
下面是一個實現思路的代碼示例:
有一組worker線程在監聽阻塞隊列,一旦有session進入隊列,它們被激活對session進行事件派發,以下:
退出臨界區後,進入事件派發處理方法fire(),在fire()方法退出前其餘線程都沒有機會對該session進行處理,保證了同一時刻只有一個線程進行處理的約束。
若是某個session一直不斷有數據進入,則派發線程可能在fire()方法中停留很長時間,具體看fire()的實現以下:
當前線程釋放對session的控制權只需簡單置事件處理狀態爲false,其餘線程就有機會從新獲取該session的控制權。
在最後退出前爲了不事件遺漏,由於可能當前線程由於處理事件達到上限數被退出循環而又沒有新的事件進入阻塞隊列觸發新的線程激活,則由當前線程主動去從新將該session放入阻塞隊列中激活新線程。