EventLoop 和 線程模型

關於 Reactor 的線程模型

首先我們來看一下 Reactor 的線程模型.
Reactor 的線程模型有三種:

  • 單線程模型
  • 多線程模型
  • 主從多線程模型

首先來看一下 單線程模型:

clipboard.png

所謂單線程, 即 acceptor 處理和 handler 處理都在一個線程中處理. 這個模型的壞處顯而易見: 當其中某個 handler 阻塞時, 會導致其他所有的 client 的 handler 都得不到執行, 並且更嚴重的是, handler 的阻塞也會導致整個服務不能接收新的 client 請求(因爲 acceptor 也被阻塞了). 因爲有這麼多的缺陷, 因此單線程 Reactor 模型用的比較少.

那麼什麼是多線程模型呢? Reactor 的多線程模型與單線程模型的區別就是 acceptor 是一個單獨的線程處理, 並且有一組特定的 NIO 線程來負責各個客戶端連接的 IO 操作. Reactor 多線程模型如下:

clipboard.png

Reactor 多線程模型 有如下特點:

  • 有專門一個線程, 即 Acceptor 線程用於監聽客戶端的TCP連接請求.
  • 客戶端連接的 IO 操作都是由一個特定的 NIO 線程池負責. 每個客戶端連接都與一個特定的 NIO 線程綁定, 因此在這個客戶端連接中的所有 IO 操作都是在同一個線程中完成的.
  • 客戶端連接有很多, 但是 NIO 線程數是比較少的, 因此一個 NIO 線程可以同時綁定到多個客戶端連接中.

接下來我們再來看一下 Reactor 的主從多線程模型.

一般情況下, Reactor 的多線程模式已經可以很好的工作了, 但是我們考慮一下如下情況: 如果我們的服務器需要同時處理大量的客戶端連接請求或我們需要在客戶端連接時, 進行一些權限的檢查, 那麼單線程的 Acceptor 很有可能就處理不過來, 造成了大量的客戶端不能連接到服務器.

Reactor 的主從多線程模型就是在這樣的情況下提出來的, 它的特點是: 服務器端接收客戶端的連接請求不再是一個線程, 而是由一個獨立的線程池組成. 它的線程模型如下:

clipboard.png

可以看到, Reactor 的主從多線程模型和 Reactor 多線程模型很類似, 只不過 Reactor 的主從多線程模型的 acceptor 使用了線程池來處理大量的客戶端請求.

NioEventLoopGroup 與 Reactor 線程模型的對應

我們介紹了三種 Reactor 的線程模型, 那麼它們和 NioEventLoopGroup 又有什麼關係呢? 其實, 不同的設置 NioEventLoopGroup 的方式就對應了不同的 Reactor 的線程模型.

單線程模型

來看一下下面的例子:

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup)
 .channel(NioServerSocketChannel.class)
 ...

注意, 我們實例化了一個 NioEventLoopGroup, 構造器參數是1, 表示 NioEventLoopGroup 的線程池大小是1.

然後接着我們調用 b.group(bossGroup) 設置了服務器端的 EventLoopGroup. 有些朋友可能會有疑惑: 我記得在啓動服務器端的 Netty 程序時, 是需要設置 bossGroupworkerGroup 的, 爲什麼這裏就只有一個 bossGroup?

其實很簡單, ServerBootstrap 重寫了 group 方法:

@Override
public ServerBootstrap group(EventLoopGroup group) {
    return group(group, group);
}

因此當傳入一個 group 時, 那麼 bossGroup 和 workerGroup 就是同一個 NioEventLoopGroup 了. 並且這個 NioEventLoopGroup 只有一個線程, 這樣就會導致 Netty 中的 acceptor 和後續的所有客戶端連接的 IO 操作都是在一個線程中處理的. 那麼對應到 Reactor 的線程模型中, 我們這樣設置 NioEventLoopGroup 時, 就相當於 Reactor 單線程模型.

多線程模型

同理, 再來看一下下面的例子:

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
 .channel(NioServerSocketChannel.class)
 ...

bossGroup 中只有一個線程, 而 workerGroup 中的線程是 CPU 核心數乘以2, 因此對應的到 Reactor 線程模型中, 我們知道, 這樣設置的 NioEventLoopGroup 其實就是 Reactor 多線程模型.

主從多線程模型

EventLoopGroup bossGroup = new NioEventLoopGroup(4);
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
 .channel(NioServerSocketChannel.class)
 ...

服務器端的 ServerSocketChannel 只綁定到了 bossGroup 中的一個線程, 因此在調用 Java NIO 的 Selector.select 處理客戶端的連接請求時, 實際上是在一個線程中的, 所以對只有一個服務的應用來說, bossGroup 設置多個線程是沒有什麼作用的, 反而還會造成資源浪費.

關於 bossGroup 與 workerGroup

bossGroup 是用於服務端的 accept, 即用於處理客戶端的連接請求. 我們可以把 Netty 比作一個飯店, bossGroup 就像一個像一個前臺接待, 當客戶來到飯店吃時, 接待員就會引導顧客就坐, 爲顧客端茶送水等.

而 workerGroup, 其實就是實際上幹活的, 它們負責客戶端連接通道的 IO 操作: 當接待員 招待好顧客後, 就可以稍做休息, 而此時後廚裏的廚師們(workerGroup)就開始忙碌地準備飯菜了.

