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

閃電俠發表於2018-10-25

上兩篇博文(netty原始碼分析之揭開reactor執行緒的面紗(一)netty原始碼分析之揭開reactor執行緒的面紗(二))已經描述了netty的reactor執行緒前兩個步驟所處理的工作,在這裡,我們用這張圖片來回顧一下:

reactor執行緒三部曲

簡單總結一下reactor執行緒三部曲

  1. 輪詢出IO事件
  2. 處理IO事件
  3. 處理任務佇列

今天,我們要進行的是三部曲中的最後一曲【處理任務佇列】,也就是上面圖中的紫色部分。

讀完本篇文章,你將瞭解到netty的非同步task機制,定時任務的處理邏輯,這些細節可以更好地幫助你寫出netty應用

netty中的task的常見使用場景

我們取三種典型的task使用場景來分析

一. 使用者自定義普通任務

ctx.channel().eventLoop().execute(new Runnable() {
    @Override
    public void run() {
        //...
    }
});
複製程式碼

我們跟進execute方法,看重點

@Override
public void execute(Runnable task) {
    //...
    addTask(task);
    //...
}
複製程式碼

execute方法呼叫 addTask方法

protected void addTask(Runnable task) {
    // ...
    if (!offerTask(task)) {
        reject(task);
    }
}
複製程式碼

然後呼叫offerTask方法,如果offer失敗,那就呼叫reject方法,通過預設的 RejectedExecutionHandler 直接丟擲異常

final boolean offerTask(Runnable task) {
    // ...
    return taskQueue.offer(task);
}
複製程式碼

跟到offerTask方法,基本上task就落地了,netty內部使用一個taskQueue將task儲存起來,那麼這個taskQueue又是何方神聖?

我們檢視 taskQueue 定義的地方和被初始化的地方

private final Queue<Runnable> taskQueue;


taskQueue = newTaskQueue(this.maxPendingTasks);

@Override
protected Queue<Runnable> newTaskQueue(int maxPendingTasks) {
    // This event loop never calls takeTask()
    return PlatformDependent.newMpscQueue(maxPendingTasks);
}

複製程式碼

我們發現 taskQueue在NioEventLoop中預設是mpsc佇列,mpsc佇列,即多生產者單消費者佇列,netty使用mpsc,方便的將外部執行緒的task聚集,在reactor執行緒內部用單執行緒來序列執行,我們可以借鑑netty的任務執行模式來處理類似多執行緒資料上報,定時聚合的應用

在本節討論的任務場景中,所有程式碼的執行都是在reactor執行緒中的,所以,所有呼叫 inEventLoop() 的地方都返回true,既然都是在reactor執行緒中執行,那麼其實這裡的mpsc佇列其實沒有發揮真正的作用,mpsc大顯身手的地方其實在第二種場景

二. 非當前reactor執行緒呼叫channel的各種方法

// non reactor thread
channel.write(...)
複製程式碼

上面一種情況在push系統中比較常見,一般在業務執行緒裡面,根據使用者的標識,找到對應的channel引用,然後呼叫write類方法向該使用者推送訊息,就會進入到這種場景

關於channel.write()類方法的呼叫鏈,後面會單獨拉出一篇文章來深入剖析,這裡,我們只需要知道,最終write方法串至以下方法

AbstractChannelHandlerContext.java

private void write(Object msg, boolean flush, ChannelPromise promise) {
    // ...
    EventExecutor executor = next.executor();
    if (executor.inEventLoop()) {
        if (flush) {
            next.invokeWriteAndFlush(m, promise);
        } else {
            next.invokeWrite(m, promise);
        }
    } else {
        AbstractWriteTask task;
        if (flush) {
            task = WriteAndFlushTask.newInstance(next, m, promise);
        }  else {
            task = WriteTask.newInstance(next, m, promise);
        }
        safeExecute(executor, task, promise, m);
    }
}
複製程式碼

外部執行緒在呼叫write的時候,executor.inEventLoop()會返回false,直接進入到else分支,將write封裝成一個WriteTask(這裡僅僅是write而沒有flush,因此flush引數為false), 然後呼叫 safeExecute方法

private static void safeExecute(EventExecutor executor, Runnable runnable, ChannelPromise promise, Object msg) {
    // ...
    executor.execute(runnable);
    // ...
}
複製程式碼

接下來的呼叫鏈就進入到第一種場景了,但是和第一種場景有個明顯的區別就是,第一種場景的呼叫鏈的發起執行緒是reactor執行緒,第二種場景的呼叫鏈的發起執行緒是使用者執行緒,使用者執行緒可能會有很多個,顯然多個執行緒併發寫taskQueue可能出現執行緒同步問題,於是,這種場景下,netty的mpsc queue就有了用武之地

