首頁>技術>

#科學燃計劃#

從原始碼角度探求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)的回撥會在這個階段執行

libuv

libuv為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_start
void 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。

17
最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • GIT教程 1.4 起步 - 命令列