從 Event Loop 談 JavaScript 的執(zhí)行機制

在上一篇文章 從進程和線程了解瀏覽器的工作原理 中,我們已經(jīng)了解了瀏覽器的渲染流程,瀏覽器初次渲染完成后,接下來就是 JS 邏輯處理了。這篇文章我們結(jié)合 event loop 來了解一下 JavaScript 代碼是如何執(zhí)行的。

瀏覽器環(huán)境下 JS 引擎的事件循環(huán)機制

上一篇文章 中我們已經(jīng)知道了 JavaScript 是單線程的,這意味著 JavaScript 只有一個主線程來處理所有的任務(wù)。所以,所有任務(wù)都需要排隊執(zhí)行,上一個任務(wù)結(jié)束,才會執(zhí)行下一個。如果上一個任務(wù)耗時很長,那么下一個任務(wù)也要一直等著。

排隊通常由兩種原因造成:

  • 任務(wù)計算量過大,CPU 處理不過來;
  • 執(zhí)行任務(wù)需要的東西沒有準備好(如 Ajax 獲取到數(shù)據(jù)才能往下執(zhí)行),所以無法繼續(xù)執(zhí)行,只好等待 IO 設(shè)備(輸入輸出設(shè)備),而 CPU 卻是閑著的。

JavaScript 的設(shè)計者意識到,這時主線程完全可以不管 IO 設(shè)備,掛起處于等待中的任務(wù),先運行排在后面的任務(wù),等到 IO 設(shè)備返回了結(jié)果,再把掛起的任務(wù)繼續(xù)執(zhí)行下去。

于是,任務(wù)可以分為兩種:

  • 同步任務(wù):在主線程上排隊執(zhí)行的任務(wù)。只有上一個任務(wù)執(zhí)行完,才能執(zhí)行下一個任務(wù);
  • 異步任務(wù):不進入主線程、而進入任務(wù)隊列(task queue)的任務(wù)。只有任務(wù)隊列通知主線程某個異步任務(wù)可以執(zhí)行了,該任務(wù)才會進入主線程執(zhí)行。

JavaScript 執(zhí)行的過程如下:

  1. 所有同步任務(wù)都在主線程上執(zhí)行,形成一個執(zhí)行棧(execution context stack)。
  2. 主線程之外還存在一個任務(wù)隊列。當遇到一個異步任務(wù)時,并不會一直等待其返回結(jié)果,而是會將這個異步任務(wù)掛起,繼續(xù)執(zhí)行執(zhí)行棧中的其他任務(wù)。當一個異步任務(wù)返回結(jié)果后,就會在任務(wù)隊列中放置一個事件。
  3. 被放入任務(wù)隊列的事件不會立刻執(zhí)行其回調(diào),而是等待執(zhí)行棧中的所有同步任務(wù)都執(zhí)行完畢,主線程處于閑置狀態(tài)時,主線程就會讀取任務(wù)隊列,看里面是否有事件。如果有,那么主線程會從中取出排在第一位的事件,并把這個事件對應(yīng)的回調(diào)放入執(zhí)行棧中,開始執(zhí)行。

只要執(zhí)行??樟?,就會去讀取任務(wù)隊列,主線程從任務(wù)隊列中讀取事件的過程是循環(huán)不斷的,這種執(zhí)行機制稱為事件循環(huán)(event loop)。

這里引用 Philip Roberts的演講《Help, I’m stuck in an event-loop》中的一張圖來協(xié)助理解:

圖中的 stack 表示我們所說的執(zhí)行棧,WebAPIs代表一些異步任務(wù),callback queue 則是任務(wù)隊列。

定時器

任務(wù)隊列除了放置異步任務(wù)的事件,還可以放置定時事件,即指定某些代碼在多長時間后執(zhí)行。

定時器功能主要有 setTimeout() 和 setInterval() 這兩個函數(shù)來完成,它們的內(nèi)部運行機制完全一樣,區(qū)別在于前者指定的代碼只執(zhí)行一次,后者為反復執(zhí)行。這里我們主要討論 setTimeout() 。

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

上面這段代碼,3000 毫秒后會將該定時事件放入任務(wù)隊列中,等待主線程執(zhí)行。

如果將延遲時間設(shè)為 0,就表示當前代碼執(zhí)行完(執(zhí)行棧清空)以后,立刻執(zhí)行指定的回調(diào)函數(shù)。

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

