目錄
隨着計算機硬件性能不斷提高,服務器 CPU 的核數越來越越多,爲了充分利用多核 CPU 的處理能力,提升系統的處理效率和併發性能,多線程併發編程越來越顯得重要。無論是 C++ 還是 Java 編寫的網絡框架,大多數都是基於 Reactor 模式進行設計和開發,Reactor 模式基於事件驅動,特別適合處理海量的 I/O 事件,今天我們就簡單聊聊 Reactor 線程模型,主要內容分爲以下幾個部分:
- 經典的 I/O 通信模型;
- Reactor 線程模型詳述;
- Reactor 線程模型幾種模式;
- Netty Reactor 線程模型的實踐;
IO 通信模型
我們先要來談談 I/O 通信。說到 I/O 通信,往往會提到同步(synchronous)I/O 、異步(asynchronous)I/O、阻塞(blocking)I/O 和非阻塞(non-blocking)I/O 四種。有關同步、異步、阻塞和非阻塞的區別很多時候解釋不清楚,不同的人知識背景不同,對概念很難達成共識。本文討論的背景是 Linux 環境下的 Network I/O。
一次 I/O 過程分析
對於一次 Network I/O (以 read 舉例),它會涉及到兩個系統對象,一個是調用這個 I/O 的進程或線程,另一個就是系統內核 (kernel)。當一個 read 操作發生時,會經歷兩個階段(記住這兩個階段很重要,因爲不同 I/O 模型的區別就是在兩個階段上各有不同的處理):
- 第一個階段:等待數據準備 (Waiting for the data to be ready);
- 第二個階段:將數據從內核拷貝到進程中 (Copying the data from the kernel to the process);
五種 I/O 模型
Richard Stevens 的《UNIX® Network Programming Volume》提到了 5 種 I/O 模型:
- Blocking I/O (同步阻塞 I/O)
- Nonblocking I/O(同步非阻塞 I/O)
- I/O multiplexing(多路複用 I/O)
- Signal driven I/O(信號驅動 I/O,實際很少用,Java 不支持)
- Asynchronous I/O (異步 I/O)
接下來我們對這 5 種 I/O 模型進行說明和對比。
Blocking I/O
在 Linux 中,默認情況下所有的 Socket 都是 blocking 的,也就是阻塞的。一個典型的讀操作時,流程如圖:
當用戶進程調用了 recvfrom 這個系統調用, 這次 I/O 調用經歷如下 2 個階段:
- 準備數據: 對於網絡請求來說,很多時候數據在一開始還沒有到達(比如,還沒有收到一個完整的 UDP 包),這個時候 kernel 就要等待足夠的數據到來。而在用戶進程這邊,整個進程會被阻塞。
- 數據返回:kernel 一但等到數據準備好了,它就會將數據從 kernel 中拷貝到用戶內存,然後 kernel 返回結果,用戶進程才解除 block 的狀態,重新運行起來。
Nonblocking IO
Linux 下,可以通過設置 socket 使其變爲 non-blocking,也就是非阻塞。當對一個 non-blocking socket 執行讀操作時,流程如圖:
當用戶進程發出 read 操作具體過程分爲如下 3 個過程:
- 開始準備數據:如果 Kernel 中的數據還沒有準備好,那麼它並不會 block 用戶進程,而是立刻返回一個 error。
- 數據準備中: 從用戶進程角度講,它發起一個read操作後,並不需要等待,而是馬上就得到了一個結果。用戶進程判斷結果是一個 error 時,它就知道數據還沒有準備好,於是它可以再次發送 read 操作(重複輪訓)。
- 一旦 kernel 中的數據準備好了,並且又再次收到了用戶進程的 system call,那麼它馬上就將數據拷貝到了用戶內存,然後返回。
I/O multiplexing
這種 I/O 方式也可稱爲 event driven I/O。Linux select/epoll 的好處就在於單個 process 就可以同時處理多個網絡連接的 I/O。它的基本原理就是 select/epoll 會不斷的輪詢所負責的所有 socket,當某個 socket 有數據到達了,就通知用戶進程。流程如圖:
當用戶進程調用了 select:
- 整個進程會被 block,與此同時kernel 會 “監視” 所有 select 負責的 socket,當任何一個 socket 中的數據準備好了,select 就會返回。
- 戶進程再調用 read 操作,將數據從 kernel 拷貝到用戶進程。這時和 blocking I/O 的圖其實並沒有太大的不同,事實上,還更差一些。因爲這裏需要使用兩個 system call (select 和 recvfrom),而 blocking I/O 只調用了一個 system call (recvfrom)。
- 在 I/O multiplexing Model 中,實際中,對於每一個 socket,一般都設置成爲 non-blocking,但是,如上圖所示,整個用戶的 process 其實是一直被 block 的。只不過 process 是被 select 這個函數 block,而不是被 socket I/O 給 block。
Asynchronous IO
Linux 下的 asynchronous I/O,即異步 I/O,其實用得很少(需要高版本系統支持)。它的流程如圖:
當用戶進程發出 read 操作具體過程:
- 用戶進程發起 read 操作之後,並不需要等待,而是馬上就得到了一個結果,立刻就可以開始去做其它的事。
- 從 kernel 的角度,當它受到一個 asynchronous read 之後,首先它會立刻返回,所以不會對用戶進程產生任何 block。然後,kernel 會等待數據準備完成,然後將數據拷貝到用戶內存,當這一切都完成之後,kernel會給用戶進程發送一個 signal,告訴它 read 操作完成了。
通過以上 4 種 I/O 通信模型的說明,總結一下它們各自的特點:
- Blocking I/O 的特點就是在 I/O 執行的兩個階段都被 block 了。
- Non-blocking I/O 特點是如果 kernel 數據沒準備好不需要阻塞。
- I/O multiplexing 的優勢在於它用 select 可以同時處理多個 connection。(如果處理的連接數不是很高的話,使用 select/epoll 的 web server 不一定比使用 multi-threading + blocking I/O 的 web server 性能更好,可能延遲還更大。select/epoll 的優勢並不是對於單個連接能處理得更快,而是在於能處理更多的連接。)
- Asynchronous IO 的特點在於整個調用過程客戶端沒有任何 block 狀態,但是需要高版本的系統支持。
生活中通信模型
以上五種 I/0 模型的介紹,比如枯燥,其實在生活中也存在類似的 “通信模型”,爲了幫助理解,我們用生活中約妹紙吃飯這個不是很恰當的例子來說明這幾個 I/O Model(假設我現在要用微信叫幾個妹紙吃飯):
- 發個微信問第一個妹紙好了沒,妹子沒回復就一直等,直到回覆在發第二個 (blocking I/O)。
- 發個微信問第一個妹紙好了沒,妹子沒回復先不管,發給第二個,但是過會要繼續問之前 沒有回覆的妹紙有沒有好(nonblocking I/O)。
- 將所有妹紙拉一個微信羣,過會在羣裏問一次,誰好了回覆一下(I/O multiplexing)。
- 直接告訴妹紙吃飯的時間地址,好了自己去就行(Asynchronous I/O)。
Reactor 線程模型
Reactor 是什麼?
Reactor 是一種處理模式。 Reactor 模式是處理併發 I/O 比較常見的一種模式,用於同步 I/O,中心思想是將所有要處理的IO事件註冊到一箇中心 I/O 多路複用器上,同時主線程/進程阻塞在多路複用器上;一旦有 I/O 事件到來或是準備就緒(文件描述符或 socket 可讀、寫),多路複用器返回並將事先註冊的相應 I/O 事件分發到對應的處理器中。
Reactor 也是一種實現機制。 Reactor 利用事件驅動機制實現,和普通函數調用的不同之處在於:應用程序不是主動的調用某個 API 完成處理,而是恰恰相反,Reactor 逆置了事件處理流程,應用程序需要提供相應的接口並註冊到 Reactor 上,如果相應的事件發生,Reactor 將主動調用應用程序註冊的接口,這些接口又稱爲 “回調函數”。用 “好萊塢原則” 來形容 Reactor 再合適不過了:不要打電話給我們,我們會打電話通知你。
爲什麼要使用 Reactor?
一般來說通過 I/O 複用,epoll 模式已經可以使服務器併發幾十萬連接的同時,維持極高 TPS,爲什麼還需要 Reactor 模式?原因是原生的 I/O 複用編程複雜性比較高。
一個個網絡請求可能涉及到多個 I/O 請求,相比傳統的單線程完整處理請求生命期的方法,I/O 複用在人的大腦思維中並不自然,因爲,程序員編程中,處理請求 A 的時候,假定 A 請求必須經過多個 I/O 操作 A1-An(兩次 IO 間可能間隔很長時間),每經過一次 I/O 操作,再調用 I/O 複用時,I/O 複用的調用返回裏,非常可能不再有 A,而是返回了請求 B。即請求 A 會經常被請求 B 打斷,處理請求 B 時,又被 C 打斷。這種思維下,編程容易出錯。
Reactor 線程模型
Reactor 有三種線程模型,用戶能夠更加自己的環境選擇適當的模型。
- 單線程模型
- 多線程模型(單 Reactor)
- 多線程模型(多 Reactor)
單線程模式
單線程模式是最簡單的 Reactor 模型。Reactor 線程是個多面手,負責多路分離套接字,Accept 新連接,並分派請求到處理器鏈中。該模型適用於處理器鏈中業務處理組件能快速完成的場景。不過這種單線程模型不能充分利用多核資源,所以實際使用的不多。
多線程模式(單 Reactor)
該模型在事件處理器(Handler)鏈部分採用了多線程(線程池),也是後端程序常用的模型。
多線程模式(多 Reactor)
比起多線程單 Rector 模型,它是將 Reactor 分成兩部分,mainReactor 負責監聽並 Accept新連接,然後將建立的 socket 通過多路複用器(Acceptor)分派給subReactor。subReactor 負責多路分離已連接的 socket,讀寫網絡數據;業務處理功能,其交給 worker 線程池完成。通常,subReactor 個數上可與 CPU 個數等同。
Reactor 使用
軟件領域很多開源的產品使用了 Ractor 模型,比如 Netty。
Netty Reactor 實踐
服務端線程模型
服務端監聽線程和 I/O 線程分離,類似於 Reactor 的多線程模型,它的工作原理圖如下:
服務端用戶線程創建
- 創建服務端的時候實例化了 2 個 EventLoopGroup。bossGroup 線程組實際就是 Acceptor 線程池,負責處理客戶端的 TCP 連接請求。workerGroup 是真正負責 I/O 讀寫操作的線程組。通過這裏能夠知道 Netty 是多 Reactor 模型。
- ServerBootstrap 類是 Netty 用於啓動 NIO 的輔助類,能夠方便開發。通過 group 方法將線程組傳遞到 ServerBootstrap 中,設置 Channel 爲 NioServerSocketChannel,接着設置 NioServerSocketChannel 的 TCP 參數,最後綁定 I/O 事件處理類 ChildChannelHandler。
- 輔助類完成配置之後調用 bind 方法綁定監聽端口,Netty 返回 ChannelFuture,f.channel().closeFuture().sync() 對同步阻塞的獲取結果。
- 調用線程組 shutdownGracefully 優雅推出,釋放資源。
public class TimeServer {
public void bind(int port) {
// 配置服務端的NIO線程組
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workGroup).channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childHandler(new ChildChannelHandler());
// 綁定端口,同步等待成功
ChannelFuture f = b.bind(port).sync();
// 等待服務端監聽端口關閉
f.channel().closeFuture().sync();
} catch (Exception e) {
e.printStackTrace();
} finally {
// 釋放線程池資源
bossGroup.shutdownGracefully();
workGroup.shutdownGracefully();
}
}
private class ChildChannelHandler extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new TimeServerHandler());
}
}
}
服務端 I/O 線程處理(TimeServerHandler)
- exceptionCaught 方法: 當 I/O 處理髮生異常時被調用,關閉 ChannelHandlerContext,釋放資源。
- channelRead 方法: 是真正處理讀寫數據的方法,通過 buf.readBytes 讀取請求數據。通過 ctx.write(resp) 將相應報文發送給客戶端。
- channelReadComplete 方法: 爲了提高性能,Netty write 是將數據先寫到緩衝數組,通過 flush 方法可以將緩衝數組的所有消息發送到 SocketChannel 中。
public class TimeServerHandler extends ChannelHandlerAdapter {
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
throws Exception {
ctx.close();
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg)
throws Exception {
// msg轉Buf
ByteBuf buf = (ByteBuf) msg;
// 創建緩衝中字節數的字節數組
byte[] req = new byte[buf.readableBytes()];
// 寫入數組
buf.readBytes(req);
String body = new String(req, "UTF-8");
String currenTime = "QUERY TIME ORDER".equalsIgnoreCase(body) ? new Date(
System.currentTimeMillis()).toString() : "BAD ORDER";
// 將要返回的信息寫入Buffer
ByteBuf resp = Unpooled.copiedBuffer(currenTime.getBytes());
// buffer寫入通道
ctx.write(resp);
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
// write讀入緩衝數組後通過invoke flush寫入通道
ctx.flush();
}
}
總結
通過以上大概瞭解 Reactor 相關知識。最後做個總結一下使用 Reactor 模型的優缺點。
- 優點
- 響應快,雖然 Reactor 本身依然是同步的,不必爲單個同步時間所阻塞。
- 編程相對簡單,可以最大程度的避免複雜的多線程及同步問題,並且避免了多線程/進程的切換開銷。
- 可擴展性,通過併發編程的方式增加 Reactor 個數來充分利用 CPU 資源。
- 可複用性,Reactor 框架本身與具體事件處理邏輯無關,具有很高的複用性。
- 缺點
- 相比傳統的簡單模型,Reactor增加了一定的複雜性,因而有一定的門檻,調試相對複雜。
- Reactor 模式需要底層的 Synchronous Event Demultiplexer 支持,例如 Java 中的 Selector,操作系統的 select 系統調用支持。
- 單線程 Reactor 模式在 I/O 讀寫數據時還是在同一個線程中實現的,即使使用多 Reactor 機制的情況下,共享一個 Reactor 的 Channel 如果出現一個長時間的數據讀寫,會影響這個 Reactor 中其他 Channel 的相應時間,比如在大文件傳輸時,I/O 操作就會影響其他 Client 的相應時間,因而對這種操作,使用傳統的 Thread-Per-Connection 或許是一個更好的選擇,或則此時使用 Proactor 模式。
全文完
原文轉載至:https://zhuanlan.zhihu.com/p/35065251