三. 使用者自定義定時任務

ctx.channel().eventLoop().schedule(new Runnable() {
    @Override
    public void run() {

    }
}, 60, TimeUnit.SECONDS);

複製程式碼

第三種場景就是定時任務邏輯了,用的最多的便是如上方法:在一定時間之後執行任務

我們跟進schedule方法

public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit) {
//...
    return schedule(new ScheduledFutureTask<Void>(
            this, command, null, ScheduledFutureTask.deadlineNanos(unit.toNanos(delay))));
} 
複製程式碼

通過 ScheduledFutureTask, 將使用者自定義任務再次包裝成一個netty內部的任務

<V> ScheduledFuture<V> schedule(final ScheduledFutureTask<V> task) {
    // ...
    scheduledTaskQueue().add(task);
    // ...
    return task;
}
複製程式碼

到了這裡,我們有點似曾相識,在非定時任務的處理中,netty通過一個mpsc佇列將任務落地,這裡,是否也有一個類似的佇列來承載這類定時任務呢?帶著這個疑問,我們繼續向前

Queue<ScheduledFutureTask<?>> scheduledTaskQueue() {
    if (scheduledTaskQueue == null) {
        scheduledTaskQueue = new PriorityQueue<ScheduledFutureTask<?>>();
    }
    return scheduledTaskQueue;
}
複製程式碼

果不其然,scheduledTaskQueue() 方法,會返回一個優先順序佇列,然後呼叫 add 方法將定時任務加入到佇列中去,但是,這裡為什麼要使用優先順序佇列,而不需要考慮多執行緒的併發?

因為我們現在討論的場景,呼叫鏈的發起方是reactor執行緒,不會存在多執行緒併發這些問題

但是,萬一有的使用者在reactor之外執行定時任務呢?雖然這類場景很少見,但是netty作為一個無比健壯的高效能io框架,必須要考慮到這種情況。

對此,netty的處理是,如果是在外部執行緒呼叫schedule,netty將新增定時任務的邏輯封裝成一個普通的task,這個task的任務是新增[新增定時任務]的任務,而不是新增定時任務,其實也就是第二種場景,這樣,對 PriorityQueue的訪問就變成單執行緒,即只有reactor執行緒

完整的schedule方法

<V> ScheduledFuture<V> schedule(final ScheduledFutureTask<V> task) {
    if (inEventLoop()) {
        scheduledTaskQueue().add(task);
    } else {
        // 進入到場景二,進一步封裝任務
        execute(new Runnable() {
            @Override
            public void run() {
                scheduledTaskQueue().add(task);
            }
        });
    }
    return task;
}
複製程式碼

在閱讀原始碼細節的過程中,我們應該多問幾個為什麼?這樣會有利於看原始碼的時候不至於犯困!比如這裡,為什麼定時任務要儲存在優先順序佇列中,我們可以先不看原始碼,來思考一下優先順序對列的特性

優先順序佇列按一定的順序來排列內部元素,內部元素必須是可以比較的,聯絡到這裡每個元素都是定時任務,那就說明定時任務是可以比較的,那麼到底有哪些地方可以比較?

每個任務都有一個下一次執行的截止時間,截止時間是可以比較的,截止時間相同的情況下,任務新增的順序也是可以比較的,就像這樣,閱讀原始碼的過程中,一定要多和自己對話,多問幾個為什麼

帶著猜想,我們研究與一下ScheduledFutureTask,抽取出關鍵部分

final class ScheduledFutureTask<V> extends PromiseTask<V> implements ScheduledFuture<V> {
    private static final AtomicLong nextTaskId = new AtomicLong();
    private static final long START_TIME = System.nanoTime();

    static long nanoTime() {
        return System.nanoTime() - START_TIME;
    }

    private final long id = nextTaskId.getAndIncrement();
    /* 0 - no repeat, >0 - repeat at fixed rate, <0 - repeat with fixed delay */
    private final long periodNanos;

    @Override
    public int compareTo(Delayed o) {
        //...
    }

