做面試的不倒翁:一道事件迴圈題引發的血案

創宇前端發表於2018-10-11

這次我們就不要那麼多前戲,直奔主題,我們的龍門陣正式開始。

開局一道題,內容全靠吹。(此處應有滑稽)

// 檔名: index.js
// 我們儘量模擬所有的非同步場景,包括 timers、Promise、nextTick等等
setTimeout(() => {
  console.log('timeout 1');
}, 1);

process.nextTick(() => {
  console.log('nextTick 1');
});

fs.readFile('./index.js', (err, data) => {
  if(err) return;
  console.log('I/O callback');
  process.nextTick(() => {
      console.log('nextTick 2');
  });
});

setImmediate(() => {
  console.log('immediate 1');
  process.nextTick(() => {
      console.log('nextTick 3');
  });
});

setTimeout(() => {
  console.log('timeout 2');
  process.nextTick(() => {
    console.log('nextTick 4');
  });
}, 100);

new Promise((resolve, reject) => {
  console.log('promise run');
  process.nextTick(() => {
      console.log('nextTick 5');
  });
  resolve('promise then');
  setImmediate(() => {
      console.log('immediate 2');
  });
}).then(res => {
  console.log(res);
});
複製程式碼

note: 上面的程式碼執行環境是 node v10.7.0,瀏覽器的事件迴圈和 node 還是有一點區別的,有興趣的可以自己找資料看一看。

好了,上面的程式碼涉及到定時器、nextTick、Promise、setImmediate 和 I/O 操作。頭皮有點小發麻哈,大家想好答案了麼?檢查一下吧!

promise run
nextTick 1
nextTick 5
promise then
timeout 1
immediate 1
immediate 2
nextTick 3
I/O callback
nextTick 2
timeout 2
nextTick 4
複製程式碼

怎麼樣?跟自己想的一樣麼?不一樣的話,就聽我慢慢道來。

event loop

在 Node.js 中,event loop 是基於 libuv 的。通過檢視 libuv 的文件可以發現整個 event loop 分為 6 個階段:

  • timers: 定時器相關任務,node 中我們關注的是它會執行 setTimeout() 和 setInterval() 中到期的回撥
  • pending callbacks: 執行某些系統操作的回撥
  • idle, prepare: 內部使用
  • poll: 執行 I/O callback,一定條件下會在這個階段阻塞住
  • check: 執行 setImmediate 的回撥
  • close callbacks: 如果 socket 或者 handle 關閉了,就會在這個階段觸發 close 事件,執行 close 事件的回撥
   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘
複製程式碼

event loop 的程式碼在檔案 deps/uv/src/unix/core.c 中。

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;
  int r;
  int ran_pending;

  // 確定 event loop 是否繼續
  r = uv__loop_alive(loop);
  if (!r)
    uv__update_time(loop);

  while (r != 0 && loop->stop_flag == 0) {
    uv__update_time(loop); // 更新時間
    uv__run_timers(loop); // timers 階段
    ran_pending = uv__run_pending(loop); // pending callbacks 階段
    uv__run_idle(loop); // idle 階段
    uv__run_prepare(loop); // prepare 階段

    timeout = 0;
    // 設定 poll 階段的超時時間,有以下情況超時時間設為 0,此時 poll 不會阻塞
    // 1. stop_flag 不為 0
    // 2. 沒有活躍的 handles 和 request
    // 3. idle、pending callback、close 階段 handle 佇列不為空
    // 否則的話會將超時時間設定成距離當前時間最近的 timer 的時間
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop);

    // poll 階段
    uv__io_poll(loop, timeout);
    // check 階段
    uv__run_check(loop);
    // close 階段
    uv__run_closing_handles(loop);

    if (mode == UV_RUN_ONCE) {
      uv__update_time(loop);
      uv__run_timers(loop);
    }

    r = uv__loop_alive(loop);
    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
      break;
  }

  if (loop->stop_flag != 0)
    loop->stop_flag = 0;

  return r;
}
複製程式碼

註冊加觸發

這一小節我們主要看看 Node 如何將我們寫的定時器等等註冊到 event loop 中去並執行的。

以 setTimeout 為例,首先我們進到了 timers.js 這個檔案中,找到了 setTimeout 函式,我們主要關注這麼兩句:

