在上一篇文章 從進程和線程了解瀏覽器的工作原理 中,我們已經了解了瀏覽器的渲染流程,瀏覽器初次渲染完成后,接下來就是 JS 邏輯處理了。這篇文章我們結合 event loop 來了解一下 JavaScript 代碼是如何執行的。
瀏覽器環境下 JS 引擎的事件循環機制
在 上一篇文章 中我們已經知道了 JavaScript 是單線程的,這意味著 JavaScript 只有一個主線程來處理所有的任務。所以,所有任務都需要排隊執行,上一個任務結束,才會執行下一個。如果上一個任務耗時很長,那么下一個任務也要一直等著。
排隊通常由兩種原因造成:
- 任務計算量過大,CPU 處理不過來;
- 執行任務需要的東西沒有準備好(如 Ajax 獲取到數據才能往下執行),所以無法繼續執行,只好等待 IO 設備(輸入輸出設備),而 CPU 卻是閑著的。
JavaScript 的設計者意識到,這時主線程完全可以不管 IO 設備,掛起處于等待中的任務,先運行排在后面的任務,等到 IO 設備返回了結果,再把掛起的任務繼續執行下去。
于是,任務可以分為兩種:
- 同步任務:在主線程上排隊執行的任務。只有上一個任務執行完,才能執行下一個任務;
- 異步任務:不進入主線程、而進入任務隊列(task queue)的任務。只有任務隊列通知主線程某個異步任務可以執行了,該任務才會進入主線程執行。
JavaScript 執行的過程如下:
- 所有同步任務都在主線程上執行,形成一個執行棧(execution context stack)。
- 主線程之外還存在一個任務隊列。當遇到一個異步任務時,并不會一直等待其返回結果,而是會將這個異步任務掛起,繼續執行執行棧中的其他任務。當一個異步任務返回結果后,就會在任務隊列中放置一個事件。
- 被放入任務隊列的事件不會立刻執行其回調,而是等待執行棧中的所有同步任務都執行完畢,主線程處于閑置狀態時,主線程就會讀取任務隊列,看里面是否有事件。如果有,那么主線程會從中取出排在第一位的事件,并把這個事件對應的回調放入執行棧中,開始執行。
只要執行棧空了,就會去讀取任務隊列,主線程從任務隊列中讀取事件的過程是循環不斷的,這種執行機制稱為事件循環(event loop)。
這里引用 Philip Roberts的演講《Help, I’m stuck in an event-loop》中的一張圖來協助理解:
圖中的 stack 表示我們所說的執行棧,WebAPIs代表一些異步任務,callback queue 則是任務隊列。
定時器
任務隊列除了放置異步任務的事件,還可以放置定時事件,即指定某些代碼在多長時間后執行。
定時器功能主要有 setTimeout() 和 setInterval() 這兩個函數來完成,它們的內部運行機制完全一樣,區別在于前者指定的代碼只執行一次,后者為反復執行。這里我們主要討論 setTimeout() 。
setTimeout(function() {
console.log('hello');
}, 3000)
上面這段代碼,3000 毫秒后會將該定時事件放入任務隊列中,等待主線程執行。
如果將延遲時間設為 0,就表示當前代碼執行完(執行棧清空)以后,立刻執行指定的回調函數。
setTimeout(function() {
console.log(1);
}, 0);
console.log(2);
上面代碼的執行結果總是:
2
1
因為只有在執行完第二個console.log
以后,才會去執行任務隊列中的回調函數。
注意:
setTimeout(fn, 0)的含義是:指定某個任務在主線程最早可得的空閑時間執行。
雖然代碼的本意是 0 毫秒后就將事件放入任務隊列,但是 W3C 在 HTML 標準中規定,setTimeout() 的延遲時間不能低于 4 毫秒。
setTimeout() 只是將事件插入了任務隊列,必須要等到執行棧執行完畢,主線程才會去執行它指定的回調函數。如果當前代碼耗時很長,那這個事件就得一直等待,所以并沒有辦法保證回調函數一定會在setTimeout() 指定的時間執行。
macro task 與 micro task
前面我們已經將 JavaScript 事件循環機制梳理了一遍,在 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');
它的結果是:
Promise1
Promise2
end
then
setTimeout
為什么呢?這里就需要解釋一個新的概念:macro-task
和 micro-task
。
除了廣義的同步任務和異步任務的劃分,對任務還有更精細的定義:
macro-task(宏任務):可以理解為每次執行棧執行的代碼就是一個宏任務,包括每次從任務隊列中獲取一個事件并將其對應的回調放入到執行棧中執行。宏任務需要多次事件循環才能執行完,任務隊列中的每一個事件都是一個宏任務。每次事件循環都會調入一個宏任務,瀏覽器為了能夠使 JS 內部宏任務與 DOM 任務有序的執行,會在一個宏任務結束后,下一個宏任務開始前,對頁面進行重新渲染。
micro-task(微任務):可以理解為在當前宏任務執行結束后立即執行的任務。微任務是一次性執行完的,在一個宏任務執行完畢后,就會將它執行期間產生的所有微任務都執行完畢。如果在微任務執行期間微任務隊列加入了新的微任務,會將新的微任務放到隊列尾部,之后會依次執行。
形成 macro-task 或 micro-task 的場景:
- macro-task:script(整體代碼),setTimeout,setInterval,setImmediate,I/O,UI 渲染等
- micro-task:process.nextTick,Promise(這里指瀏覽器實現的原生 Promise),Object.observe,MutationObserver
宏任務和微任務執行的順序如下:
現在我們再來看看上面那段代碼是怎么執行的:
- 整個 script 代碼,放在了macro-task 隊列中,取出來放入執行棧開始執行;
- 遇到 setTimeout,加入到 macro-task 隊列;
- 遇到 Promise.then,放入到另一個隊列 micro-task 隊列;
- 等執行棧執行完后,下一步該取出 micro-task 隊列中的任務了,在這里也就是 Promise.then;
- 等到 micro-task 隊列都執行完后,然后再去取出 macro-task 隊列中的setTimeout。
Node.js 中的 Event Loop
在 Node.js 中,事件循環表現出的狀態與瀏覽器中大致相同。不同的是Node.js 中有一套自己的模型,它是通過 libuv 引擎來實現事件循環的。
下面我們來看看 Node.js 是如何執行的?
- Node.js 是 使用 V8 引擎作為 JS 解釋器,V8 引擎將 JS 代碼解析后去調用Node API;
- 這些 API 由 libuv 引擎驅動,執行對應的任務。libuv 引擎將不同的任務分配給不同的線程,形成一個事件循環(event loop),以異步的方式將任務的執行結果返回給 V8 引擎;
- V8 引擎再將結果返回給用戶。
事件循環模型
下面是一個 libuv 引擎中的事件循環的模型:
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────| connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
注:模型中的每一個方塊代表事件循環的一個階段。
(這塊引用 Node 官網上的一篇文章 https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/,有興趣的朋友可以看看原文)
事件循環各階段詳解
從上面這個模型中,我們大致可以分析出 Node.js 中事件循環的順序:
外部輸入數據 --> 輪詢階段(poll) --> 檢查階段(check) --> 關閉事件回調階段(close callback) --> 定時器檢測階段(timers) --> I/O 事件回調階段(I/O callback) --> 閑置階段(idle, prepare) --> 輪詢階段...
各階段的功能大致如下:
- timers 階段:這個階段執行 setTimeout() 和 setInterval() 的回調;
- I/O callbacks 階段:這個階段執行除了close 事件、定時器和 setImmediate() 的回調之外的其它回調;
- idle,prepare 階段:僅 Node 內部使用,可以不用理會;
- poll 階段:獲取新的 I/O 事件,在一些特殊情況下 Node 會阻塞在這里;
- check 階段:執行 setImmediate() 的回調;
- close callbacks 階段:比如 socket.on('close', callback) 的回調會在這個階段執行。
每個階段都有一個裝有 callbacks 的 queue(隊列),當 event loop 執行到一個指定階段時,Node 將按先進先出的順序執行該階段的隊列,當隊列的 callback 執行完或者執行 callbacks 數量超過該階段的上限時,event loop 會進入下一個階段。
下面我們來詳細說說各個階段:
poll 階段
poll 階段是銜接整個 event loop 各個階段比較重要的階段。在 Node.js 里,任何異步方法(除 timer, close, setImmediate 之外)完成時,都會將 callback 加到 poll queue 里,并立即執行。
當 V8 引擎將 JS 代碼解析并傳入 libuv 引擎后,循環首先進入 poll 階段。poll 階段的執行邏輯如下:
- 先查看 poll queue 中是否有事件,如果有,就按順序依次執行 callbacks。
- 當 poll queue 為空時,
- 會檢查是否有 setImmediate() 的 callback,如果有就進入 check 階段執行這些 callback。
- 同時也會檢查是否有到期的 timer,如果有,就把這些到期的 timer 的 callback 按照調用順序放到 timer queue 中,之后循環會進入 timer 階段執行 timer queue 中的 callback。
這兩者的順序是不固定的,受到代碼運行環境的影響。如果兩者的 queue 都是空的,那么 event loop 會停留在 poll 階段,直到有一個 I/O 事件返回,循環會進入 I/O callback 階段,并立即執行這個事件的 callback。
check 階段
check 階段專門用來執行 setImmediate()
方法的 callback,當 poll 階段進入空閑狀態,并且 setImmediate queue 中有 callback 時,事件循環進入這個階段。
close 階段
當一個 socket 連接或者一個 handle 被突然關閉時(例如,調用了 socket.destroy() 方法),close 事件會被發送到這個階段執行回調;否則事件會用 process.nextTick() 方法發送出去。
timers 階段
這個階段執行所有到期的 timer 加入到 timer queue 中 callback。timer callback 指通過 setTimeout()
或 setInterval()
設定的 callback。
I/O callback 階段
這個階段主要執行大部分 I/O 事件的 callback,包括一些為操作系統執行的 callback,例如:一個 TCP 連接發生錯誤時,系統需要執行 callback 來獲得這個錯誤的報告。
process.nextTick() 與 setImmediate()
Node.js 中有三個常用的用來推遲任務執行的方法,分別是:process.nextTick()
,setTimeout()
(setInterval() 與之相同)和 setImmediate()
。
process.nextTick()
process.nextTick() 不在 event loop 的任何階段內執行,而是在各個階段切換的中間執行,即一個階段執行完畢準備進入到下一個階段前執行。
下面我們來看一段代碼:
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');
});
});
結果為:
nextTick1
nextTick2
setImmediate
nextTick3
setTimeout
從 poll --> check 階段,先執行process.nextTick,輸出 nextTick1,nextTick2;
然后進入 check 階段,執行setImmediate,輸出 setImmediate;
執行完 setImmediate 后,出 check,進入 close callback 前,輸出 nextTick3;
最后進入 timer 階段,執行 setTimeout,輸出 setTimeout。
setImmediate()
在三個方法中,setImmediate() 和 setTimeout() 這兩個方法很容易被弄混,然而實際上這兩個方法的意義確大為不同。
setTimeout()
是定義一個回調,并且希望這個回調在指定的時間間隔后第一時間去執行。注意這個“第一時間執行”,意味著,受到操作系統和當前執行任務的諸多影響,該回調并不會在我們預期的時間間隔后精準地執行。
setImmediate()
從意義上是立即執行的意思,但實際上是在一個固定的階段(poll 階段之后)才會執行回調。這個名字的意義和上面提到的 process.nextTick()
才是最匹配的。
setImmediate()
和 setTimeout(fn, 0)
表現上非常相似。猜猜下面這段代碼的結果是什么?
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
答案是不確定。這取決于這段代碼的運行環境,運行環境中各種復雜情況會導致在同步隊列里兩個方法的順序隨機決定。但是,在一種情況下可以準確判斷兩個方法回調的執行順序,那就是在一個 I/O 事件的回調中。下面這段代碼的順序永遠是固定的:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
答案永遠是:
setImmediate
setTimeout
在 I/O 事件的回調中,setImmediate() 方法的回調永遠在 setTimeout() 的回調前執行。
從上面 process.nextTick() 的示例代碼我們可以看出:多個 process.nextTick() 總是在一次 event loop 執行完;多個 setImmediate() 可能需要多次 event loop 才能執行完。這正是 Node.js 10.0 版添加 setImmediate() 方法的原因,否則像下面這樣遞歸調用 process.nextTick() 時,將會導致 Node 進入死循環,主線程根本不會去讀取事件隊列。
process.nextTick(function foo() {
process.nextTick(foo);
});
小結
JavaScript 的事件循環是這門語言中非常重要且基礎的概念,清楚的了解事件循環的執行順序和各階段的特點,可以使我們對一段異步代碼的執行順序有一個清晰的認知,從而減少代碼執行的不確定性。