跳到主要內容
黯羽輕揚每天積累一點點

macrotask 與 microtask

免費2018-03-31#Node#微任务与宏任务#js微任务#JavaScript Task and Job#JavaScript Event Loop#JavaScript Call Stack#JavaScript track Event Loop

setImmediateprocess.nextTick 的區別到底是什麼?setTimout 0 呢?

寫在前面

從一個最簡單的示例說起:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

log 輸出順序如下:

script start
script end
promise1
promise2
setTimeout

為什麼?

macrotask

姑且稱為宏任務,在很多上下文也被簡稱為 task。例如:

  • setTimeout

  • setInterval

  • setImmediate

  • requestAnimationFrame

  • I/O

  • UI rendering

最常見的延遲調用與間歇調用,Node 環境的立即調用,高頻的 RAF,以及 I/O 操作和改 UI。這些都是 macrotask,事件循環的主要工作就是一輪一輪地檢查 macrotask queue,並處理這些任務

例如:

setImmediate(() => {
  console.log('#1');
});
setImmediate(() => {
  console.log('#2');
});
setImmediate(() => {
  console.log('#3');
  setImmediate(() => {
    console.log('#4');
  });
});

下一次檢查 immediate macrotask queue 時,會依次執行外層的 3 個回呼函式,下下一次才執行內層的那個,所以 macrotask 的規則是等下一班車(下一輪事件循環,或者當前事件循環尚未發生的特定階段)

microtask

微任務,也稱 job。例如:

  • process.nextTick

  • Promise callback

  • Object.observe

  • MutationObserver

nextTick 和 Promise 經常見到,Object.observe 應該個 廢棄 API,原生觀察者實現,MutationObserver 來歷比較久遠了,用來監聽 DOM change

一般情況下,這些回呼函式都會在某些條件下被添加到 microtask queue,在當前 macrotask 隊列 flush 結束後檢查該隊列並 flush 掉(處理完隊列中的所有 microtask)

P.S. 二般情況指的是某些瀏覽器版本下的 Promise callback 不一定走 microtask queue,因為 Promises/A+ 規範沒有明確要求這一點(說是都行)

例如:

setImmediate(() => {
  console.log('immediate');
});
Promise.resolve(1).then(x => {
  console.log(x);
  return x + 1;
}).then(x => {
  console.log(x);
  return x + 1;
}).then(x => console.log(x));

下一次檢查 microtask queue 的時候,發現只有一個 Promise callback,立即執行,再檢查發現又冒出來一個,繼續執行,誒檢查又刷出來一個,接著執行,再檢查,沒了,繼續事件循環,檢查 immediate macrotask queue,這時才執行 setImmediate 回呼。所以 microtask 的規則是掛在當前車尾,而且允許現做現賣(當前 macrotask 隊列 flush 結束時就執行,不用等下一班車,而且 microtask queue flush 過程中產生的同類型 microtask 也會被立即處理掉,即允許阻塞)

Event Loop

我們知道 JS 天生的非同步特性是靠 Event Loop 來完成的,例如:

const afterOneSecond = console.log.bind(console, '1s later');
setTimeout(afterOneSecond, 1000);

具體執行過程大致如下:

  1. JS 執行緒啟動,建立事件循環

  2. script 加入調用棧

  3. 執行第一行建立了一個 Function

  4. 執行第二行,(由 Event Table)記下 1000ms 後,再處理 afterOneSecond 回呼

  5. script 出棧,調用棧空了

  6. 事件循環空跑一會兒(macrotask queue 為空,無事可做)

  7. 1s 多後,timer 過期了,afterOneSecond 回呼被插入 macrotask queue

  8. 接下來的一輪事件循環檢查 macrotask queue 發現非空,先進先出,取出 afterOneSecond 回呼加入調用棧

  9. 執行 afterOneSecond,log 輸出 1s later

  10. afterOneSecond 出棧,調用棧又空了

  11. 不會再有事情發生了,事件循環結束

到這裡開始有點意思了,比如事件循環結束的時間點,一個 常見的誤解 是:

JS 程式碼執行都處於事件循環裡

這當然是含糊的,實際上直到調用棧為空的時候,事件循環才有存在感(檢查任務隊列),確認不會再有事情發生的時候,就結束事件循環,例如:

// 把上例写入./setTimeout.js文件
$ node ./setTimeout.js
1s later

用來執行 ./setTimeout.js 的 Node 程序大約存活了 1s,伴隨著事件循環的結束而正常 exit 了。而 Server 程式則不同,比如一直監聽著特定埠的請求,事件循環無法結束,所以 Node 程序也一直存在

P.S. 每個 JS 執行緒都有自己的事件循環,所以 Web Worker 也有獨立的事件循環

P.S. Event Table 是一個資料結構,配合 Event Loop 使用,用來記錄回呼觸發條件與回呼函式的映射關係:

Every time you call a setTimeout function or you do some async operation?—?it is added to the Event Table. This is a data structure which knows that a certain function should be triggered after a certain event.