function setTimeout(callback, after, arg1, arg2, arg3) {
  // ...
  const timeout = new Timeout(callback, after, args, false);
  active(timeout);

  return timeout;
}
複製程式碼

我們看到它 new 了一個 Timeout 類,我們順著這條線索找到了 Timeout 的建構函式:

function Timeout(callback, after, args, isRepeat) {
  // ...
  this._onTimeout = callback;
  // ...
}
複製程式碼

我們主要關注這一句,Node 將回撥掛載到了 _onTimeout 這個屬性上。那麼這個回撥是在什麼時候執行的呢?我們全域性搜一下 _onTimeout(),我們可以發現是一個叫做 ontimeout 的方法執行了回撥,好了,我們開始順藤摸瓜,可以找到這麼一條呼叫路徑 processTimers -> listOnTimeout -> tryOnTimeout -> ontimeout -> _onTimeout

最後的最後,我們在檔案的頭部發現了這麼幾行程式碼:

const {
  getLibuvNow,
  setupTimers,
  scheduleTimer,
  toggleTimerRef,
  immediateInfo,
  toggleImmediateRef
} = internalBinding('timers');
setupTimers(processImmediate, processTimers);
複製程式碼

我們一看,setupTimers 是從 internalBinding('timers') 獲取的,我們去看一下 internalBinding 就知道這就是 js 程式碼和內建模組關聯的地方了。於是,我們順著這條線索往下找,我們去 src 目錄下去找叫 timers 的檔案,果不其然,我們找到一個叫 timers.cc 的檔案,同時,找到了一個叫 SetupTimers 的函式。

void SetupTimers(const FunctionCallbackInfo<Value>& args) {
  CHECK(args[0]->IsFunction());
  CHECK(args[1]->IsFunction());
  auto env = Environment::GetCurrent(args);

  env->set_immediate_callback_function(args[0].As<Function>());
  env->set_timers_callback_function(args[1].As<Function>());
}
複製程式碼

上面的 args[1] 就是我們傳遞的 processTimers,在這個函式中我們其實就完成了 processTimers 的註冊,它成功的註冊到了 node 中。

那是如何觸發的回撥呢?這裡我們首先先看到 event loop 程式碼中的 timers 階段執行的函式,然後跟進去:

void uv__run_timers(uv_loop_t* loop) {
  struct heap_node* heap_node;
  uv_timer_t* handle;

  for (;;) {
    heap_node = heap_min(timer_heap(loop));
    if (heap_node == NULL)
      break;

    handle = container_of(heap_node, uv_timer_t, heap_node);
    if (handle->timeout > loop->time)
      break;

    uv_timer_stop(handle);
    uv_timer_again(handle);
    handle->timer_cb(handle);
  }
}
複製程式碼

這段程式碼我們將我們的目光放在 handle->timer_cb(handle) 這一行,這個函式是在哪兒定義的呢?我們全域性搜一下 timer_cb 發現 uv_timer_start 中有這麼一行程式碼:

handle->timer_cb = cb;
複製程式碼

所以我們知道是呼叫了 uv_timer_start 將回撥函式掛載到了 handle 上。那麼 cb 又是什麼呢?其實你沿著程式碼上去找就能發現其實 cb 就是 timers_callback_function,眼熟對麼?這就是我們上面註冊進來觸發回撥的函式 processTimers

恍然大悟,原來是這麼觸發的回撥,現在還有個問題,誰去呼叫的 uv_timer_start 呢?這個問題就簡單了,我們通過原始碼可以知道是 ScheduleTimer 這個函式呼叫了,是不是感覺很熟悉,對,這個函式就是我們通過 internalBinding 引進來的 scheduleTimer 函式。

在這個地方就有點不一樣了。現在最新的 tag 版本和 github 上 node 最新的程式碼是有區別的,在一次 pr 中,將 timer_wrap.cc 重構成了 timers.cc,並且移除了 TimerWrap 類,再說下面的區別之前,先補充一下 timer 對應的資料結構:

// 這是在有 TimeWrap 的版本
// 對應的時間後面是一個 timer 連結串列
refedLists = {
  1000: TimeWrap._list(TimersList(item<->item<->item<->item)),
  2000: TimeWrap._list(TimersList(item<->item<->item<->item)),
};
// 這是 scheduleTimer 的版本
refedLists = {
  1000: TimersList(item<->item<->item<->item),
  2000: TimersList(item<->item<->item<->item),
};
複製程式碼

