setTimeout和setImmediate到底誰先執行,讓你徹底理解Event Loop


setTimeout和setImmediate到底誰先執行,讓你徹底理解Event Loop


筆者以前面試的時候經常遇到寫一堆setTimeout,setImmediate來問哪個先執行。本文主要就是來講這個問題的,但是不是簡單的講講哪個先,哪個後。籠統的知道setImmediate比setTimeout(fn, 0)先執行是不夠的,因為有些情況下setTimeout(fn, 0)是會比setImmediate先執行的。要徹底搞明白這個問題,我們需要系統的學習JS的異步機制和底層原理。本文就會從異步基本概念出發,一直講到Event Loop的底層原理,讓你徹底搞懂setTimeout,setImmediate,Promise, process.nextTick誰先誰後這一類問題。

同步和異步

同步異步簡單理解就是,同步的代碼都是按照書寫順序執行的,異步的代碼可能跟書寫順序不一樣,寫在後面的可能先執行。下面來看個例子:

<code>const syncFunc = () => {
const time = new Date().getTime();
while(true) {
if(new Date().getTime() - time > 2000) {
break;
}
}
console.log(2);
}

console.log(1);
syncFunc();
console.log(3);
複製代碼/<code>

上述代碼會先打印出1,然後調用syncFunc,syncFunc裡面while循環會運行2秒,然後打印出2,最後打印出3。所以這裡代碼的執行順序跟我們的書寫順序是一致,他是同步代碼:


setTimeout和setImmediate到底誰先執行,讓你徹底理解Event Loop

再來看個異步例子:

<code>const asyncFunc = () => {
setTimeout(() => {
console.log(2);
}, 2000);
}

console.log(1);
asyncFunc();
console.log(3);
複製代碼/<code>

上述代碼的輸出是:


setTimeout和setImmediate到底誰先執行,讓你徹底理解Event Loop

可以看到我們中間調用的asyncFunc裡面的2卻是最後輸出的,這是因為setTimeout是一個異步方法。他的作用是設置一個定時器,等定時器時間到了再執行回調裡面的代碼。所以異步就相當於做一件事,但是並不是馬上做,而是你先給別人打了個招呼,說xxx條件滿足的時候就幹什麼什麼。就像你晚上睡覺前在手機上設置了一個第二天早上7天的鬧鐘,就相當於給了手機一個異步事件,觸發條件是時間到達早上7點。使用異步的好處是你只需要設置好異步的觸發條件就可以去幹別的事情了,所以異步不會阻塞主幹上事件的執行。特別是對於JS這種只有一個線程的語言,如果都像我們第一個例子那樣去while(true),那瀏覽器就只有一直卡死了,只有等這個循環運行完才會有響應

JS異步是怎麼實現的

我們都知道JS是單線程的,那單線程是怎麼實現異步的呢?事實上所謂的"JS是單線程的"只是指JS的主運行線程只有一個,而不是整個運行環境都是單線程。JS的運行環境主要是瀏覽器,以大家都很熟悉的Chrome的內核為例,他不僅是多線程的,而且是多進程的:


setTimeout和setImmediate到底誰先執行,讓你徹底理解Event Loop

上圖只是一個概括分類,意思是Chrome有這幾類的進程和線程,並不是每種只有一個,比如渲染進程就有多個,每個選項卡都有自己的渲染進程。有時候我們使用Chrome會遇到某個選項卡崩潰或者沒有響應的情況,這個選項卡對應的渲染進程可能就崩潰了,但是其他選項卡並沒有用這個渲染進程,他們有自己的渲染進程,所以其他選項卡並不會受影響。這也是Chrome單個頁面崩潰並不會導致瀏覽器崩潰的原因,而不是像老IE那樣,一個頁面卡了導致整個瀏覽器都卡。

對於前端工程師來說,主要關心的還是渲染進程,下面來分別看下里面每個線程是做什麼的。

GUI線程

GUI線程就是渲染頁面的,他解析HTML和CSS,然後將他們構建成DOM樹和渲染樹就是這個線程負責的。