作用

那麼,*事件循環的存在意義是什麼?*沒這個東西不行嗎?

就是為了支持非同步特性。試想,JS 用於瀏覽器環境這麼多年,無論 UI 互動還是網路請求都是比較慢的,而 JS 執行在主執行緒,會阻塞渲染,如果這些慢動作都是同步阻塞的,那麼體驗會相當差,例如:

document.body.addEventListener('click', () => alert(+new Date));
const xhr = new XMLHttpRequest();
// Sync xhr
xhr.open('GET', 'http://www.ayqy.net', false);
xhr.send(null);
console.log(xhr.responseText);

執行 send() 的大約 3 秒內,頁面 完全無響應,在此期間點出來的 alert 框會被插入 macrotask 隊列,直到請求響應回來,這些框才會一個接一個地彈出來

如果沒有事件循環,這 3 秒將徹底無法互動,alert 框也不會再在將來某一刻彈出來。所以,事件循環帶來了非同步特性,以應對慢動作阻塞渲染的問題

P.S. 實際上,DOM 事件回呼都是 macrotask,同樣依賴著事件循環

Call Stack

JS 的單執行緒環境意味著某一時刻只能做一件事,所以(一個 JS 執行緒下)調用棧只有一個。例如:

function mult(a, b) { return a * b; }
function double(a) { return mult(a, 2); }
+ function main() {
  return double(12);
}();

執行過程中調用棧的變化情況如下:

// push script
// push main
// push double
// push mult
// pop mult
// pop double
// pop main
// pop script

注意,只有在調用棧為空的時候,事件循環才有機會工作,例如:

function onClick() {
  console.log('click');
  setTimeout(console.log.bind(console, 'timeout'), 0);
  // Wait 10ms
  let now = Date.now();
  while (Date.now() - now < 10) {}
}
document.body.addEventListener('click', onClick);
document.body.firstElementChild.addEventListener('click', onClick);
document.body.firstElementChild.click();

上例的輸出結果是:

click
click
timeout
timeout

第一個 click 輸出後沒有立即輸出 timeout因為此時調用棧不空(棧裡只有個 onClick,是孩子身上的),事件循環就不檢查 macrotask 隊列,雖然裡面確實有個過期 timer 的回呼。具體來講,是因為事件冒泡觸發了 body 身上的 onClick,所以孩子身上的 onClick 還不能出棧,直到一串同步冒泡結束

P.S. 所以,這個場景有意思的地方在於事件冒泡帶來的「隱式函式調用」

6 個任務隊列

NodeJS 中有 4 個 macrotask 隊列(有明確的處理順序)

  1. Expired timers/intervals queue:setTimeoutsetInterval

  2. IO events queue:如檔案讀寫、網路請求等回呼

  3. Immediates queue:setImmediate

  4. Close handlers queue:如 socket 的 close 事件回呼

事件循環從過期的 timer 開始檢查,按順序依次處理各個隊列中等待著的所有回呼

此外,還有 2 個 microtask 隊列(也有明確的處理順序)

  1. Next tick queue:process.nextTick

  2. Micro task queue:如 Promise callback

nextTick 微任務隊列優先級高於其它微任務隊列,所以只有在 nextTick 空了才處理其發的比如 Promise

Next tick queue has even higher priority over the Other Micro tasks queue.

nextTick 與 setImmediate

前者是 microtask,後者是 macrotask,這意味著過多連續的 nextTick 調用會阻塞事件循環,進而阻塞 I/O,所以 除非必要,不要濫用 nextTick

It is suggested you use setImmediate() over process.nextTick(). setImmediate() likely does what you are hoping for (a more efficient setTimeout(..., 0)), and runs after this tick's I/O. process.nextTick() does not actually run in the "next" tick anymore and will block I/O as if it were a synchronous operation.

另外,二者的主要區別是,nextTick 掛在車尾執行,而 setImmediate 要等下一班車

  • process.nextTick() fires immediately on the same phase
  • setImmediate() fires on the following iteration or 'tick' of the event loop

P.S. setImmediate 描述不是十分嚴謹,等到下一個 immediate 階段就可以執行了,不一定是下一輪事件循環(取決於當前處於哪個階段)

P.S. 單從名字上來看,似乎 immediate 更近,實際上 nextTick 才是 最近的將來,歷史原因,沒得換了

注意,之所以存在 nextTick,是為了 提供更細粒度的 task,讓它能夠在事件循環各階段的夾縫中執行,比如做一些著急的清理工作,錯誤處理/重試,也就是說有實際需求場景,具體見 Why use process.nextTick()?,這裡不展開

setTimeout 與 setImmediate

setTimeout(function() {
    console.log('setTimeout')
}, 0);
setImmediate(function() {
    console.log('setImmediate')
});

根據 timer-IO-immediate-close 的 macrotask 處理順序,猜測 log 先後順序是:

setTimeout
setImmediate

實際情況 是:

