netty原始碼分析之揭開reactor執行緒的面紗(一)

閃電俠發表於2018-10-22

netty最核心的就是reactor執行緒,對應專案中使用廣泛的NioEventLoop,那麼NioEventLoop裡面到底在幹些什麼事?netty是如何保證事件迴圈的高效輪詢和任務的及時執行?又是如何來優雅地fix掉jdk的nio bug?帶著這些疑問,本篇文章將庖丁解牛,帶你逐步瞭解netty reactor執行緒的真相[原始碼基於4.1.6.Final]

reactor 執行緒的啟動

NioEventLoop的run方法是reactor執行緒的主體,在第一次新增任務的時候被啟動

NioEventLoop 父類 SingleThreadEventExecutor 的execute方法

@Override
public void execute(Runnable task) {
    ...
    boolean inEventLoop = inEventLoop();
    if (inEventLoop) {
        addTask(task);
    } else {
        startThread();
        addTask(task);
        ...
    }
    ...
}
複製程式碼

外部執行緒在往任務佇列裡面新增任務的時候執行 startThread() ,netty會判斷reactor執行緒有沒有被啟動,如果沒有被啟動,那就啟動執行緒再往任務佇列裡面新增任務

private void startThread() {
    if (STATE_UPDATER.get(this) == ST_NOT_STARTED) {
        if (STATE_UPDATER.compareAndSet(this, ST_NOT_STARTED, ST_STARTED)) {
            doStartThread();
        }
    }
}
複製程式碼

SingleThreadEventExecutor 在執行doStartThread的時候,會呼叫內部執行器executor的execute方法,將呼叫NioEventLoop的run方法的過程封裝成一個runnable塞到一個執行緒中去執行

private void doStartThread() {
    ...
    executor.execute(new Runnable() {
        @Override
        public void run() {
            thread = Thread.currentThread();
            ...
                SingleThreadEventExecutor.this.run();
            ...
        }
    }
}
複製程式碼

該執行緒就是executor建立,對應netty的reactor執行緒實體。executor 預設是ThreadPerTaskExecutor

預設情況下,ThreadPerTaskExecutor 在每次執行execute 方法的時候都會通過DefaultThreadFactory建立一個FastThreadLocalThread執行緒,而這個執行緒就是netty中的reactor執行緒實體

ThreadPerTaskExecutor

public void execute(Runnable command) {
    threadFactory.newThread(command).start();
}
複製程式碼

關於為啥是 ThreadPerTaskExecutorDefaultThreadFactory的組合來new一個FastThreadLocalThread,這裡就不再詳細描述,通過下面幾段程式碼來簡單說明

標準的netty程式會呼叫到NioEventLoopGroup的父類MultithreadEventExecutorGroup的如下程式碼

protected MultithreadEventExecutorGroup(int nThreads, Executor executor,
                                        EventExecutorChooserFactory chooserFactory, Object... args) {
    if (executor == null) {
        executor = new ThreadPerTaskExecutor(newDefaultThreadFactory());
    }
}
複製程式碼

然後通過newChild的方式傳遞給NioEventLoop

@Override
protected EventLoop newChild(Executor executor, Object... args) throws Exception {
    return new NioEventLoop(this, executor, (SelectorProvider) args[0],
        ((SelectStrategyFactory) args[1]).newSelectStrategy(), (RejectedExecutionHandler) args[2]);
}
複製程式碼

關於reactor執行緒的建立和啟動就先講這麼多,我們總結一下:netty的reactor執行緒在新增一個任務的時候被建立,該執行緒實體為 FastThreadLocalThread(這玩意以後會開篇文章重點講講),最後執行緒執行主體為NioEventLooprun方法。

reactor 執行緒的執行

那麼下面我們就重點剖析一下 NioEventLoop 的run方法

@Override
protected void run() {
    for (;;) {
        try {
            switch (selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())) {
                case SelectStrategy.CONTINUE:
                    continue;
                case SelectStrategy.SELECT:
                    select(wakenUp.getAndSet(false));
                    if (wakenUp.get()) {
                        selector.wakeup();
                    }
                default:
                    // fallthrough
            }
            processSelectedKeys();
            runAllTasks(...);
            }
        } catch (Throwable t) {
            handleLoopException(t);
        }
        ...
    }
複製程式碼

我們抽取出主幹,reactor執行緒做的事情其實很簡單,用下面一幅圖就可以說明

reactor action

reactor執行緒大概做的事情分為對三個步驟不斷迴圈

1.首先輪詢註冊到reactor執行緒對用的selector上的所有的channel的IO事件

select(wakenUp.getAndSet(false));
if (wakenUp.get()) {
    selector.wakeup();
}
複製程式碼