上面代碼的執(zhí)行結(jié)果總是:

2
1

因為只有在執(zhí)行完第二個console.log以后,才會去執(zhí)行任務(wù)隊列中的回調(diào)函數(shù)。

注意:

  • setTimeout(fn, 0)的含義是:指定某個任務(wù)在主線程最早可得的空閑時間執(zhí)行。

  • 雖然代碼的本意是 0 毫秒后就將事件放入任務(wù)隊列,但是 W3C 在 HTML 標準中規(guī)定,setTimeout() 的延遲時間不能低于 4 毫秒。

  • setTimeout() 只是將事件插入了任務(wù)隊列,必須要等到執(zhí)行棧執(zhí)行完畢,主線程才會去執(zhí)行它指定的回調(diào)函數(shù)。如果當前代碼耗時很長,那這個事件就得一直等待,所以并沒有辦法保證回調(diào)函數(shù)一定會在setTimeout() 指定的時間執(zhí)行。

macro task 與 micro task

前面我們已經(jīng)將 JavaScript 事件循環(huán)機制梳理了一遍,在 ES5 中是夠用了,但是在 ES6 中仍然會遇到一些問題,比如下面這段代碼:

setTimeout(function() {
    console.log('setTimeout');
}, 0);
new Promise(function(resolve) {
    console.log('Promise1');
    for (var i=0; i < 10000; i++) {
        i == 9999 && resolve();
    }
    console.log('Promise2');
}).then(function() {
    console.log('then');
});
console.log('end');

它的結(jié)果是:

Promise1
Promise2
end
then
setTimeout

為什么呢?這里就需要解釋一個新的概念:macro-taskmicro-task。

除了廣義的同步任務(wù)和異步任務(wù)的劃分,對任務(wù)還有更精細的定義:

  • macro-task(宏任務(wù)):可以理解為每次執(zhí)行棧執(zhí)行的代碼就是一個宏任務(wù),包括每次從任務(wù)隊列中獲取一個事件并將其對應(yīng)的回調(diào)放入到執(zhí)行棧中執(zhí)行。宏任務(wù)需要多次事件循環(huán)才能執(zhí)行完,任務(wù)隊列中的每一個事件都是一個宏任務(wù)。每次事件循環(huán)都會調(diào)入一個宏任務(wù),瀏覽器為了能夠使 JS 內(nèi)部宏任務(wù)與 DOM 任務(wù)有序的執(zhí)行,會在一個宏任務(wù)結(jié)束后,下一個宏任務(wù)開始前,對頁面進行重新渲染。

  • micro-task(微任務(wù)):可以理解為在當前宏任務(wù)執(zhí)行結(jié)束后立即執(zhí)行的任務(wù)。微任務(wù)是一次性執(zhí)行完的,在一個宏任務(wù)執(zhí)行完畢后,就會將它執(zhí)行期間產(chǎn)生的所有微任務(wù)都執(zhí)行完畢。如果在微任務(wù)執(zhí)行期間微任務(wù)隊列加入了新的微任務(wù),會將新的微任務(wù)放到隊列尾部,之后會依次執(zhí)行。

形成 macro-task 或 micro-task 的場景:

  • macro-task:script(整體代碼),setTimeout,setInterval,setImmediate,I/O,UI 渲染等
  • micro-task:process.nextTick,Promise(這里指瀏覽器實現(xiàn)的原生 Promise),Object.observe,MutationObserver

宏任務(wù)和微任務(wù)執(zhí)行的順序如下:


現(xiàn)在我們再來看看上面那段代碼是怎么執(zhí)行的:

  • 整個 script 代碼,放在了macro-task 隊列中,取出來放入執(zhí)行棧開始執(zhí)行;
  • 遇到 setTimeout,加入到 macro-task 隊列;
  • 遇到 Promise.then,放入到另一個隊列 micro-task 隊列;
  • 等執(zhí)行棧執(zhí)行完后,下一步該取出 micro-task 隊列中的任務(wù)了,在這里也就是 Promise.then;
  • 等到 micro-task 隊列都執(zhí)行完后,然后再去取出 macro-task 隊列中的setTimeout。

Node.js 中的 Event Loop

