本文主要討論Netty NioEventLoop原理及實踐,關於Netty NioEventLoop,首先要知道NioEventLoop是什麼,爲什麼它會是Netty核心Reactor處理器,實現原理是什麼,進而再討論Netty對其的實現及使用上我們可以學到哪些。
EventLoop是一個Reactor模型的事件處理器,一個EventLoop對應一個線程,其內部會維護一個selector和taskQueue,負責處理客戶端請求和內部任務,內部任務如ServerSocketChannel註冊、ServerSocket綁定和延時任務處理等操作。
EventLoop是由事件驅動的,比如IO事件和任務等,IO事件即selectionKey中ready的事件,如accept、connect、read、write
等,由processSelectedKeys方法觸發。處理完請求時間之後,會處理內部添加到taskQueue中的任務,如register0、bind0
等任務,由runAllTasks方法觸發。注意NioEventLoop在Linux中默認底層是基於epoll機制。
上圖是EventLoop的核心流程圖,如果從Netty整體視角看EventLoop的事件流轉,下圖來的更直觀:
注意:bossGroup和WorkerGroup中的NioEventLoop流程是一致的,只不過前者處理Accept事件之後將連接註冊到後者,由後者處理該連接上後續的讀寫事件。
大致瞭解了NioEventLoop之後,不知道有沒有小夥伴有這樣的疑問,爲什麼Netty要這樣實現呢,這種實現方案對於我們後續開發如何借鑑呢?關於這些疑問,本文最後討論哈 :)
EventLoop實現原理
EventLoop是一個Reactor模型的事件處理器,一個EventLoop對應一個線程,其內部會維護一個selector和taskQueue,負責處理IO事件和內部任務。IO事件和內部任務執行時間百分比通過ioRatio來調節,ioRatio表示執行IO時間所佔百分比。任務包括普通任務和已經到時的延遲任務,延遲任務存放到一個優先級隊列PriorityQueue中,執行任務前從PriorityQueue讀取所有到時的task,然後添加到taskQueue中,最後統一執行task。
事件處理機制
EventLoop是由事件驅動的,比如IO事件即selectionKey中ready的事件,如accept、connect、read、write
等,處理的核心邏輯主要是在NioEventLoop.run
方法中,流程如下:
protected void run() {
for (;;) {
/* 如果hasTasks,則調用selector.selectNow(),非阻塞方式獲取channel事件,沒有channel事件時可能返回爲0。這裏用非阻塞方式是爲了儘快獲取連接事件,然後處理連接事件和內部任務。*/
switch (selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())) {
case SelectStrategy.CONTINUE:
continue;
case SelectStrategy.SELECT:
select(wakenUp.getAndSet(false));
if (wakenUp.get()) {
selector.wakeup();
}
default:
}
cancelledKeys = 0;
needsToSelectAgain = false;
/* ioRatio調節連接事件和內部任務執行事件百分比
* ioRatio越大,連接事件處理佔用百分比越大 */
final int ioRatio = this.ioRatio;
if (ioRatio == 100) {
try {
processSelectedKeys();
} finally {
runAllTasks();
}
} else {
final long ioStartTime = System.nanoTime();
try {
processSelectedKeys();
} finally {
final long ioTime = System.nanoTime() - ioStartTime;
runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
}
}
}
}
從代碼上,在執行select()
前有一個hasTasks()
的操作,這個hasTasks()
方法判斷當前taskQueue是否有元素。如果taskQueue中有元素,執行 selectNow() 方法,最終執行selector.selectNow()
,該方法會立即返回,保證了EventLoop在有任務執行時不會因爲IO事件遲遲不來造成延後處理,這裏優先處理IO事件,然後再處理任務。
如果當前taskQueue沒有任務時,就會執行select(wakenUp.getAndSet(false))
方法,代碼如下:
/* 這個方法解決了Nio中臭名昭著的bug:selector的select方法導致空輪詢 cpu100% */
private void select(boolean oldWakenUp) throws IOException {
Selector selector = this.selector;
try {
int selectCnt = 0;
long currentTimeNanos = System.nanoTime();
/* delayNanos(currentTimeNanos):計算延遲任務隊列中第一個任務的到期執行時間(即最晚還能延遲多長時間執行),默認返回1s。每個SingleThreadEventExecutor都持有一個延遲執行任務的優先隊列PriorityQueue,啓動線程時,往隊列中加入一個任務。*/
long selectDeadLineNanos = currentTimeNanos + delayNanos(currentTimeNanos);
for (;;) {
/* 如果延遲任務隊列中第一個任務的最晚還能延遲執行的時間小於500000納秒,且selectCnt == 0(selectCnt 用來記錄selector.select方法的執行次數和標識是否執行過selector.selectNow()),則執行selector.selectNow()方法並立即返回。*/
long timeoutMillis = (selectDeadLineNanos - currentTimeNanos + 500000L) / 1000000L;
if (timeoutMillis <= 0) {
if (selectCnt == 0) {
selector.selectNow();
selectCnt = 1;
}
break;
}
if (hasTasks() && wakenUp.compareAndSet(false, true)) {
selector.selectNow();
selectCnt = 1;
break;
}
// 超時阻塞select
int selectedKeys = selector.select(timeoutMillis);
selectCnt ++;
System.out.println(selectCnt);
// 有事件到來 | 被喚醒 | 有內部任務 | 有定時任務時,會返回
if (selectedKeys != 0 || oldWakenUp || wakenUp.get() || hasTasks() || hasScheduledTasks()) {
break;
}
long time = System.nanoTime();
if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {
// 阻塞超時後沒有事件到來,重置selectCnt
selectCnt = 1;
} else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&
selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
// Selector重建
rebuildSelector();
selector = this.selector;
// Select again to populate selectedKeys.
selector.selectNow();
selectCnt = 1;
break;
}
currentTimeNanos = time;
}
} catch (CancelledKeyException e) {
// Harmless exception - log anyway
}
}
當java NIO bug觸發時,進行Selector重建,rebuildSelector過程如下:
- 通過方法openSelector創建一個新的selector。
- 將old selector的selectionKey執行cancel。
- 將old selector的channel重新註冊到新的selector中。
Netty的連接處理就是IO事件的處理,IO事件包括讀事件、ACCEPT事件、寫事件和OP_CONNECT事件:
- ACCEPT事件:連接建立好之後將該連接的channel註冊到workGroup中某個NIOEventLoop的selector中;
- READ事件:從channel中讀取數據,存放到byteBuf中,觸發後續的ChannelHandler來處理數據;
- WRITE事件:正常情況下一般是不會註冊寫事件的,如果Socket發送緩衝區中沒有空閒內存時,在寫入會導致阻塞,此時可以註冊寫事件,當有空閒內存(或者可用字節數大於等於其低水位標記)時,再響應寫事件,並觸發對應回調。
- CONNECT事件:該事件是client觸發的,由主動建立連接這一側觸發的。
任務處理機制
任務處理也就是處理內部任務,這裏也包括延時任務,延時任務到時後會移動到taskQueue然後被執行。任務處理是在IO事件處理之後進行的,IO事件和內部任務執行時間百分比可以通過ioRatio來調節,ioRatio表示執行IO時間所佔百分比。
/* timeoutNanos:任務執行花費最長耗時/
protected boolean runAllTasks(long timeoutNanos) {
// 把scheduledTaskQueue中已經超過延遲執行時間的任務移到taskQueue中等待被執行。
fetchFromScheduledTaskQueue();
// 非阻塞方式pollTask
Runnable task = pollTask();
if (task == null) {
afterRunningAllTasks();
return false;
}
final long deadline = ScheduledFutureTask.nanoTime() + timeoutNanos;
long runTasks = 0;
long lastExecutionTime;
for (;;) {
// 執行task
safeExecute(task);
runTasks ++;
// 依次從taskQueue任務task執行,每執行64個任務,進行耗時檢查。
// 如果已執行時間超過預先設定的執行時間,則停止執行非IO任務,避免非IO任務太多,影響IO任務的執行。
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;
}
注意,任務的處理過程中有個執行一定量任務後的執行時間耗時檢查動作,這裏是爲了避免任務的處理時間過長,影響Netty網絡IO的處理效率,畢竟Netty是要處理大量網絡IO的。
對於NioEventLoop實現的思考
EventLoop是一個Reactor模型的事件處理器,一個EventLoop對應一個線程,其內部會維護一個selector和taskQueue,負責處理網絡IO請求和內部任務,這裏的selector和taskQueue是線程內部的。
Netty的BossGroup和WorkerGroup可能包含多個EventLoop,BossGroup接收到請求之後輪詢交給WorkerGroup中的其中一個線程(對應一個NioEventLoop)來處理,也就是連接之間的處理是線程獨立的,這也就是NioEventLoop流程的無鎖化設計。
從EventLoop“無鎖化”設計和常見的鎖機制對比來看,要實現線程併發安全,有兩種實現策略:
- 數據隔離:數據隔離就是數據產生後就提交給不同的線程來處理,線程內部一般有一個數據容器來保存待處理的數據,這裏的提交動作需要保證是安全的,比如Netty的BossGroup將建立好的連接註冊到WorkerGroup時,是由內核來保證線程安全的(比如Linux就是通過epoll_ctl方法,該方法是線程安全的);
- 數據分配:數據產生後統一放在數據容器中,由數據消費線程自己來獲取數據進行處理,這裏的獲取動作需要保證是安全的,一般通過鎖機制來保護,比如Java線程池中線程從阻塞隊列中獲取任務進行執行,就是由阻塞隊列保證線程安全。
對於數據隔離和數據分配來說,二者都有優缺點及適用場景。對於數據隔離來說,一般“鎖”交互少成本較低,並且其隔離性較好,線程內部如果有新數據產生還繼續由該線程來處理,但是可能造成數據負載不均衡;對於數據分配來說,“鎖”交互較多,但是由於數據處理線程都是從同一數據容器消費數據,所以不會出現數據處理負載不均衡問題。
如果想實現類似EventLoop中單個線程對應一個處理隊列的方案,可以使用只配置一個線程的Java線程池,達到類似的實現效果。
推薦閱讀
- Netty 入門,這一篇文章就夠了
- Netty 啓動流程解析
- Netty 處理連接那些事
- 徹底搞懂 netty 線程模型
- Netty Pipeline與ChannelHandler那些事
- Netty 黏包拆包機制
- Java nio 空輪詢bug到底是什麼
歡迎小夥伴關注【TopCoder】閱讀更多精彩好文。