    // 精簡過的程式碼
    @Override
    public void run() {
    }
複製程式碼

這裡,我們一眼就找到了compareTo 方法,cmd+u跳轉到實現的介面,發現就是Comparable介面

public int compareTo(Delayed o) {
    if (this == o) {
        return 0;
    }

    ScheduledFutureTask<?> that = (ScheduledFutureTask<?>) o;
    long d = deadlineNanos() - that.deadlineNanos();
    if (d < 0) {
        return -1;
    } else if (d > 0) {
        return 1;
    } else if (id < that.id) {
        return -1;
    } else if (id == that.id) {
        throw new Error();
    } else {
        return 1;
    }
}
複製程式碼

進入到方法體內部,我們發現,兩個定時任務的比較,確實是先比較任務的截止時間,截止時間相同的情況下,再比較id,即任務新增的順序,如果id再相同的話,就拋Error

這樣,在執行定時任務的時候,就能保證最近截止時間的任務先執行

下面,我們再來看下netty是如何來保證各種定時任務的執行的,netty裡面的定時任務分以下三種

1.若干時間後執行一次 2.每隔一段時間執行一次 3.每次執行結束,隔一定時間再執行一次

netty使用一個 periodNanos 來區分這三種情況,正如netty的註釋那樣

/* 0 - no repeat, >0 - repeat at fixed rate, <0 - repeat with fixed delay */
private final long periodNanos;
複製程式碼

瞭解這些背景之後,我們來看下netty是如何來處理這三種不同型別的定時任務的

public void run() {
    if (periodNanos == 0) {
        V result = task.call();
        setSuccessInternal(result);
    } else { 
        task.call();
        long p = periodNanos;
        if (p > 0) {
            deadlineNanos += p;
        } else {
            deadlineNanos = nanoTime() - p;
        }
            scheduledTaskQueue.add(this);
        }
    }
}
複製程式碼

if (periodNanos == 0) 對應 若干時間後執行一次 的定時任務型別,執行完了該任務就結束了。

否則,進入到else程式碼塊,先執行任務,然後再區分是哪種型別的任務,periodNanos大於0,表示是以固定頻率執行某個任務,和任務的持續時間無關,然後,設定該任務的下一次截止時間為本次的截止時間加上間隔時間periodNanos,否則,就是每次任務執行完畢之後,間隔多長時間之後再次執行,截止時間為當前時間加上間隔時間,-p就表示加上一個正的間隔時間,最後,將當前任務物件再次加入到佇列,實現任務的定時執行

netty內部的任務新增機制瞭解地差不多之後,我們就可以檢視reactor第三部曲是如何來排程這些任務的

reactor執行緒task的排程

首先,我們將目光轉向最外層的外觀程式碼

runAllTasks(long timeoutNanos);
複製程式碼

顧名思義,這行程式碼表示了儘量在一定的時間內,將所有的任務都取出來run一遍。timeoutNanos 表示該方法最多執行這麼長時間,netty為什麼要這麼做?我們可以想一想,reactor執行緒如果在此停留的時間過長,那麼將積攢許多的IO事件無法處理(見reactor執行緒的前面兩個步驟),最終導致大量客戶端請求阻塞,因此,預設情況下,netty將控制內部佇列的執行時間

好,我們繼續跟進