在 Node.js 中,事件循環(huán)表現(xiàn)出的狀態(tài)與瀏覽器中大致相同。不同的是Node.js 中有一套自己的模型,它是通過 libuv 引擎來實現(xiàn)事件循環(huán)的。

下面我們來看看 Node.js 是如何執(zhí)行的?


  • Node.js 是 使用 V8 引擎作為 JS 解釋器,V8 引擎將 JS 代碼解析后去調(diào)用Node API;
  • 這些 API 由 libuv 引擎驅(qū)動,執(zhí)行對應(yīng)的任務(wù)。libuv 引擎將不同的任務(wù)分配給不同的線程,形成一個事件循環(huán)(event loop),以異步的方式將任務(wù)的執(zhí)行結(jié)果返回給 V8 引擎;
  • V8 引擎再將結(jié)果返回給用戶。

事件循環(huán)模型

下面是一個 libuv 引擎中的事件循環(huán)的模型:

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────|  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

注:模型中的每一個方塊代表事件循環(huán)的一個階段。

(這塊引用 Node 官網(wǎng)上的一篇文章 https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/,有興趣的朋友可以看看原文)

事件循環(huán)各階段詳解

從上面這個模型中,我們大致可以分析出 Node.js 中事件循環(huán)的順序:

外部輸入數(shù)據(jù) --> 輪詢階段(poll) --> 檢查階段(check) --> 關(guān)閉事件回調(diào)階段(close callback) --> 定時器檢測階段(timers) --> I/O 事件回調(diào)階段(I/O callback) --> 閑置階段(idle, prepare) --> 輪詢階段...

各階段的功能大致如下:

  • timers 階段:這個階段執(zhí)行 setTimeout() 和 setInterval() 的回調(diào);
  • I/O callbacks 階段:這個階段執(zhí)行除了close 事件、定時器和 setImmediate() 的回調(diào)之外的其它回調(diào);
  • idle,prepare 階段:僅 Node 內(nèi)部使用,可以不用理會;
  • poll 階段:獲取新的 I/O 事件,在一些特殊情況下 Node 會阻塞在這里;
  • check 階段:執(zhí)行 setImmediate() 的回調(diào);
  • close callbacks 階段:比如 socket.on('close', callback) 的回調(diào)會在這個階段執(zhí)行。

每個階段都有一個裝有 callbacks 的 queue(隊列),當 event loop 執(zhí)行到一個指定階段時,Node 將按先進先出的順序執(zhí)行該階段的隊列,當隊列的 callback 執(zhí)行完或者執(zhí)行 callbacks 數(shù)量超過該階段的上限時,event loop 會進入下一個階段。

下面我們來詳細說說各個階段:

poll 階段

poll 階段是銜接整個 event loop 各個階段比較重要的階段。在 Node.js 里,任何異步方法(除 timer, close, setImmediate 之外)完成時,都會將 callback 加到 poll queue 里,并立即執(zhí)行。

當 V8 引擎將 JS 代碼解析并傳入 libuv 引擎后,循環(huán)首先進入 poll 階段。poll 階段的執(zhí)行邏輯如下:

  • 先查看 poll queue 中是否有事件,如果有,就按順序依次執(zhí)行 callbacks。
  • 當 poll queue 為空時,
    • 會檢查是否有 setImmediate() 的 callback,如果有就進入 check 階段執(zhí)行這些 callback。
    • 同時也會檢查是否有到期的 timer,如果有,就把這些到期的 timer 的 callback 按照調(diào)用順序放到 timer queue 中,之后循環(huán)會進入 timer 階段執(zhí)行 timer queue 中的 callback。
      這兩者的順序是不固定的,受到代碼運行環(huán)境的影響。如果兩者的 queue 都是空的,那么 event loop 會停留在 poll 階段,直到有一個 I/O 事件返回,循環(huán)會進入 I/O callback 階段,并立即執(zhí)行這個事件的 callback。

check 階段

check 階段專門用來執(zhí)行 setImmediate() 方法的 callback,當 poll 階段進入空閑狀態(tài),并且 setImmediate queue 中有 callback 時,事件循環(huán)進入這個階段。

close 階段

當一個 socket 連接或者一個 handle 被突然關(guān)閉時(例如,調(diào)用了 socket.destroy() 方法),close 事件會被發(fā)送到這個階段執(zhí)行回調(diào);否則事件會用 process.nextTick() 方法發(fā)送出去。