JS引擎線程

這個線程就是負責執行JS的主線程,前面說的"JS是單線程的"就是指的這個線程。大名鼎鼎的Chrome V8引擎就是在這個線程運行的。需要注意的是,這個線程跟GUI線程是互斥的。互斥的原因是JS也可以操作DOM,如果JS線程和GUI線程同時操作DOM,結果就混亂了,不知道到底渲染哪個結果。這帶來的後果就是如果JS長時間運行,GUI線程就不能執行,整個頁面就感覺卡死了。所以我們最開始例子的while(true)這樣長時間的同步代碼在真正開發時是絕對不允許的

定時器線程

前面異步例子的setTimeout其實就運行在這裡,他跟JS主線程根本不在同一個地方,所以“單線程的JS”能夠實現異步。JS的定時器方法還有setInterval,也是在這個線程。

事件觸發線程

定時器線程其實只是一個計時的作用,他並不會真正執行時間到了的回調,真正執行這個回調的還是JS主線程。所以當時間到了定時器線程會將這個回調事件給到事件觸發線程,然後事件觸發線程將它加到事件隊列裡面去。最終JS主線程從事件隊列取出這個回調執行。事件觸發線程不僅會將定時器事件放入任務隊列,其他滿足條件的事件也是他負責放進任務隊列。

異步HTTP請求線程

這個線程負責處理異步的ajax請求,當請求完成後,他也會通知事件觸發線程,然後事件觸發線程將這個事件放入事件隊列給主線程執行。

所以JS異步的實現靠的就是瀏覽器的多線程,當他遇到異步API時,就將這個任務交給對應的線程,當這個異步API滿足回調條件時,對應的線程又通過事件觸發線程將這個事件放入任務隊列,然後主線程從任務隊列取出事件繼續執行。這個流程我們多次提到了任務隊列,這其實就是Event Loop,下面我們詳細來講解下。

Event Loop

所謂Event Loop,就是事件循環,其實就是JS管理事件執行的一個流程,具體的管理辦法由他具體的運行環境確定。目前JS的主要運行環境有兩個,瀏覽器和Node.js。這兩個環境的Event Loop還有點區別,我們會分開來講。

瀏覽器的Event Loop

事件循環就是一個循環,是各個異步線程用來通訊和協同執行的機制。各個線程為了交換消息,還有一個公用的數據區,這就是事件隊列。各個異步線程執行完後,通過事件觸發線程將回調事件放到事件隊列,主線程每次幹完手上的活兒就來看看這個隊列有沒有新活兒,有的話就取出來執行。畫成一個流程圖就是這樣:


setTimeout和setImmediate到底誰先執行,讓你徹底理解Event Loop

流程講解如下:

主線程每次執行時,先看看要執行的是同步任務,還是異步的API 同步任務就繼續執行,一直執行完 遇到異步API就將它交給對應的異步線程,自己繼續執行同步任務 異步線程執行異步API,執行完後,將異步回調事件放入事件隊列上 主線程手上的同步任務幹完後就來事件隊列看看有沒有任務 主線程發現事件隊列有任務,就取出裡面的任務執行 主線程不斷循環上述流程

定時器不準

Event Loop的這個流程裡面其實還是隱藏了一些坑的,最典型的問題就是總是先執行同步任務,然後再執行事件隊列裡面的回調。這個特性就直接影響了定時器的執行,我們想想我們開始那個2秒定時器的執行流程:

主線程執行同步代碼 遇到setTimeout,將它交給定時器線程 定時器線程開始計時,2秒到了通知事件觸發線程 事件觸發線程將定時器回調放入事件隊列,異步流程到此結束 主線程如果有空,將定時器回調拿出來執行,如果沒空這個回調就一直放在隊列裡。

上述流程我們可以看出,如果主線程長時間被阻塞,定時器回調就沒機會執行,即使執行了,那時間也不準了,我們將開頭那兩個例子結合起來就可以看出這個效果:

