從 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í)行一次闽晦,后者為反復(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-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)并不會在我們預(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í)行的不確定性扼脐。

參考資料

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末瓦侮,一起剝皮案震驚了整個濱河市艰赞,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌肚吏,老刑警劉巖猖毫,帶你破解...
    沈念sama閱讀 219,270評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異须喂,居然都是意外死亡吁断,警方通過查閱死者的電腦和手機趁蕊,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,489評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來仔役,“玉大人掷伙,你說我怎么就攤上這事∮直” “怎么了任柜?”我有些...
    開封第一講書人閱讀 165,630評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長沛厨。 經(jīng)常有香客問我宙地,道長,這世上最難降的妖魔是什么逆皮? 我笑而不...
    開封第一講書人閱讀 58,906評論 1 295
  • 正文 為了忘掉前任宅粥,我火速辦了婚禮,結(jié)果婚禮上电谣,老公的妹妹穿的比我還像新娘秽梅。我一直安慰自己,他們只是感情好剿牺,可當我...
    茶點故事閱讀 67,928評論 6 392
  • 文/花漫 我一把揭開白布企垦。 她就那樣靜靜地躺著,像睡著了一般晒来。 火紅的嫁衣襯著肌膚如雪钞诡。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,718評論 1 305
  • 那天湃崩,我揣著相機與錄音荧降,去河邊找鬼。 笑死竹习,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的列牺。 我是一名探鬼主播整陌,決...
    沈念sama閱讀 40,442評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼瞎领!你這毒婦竟也來了泌辫?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,345評論 0 276
  • 序言:老撾萬榮一對情侶失蹤九默,失蹤者是張志新(化名)和其女友劉穎震放,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體驼修,經(jīng)...
    沈念sama閱讀 45,802評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡殿遂,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,984評論 3 337
  • 正文 我和宋清朗相戀三年诈铛,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片墨礁。...
    茶點故事閱讀 40,117評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡幢竹,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出恩静,到底是詐尸還是另有隱情焕毫,我是刑警寧澤,帶...
    沈念sama閱讀 35,810評論 5 346
  • 正文 年R本政府宣布驶乾,位于F島的核電站邑飒,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏级乐。R本人自食惡果不足惜疙咸,卻給世界環(huán)境...
    茶點故事閱讀 41,462評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望唇牧。 院中可真熱鬧罕扎,春花似錦、人聲如沸丐重。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,011評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽扮惦。三九已至臀蛛,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間崖蜜,已是汗流浹背浊仆。 一陣腳步聲響...
    開封第一講書人閱讀 33,139評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留豫领,地道東北人抡柿。 一個月前我還...
    沈念sama閱讀 48,377評論 3 373
  • 正文 我出身青樓,卻偏偏與公主長得像等恐,于是被迫代替她去往敵國和親洲劣。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 45,060評論 2 355

推薦閱讀更多精彩內(nèi)容