關於 bossGroup 與 workerGroup 的關係, 我們可以用如下圖來展示:

clipboard.png

首先, 服務器端 bossGroup 不斷地監聽是否有客戶端的連接, 當發現有一個新的客戶端連接到來時, bossGroup 就會爲此連接初始化各項資源, 然後從 workerGroup 中選出一個 EventLoop 綁定到此客戶端連接中. 那麼接下來的服務器與客戶端的交互過程就全部在此分配的 EventLoop 中了.

NioEventLoop

NioEventLoop 繼承於 SingleThreadEventLoop, 而 SingleThreadEventLoop 又繼承於 SingleThreadEventExecutor. SingleThreadEventExecutor 是 Netty 中對本地線程的抽象, 它內部有一個 Thread thread 屬性, 存儲了一個本地 Java 線程. 因此我們可以認爲, 一個 NioEventLoop 其實和一個特定的線程綁定, 並且在其生命週期內, 綁定的線程都不會再改變.

NioEventLoop -> SingleThreadEventLoop -> SingleThreadEventExecutor -> AbstractScheduledEventExecutor

在 AbstractScheduledEventExecutor 中, Netty 實現了 NioEventLoop 的 schedule 功能, 即我們可以通過調用一個 NioEventLoop 實例的 schedule 方法來運行一些定時任務. 而在 SingleThreadEventLoop 中, 又實現了任務隊列的功能, 通過它, 我們可以調用一個 NioEventLoop 實例的 execute 方法來向任務隊列中添加一個 task, 並由 NioEventLoop 進行調度執行.

通常來說, NioEventLoop 肩負着兩種任務, 第一個是作爲 IO 線程, 執行與 Channel 相關的 IO 操作, 包括 調用 select 等待就緒的 IO 事件、讀寫數據與數據的處理等; 而第二個任務是作爲任務隊列, 執行 taskQueue 中的任務, 例如用戶調用 eventLoop.schedule 提交的定時任務也是這個線程執行的.

NioEventLoopGroup

  • EventLoopGroup(其實是MultithreadEventExecutorGroup) 內部維護一個類型爲 EventExecutor children 數組, 其大小是 nThreads, 這樣就構成了一個線程池
  • 如果我們在實例化 NioEventLoopGroup 時, 如果指定線程池大小, 則 nThreads 就是指定的值, 反之是處理器核心數 * 2
  • MultithreadEventExecutorGroup 中會調用 newChild 抽象方法來初始化 children 數組
  • 抽象方法 newChild 是在 NioEventLoopGroup 中實現的, 它返回一個 NioEventLoop 實例
NioEventLoopGroup 就像一個線程池, 負責爲每個新創建的 Channel 分配一個 EventLoop. 而 EventLoop 就是一個線程, 負責執行用戶任務和 IO 事件.

Netty 的任務隊列機制

我們已經提到過, 在Netty 中, 一個 NioEventLoop 通常需要肩負起兩種任務, 第一個是作爲 IO 線程, 處理 IO 操作; 第二個就是作爲任務線程, 處理 taskQueue 中的任務.

Task 的添加

普通 Runnable 任務

NioEventLoop 繼承於 SingleThreadEventExecutor, 而 SingleThreadEventExecutor 中有一個 Queue<Runnable> taskQueue 字段, 用於存放添加的 Task. 在 Netty 中, 每個 Task 都使用一個實現了 Runnable 接口的實例來表示.
例如當我們需要將一個 Runnable 添加到 taskQueue 中時, 我們可以進行如下操作:

EventLoop eventLoop = channel.eventLoop();
eventLoop.execute(new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello, Netty!");
    }
});

任務的執行

當一個任務被添加到 taskQueue 後, 它是怎麼被 EventLoop 執行的呢?
讓我們回到 NioEventLoop.run() 方法中, 在這個方法裏, 會分別調用 processSelectedKeys() 和 runAllTasks() 方法, 來進行 IO 事件的處理和 task 的處理.
runAllTasks 方法有兩個重載的方法, 一個是無參數的, 另一個有一個參數的. 首先來看一下無參數的 runAllTasks:

protected boolean runAllTasks() {
    fetchFromScheduledTaskQueue();
    Runnable task = pollTask();
    if (task == null) {
        return false;
    }

    for (;;) {
        try {
            task.run();
        } catch (Throwable t) {
            logger.warn("A task raised an exception.", t);
        }

        task = pollTask();
        if (task == null) {
            lastExecutionTime = ScheduledFutureTask.nanoTime();
            return true;
        }
    }
}

在此方法的一開始調用的 fetchFromScheduledTaskQueue() 其實就是將 scheduledTaskQueue 中已經可以執行的(即定時時間已到的 schedule 任務) 拿出來並添加到 taskQueue 中, 作爲可執行的 task 等待被調度執行.

private void fetchFromScheduledTaskQueue() {
    if (hasScheduledTasks()) {
        long nanoTime = AbstractScheduledEventExecutor.nanoTime();
        for (;;) {
            Runnable scheduledTask = pollScheduledTask(nanoTime);
            if (scheduledTask == null) {
                break;
            }
            taskQueue.add(scheduledTask);
        }
    }
}

接下來 runAllTasks() 方法就會不斷調用 task = pollTask() 從 taskQueue 中獲取一個可執行的 task, 然後調用它的 run() 方法來運行此 task.

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