// 1st
setImmediate
setTimeout
// 2nd
setImmediate
setTimeout
// 3rd
setImmediate
setTimeout
// 4th
setTimeout
setImmediate
// 5th
setImmediate
setTimeout
// 6th
setTimeout
setImmediate

輸出是無序的,不是因為存在競爭關係,而是因為 setTimeout 00 並不是嚴格意義上的「立即」,也就是說一個 0ms 的 timer 不一定會立即把回呼函式插入任務隊列,所以 setTimeout 0 可能趕不上接下來最近的一輪事件循環,此時就會出現不合常理的輸出

那麼什麼情況下能確定二者的順序呢?

const fs = require('fs');

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout')
    }, 0);
    setImmediate(() => {
        console.log('immediate')
    })
});

IO 隊列處理中產生新的 timer task 和 immediate task,按照順序,接下來開始處理 immediate 隊列,所以總是先輸出 'immediate',順序不會亂

那麼,有辦法能讓它們保持相反的順序嗎?

有的。這樣做:

setTimeout(function() {
    console.log('setTimeout')
}, 0);
//! wait timer to be expired
var now = Date.now();
while (Date.now() - now < 2) {
    //...
}
setImmediate(function() {
    console.log('setImmediate')
});

上例會穩定先輸出 setTimeout,中間的阻塞 2ms 是在等待 timer 過期,這樣就能保證在啟動事件循環之前, timer 因為過期,其回呼就已經被插進待處理隊列中了

P.S. 至於為什麼這裡用 2ms,因為據說 setTimeout 0 被轉換成了 setTimeout 1ms,所以我們恰好多等一點點,具體見 Understanding Non-deterministic order of execution of setTimeout vs setImmediate in node.js event-loop 的 uvlib 原始碼分析

P.S. 如果 2ms 不夠,就多等一會兒,反正關鍵點就是等 timer 過期,只有這樣才能讓事件循環第一眼就看見 setTimeout 0 的回呼,而不用等到下一輪

IO starvation

microtask 機制帶來了 IO starvation 問題,無限長的 microtask 隊列會阻塞事件循環,為了避免這個問題,NodeJS 早期版本(v0.12)設置了 1000 的深度限制(process.maxTickDepth),後來去掉了

process.maxTickDepth has been removed, allowing process.nextTick to starve I/O indefinitely. This is due to adding setImmediate in 0.10.

P.S. 具體見 https://github.com/nodejs/node/wiki/API-changes-between-v0.10-and-v0.12#process

例如:

const fs = require('fs');

function addNextTickRecurs(count) {
    let self = this;
    if (self.id === undefined) {
        self.id = 0;
    }

    if (self.id === count) return;

    process.nextTick(() => {
        console.log(`process.nextTick call ${++self.id}`);
        addNextTickRecurs.call(self, count);
    });
}

addNextTickRecurs(Infinity);
setTimeout(console.log.bind(console, 'omg! setTimeout was called'), 10);
setImmediate(console.log.bind(console, 'omg! setImmediate also was called'));
fs.readFile(__filename, () => {
    console.log('omg! file read complete callback was called!');
});

console.log('started');

永遠不會輸出 omg! xxx,因為同步程式碼執行完後,調用棧空了,事件循環檢查任務隊列發現 nextTick 微任務隊列非空,取出該微任務,把回呼扔進調用棧執行一下,又插進去一個,沒完沒了,停不下來了

注意,是立即檢查 nextTick 隊列,而不用管此刻處於事件循環的哪個階段:

the nextTickQueue will be processed after the current operation completes, regardless of the current phase of the event loop.

(引自 The Node.js Event Loop, Timers, and process.nextTick())

Event Loop Counter

怎麼對事件循環計數?

不妨這樣做:

const LoopCounter = {
  counter: 0,
  active: true,
  start() {
    setImmediate(this.countLoop.bind(this));
  },
  stop() {
    this.active = false;
  },
  get() {
    return this.counter;
  },
  countLoop() {
    this.counter++;
    if (this.active) setImmediate(this.countLoop.bind(this));
  }
};

// test
LoopCounter.start();
let now = Date.now();
let intervals = 0;
let MAX_COUNT = 10;
let handle = setInterval(() => {
  console.log(LoopCounter.get());
  if (++intervals >= MAX_COUNT) {
    clearInterval(handle);
    LoopCounter.stop();
  }
}, 10);

setImmediate 做時鐘,是因為 4 種 macrotask 裡,只有 setImmediate 能夠 確保在下一輪事件循環立即得到處理

這個計數器有什麼用?

可以透過計數器來 追蹤事件循環,比如確認是否處於同一個事件循環,比如之前討論的 setTimeout 0setImmediate 的順序問題,可以透過計數器做進一步驗證,結果如下:

// 1st
setImmediate 1
setTimeout 1
// 2nd
setTimeout 0
setImmediate 1

1 1 表示 timer 沒趕上接下來的第一輪事件循環,到第二輪的時候才執行,0 1 表示在接下來的第一輪事件循環之前,timer 已經過期了(成功趕上了)

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論