#科學燃計劃#
從原始碼角度探求Node.js的事件迴圈本質。
事件迴圈事件迴圈的執行順序從圖中可以看出,每次的事件迴圈都包含了上圖中的6個階段,接下來我們來一一解讀它們。
timers 定時器計時器分為兩類:
Immediate 在下一個check階段執行Timeout 定時器過期後執行(delay引數預設值為1ms)Timeout計時器又有兩種型別:
IntervalTimeout這個階段會執行setTimeout()和setInterval()設定的回撥
timers的執行是由poll階段控制的
setTimeout()和setInterval()和瀏覽器中的API是相同的。它們的實現原理與非同步I/O比較類似,但是不需要I/O執行緒池的參與。
這兩個定時器建立後會被插入到定時器觀察者內部的一個紅黑樹中。每次Tick執行時,都會從紅黑樹中取出定時器物件,來檢查它們是否超過定時時間,超過便執行它們的回撥。
注意:定時器存在一個問題,就是它不是絕對精確的(在容忍範圍內)。一旦某個事件迴圈中,有一個任務佔用了較多的時間,那麼再次輪到定時器執行時,時間就會受到影響。
無IO處理情況setTimeout(function timeout () { console.log('timeout');},0);setImmediate(function immediate () { console.log('immediate');});
透過執行上面的程式碼我們可以發現,輸出結果是不確定的。
因為setTimeout(fn, 0)具有幾毫秒的不確定性,無法保證進入timers階段,定時器能立即執行處理程式。
有IO處理情況var fs = require('fs');fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); });})// immediate// timeout
此時setImmediate優先於setTimeout執行,因為poll階段執行完成後進入check階段,而timers階段則處於下一個事件迴圈階段了。
pending callbacks 待定回撥執行大部分回撥,除了close,times和setImmediate()設定的回撥
idle,perpare僅供內部使用
poll 輪詢獲取新的I/O事件,在適當的條件下,Node.js會在這裡阻塞
這個階段的主要任務是執行到達delay時間的timers定時器的回撥,並且處理poll佇列裡的事件。
當事件迴圈進入poll階段,並且沒有呼叫定時器時,將會發生以下兩種情況:
1.如果poll佇列不為空,事件迴圈將遍歷同步執行它們的回撥佇列。
2.如果poll佇列為空,又分為兩種情況:
如果被setImmediate()回撥呼叫,事件迴圈會結束poll階段,進入到check階段。如果沒有被setImmediate()回撥呼叫,事件迴圈將阻塞並等待回撥新增到poll佇列中執行。一旦poll佇列為空,事件迴圈將檢視計時器是否到達delay時間,如果一個或多個定時器已達到delay時間,事件迴圈將回滾到timers定時器階段,執行它們的回撥。
check 檢測setImmediate()設定的回撥會在這一階段執行
如同上文poll階段的第二種情況中,如果poll佇列為空,並且被setImmediate()回撥呼叫,事件迴圈將直接進入check階段。
close callbacks 關閉的回撥函式socket.on('close',callback)的回撥會在這個階段執行
libuvlibuv為Node.js提供了整個事件迴圈功能。
如上圖所示,在Windows下,事件迴圈基於IOCP建立,在linux下透過epoll實現,FreeBSD下透過kqueue實現,在Solaris下透過Event ports實現。
我們再細心的去看上圖,Network I/O和file I/O、DNS等實現方式是被分隔開的,這是因為他們的本質是由兩套機制來實現的。我們一會兒來透過原始碼窺探它們的本質。
實質上,當我們寫JavaScript程式碼去呼叫Node的核心模組時,核心模組會呼叫C++內建模組,內建模組透過libuv進行系統呼叫。
libuv主要解決的問題在現實世界中,在所有不同型別的作業系統平臺下,支援不同型別的I/O是非常困難的。那麼為了支援跨平臺I/O的同時,能更好的管理整個流程,抽象出了libuv。
簡單說,就是libuv抽象出一層API,可以幫助你呼叫各個平臺和機器上各種系統特性,包括操作檔案、監聽socket等,而你不需要了解它們的具體實現。
核心原始碼解讀核心函式uv_run原始碼
int uv_run(uv_loop_t* loop, uv_run_mode mode) { int timeout; int r; int ran_pending; // 檢查loop中是否有非同步任務,沒有就結束。 r = uv__loop_alive(loop); if (!r) uv__update_time(loop); // 事件迴圈while while (r != 0 && loop->stop_flag == 0) { // 更新事件階段 uv__update_time(loop); // 處理timer回撥 uv__run_timers(loop); // 處理非同步任務回撥 ran_pending = uv__run_pending(loop); // 供內部使用 uv__run_idle(loop); uv__run_prepare(loop); // uv_backend_timeout計算完畢後,會傳給uv__io_poll // 如果timeout = 0,則uv__io_poll會直接跳過 timeout = 0; if ((mode == UV_RUN_ONCE && !ran_pending || mode == UV_RUN_DEFAULT)) timeout = uv_backend_timeout(loop); uv__io_poll(loop, timeout); // check階段 uv__run_check(loop); // 關閉檔案描述符等操作 uv__run_closing_handles(loop); // 檢查loop中是否有非同步任務,沒有就結束。 r = uv__loop_alive(loop); if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT) break; } return r;}
事件迴圈的真實面目是一個while。
上文說到Network I/O與file I/O、DNS等是由兩套機制來實現的。
首先我們來看Network I/O,它最後的呼叫都會歸結到uv__io_start這個函式,而該函式會將需要執行的I/O事件和回撥放入watcher佇列中,而uv__io_poll階段會從watcher佇列中取出事件呼叫系統的介面並執行。
(uv__io_poll部分的程式碼過長大家感興趣可自行檢視)
uv__io_startvoid uv__io_start(uv_loop_t* loop, uv__io_t* w, unsigned int events) { assert(0 == (events & ~(POLLIN | POLLOUT | UV__POLLRDHUP | UV__POLLPRI))); assert(0 != events); assert(w->fd >= 0); assert(w->fd < INT_MAX); w->pevents |= events; maybe_resize(loop, w->fd + 1); if (w->events == w->pevents) return; if (QUEUE_EMPTY(&w->watcher_queue)) QUEUE_INSERT_TAIL(&loop->watcher_queue, &w->watcher_queue); if (loop->watchers[w->fd] == NULL) { loop->watchers[w->fd] = w; loop->nfds++; }}
如上所示就是我們libuv中Network I/O這條主線實現過程。
而另外一條主線是Fs I/O和DNS等操作則會呼叫uv__work_sumit這個函式,這個函式是執行執行緒池初始化uv_queue_work中最終呼叫的函式。
void uv__work_submit(uv_loop_t* loop, struct uv__work* w, enum uv__work_kind kind, void (*work)(struct uv__work* w), void (*done)(struct uv__work* w, int status)) { uv_once(&once, init_once); w->loop = loop; w->work = work; w->done = done; post(&w->wq, kind);}
int uv_queue_work(uv_loop_t* loop, uv_work_t* req, uv_work_cb work_cb, uv_after_work_cb after_work_cb) { if (work_cb == NULL) return UV_EINVAL; uv__req_init(loop, req, UV_WORK); req->loop = loop; req->work_cb = work_cb; req->after_work_cb = after_work_cb; uv__work_submit(loop, &req->work_req, UV__WORK_CPU, uv__queue_work, uv__queue_done); return 0;}
Node.js中的事件佇列Node.js中有多個佇列,不同型別的事件在各自的佇列中排隊。在一個階段結束後,進入下一個階段之前,事件迴圈會在這中間處理中間佇列。
原生的libuv事件迴圈中的佇列主要又4種類型:
過期的定時器和間隔佇列IO事件佇列Immediates佇列close handlers佇列除此之外,Node.js還有兩個中間佇列
Next Ticks佇列Other Microtasks佇列Node.js與瀏覽器的Event Loop差異我們可以回顧下瀏覽器中JavaScript事件迴圈,請移步我的另一篇系列專欄瀏覽器中JavaScript的事件迴圈
回來後,先說結論:
在瀏覽器中,microtask的任務佇列是每個macrotask執行完之後執行。
在Node.js中,microtask會在事件迴圈的各個階段之間執行,也就是一個階段執行完畢,就會去執行microtask佇列的任務。
(本文的Macrotask在WHATWG 中叫task。Macrotask為了便於理解,並沒有實際的出處。)
相比於瀏覽器,node多出了setImmediate(宏任務)和process.nextTick(微任務)這兩種非同步操作。
setImmediate的回撥函式被放在check階段執行。而process.nextTick會被當做一種microtask,每個階段結束後都會執行所有的microtask,你可以理解為process.nextTick可以插隊,在下個階段前執行。
process.nextTick插隊帶來的危害process.nextTick的回撥會導致事件迴圈無法進入到下一個階段。I/O處理完成或者定時器過期後仍然無法執行。會讓其他的事件處理程式處於飢餓狀態,為了防止這個問題,Node.js提供了一個process.maxTickDepth(預設為1000)。
Node.js中的微任務process.nextTick()Promise.then()Promise.resolve().then(function(){ console.log('then')})process.nextTick(function(){ console.log('nextTick')});// nextTick// then
我們可以看到nextTick要早於then執行。
Node.js v11變更的事件迴圈從Node.js v11開始,事件迴圈的原理髮生了變化,在同一個階段中只要執行了macrotask就會立即執行microtask佇列,與瀏覽器表現一致。具體請參考這個pr。