TimeWrap 的版本里,js 是通過呼叫例項化後的 start() 函式去呼叫了 uv_timer_start

scheduleTimer 版本是註冊定時器的時候通過比較哪個定時器是最近要執行的,從而將對應時間的 timerList 註冊到 uv_timer 中去。

那麼,為什麼要這麼改呢?是為了讓定時器和 Immediate 擁有更相似的行為,也就是將單個 uv_timer_t handle 存在 Environment 上(Immediate 是有一個 ImmediateQueue,這也是個連結串列)。

這裡就只說了一個 timer,其他的大家就自己去看看吧,順著這個思路大家肯定會有所收穫的。

事件迴圈流程

在載入 node 的時候,將 setTimeout、setInterval 的回撥註冊到 timerList,將 Promise.resolve 等 microTask 的回撥註冊到 microTasks,將 setImmediate 註冊到 immediateQueue 中,將 process.nextTick 註冊到 nextTickQueue 中。

當我們開始 event loop 的時候,首先進入 timers 階段(我們只看跟我們上面說的相關的階段),然後就判斷 timerList 的時間是否到期了,如果到期了就執行,沒有就下一個階段(其實還有 nextTick,等下再說)。

接下來我們說 poll 階段,在這個階段,我們先計算需要在這個階段阻塞輪詢的時間(簡單點就是下個 timer 的時間),然後等待監聽的事件。

下個階段是 check 階段,對應的是 immediate,當有 immediateQueue 的時候就會跳過 poll 直接到 check 階段執行 setImmediate 的回撥。

那有同學就要問了,nextTick 和 microTasks 去哪兒了啊?別慌,聽我慢慢道來。

process.nextTick 和 microTasks

現在我們有了剛剛找 timer 的經驗,我們繼續去看看 nextTick 是怎麼執行的。

經過排查我們能找到一個叫 _tickCallback 的函式,它不斷的從 nextTickQueue 中獲取 nextTick 的回撥執行。

function _tickCallback() {
    let tock;
    do {
      while (tock = queue.shift()) {
        // ...
        const callback = tock.callback;
        if (tock.args === undefined)
          callback();
        else
          Reflect.apply(callback, undefined, tock.args);

        emitAfter(asyncId);
      }
      tickInfo[kHasScheduled] = 0;
      runMicrotasks();
    } while (!queue.isEmpty() || emitPromiseRejectionWarnings());
    tickInfo[kHasPromiseRejections] = 0;
  }
複製程式碼

我們看到了什麼?在將 nextTick 的回撥執行完之後,它執行了 runMicrotasks。一切都真相大白了,microTasks 的執行時機是當執行完所有的 nextTick 的回撥之後。那 nextTick 又是在什麼時候執行的呢?

這就需要我們去找 C++ 的程式碼了,在 bootstrapper.cc 裡找到了 BOOTSTRAP_METHOD(_setupNextTick, SetupNextTick),所以我們就要去找 SetupNextTick 函式。

void SetupNextTick(const FunctionCallbackInfo<Value>& args) {
  Environment* env = Environment::GetCurrent(args);
  // ...
  env->set_tick_callback_function(args[0].As<Function>());
  // ...
}
複製程式碼

我們關注這一句,是不是很熟啊,跟上面 timer 一樣是吧,我們將 __tickCallback 註冊到了 node,在 C++ 中通過 tick_callback_function 來呼叫這個函式。

我們通過檢視原始碼可以發現是 InternalCallbackScope 這個類呼叫 Close 函式的時候就會觸發 nextTixk 執行。

void InternalCallbackScope::Close() {
  if (closed_) return;
  closed_ = true;
  HandleScope handle_scope(env_->isolate());
  // ...
  if (!tick_info->has_scheduled()) {
    env_->isolate()->RunMicrotasks();
  }
  // ...
  if (!tick_info->has_scheduled() && !tick_info->has_promise_rejections()) {
    return;
  }
  // ...
  if (env_->tick_callback_function()->Call(process, 0, nullptr).IsEmpty()) {
    failed_ = true;
  }
}
複製程式碼