2.處理產生網路IO事件的channel

processSelectedKeys();
複製程式碼

3.處理任務佇列

runAllTasks(...);
複製程式碼

下面對每個步驟詳細說明

select操作

select(wakenUp.getAndSet(false));
if (wakenUp.get()) {
      selector.wakeup();
}
複製程式碼

wakenUp 表示是否應該喚醒正在阻塞的select操作,可以看到netty在進行一次新的loop之前,都會將wakeUp 被設定成false,標誌新的一輪loop的開始,具體的select操作我們也拆分開來看

1.定時任務截止事時間快到了,中斷本次輪詢

int selectCnt = 0;
long currentTimeNanos = System.nanoTime();
long selectDeadLineNanos = currentTimeNanos + delayNanos(currentTimeNanos);

for (;;) {
    long timeoutMillis = (selectDeadLineNanos - currentTimeNanos + 500000L) / 1000000L;
    if (timeoutMillis <= 0) {
        if (selectCnt == 0) {
            selector.selectNow();
            selectCnt = 1;
        }
        break;
    }
    ....
}
複製程式碼

我們可以看到,NioEventLoop中reactor執行緒的select操作也是一個for迴圈,在for迴圈第一步中,如果發現當前的定時任務佇列中有任務的截止事件快到了(<=0.5ms),就跳出迴圈。此外,跳出之前如果發現目前為止還沒有進行過select操作(if (selectCnt == 0)),那麼就呼叫一次selectNow(),該方法會立即返回,不會阻塞

這裡說明一點,netty裡面定時任務佇列是按照延遲時間從小到大進行排序, delayNanos(currentTimeNanos)方法即取出第一個定時任務的延遲時間

protected long delayNanos(long currentTimeNanos) {
    ScheduledFutureTask<?> scheduledTask = peekScheduledTask();
    if (scheduledTask == null) {
        return SCHEDULE_PURGE_INTERVAL;
    }
    return scheduledTask.delayNanos(currentTimeNanos);
 }
複製程式碼

關於netty的任務佇列(包括普通任務,定時任務,tail task)相關的細節後面會另起一片文章,這裡不過多展開

2.輪詢過程中發現有任務加入,中斷本次輪詢

for (;;) {
    // 1.定時任務截至事時間快到了,中斷本次輪詢
    ...
    // 2.輪詢過程中發現有任務加入,中斷本次輪詢
    if (hasTasks() && wakenUp.compareAndSet(false, true)) {
        selector.selectNow();
        selectCnt = 1;
        break;
    }
    ....
}
複製程式碼

netty為了保證任務佇列能夠及時執行,在進行阻塞select操作的時候會判斷任務佇列是否為空,如果不為空,就執行一次非阻塞select操作,跳出迴圈

3.阻塞式select操作

for (;;) {
    // 1.定時任務截至事時間快到了,中斷本次輪詢
    ...
    // 2.輪詢過程中發現有任務加入,中斷本次輪詢
    ...
    // 3.阻塞式select操作
    int selectedKeys = selector.select(timeoutMillis);
    selectCnt ++;
    if (selectedKeys != 0 || oldWakenUp || wakenUp.get() || hasTasks() || hasScheduledTasks()) {
        break;
    }
    ....
}
複製程式碼

執行到這一步,說明netty任務佇列裡面佇列為空,並且所有定時任務延遲時間還未到(大於0.5ms),於是,在這裡進行一次阻塞select操作,截止到第一個定時任務的截止時間

這裡,我們可以問自己一個問題,如果第一個定時任務的延遲非常長,比如一個小時,那麼有沒有可能執行緒一直阻塞在select操作,當然有可能!But,只要在這段時間內,有新任務加入,該阻塞就會被釋放

外部執行緒呼叫execute方法新增任務

@Override
public void execute(Runnable task) { 
    ...
    wakeup(inEventLoop); // inEventLoop為false
    ...
}
複製程式碼

呼叫wakeup方法喚醒selector阻塞

protected void wakeup(boolean inEventLoop) {
    if (!inEventLoop && wakenUp.compareAndSet(false, true)) {
        selector.wakeup();
    }
}
複製程式碼

可以看到,在外部執行緒新增任務的時候,會呼叫wakeup方法來喚醒 selector.select(timeoutMillis)

