Linux 的 IO 通信 以及 Reactor 線程模型淺析

目錄

隨着計算機硬件性能不斷提高,服務器 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 模型:

  1. Blocking I/O (同步阻塞 I/O)
  2. Nonblocking I/O(同步非阻塞 I/O)
  3. I/O multiplexing(多路複用 I/O)
  4. Signal driven I/O(信號驅動 I/O,實際很少用,Java 不支持)
  5. Asynchronous I/O (異步 I/O)

接下來我們對這 5 種 I/O 模型進行說明和對比。

Blocking I/O

在 Linux 中,默認情況下所有的 Socket 都是 blocking 的,也就是阻塞的。一個典型的讀操作時,流程如圖:



當用戶進程調用了 recvfrom 這個系統調用, 這次 I/O 調用經歷如下 2 個階段:

  1. 準備數據: 對於網絡請求來說,很多時候數據在一開始還沒有到達(比如,還沒有收到一個完整的 UDP 包),這個時候 kernel 就要等待足夠的數據到來。而在用戶進程這邊,整個進程會被阻塞。
  2. 數據返回:kernel 一但等到數據準備好了,它就會將數據從 kernel 中拷貝到用戶內存,然後 kernel 返回結果,用戶進程才解除 block 的狀態,重新運行起來。

Nonblocking IO

Linux 下,可以通過設置 socket 使其變爲 non-blocking,也就是非阻塞。當對一個 non-blocking socket 執行讀操作時,流程如圖:



當用戶進程發出 read 操作具體過程分爲如下 3 個過程:

  1. 開始準備數據:如果 Kernel 中的數據還沒有準備好,那麼它並不會 block 用戶進程,而是立刻返回一個 error。
  2. 數據準備中: 從用戶進程角度講,它發起一個read操作後,並不需要等待,而是馬上就得到了一個結果。用戶進程判斷結果是一個 error 時,它就知道數據還沒有準備好,於是它可以再次發送 read 操作(重複輪訓)。
  3. 一旦 kernel 中的數據準備好了,並且又再次收到了用戶進程的 system call,那麼它馬上就將數據拷貝到了用戶內存,然後返回。

I/O multiplexing

這種 I/O 方式也可稱爲 event driven I/O。Linux select/epoll 的好處就在於單個 process 就可以同時處理多個網絡連接的 I/O。它的基本原理就是 select/epoll 會不斷的輪詢所負責的所有 socket,當某個 socket 有數據到達了,就通知用戶進程。流程如圖:



當用戶進程調用了 select:

  1. 整個進程會被 block,與此同時kernel 會 “監視” 所有 select 負責的 socket,當任何一個 socket 中的數據準備好了,select 就會返回。
  2. 戶進程再調用 read 操作,將數據從 kernel 拷貝到用戶進程。這時和 blocking I/O 的圖其實並沒有太大的不同,事實上,還更差一些。因爲這裏需要使用兩個 system call (select 和 recvfrom),而 blocking I/O 只調用了一個 system call (recvfrom)。
  3. 在 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 操作具體過程:

  1. 用戶進程發起 read 操作之後,並不需要等待,而是馬上就得到了一個結果,立刻就可以開始去做其它的事。
  2. 從 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 有三種線程模型,用戶能夠更加自己的環境選擇適當的模型。

  1. 單線程模型
  2. 多線程模型(單 Reactor)
  3. 多線程模型(多 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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章