在上一篇文章 從進程和線程了解瀏覽器的工作原理 中,我們已經(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í)行的過程如下:
- 所有同步任務(wù)都在主線程上執(zhí)行炉媒,形成一個執(zhí)行棧(execution context stack)踪区。
- 主線程之外還存在一個任務(wù)隊列。當遇到一個異步任務(wù)時吊骤,并不會一直等待其返回結(jié)果缎岗,而是會將這個異步任務(wù)掛起,繼續(xù)執(zhí)行執(zhí)行棧中的其他任務(wù)白粉。當一個異步任務(wù)返回結(jié)果后传泊,就會在任務(wù)隊列中放置一個事件。
- 被放入任務(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í)行一次闽晦,后者為反復(fù)執(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-task
和 micro-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)并不會在我們預(yù)期的時間間隔后精準地執(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)境中各種復(fù)雜情況會導(dǎo)致在同步隊列里兩個方法的順序隨機決定棚放。但是照瘾,在一種情況下可以準確判斷兩個方法回調(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() 時如绸,將會導(dǎo)致 Node 進入死循環(huán)嘱朽,主線程根本不會去讀取事件隊列。
process.nextTick(function foo() {
process.nextTick(foo);
});
小結(jié)
JavaScript 的事件循環(huán)是這門語言中非常重要且基礎(chǔ)的概念怔接,清楚的了解事件循環(huán)的執(zhí)行順序和各階段的特點搪泳,可以使我們對一段異步代碼的執(zhí)行順序有一個清晰的認知,從而減少代碼執(zhí)行的不確定性扼脐。