<code>const syncFunc = (startTime) => {
const time = new Date().getTime();
while(true) {
if(new Date().getTime() - time > 5000) {
break;
}
}
const offset = new Date().getTime() - startTime;
console.log(`syncFunc run, time offset: ${offset}`);
}

const asyncFunc = (startTime) => {
setTimeout(() => {
const offset = new Date().getTime() - startTime;
console.log(`asyncFunc run, time offset: ${offset}`);
}, 2000);
}

const startTime = new Date().getTime();

asyncFunc(startTime);

syncFunc(startTime);
複製代碼/<code>

執行結果如下:


setTimeout和setImmediate到底誰先執行,讓你徹底理解Event Loop

通過結果可以看出,雖然我們先調用的asyncFunc,雖然asyncFunc寫的是2秒後執行,但是syncFunc的執行時間太長,達到了5秒,asyncFunc雖然在2秒的時候就已經進入了事件隊列,但是主線程一直在執行同步代碼,一直沒空,所以也要等到5秒後,同步代碼執行完畢才有機會執行這個定時器回調。所以再次強調,寫代碼時一定不要長時間佔用主線程

引入微任務

前面的流程圖我為了便於理解,簡化了事件隊列,其實事件隊列裡面的事件還可以分兩類:宏任務和微任務。微任務擁有更高的優先級,當事件循環遍歷隊列時,先檢查微任務隊列,如果裡面有任務,就全部拿來執行,執行完之後再執行一個宏任務。執行每個宏任務之前都要檢查下微任務隊列是否有任務,如果有,優先執行微任務隊列。所以完整的流程圖如下:


setTimeout和setImmediate到底誰先執行,讓你徹底理解Event Loop

上圖需要注意以下幾點:

一個Event Loop可以有一個或多個事件隊列,但是隻有一個微任務隊列。 微任務隊列全部執行完會重新渲染一次 每個宏任務執行完都會重新渲染一次 requestAnimationFrame處於渲染階段,不在微任務隊列,也不在宏任務隊列

所以想要知道一個異步API在哪個階段執行,我們得知道他是宏任務還是微任務。

常見宏任務有:

script (可以理解為外層同步代碼) setTimeout/setInterval setImmediate(Node.js) I/O UI事件 postMessage

常見微任務有:

Promise process.nextTick(Node.js) Object.observe MutaionObserver

上面這些事件類型中要注意Promise,他是微任務,也就是說他會在定時器前面運行,我們來看個例子:

<code>console.log('1');
setTimeout(() => {
console.log('2');
},0);
Promise.resolve().then(() => {
console.log('5');
})
new Promise((resolve) => {
console.log('3');
resolve();
}).then(() => {
console.log('4');
})

複製代碼/<code>

上述代碼的輸出是1,3,5,4,2。因為:

先輸出1,這個沒什麼說的,同步代碼最先執行 console.log('2');在setTimeout裡面,setTimeout是宏任務,“2”進入宏任務隊列 console.log('5');在Promise.then裡面,進入微任務隊列 console.log('3');在Promise構造函數的參數裡面,這其實是同步代碼,直接輸出 console.log('4');在then裡面,他會進入微任務隊列,檢查事件隊列時先執行微任務 同步代碼運行結果是“1,3” 然後檢查微任務隊列,輸出“5,4” 最後執行宏任務隊列,輸出“2”

Node.js的Event Loop

Node.js是運行在服務端的js,雖然他也用到了V8引擎,但是他的服務目的和環境不同,導致了他API與原生JS有些區別,他的Event Loop還要處理一些I/O,比如新的網絡連接等,所以與瀏覽器Event Loop也是不一樣的。Node的Event Loop是分階段的,如下圖所示:


setTimeout和setImmediate到底誰先執行,讓你徹底理解Event Loop

timers: 執行setTimeout和setInterval的回調 pending callbacks: 執行延遲到下一個循環迭代的 I/O 回調 idle, prepare: 僅系統內部使用 poll: 檢索新的 I/O 事件;執行與 I/O 相關的回調。事實上除了其他幾個階段處理的事情,其他幾乎所有的異步都在這個階段處理。 check: setImmediate在這裡執行 close callbacks: 一些關閉的回調函數,如:socket.on('close', ...)

