筆者以前面試的時候經常遇到寫一堆setTimeout
,setImmediate
來問哪個先執行。本文主要就是來講這個問題的,但是不是簡單的講講哪個先,哪個後。籠統的知道setImmediate
比setTimeout(fn, 0)
先執行是不夠的,因為有些情況下setTimeout(fn, 0)
是會比setImmediate
先執行的。要徹底搞明白這個問題,我們需要系統的學習JS的非同步機制和底層原理。本文就會從非同步基本概念出發,一直講到Event Loop的底層原理,讓你徹底搞懂setTimeout
,setImmediate
,Promise
, process.nextTick
誰先誰後這一類問題。
同步和非同步
同步非同步簡單理解就是,同步的程式碼都是按照書寫順序執行的,非同步的程式碼可能跟書寫順序不一樣,寫在後面的可能先執行。下面來看個例子:
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);
上述程式碼會先列印出1,然後呼叫syncFunc
,syncFunc
裡面while迴圈會執行2秒,然後列印出2,最後列印出3。所以這裡程式碼的執行順序跟我們的書寫順序是一致,他是同步程式碼:
再來看個非同步例子:
const asyncFunc = () => {
setTimeout(() => {
console.log(2);
}, 2000);
}
console.log(1);
asyncFunc();
console.log(3);
上述程式碼的輸出是:
可以看到我們中間呼叫的asyncFunc
裡面的2卻是最後輸出的,這是因為setTimeout
是一個非同步方法。他的作用是設定一個定時器,等定時器時間到了再執行回撥裡面的程式碼。所以非同步就相當於做一件事,但是並不是馬上做,而是你先給別人打了個招呼,說xxx條件滿足的時候就幹什麼什麼。就像你晚上睡覺前在手機上設定了一個第二天早上7天的鬧鐘,就相當於給了手機一個非同步事件,觸發條件是時間到達早上7點。使用非同步的好處是你只需要設定好非同步的觸發條件就可以去幹別的事情了,所以非同步不會阻塞主幹上事件的執行。特別是對於JS這種只有一個執行緒的語言,如果都像我們第一個例子那樣去while(true)
,那瀏覽器就只有一直卡死了,只有等這個迴圈執行完才會有響應。
JS非同步是怎麼實現的
我們都知道JS是單執行緒的,那單執行緒是怎麼實現非同步的呢?事實上所謂的"JS是單執行緒的"只是指JS的主執行執行緒只有一個,而不是整個執行環境都是單執行緒。JS的執行環境主要是瀏覽器,以大家都很熟悉的Chrome的核心為例,他不僅是多執行緒的,而且是多程式的:
上圖只是一個概括分類,意思是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
事件迴圈就是一個迴圈,是各個非同步執行緒用來通訊和協同執行的機制。各個執行緒為了交換訊息,還有一個公用的資料區,這就是事件佇列。各個非同步執行緒執行完後,通過事件觸發執行緒將回撥事件放到事件佇列,主執行緒每次幹完手上的活兒就來看看這個佇列有沒有新活兒,有的話就取出來執行。畫成一個流程圖就是這樣:
流程講解如下:
- 主執行緒每次執行時,先看看要執行的是同步任務,還是非同步的API
- 同步任務就繼續執行,一直執行完
- 遇到非同步API就將它交給對應的非同步執行緒,自己繼續執行同步任務
- 非同步執行緒執行非同步API,執行完後,將非同步回撥事件放入事件佇列上
- 主執行緒手上的同步任務幹完後就來事件佇列看看有沒有任務
- 主執行緒發現事件佇列有任務,就取出裡面的任務執行
- 主執行緒不斷迴圈上述流程
定時器不準
Event Loop的這個流程裡面其實還是隱藏了一些坑的,最典型的問題就是總是先執行同步任務,然後再執行事件佇列裡面的回撥。這個特性就直接影響了定時器的執行,我們想想我們開始那個2秒定時器的執行流程:
- 主執行緒執行同步程式碼
- 遇到
setTimeout
,將它交給定時器執行緒- 定時器執行緒開始計時,2秒到了通知事件觸發執行緒
- 事件觸發執行緒將定時器回撥放入事件佇列,非同步流程到此結束
- 主執行緒如果有空,將定時器回撥拿出來執行,如果沒空這個回撥就一直放在佇列裡。
上述流程我們可以看出,如果主執行緒長時間被阻塞,定時器回撥就沒機會執行,即使執行了,那時間也不準了,我們將開頭那兩個例子結合起來就可以看出這個效果:
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);
執行結果如下:
通過結果可以看出,雖然我們先呼叫的asyncFunc
,雖然asyncFunc
寫的是2秒後執行,但是syncFunc
的執行時間太長,達到了5秒,asyncFunc
雖然在2秒的時候就已經進入了事件佇列,但是主執行緒一直在執行同步程式碼,一直沒空,所以也要等到5秒後,同步程式碼執行完畢才有機會執行這個定時器回撥。所以再次強調,寫程式碼時一定不要長時間佔用主執行緒。
引入微任務
前面的流程圖我為了便於理解,簡化了事件佇列,其實事件佇列裡面的事件還可以分兩類:巨集任務和微任務。微任務擁有更高的優先順序,當事件迴圈遍歷佇列時,先檢查微任務佇列,如果裡面有任務,就全部拿來執行,執行完之後再執行一個巨集任務。執行每個巨集任務之前都要檢查下微任務佇列是否有任務,如果有,優先執行微任務佇列。所以完整的流程圖如下:
上圖需要注意以下幾點:
- 一個Event Loop可以有一個或多個事件佇列,但是隻有一個微任務佇列。
- 微任務佇列全部執行完會重新渲染一次
- 每個巨集任務執行完都會重新渲染一次
- requestAnimationFrame處於渲染階段,不在微任務佇列,也不在巨集任務佇列
所以想要知道一個非同步API在哪個階段執行,我們得知道他是巨集任務還是微任務。
常見巨集任務有:
script
(可以理解為外層同步程式碼)setTimeout/setInterval
setImmediate
(Node.js)- I/O
- UI事件
postMessage
常見微任務有:
Promise
process.nextTick
(Node.js)Object.observe
MutaionObserver
上面這些事件型別中要注意Promise
,他是微任務,也就是說他會在定時器前面執行,我們來看個例子:
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');
})
上述程式碼的輸出是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是分階段的,如下圖所示:
- 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
但是有定時器到期,他會繞回去執行定時器階段:
setImmediate
和setTimeout
上面的這個流程說簡單點就是在一個非同步流程裡,setImmediate
會比定時器先執行,我們寫點程式碼來試試:
console.log('outer');
setTimeout(() => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
}, 0);
上述程式碼執行如下:
和我們前面講的一樣,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
裡面,如果直接寫在最外層會怎麼樣呢?程式碼改寫如下:
console.log('outer');
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
我們來執行下看看效果:
好像是setTimeout
先輸出來,我們多執行幾次看看:
怎麼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也是這樣的,比如:
var fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
這裡setTimeout
和setImmediate
在readFile
的回撥裡面,由於readFile
回撥是I/O操作,他本身就在poll
階段,所以他裡面的定時器只能進入下個timers
階段,但是setImmediate
卻可以在接下來的check
階段執行,所以setImmediate
肯定先執行,他執行完後,去檢查timers
,才會執行setTimeout
。
類似的,我們再來看一段程式碼,如果他們兩個不是在最外層,而是在setImmediate
的回撥裡面,其實情況跟外層一樣,結果也是隨緣的,看下面程式碼:
console.log('outer');
setImmediate(() => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
原因跟寫在最外層差不多,因為setImmediate
已經在check
階段了,裡面的迴圈會從timers
階段開始,會先看setTimeout
的回撥,如果這時候已經過了1毫秒,就執行他,如果沒過就執行setImmediate
。
process.nextTick()
process.nextTick()
是一個特殊的非同步API,他不屬於任何的Event Loop階段。事實上Node在遇到這個API時,Event Loop根本就不會繼續進行,會馬上停下來執行process.nextTick()
,這個執行完後才會繼續Event Loop。我們寫個例子來看下:
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');
});
});
這段程式碼的列印如下:
我們還是來理一下流程:
- 我們程式碼基本都在
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
佇列優先順序更高。來看個例子:
const promise = Promise.resolve()
setImmediate(() => {
console.log('setImmediate');
});
promise.then(()=>{
console.log('promise')
})
process.nextTick(()=>{
console.log('nextTick')
})
程式碼執行結果如下:
總結
本文從非同步基本概念出發一直講到了瀏覽器和Node.js的Event Loop,現在我們再來總結一下:
- JS所謂的“單執行緒”只是指主執行緒只有一個,並不是整個執行環境都是單執行緒
- JS的非同步靠底層的多執行緒實現
- 不同的非同步API對應不同的實現執行緒
- 非同步執行緒與主執行緒通訊靠的是Event Loop
- 非同步執行緒完成任務後將其放入任務佇列
- 主執行緒不斷輪詢任務佇列,拿出任務執行
- 任務佇列有巨集任務佇列和微任務佇列的區別
- 微任務佇列的優先順序更高,所有微任務處理完後才會處理巨集任務
-
Promise
是微任務 - Node.js的Event Loop跟瀏覽器的Event Loop不一樣,他是分階段的
-
setImmediate
和setTimeout(fn, 0)
哪個回撥先執行,需要看他們本身在哪個階段註冊的,如果在定時器回撥或者I/O回撥裡面,setImmediate
肯定先執行。如果在最外層或者setImmediate
回撥裡面,哪個先執行取決於當時機器狀況。 -
process.nextTick
不在Event Loop的任何階段,他是一個特殊API,他會立即執行,然後才會繼續執行Event Loop
文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。
作者博文GitHub專案地址: https://github.com/dennis-jiang/Front-End-Knowledges