timers 階段

這個階段執(zhí)行所有到期的 timer 加入到 timer queue 中 callback。timer callback 指通過 setTimeout()setInterval() 設(shè)定的 callback。

I/O callback 階段

這個階段主要執(zhí)行大部分 I/O 事件的 callback,包括一些為操作系統(tǒng)執(zhí)行的 callback,例如:一個 TCP 連接發(fā)生錯誤時,系統(tǒng)需要執(zhí)行 callback 來獲得這個錯誤的報告。

process.nextTick() 與 setImmediate()

Node.js 中有三個常用的用來推遲任務(wù)執(zhí)行的方法,分別是:process.nextTick(),setTimeout()(setInterval() 與之相同)和 setImmediate()。

process.nextTick()

process.nextTick() 不在 event loop 的任何階段內(nèi)執(zhí)行,而是在各個階段切換的中間執(zhí)行,即一個階段執(zhí)行完畢準備進入到下一個階段前執(zhí)行。

下面我們來看一段代碼:

const fs = require('fs);

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('setTimeout);
    }, 0);
    setImmediate(() => {
        console.log('setImmediate');
        process.nextTick(() => {
            console.log('nextTick3');
        });
    });
    process.nextTick(() => {
        console.log('nextTick1');
    });
    process.nextTick(() => {
        console.log('nextTick2');
    });
});

結(jié)果為:

nextTick1
nextTick2
setImmediate
nextTick3
setTimeout

從 poll --> check 階段,先執(zhí)行process.nextTick,輸出 nextTick1,nextTick2;
然后進入 check 階段,執(zhí)行setImmediate,輸出 setImmediate;
執(zhí)行完 setImmediate 后,出 check,進入 close callback 前,輸出 nextTick3;
最后進入 timer 階段,執(zhí)行 setTimeout,輸出 setTimeout。

setImmediate()

在三個方法中,setImmediate() 和 setTimeout() 這兩個方法很容易被弄混,然而實際上這兩個方法的意義確大為不同。

setTimeout()是定義一個回調(diào),并且希望這個回調(diào)在指定的時間間隔后第一時間去執(zhí)行。注意這個“第一時間執(zhí)行”,意味著,受到操作系統(tǒng)和當前執(zhí)行任務(wù)的諸多影響,該回調(diào)并不會在我們預期的時間間隔后精準地執(zhí)行。

setImmediate() 從意義上是立即執(zhí)行的意思,但實際上是在一個固定的階段(poll 階段之后)才會執(zhí)行回調(diào)。這個名字的意義和上面提到的 process.nextTick() 才是最匹配的。

setImmediate()setTimeout(fn, 0) 表現(xiàn)上非常相似。猜猜下面這段代碼的結(jié)果是什么?

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

setImmediate(() => {
    console.log('setImmediate');
});

答案是不確定。這取決于這段代碼的運行環(huán)境,運行環(huán)境中各種復雜情況會導致在同步隊列里兩個方法的順序隨機決定。但是,在一種情況下可以準確判斷兩個方法回調(diào)的執(zhí)行順序,那就是在一個 I/O 事件的回調(diào)中。下面這段代碼的順序永遠是固定的:

const fs = require('fs');

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

答案永遠是:

setImmediate
setTimeout

在 I/O 事件的回調(diào)中,setImmediate() 方法的回調(diào)永遠在 setTimeout() 的回調(diào)前執(zhí)行。

從上面 process.nextTick() 的示例代碼我們可以看出:多個 process.nextTick() 總是在一次 event loop 執(zhí)行完;多個 setImmediate() 可能需要多次 event loop 才能執(zhí)行完。這正是 Node.js 10.0 版添加 setImmediate() 方法的原因,否則像下面這樣遞歸調(diào)用 process.nextTick() 時,將會導致 Node 進入死循環(huán),主線程根本不會去讀取事件隊列。

process.nextTick(function foo() {
    process.nextTick(foo);
});

小結(jié)

JavaScript 的事件循環(huán)是這門語言中非常重要且基礎(chǔ)的概念,清楚的了解事件循環(huán)的執(zhí)行順序和各階段的特點,可以使我們對一段異步代碼的執(zhí)行順序有一個清晰的認知,從而減少代碼執(zhí)行的不確定性。

參考資料

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容