每個階段都有一個自己的先進先出的隊列,只有當這個隊列的事件執行完或者達到該階段的上限時,才會進入下一個階段。在每次事件循環之間,Node.js都會檢查它是否在等待任何一個I/O或者定時器,如果沒有的話,程序就關閉退出了。我們的直觀感受就是,如果一個Node程序只有同步代碼,你在控制檯運行完後,他就自己退出了。

還有個需要注意的是poll階段,他後面並不一定每次都是check階段,poll隊列執行完後,如果沒有setImmediate但是有定時器到期,他會繞回去執行定時器階段:


setTimeout和setImmediate到底誰先執行,讓你徹底理解Event Loop

setImmediate和setTimeout

上面的這個流程說簡單點就是在一個異步流程裡,setImmediate會比定時器先執行,我們寫點代碼來試試:

<code>console.log('outer');

setTimeout(() => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
}, 0);
複製代碼/<code>

上述代碼運行如下:


setTimeout和setImmediate到底誰先執行,讓你徹底理解Event Loop

和我們前面講的一樣,setImmediate先執行了。我們來理一下這個流程:

外層是一個setTimeout,所以執行他的回調的時候已經在timers階段了 處理裡面的setTimeout,因為本次循環的timers正在執行,所以他的回調其實加到了下個timers階段 處理裡面的setImmediate,將它的回調加入check階段的隊列 外層timers階段執行完,進入pending callbacks,idle, prepare,poll,這幾個隊列都是空的,所以繼續往下 到了check階段,發現了setImmediate的回調,拿出來執行 然後是close callbacks,隊列是空的,跳過 又是timers階段,執行我們的console

但是請注意我們上面console.log('setTimeout')和console.log('setImmediate')都包在了一個setTimeout裡面,如果直接寫在最外層會怎麼樣呢?代碼改寫如下:

<code>console.log('outer');

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

setImmediate(() => {
console.log('setImmediate');
});
複製代碼/<code>

我們來運行下看看效果:


setTimeout和setImmediate到底誰先執行,讓你徹底理解Event Loop

好像是setTimeout先輸出來,我們多運行幾次看看:


setTimeout和setImmediate到底誰先執行,讓你徹底理解Event Loop

怎麼setImmediate又先出來了,這代碼是見鬼了還是啥?這個世界上是沒有鬼怪的,所以事情都有原因的,我們順著之前的Event Loop再來理一下。在理之前,需要告訴大家一件事情,node.js裡面setTimeout(fn, 0)會被強制改為setTimeout(fn, 1),這在官方文檔中有說明。(說到這裡順便提下,HTML 5裡面setTimeout最小的時間限制是4ms)。原理我們都有了,我們來理一下流程:

外層同步代碼一次性全部執行完,遇到異步API就塞到對應的階段 遇到setTimeout,雖然設置的是0毫秒觸發,但是被node.js強制改為1毫秒,塞入times階段 遇到setImmediate塞入check階段 同步代碼執行完畢,進入Event Loop 先進入times階段,檢查當前時間過去了1毫秒沒有,如果過了1毫秒,滿足setTimeout條件,執行回調,如果沒過1毫秒,跳過 跳過空的階段,進入check階段,執行setImmediate回調

通過上述流程的梳理,我們發現關鍵就在這個1毫秒,如果同步代碼執行時間較長,進入Event Loop的時候1毫秒已經過了,setTimeout執行,如果1毫秒還沒到,就先執行了setImmediate。每次我們運行腳本時,機器狀態可能不一樣,導致運行時有1毫秒的差距,一會兒setTimeout先執行,一會兒setImmediate先執行。但是這種情況只會發生在還沒進入timers階段的時候。像我們第一個例子那樣,因為已經在timers階段,所以裡面的setTimeout只能等下個循環了,所以setImmediate肯定先執行。同理的還有其他poll階段的API也是這樣的,比如:

