NIO+reactor 模式的網(wǎng)路服務器設計方案
1、前言
在前一篇文章中,介紹了基于 BlockingIO +thread-per-connection 的方案,由于該方案為每一個連接分配一個線程,而線程里的大部分操作都是阻塞式的,所以在高并發(fā)的情況下,會導致產(chǎn)生大量的線程,線程間的上下文切換會浪費大量的 CPU 時間,而且每個線程是需要占用堆??臻g的,所以服務器能分配的線程數(shù)量也是有限的,當客戶端的并發(fā)訪問量達到一定的數(shù)量級后,服務器的資源就會耗盡,可伸縮性差。
根據(jù)上面的分析,要提高網(wǎng)絡服務器的可伸縮性,就必須解決兩個問題:
因此, java1.4 引入了非阻塞式 NIO ( Non-blocking IO ) , 解決了問題 2 ;而采用基于異步事件通知的 reactor 模式則可以僅僅用一個線程來并發(fā)的為多個連接服務,這樣就解決了問題 1
2、Reactor 模式
2.1 示例
首先舉一個生活中的例子來比較 thread-per-connection和 reactor方案
某火車票售票廳,只有 1 個售票窗口工作。兩個乘客 a 、 b 先后來購票,由于 a 先到,所以售票窗口先為 a 服務, b 只能排隊
乘客 a與售票窗口開始溝通時,就相當于在客戶端(乘客 a)與服務端(售票廳)之間建立了一個 connection,服務端為每一個 connection分配一個 thread(售票窗口)。當沒有 thread可以分配時,后續(xù)的客戶端請求(乘客 b)就不能及時響應了,所以 b只能排隊。假設存在這種場景,售票窗口的服務員告訴乘客 a票價后,乘客 a準備付款時發(fā)現(xiàn)自己忘記了帶錢包,所以乘客 a打電話給家里人讓他們把錢包送過來,但從 a的家步行到售票廳需要 5分鐘,于是售票窗口的服務員就一直等著(被阻塞),但又不為乘客 b服務,因為她的做事風格( thread-per-connection)是一定要為一個乘客完完整整服務完后才能接著服務下一位乘客。
這種情況下,乘客 b 肯定會抱怨,而且 5 分鐘后, b 的后面也肯定排了很多人,售票廳發(fā)現(xiàn)這種情況后,就只能選擇再打開一個售票窗口(分配一個 thread )為 b 服務,但 b 后面的人也只能排隊。之前那個窗口的服務員一直等著,又不干活,但工資還是照樣拿,所以售票廳(服務端)的開銷很大。
服務員在等待 a 取錢包的過程中,被通知乘客 b 要求服務,所以窗口和 b 建立連接,悲劇的是 b 也沒有帶錢包,需要家里人送來。此時服務員又被通知 a 的錢包送過來了,所以窗口接著為 a 服務,出票完成后,服務員又被通知 b 的錢包送過來了,所以接著又為 b 服務。這樣,售票廳(服務端)的開銷就小了,現(xiàn)在只需要一個窗口就可以搞定所有事情。
2.2 Reactor 模式的思想:分而治之 + 事件驅(qū)動
一個 connection里發(fā)生的完整的網(wǎng)絡處理過程一般分為 accept、 read、 decode、 compute、 encode、 send這幾步。 Reactor將每個步驟映射為一個 task,服務端端的線程執(zhí)行的最小邏輯單元不再是一次完整的網(wǎng)絡處理過程,而是更小的 task,且采用非阻塞的執(zhí)行方式;
每個 task對應一個特定的事件,當 task準備就緒時,對應的事件通知就會發(fā)出。 Reactor收到事件后,分發(fā)給綁定了對應事件的 Handler執(zhí)行 task。
下圖描述了單線程版本的 reactor 模式結(jié)構圖。
關鍵概念:
2.3 基于 reactor 的網(wǎng)絡交互
1) 服務器將綁定了 accept事件的 Acceptor注冊到 Reactor中,準備 accept新的 connection;
2) 服務器啟動 Reactor的事件循環(huán)處理功能(注意:該循環(huán)會阻塞,直到收到事件)
3) 客戶端 connect服務器
4) Reactor響應 accept事件,分發(fā)給 Acceptor, Acceptor 確定建立一個新的連接。
5) Acceptor創(chuàng)建一個 handler專門服務該 connection后續(xù)的請求;
6) Handler綁定該 connection的 read事件,并將自己注冊到 Reactor中
1) 客戶端發(fā)送請求
2) 當客戶端的請求數(shù)據(jù)到達服務器時, Reactor響應 read事件,分發(fā)給綁定了 read事件的 handler(即上面第 6步創(chuàng)建的 handler)
3) Handler執(zhí)行 task,讀取客戶端的請求數(shù)據(jù)(此處是非阻塞讀,即如果當前操作會導致當前線程阻塞,當前操作會立即返回,并重復執(zhí)行第 2、 3步,直到客戶端的請求讀取完畢。)
4) 解析客戶端請求數(shù)據(jù)
5) 讀取文件
6) Handler重新綁定 write事件
7) 當 connection可以開始 write的時候, Reactor響應 write事件,分發(fā)給綁定了 write事件的 handler
8) Handler 執(zhí)行 task ,向客戶端發(fā)送文件(此處是非阻塞寫,即如果當前操作會導致當前線程阻塞,當前操作會立即返回,并重復執(zhí)行第 7 、 8 步,直到文件全部發(fā)送完畢。)
注意:上述兩個過程都是在服務器的一個線程里完成的,該線程響應所有客戶端的請求。譬如服務端在處理客戶端 A 的請求時,如果在第 2 步 read 事件還沒有就緒(或者在第 3 步讀取數(shù)據(jù)的時候發(fā)生阻塞了),則該線程會立即重新回到客戶端連接服務器過程的第 2 步(即事件循環(huán)處理),等待新的事件通知。如果此時客戶端 B 請求連接,則該線程會響應 B 的連接請求,這樣就實現(xiàn)了一個線程同時為多個連接服務的效果。
3、 代碼示例
3.1 NIO的幾個關鍵概念
Reactor里的一個核心組成部分,通過調(diào)用 selector.select()方法,可以知道感興趣的 IO事件里哪些已經(jīng) ready,該方法是阻塞的,直到有 IO事件 ready;通過調(diào)用 selector.selectedKeys()方法,可以獲取到 selectionKey對象,這些對象關聯(lián)有已經(jīng) ready的 IO事件。
當 selector注冊一個 channel時,會產(chǎn)生一個該對象,譬如SelectionKey selectionKey = channel .register(selector, SelectionKey. OP_ACCEPT );它維護著 channel 、 selector 、 IO 事件、 Handler 之間的關系。通過調(diào)用 attach 方法,可以綁定一個 handler ,譬如: selectionKey.attach(new Acceptor());
類似于 ServerSocket,唯一的區(qū)別在于: ServerSocketChannel可以使用 selector,而且可以設置為非阻塞模式。
類似于 Socket,唯一的區(qū)別在于: SocketChannel可以使用 selector,而且可以設置為非阻塞模式。
3.2 code
注:所有代碼只用來作為原理的進一步闡述,不能用于生產(chǎn)環(huán)境
4、 Reactor 的其他實現(xiàn)方式
單線程版本的 Reactor 最大的優(yōu)勢是:不需要做并發(fā)控制,簡化了實現(xiàn)。缺點是不能充分利用多核 CPU的優(yōu)勢,因為只有一個線程,該線程需要執(zhí)行所有的操作: accept、 read、 decode、 compute、 encode、 send,而其中的 decode、 compute、 encode如果很耗時,則該線程就不能及時的響應其他客戶端的請求。
為了解決該問題,可以采用另外兩種版本:
4.1 Worker threads:
Reactor所在的線程只需要專心的響應客戶端的請求: accept、 read、 send。對數(shù)據(jù)的具體處理過程則交給另外的線程池。這樣可以提高服務端對客戶端的響應速度,但同時增加了復雜度,也沒有充分利用到多核的優(yōu)勢,因為 reactor只有一個,譬如同一時刻只能 read一個客戶端的請求數(shù)據(jù)。
4.2Multiple reactor threads :
采用多個 reactor ,每個 reactor 都在自己單獨的線程里執(zhí)行。如果是多核,則可以同時響應多個客戶端的請求。( Netty 采用的是類似這種方式,boss線程池就是多個mainReactor,worker線程池就是多個subReactor)
5、總結(jié)
本文分析了基于 NIO和 Reactor模式的網(wǎng)絡服務器設計方案,在后續(xù)的 blog中將結(jié)合 Netty進一步分析高性能網(wǎng)絡服務器的設計。
本文為原創(chuàng),轉(zhuǎn)載請注明出處