阻塞select操作結束之後,netty又做了一系列的狀態判斷來決定是否中斷本次輪詢,中斷本次輪詢的條件有

  • 輪詢到IO事件 (selectedKeys != 0
  • oldWakenUp 引數為true
  • 任務佇列裡面有任務(hasTasks
  • 第一個定時任務即將要被執行 (hasScheduledTasks()
  • 使用者主動喚醒(wakenUp.get()

4.解決jdk的nio bug

關於該bug的描述見 bugs.java.com/bugdatabase…

該bug會導致Selector一直空輪詢,最終導致cpu 100%,nio server不可用,嚴格意義上來說,netty沒有解決jdk的bug,而是通過一種方式來巧妙地避開了這個bug,具體做法如下

long currentTimeNanos = System.nanoTime();
for (;;) {
    // 1.定時任務截止事時間快到了,中斷本次輪詢
    ...
    // 2.輪詢過程中發現有任務加入,中斷本次輪詢
    ...
    // 3.阻塞式select操作
    selector.select(timeoutMillis);
    // 4.解決jdk的nio bug
    long time = System.nanoTime();
    if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {
        selectCnt = 1;
    } else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&
            selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {

        rebuildSelector();
        selector = this.selector;
        selector.selectNow();
        selectCnt = 1;
        break;
    }
    currentTimeNanos = time; 
    ...
 }
複製程式碼

netty 會在每次進行 selector.select(timeoutMillis) 之前記錄一下開始時間currentTimeNanos,在select之後記錄一下結束時間,判斷select操作是否至少持續了timeoutMillis秒(這裡將time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos改成time - currentTimeNanos >= TimeUnit.MILLISECONDS.toNanos(timeoutMillis)或許更好理解一些), 如果持續的時間大於等於timeoutMillis,說明就是一次有效的輪詢,重置selectCnt標誌,否則,表明該阻塞方法並沒有阻塞這麼長時間,可能觸發了jdk的空輪詢bug,當空輪詢的次數超過一個閥值的時候,預設是512,就開始重建selector

空輪詢閥值相關的設定程式碼如下

int selectorAutoRebuildThreshold = SystemPropertyUtil.getInt("io.netty.selectorAutoRebuildThreshold", 512);
if (selectorAutoRebuildThreshold < MIN_PREMATURE_SELECTOR_RETURNS) {
    selectorAutoRebuildThreshold = 0;
}
SELECTOR_AUTO_REBUILD_THRESHOLD = selectorAutoRebuildThreshold;
複製程式碼

下面我們簡單描述一下netty 通過rebuildSelector來fix空輪詢bug的過程,rebuildSelector的操作其實很簡單:new一個新的selector,將之前註冊到老的selector上的的channel重新轉移到新的selector上。我們抽取完主要程式碼之後的骨架如下

public void rebuildSelector() {
    final Selector oldSelector = selector;
    final Selector newSelector;
    newSelector = openSelector();

    int nChannels = 0;
     try {
        for (;;) {
                for (SelectionKey key: oldSelector.keys()) {
                    Object a = key.attachment();
                     if (!key.isValid() || key.channel().keyFor(newSelector) != null) {
                         continue;
                     }
                     int interestOps = key.interestOps();
                     key.cancel();
                     SelectionKey newKey = key.channel().register(newSelector, interestOps, a);
                     if (a instanceof AbstractNioChannel) {
                         ((AbstractNioChannel) a).selectionKey = newKey;
                      }
                     nChannels ++;
                }
                break;
        }
    } catch (ConcurrentModificationException e) {
        // Probably due to concurrent modification of the key set.
        continue;
    }
    selector = newSelector;
    oldSelector.close();
}
複製程式碼

首先,通過openSelector()方法建立一個新的selector,然後執行一個死迴圈,只要執行過程中出現過一次併發修改selectionKeys異常,就重新開始轉移

具體的轉移步驟為

  1. 拿到有效的key
  2. 取消該key在舊的selector上的事件註冊
  3. 將該key對應的channel註冊到新的selector上
  4. 重新繫結channel和新的key的關係

轉移完成之後,就可以將原有的selector廢棄,後面所有的輪詢都是在新的selector進行

最後,我們總結reactor執行緒select步驟做的事情:不斷地輪詢是否有IO事件發生,並且在輪詢的過程中不斷檢查是否有定時任務和普通任務,保證了netty的任務佇列中的任務得到有效執行,輪詢過程順帶用一個計數器避開了了jdk空輪詢的bug,過程清晰明瞭

由於篇幅原因,下面兩個過程將分別放到一篇文章中去講述,盡請期待

process selected keys

未完待續

run tasks

未完待續

最後,通過文章開頭一副圖,我們再次熟悉一下netty的reactor執行緒做的事兒

reactor action

  1. 輪詢IO事件
  2. 處理輪詢到的事件
  3. 執行任務佇列中的任務

如果你想系統地學Netty,我的小冊《Netty 入門與實戰:仿寫微信 IM 即時通訊系統》可以幫助你,如果你想系統學習Netty原理,那麼你一定不要錯過我的Netty原始碼分析系列視訊:coding.imooc.com/class/230.h…

相關文章