可能有同學有疑問了,為啥在執行 nextTick 上面還有 RunMicrotasks 呢?其實這是對 event loop 的優化,假如沒有 process.nextTick 就直接從 node 裡面呼叫 RunMicrotasks 加快速度。

現在在 node.cc 裡我們找到了呼叫 Close 的地方:

MaybeLocal<Value> InternalMakeCallback(Environment* env,
                                       Local<Object> recv,
                                       const Local<Function> callback,
                                       int argc,
                                       Local<Value> argv[],
                                       async_context asyncContext) {
  CHECK(!recv.IsEmpty());
  InternalCallbackScope scope(env, recv, asyncContext);

  scope.Close();

  return ret;
}
複製程式碼

InternalMakeCallback() 則是在 async_wrap.ccAsyncWrap::MakeCallback() 中被呼叫。

找了半天,只找到了 setImmediate 註冊時,註冊函式執行回撥執行了這個函式,沒有找到 timer 的。之前因為使用的 TimeWrap,TimeWrap 繼承了 AsyncWrap,在執行回撥的時候呼叫了 MakeCallback(),問題是現在移除了 TimeWrap,那是怎麼呼叫的呢?我們會到 js 程式碼,發現了這樣的程式碼:

const { _tickCallback: runNextTicks } = process;
function processTimers(now) {
  runNextTicks();
}
複製程式碼

一切都明瞭了,在移除了 TimeWrap 之後,將 _tickCallback 放到了這裡執行,所以我們剛剛在 C++ 裡找不到。

其實,每一個階段執行完之後,都會去執行 _tickCallback ,只是方式可能有點不同。

答案解析

好了,剛剛瞭解了關於 event loop 的一些情況,我們再來看看文章開頭的那段程式碼,我們一起來分析。

第一步

首先執行 Promise 裡的程式碼,輸出了 promise run,然後 promise.resolve 將 then 放入 microTasks。

第二步

這裡要提到的一點是 nextTick 在註冊之後,bootstrap 構建結束後執行SetupNextTick函式,這時候就會清空 nextTickQueue 和 MicroTasks,所以輸出 nextTick 1、nextTick 5、promise then

第三步

在 bootstrap 之後便進入了 event loop,第一個階段 timers,這時 timeout 1 定時器時間到期,執行回撥輸出 timeout 1,timerList 沒有其他定時器了,去清空 nextTickQueue 和 MicroTasks,沒有任務,這時繼續下階段,這時候有 immediate,所以跳過 poll,進入 check,執行 immediate 回撥,輸出 immediate 1 和 immediate 2,並將 nextTick 3 推入 nextTickQueue,階段完成 immediateQueue 沒有需要處理的東西了,就去清空 nextTickQueue 和 MicroTasks 輸出 nextTick 3

第四步

在這一輪,檔案讀取完成,並且 timers 沒到期,進入 poll 階段,超時時間設定為 timeout 2 的時間,執行回撥輸出 I/O callback,並且向 nextTickQueue 推入 nextTick 2。阻塞過程中沒有其他的 I/O 事件,去清空 nextTickQueue 和 MicroTasks,輸出 nextTick 2

第五步

這時候又到了 timers 階段,執行 timeout 2 的回撥,輸出 timeout 2,將 nextTick 4 推入 nextTickQueue,這時 timeList 已經沒有定時器了,清空 nextTickQueue 和 MicroTasks 輸出 nextTick 4

總結

不知道大家懂了沒有,整個過程其實還比較粗糙,在學習過程中也看了不少的原始碼分析,但是 node 發展很快,很多分析已經過時了,原始碼改變了不少,但是對於理清思路還是很有作用的。

各位看官如果覺得還行、OK、有點用,歡迎來我 GitHub 給個小星星,我會很舒服的,哈哈。


文 / 小烜同學

天衣無縫的祕密是:做技術,你快樂嗎?

編 / 熒聲

編者按: 作者也在玩掘金,關注他呀~

本文已由作者授權釋出,版權屬於創宇前端。歡迎註明出處轉載本文。本文連結:knownsec-fed.com/2018-09-13-…

想要訂閱更多來自知道創宇開發一線的分享,請搜尋關注我們的微信公眾號:創宇前端(KnownsecFED)。歡迎留言討論,我們會盡可能回覆。

做面試的不倒翁:一道事件迴圈題引發的血案

感謝您的閱讀。

相關文章