protected boolean runAllTasks(long timeoutNanos) {
    fetchFromScheduledTaskQueue();
    Runnable task = pollTask();
    //...

    final long deadline = ScheduledFutureTask.nanoTime() + timeoutNanos;
    long runTasks = 0;
    long lastExecutionTime;
    for (;;) {
        safeExecute(task);
        runTasks ++;
        if ((runTasks & 0x3F) == 0) {
            lastExecutionTime = ScheduledFutureTask.nanoTime();
            if (lastExecutionTime >= deadline) {
                break;
            }
        }

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

    afterRunningAllTasks();
    this.lastExecutionTime = lastExecutionTime;
    return true;
}
複製程式碼

這段程式碼便是reactor執行task的所有邏輯,可以拆解成下面幾個步驟

  1. 從scheduledTaskQueue轉移定時任務到taskQueue(mpsc queue)
  2. 計算本次任務迴圈的截止時間
  3. 執行任務
  4. 收尾

按照這個步驟,我們一步步來分析下

從scheduledTaskQueue轉移定時任務到taskQueue(mpsc queue)

首先呼叫 fetchFromScheduledTaskQueue()方法,將到期的定時任務轉移到mpsc queue裡面

private boolean fetchFromScheduledTaskQueue() {
    long nanoTime = AbstractScheduledEventExecutor.nanoTime();
    Runnable scheduledTask  = pollScheduledTask(nanoTime);
    while (scheduledTask != null) {
        if (!taskQueue.offer(scheduledTask)) {
            // No space left in the task queue add it back to the scheduledTaskQueue so we pick it up again.
            scheduledTaskQueue().add((ScheduledFutureTask<?>) scheduledTask);
            return false;
        }
        scheduledTask  = pollScheduledTask(nanoTime);
    }
    return true;
}
複製程式碼

可以看到,netty在把任務從scheduledTaskQueue轉移到taskQueue的時候還是非常小心的,當taskQueue無法offer的時候,需要把從scheduledTaskQueue裡面取出來的任務重新新增回去

從scheduledTaskQueue從拉取一個定時任務的邏輯如下,傳入的引數nanoTime為當前時間(其實是當前納秒減去ScheduledFutureTask類被載入的納秒個數)

protected final Runnable pollScheduledTask(long nanoTime) {
    assert inEventLoop();

    Queue<ScheduledFutureTask<?>> scheduledTaskQueue = this.scheduledTaskQueue;
    ScheduledFutureTask<?> scheduledTask = scheduledTaskQueue == null ? null : scheduledTaskQueue.peek();
    if (scheduledTask == null) {
        return null;
    }

    if (scheduledTask.deadlineNanos() <= nanoTime) {
        scheduledTaskQueue.remove();
        return scheduledTask;
    }
    return null;
}
複製程式碼

可以看到,每次 pollScheduledTask 的時候,只有在當前任務的截止時間已經到了,才會取出來

計算本次任務迴圈的截止時間

     Runnable task = pollTask();
     //...
    final long deadline = ScheduledFutureTask.nanoTime() + timeoutNanos;
    long runTasks = 0;
    long lastExecutionTime;
複製程式碼

這一步將取出第一個任務,用reactor執行緒傳入的超時時間 timeoutNanos 來計算出當前任務迴圈的deadline,並且使用了runTaskslastExecutionTime來時刻記錄任務的狀態

迴圈執行任務

for (;;) {
    safeExecute(task);
    runTasks ++;
    if ((runTasks & 0x3F) == 0) {
        lastExecutionTime = ScheduledFutureTask.nanoTime();
        if (lastExecutionTime >= deadline) {
            break;
        }
    }

    task = pollTask();
    if (task == null) {
        lastExecutionTime = ScheduledFutureTask.nanoTime();
        break;
    }
}
複製程式碼

這一步便是netty裡面執行所有任務的核心程式碼了。 首先呼叫safeExecute來確保任務安全執行,忽略任何異常

protected static void safeExecute(Runnable task) {
    try {
        task.run();
    } catch (Throwable t) {
        logger.warn("A task raised an exception. Task: {}", task, t);
    }
}
複製程式碼

然後將已執行任務 runTasks 加一,每隔0x3F任務,即每執行完64個任務之後,判斷當前時間是否超過本次reactor任務迴圈的截止時間了,如果超過,那就break掉,如果沒有超過,那就繼續執行。可以看到,netty對效能的優化考慮地相當的周到,假設netty任務佇列裡面如果有海量小任務,如果每次都要執行完任務都要判斷一下是否到截止時間,那麼效率是比較低下的

收尾

afterRunningAllTasks();
this.lastExecutionTime = lastExecutionTime;
複製程式碼

收尾工作很簡單,呼叫一下 afterRunningAllTasks 方法

@Override
protected void afterRunningAllTasks() {
        runAllTasksFrom(tailTasks);
}
複製程式碼

NioEventLoop可以通過父類SingleTheadEventLoopexecuteAfterEventLoopIteration方法向tailTasks中新增收尾任務,比如,你想統計一下一次執行一次任務迴圈花了多長時間就可以呼叫此方法

public final void executeAfterEventLoopIteration(Runnable task) {
        // ...
        if (!tailTasks.offer(task)) {
            reject(task);
        }
        //...
}
複製程式碼

this.lastExecutionTime = lastExecutionTime;簡單記錄一下任務執行的時間,搜了一下該field的引用,發現這個field並沒有使用過,只是每次不停地賦值,賦值,賦值...,改天再去向netty官方提個issue...

reactor執行緒第三曲到了這裡基本上就給你講完了,如果你讀到這覺得很輕鬆,那麼恭喜你,你對netty的task機制已經非常比較熟悉了,也恭喜一下我,把這些機制給你將清楚了。我們最後再來一次總結,以tips的方式

  • 當前reactor執行緒呼叫當前eventLoop執行任務,直接執行,否則,新增到任務佇列稍後執行
  • netty內部的任務分為普通任務和定時任務,分別落地到MpscQueue和PriorityQueue
  • netty每次執行任務迴圈之前,會將已經到期的定時任務從PriorityQueue轉移到MpscQueue
  • netty每隔64個任務檢查一下是否該退出任務迴圈

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

相關文章