<code>var fs = require('fs')

fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
複製代碼/<code>

這裡setTimeout和setImmediate在readFile的回調裡面,由於readFile回調是I/O操作,他本身就在poll階段,所以他裡面的定時器只能進入下個timers階段,但是setImmediate卻可以在接下來的check階段運行,所以setImmediate肯定先運行,他運行完後,去檢查timers,才會運行setTimeout。

類似的,我們再來看一段代碼,如果他們兩個不是在最外層,而是在setImmediate的回調裡面,其實情況跟外層一樣,結果也是隨緣的,看下面代碼:

<code>console.log('outer');

setImmediate(() => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
複製代碼/<code>

原因跟寫在最外層差不多,因為setImmediate已經在check階段了,裡面的循環會從timers階段開始,會先看setTimeout的回調,如果這時候已經過了1毫秒,就執行他,如果沒過就執行setImmediate。

process.nextTick()

process.nextTick()是一個特殊的異步API,他不屬於任何的Event Loop階段。事實上Node在遇到這個API時,Event Loop根本就不會繼續進行,會馬上停下來執行process.nextTick(),這個執行完後才會繼續Event Loop。我們寫個例子來看下:

<code>var fs = require('fs') 


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

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

process.nextTick(() => {
console.log('nextTick 2');
});
});

process.nextTick(() => {
console.log('nextTick 1');
});
});
複製代碼/<code>

這段代碼的打印如下:


setTimeout和setImmediate到底誰先執行,讓你徹底理解Event Loop

我們還是來理一下流程:

我們代碼基本都在readFile回調裡面,他自己執行時,已經在poll階段 遇到setTimeout(fn, 0),其實是setTimeout(fn, 1),塞入後面的timers階段 遇到setImmediate,塞入後面的check階段 遇到nextTick,立馬執行,輸出'nextTick 1' 到了check階段,輸出'setImmediate',又遇到個nextTick,立馬輸出'nextTick 2' 到了下個timers階段,輸出'setTimeout'

這種機制其實類似於我們前面講的微任務,但是並不完全一樣,比如同時有nextTick和Promise的時候,肯定是nextTick先執行,原因是nextTick的隊列比Promise隊列優先級更高。來看個例子:

<code>const promise = Promise.resolve()
setImmediate(() => {
console.log('setImmediate');
});
promise.then(()=>{
console.log('promise')
})
process.nextTick(()=>{
console.log('nextTick')
})
複製代碼/<code>

代碼運行結果如下:


setTimeout和setImmediate到底誰先執行,讓你徹底理解Event Loop

總結

本文從異步基本概念出發一直講到了瀏覽器和Node.js的Event Loop,現在我們再來總結一下:

  1. JS所謂的“單線程”只是指主線程只有一個,並不是整個運行環境都是單線程
  2. JS的異步靠底層的多線程實現
  3. 不同的異步API對應不同的實現線程
  4. 異步線程與主線程通訊靠的是Event Loop
  5. 異步線程完成任務後將其放入任務隊列
  6. 主線程不斷輪詢任務隊列,拿出任務執行
  7. 任務隊列有宏任務隊列和微任務隊列的區別
  8. 微任務隊列的優先級更高,所有微任務處理完後才會處理宏任務
  9. Promise是微任務
  10. Node.js的Event Loop跟瀏覽器的Event Loop不一樣,他是分階段的
  11. setImmediate和setTimeout(fn, 0)哪個回調先執行,需要看他們本身在哪個階段註冊的,如果在定時器回調或者I/O回調裡面,setImmediate肯定先執行。如果在最外層或者setImmediate回調裡面,哪個先執行取決於當時機器狀況。
  12. process.nextTick不在Event Loop的任何階段,他是一個特殊API,他會立即執行,然後才會繼續執行Event Loop

文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支持是作者持續創作的動力。

GitHub項目地址: https://github.com/dennis-jiang/Front-End-Knowledges


分享到:


相關文章: