詳解JavaScript中的Event Loop(事件循環(huán))機(jī)制
前言
我們都知道,javascript從誕生之日起就是一門(mén)單線(xiàn)程的非阻塞的腳本語(yǔ)言。這是由其最初的用途來(lái)決定的:與瀏覽器交互。
單線(xiàn)程意味著,javascript代碼在執(zhí)行的任何時(shí)候,都只有一個(gè)主線(xiàn)程來(lái)處理所有的任務(wù)。
而非阻塞則是當(dāng)代碼需要進(jìn)行一項(xiàng)異步任務(wù)(無(wú)法立刻返回結(jié)果,需要花一定時(shí)間才能返回的任務(wù),如I/O事件)的時(shí)候,主線(xiàn)程會(huì)掛起(pending)這個(gè)任務(wù),然后在異步任務(wù)返回結(jié)果的時(shí)候再根據(jù)一定規(guī)則去執(zhí)行相應(yīng)的回調(diào)。
單線(xiàn)程是必要的,也是javascript這門(mén)語(yǔ)言的基石,原因之一在其最初也是最主要的執(zhí)行環(huán)境——瀏覽器中,我們需要進(jìn)行各種各樣的dom操作。試想一下
如果javascript是多線(xiàn)程的,那么當(dāng)兩個(gè)線(xiàn)程同時(shí)對(duì)dom進(jìn)行一項(xiàng)操作,例如一個(gè)向其添加事件,而另一個(gè)刪除了這個(gè)dom,此時(shí)該如何處理呢?因此,為了保證不會(huì)
發(fā)生類(lèi)似于這個(gè)例子中的情景,javascript選擇只用一個(gè)主線(xiàn)程來(lái)執(zhí)行代碼,這樣就保證了程序執(zhí)行的一致性。
當(dāng)然,現(xiàn)如今人們也意識(shí)到,單線(xiàn)程在保證了執(zhí)行順序的同時(shí)也限制了javascript的效率,因此開(kāi)發(fā)出了web worker技術(shù)。這項(xiàng)技術(shù)號(hào)稱(chēng)讓javascript成為一門(mén)多線(xiàn)程語(yǔ)言。
然而,使用web worker技術(shù)開(kāi)的多線(xiàn)程有著諸多限制,例如:所有新線(xiàn)程都受主線(xiàn)程的完全控制,不能獨(dú)立執(zhí)行。這意味著這些“線(xiàn)程”
實(shí)際上應(yīng)屬于主線(xiàn)程的子線(xiàn)程。另外,這些子線(xiàn)程并沒(méi)有執(zhí)行I/O操作的權(quán)限,只能為主線(xiàn)程分擔(dān)一些諸如計(jì)算等任務(wù)。所以嚴(yán)格來(lái)講這些線(xiàn)程并沒(méi)有完整的功能,也因此這項(xiàng)技術(shù)并非改變了javascript語(yǔ)言的單線(xiàn)程本質(zhì)。
可以預(yù)見(jiàn),未來(lái)的javascript也會(huì)一直是一門(mén)單線(xiàn)程的語(yǔ)言。
話(huà)說(shuō)回來(lái),前面提到j(luò)avascript的另一個(gè)特點(diǎn)是“非阻塞”,那么javascript引擎到底是如何實(shí)現(xiàn)的這一點(diǎn)呢?答案就是今天這篇文章的主角——event loop(事件循環(huán))。
注:雖然nodejs中的也存在與傳統(tǒng)瀏覽器環(huán)境下的相似的事件循環(huán)。然而兩者間卻有著諸多不同,故把兩者分開(kāi),單獨(dú)解釋。
正文
瀏覽器環(huán)境下js引擎的事件循環(huán)機(jī)制
1.執(zhí)行棧與事件隊(duì)列
2.macro task與micro task
以上的事件循環(huán)過(guò)程是一個(gè)宏觀的表述,實(shí)際上因?yàn)楫惒饺蝿?wù)之間并不相同,因此他們的執(zhí)行優(yōu)先級(jí)也有區(qū)別。不同的異步任務(wù)被分為兩類(lèi):微任務(wù)(micro task)和宏任務(wù)(macro task)。
以下事件屬于宏任務(wù):
setInterval()-
setTimeout()
以下事件屬于微任務(wù) new Promise()-
new MutaionObserver()
前面我們介紹過(guò),在一個(gè)事件循環(huán)中,異步事件返回結(jié)果后會(huì)被放到一個(gè)任務(wù)隊(duì)列中。然而,根據(jù)這個(gè)異步事件的類(lèi)型,這個(gè)事件實(shí)際上會(huì)被對(duì)應(yīng)的宏任務(wù)隊(duì)列或者微任務(wù)隊(duì)列中去。并且在當(dāng)前執(zhí)行棧為空的時(shí)候,主線(xiàn)程會(huì)
查看微任務(wù)隊(duì)列是否有事件存在。如果不存在,那么再去宏任務(wù)隊(duì)列中取出一個(gè)事件并把對(duì)應(yīng)的回到加入當(dāng)前執(zhí)行棧;如果存在,則會(huì)依次執(zhí)行隊(duì)列中事件對(duì)應(yīng)的回調(diào),直到微任務(wù)隊(duì)列為空,然后去宏任務(wù)隊(duì)列中取出最前面的一個(gè)事件,把對(duì)應(yīng)的回調(diào)加入當(dāng)前執(zhí)行棧…如此反復(fù),進(jìn)入循環(huán)。
我們只需記住當(dāng)當(dāng)前執(zhí)行棧執(zhí)行完畢時(shí)會(huì)立刻先處理所有微任務(wù)隊(duì)列中的事件,然后再去宏任務(wù)隊(duì)列中取出一個(gè)事件。同一次事件循環(huán)中,微任務(wù)永遠(yuǎn)在宏任務(wù)之前執(zhí)行。
這樣就能解釋下面這段代碼的結(jié)果:
setTimeout(function () {
console.log(1);
});
new Promise(function(resolve,reject){
console.log(2)
resolve(3)
}).then(function(val){
console.log(val);
})
結(jié)果為:
2
3
1node環(huán)境下的事件循環(huán)機(jī)制
1.與瀏覽器環(huán)境有何不同?
在node中,事件循環(huán)表現(xiàn)出的狀態(tài)與瀏覽器中大致相同。不同的是node中有一套自己的模型。node中事件循環(huán)的實(shí)現(xiàn)是依靠的libuv引擎。我們知道node選擇chrome v8引擎作為js解釋器,v8引擎將js代碼分析后去調(diào)用對(duì)應(yīng)的node api,而這些api最后則由libuv引擎驅(qū)動(dòng),執(zhí)行對(duì)應(yīng)的任務(wù),并把不同的事件放在不同的隊(duì)列中等待主線(xiàn)程執(zhí)行。
因此實(shí)際上node中的事件循環(huán)存在于libuv引擎中。2.事件循環(huán)模型
下面是一個(gè)libuv引擎中的事件循環(huán)的模型:
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<──connections─── │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
注:模型中的每一個(gè)方塊代表事件循環(huán)的一個(gè)階段
這個(gè)模型是node官網(wǎng)上的一篇文章中給出的,我下面的解釋也都來(lái)源于這篇文章。我會(huì)在文末把文章地址貼出來(lái),有興趣的朋友可以親自與看看原文。3.事件循環(huán)各階段詳解
從上面這個(gè)模型中,我們可以大致分析出node中的事件循環(huán)的順序:
外部輸入數(shù)據(jù)→輪詢(xún)階段(poll)→檢查階段(check)→關(guān)閉事件回調(diào)階段(close callback)→定時(shí)器檢測(cè)階段(timer)→I/O事件回調(diào)階段(I/O callbacks)→閑置階段(idle, prepare)→輪詢(xún)階段…
以上各階段的名稱(chēng)是根據(jù)我個(gè)人理解的翻譯,為了避免錯(cuò)誤和歧義,下面解釋的時(shí)候會(huì)用英文來(lái)表示這些階段。
這些階段大致的功能如下: - timers: 這個(gè)階段執(zhí)行定時(shí)器隊(duì)列中的回調(diào)如
setTimeout()和setInterval()。 - I/O callbacks: 這個(gè)階段執(zhí)行大部分I/O事件的回調(diào)。但是不包括close事件,定時(shí)器和
setImmediate()的回調(diào)。 - idle, prepare: 這個(gè)階段僅在內(nèi)部使用,可以不必理會(huì)。
- poll: 等待新的I/O事件,node在一些特殊情況下會(huì)阻塞在這里。
- check:
setImmediate()的回調(diào)會(huì)在這個(gè)階段執(zhí)行。 - close callbacks: 例如
socket.on('close', ...)這種close事件的回調(diào)。
下面我們來(lái)按照代碼第一次進(jìn)入libuv引擎后的順序來(lái)詳細(xì)解說(shuō)這些階段:poll階段
當(dāng)個(gè)v8引擎將js代碼解析后傳入libuv引擎后,循環(huán)首先進(jìn)入poll階段。poll階段的執(zhí)行邏輯如下:
先查看poll queue中是否有事件,有任務(wù)就按先進(jìn)先出的順序依次執(zhí)行回調(diào)。
當(dāng)queue為空時(shí),會(huì)檢查是否有setImmediate()的callback,如果有就進(jìn)入check階段執(zhí)行這些callback。但同時(shí)也會(huì)檢查是否有到期的timer,如果有,就把這些到期的timer的callback按照調(diào)用順序放到timer queue中,之后循環(huán)會(huì)進(jìn)入timer階段執(zhí)行queue中的 callback。
這兩者的順序是不固定的,收到代碼運(yùn)行的環(huán)境的影響。如果兩者的queue都是空的,那么loop會(huì)在poll階段停留,直到有一個(gè)i/o事件返回,循環(huán)會(huì)進(jìn)入i/o callback階段并立即執(zhí)行這個(gè)事件的callback。
值得注意的是,poll階段在執(zhí)行poll queue中的回調(diào)時(shí)實(shí)際上不會(huì)無(wú)限的執(zhí)行下去。有兩種情況poll階段會(huì)終止執(zhí)行poll queue中的下一個(gè)回調(diào):1.所有回調(diào)執(zhí)行完畢。2.執(zhí)行數(shù)超過(guò)了node的限制。check階段
check階段專(zhuān)門(mén)用來(lái)執(zhí)行setImmediate()方法的回調(diào),當(dāng)poll階段進(jìn)入空閑狀態(tài),并且setImmediate queue中有callback時(shí),事件循環(huán)進(jìn)入這個(gè)階段。close階段
當(dāng)一個(gè)socket連接或者一個(gè)handle被突然關(guān)閉時(shí)(例如調(diào)用了socket.destroy()方法),close事件會(huì)被發(fā)送到這個(gè)階段執(zhí)行回調(diào)。否則事件會(huì)用process.nextTick()方法發(fā)送出去。timer階段
這個(gè)階段以先進(jìn)先出的方式執(zhí)行所有到期的timer加入timer隊(duì)列里的callback,一個(gè)timer callback指得是一個(gè)通過(guò)setTimeout或者setInterval函數(shù)設(shè)置的回調(diào)函數(shù)。I/O callback階段
如上文所言,這個(gè)階段主要執(zhí)行大部分I/O事件的回調(diào),包括一些為操作系統(tǒng)執(zhí)行的回調(diào)。例如一個(gè)TCP連接生錯(cuò)誤時(shí),系統(tǒng)需要執(zhí)行回調(diào)來(lái)獲得這個(gè)錯(cuò)誤的報(bào)告。4.process.nextTick,setTimeout與setImmediate的區(qū)別與使用場(chǎng)景
在node中有三個(gè)常用的用來(lái)推遲任務(wù)執(zhí)行的方法:process.nextTick,setTimeout(setInterval與之相同)與setImmediate
這三者間存在著一些非常不同的區(qū)別:process.nextTick()
盡管沒(méi)有提及,但是實(shí)際上node中存在著一個(gè)特殊的隊(duì)列,即nextTick queue。這個(gè)隊(duì)列中的回調(diào)執(zhí)行雖然沒(méi)有被表示為一個(gè)階段,當(dāng)時(shí)這些事件卻會(huì)在每一個(gè)階段執(zhí)行完畢準(zhǔn)備進(jìn)入下一個(gè)階段時(shí)優(yōu)先執(zhí)行。當(dāng)事件循環(huán)準(zhǔn)備進(jìn)入下一個(gè)階段之前,會(huì)先檢查nextTick queue中是否有任務(wù),如果有,那么會(huì)先清空這個(gè)隊(duì)列。與執(zhí)行poll queue中的任務(wù)不同的是,這個(gè)操作在隊(duì)列清空前是不會(huì)停止的。這也就意味著,錯(cuò)誤的使用process.nextTick()方法會(huì)導(dǎo)致node進(jìn)入一個(gè)死循環(huán)。。直到內(nèi)存泄漏。
那么合適使用這個(gè)方法比較合適呢?下面有一個(gè)例子:
const server = net.createServer(() => {}).listen(8080);
server.on(‘listening’, () => {});
這個(gè)例子中當(dāng),當(dāng)listen方法被調(diào)用時(shí),除非端口被占用,否則會(huì)立刻綁定在對(duì)應(yīng)的端口上。這意味著此時(shí)這個(gè)端口可以立刻觸發(fā)listening事件并執(zhí)行其回調(diào)。然而,這時(shí)候on('listening)還沒(méi)有將callback設(shè)置好,自然沒(méi)有callback可以執(zhí)行。為了避免出現(xiàn)這種情況,node會(huì)在listen事件中使用process.nextTick()方法,確保事件在回調(diào)函數(shù)綁定后被觸發(fā)。setTimeout()和setImmediate()
在三個(gè)方法中,這兩個(gè)方法最容易被弄混。實(shí)際上,某些情況下這兩個(gè)方法的表現(xiàn)也非常相似。然而實(shí)際上,這兩個(gè)方法的意義卻大為不同。setTimeout()方法是定義一個(gè)回調(diào),并且希望這個(gè)回調(diào)在我們所指定的時(shí)間間隔后第一時(shí)間去執(zhí)行。注意這個(gè)“第一時(shí)間執(zhí)行”,這意味著,受到操作系統(tǒng)和當(dāng)前執(zhí)行任務(wù)的諸多影響,該回調(diào)并不會(huì)在我們預(yù)期的時(shí)間間隔后精準(zhǔn)的執(zhí)行。執(zhí)行的時(shí)間存在一定的延遲和誤差,這是不可避免的。node會(huì)在可以執(zhí)行timer回調(diào)的第一時(shí)間去執(zhí)行你所設(shè)定的任務(wù)。setImmediate()方法從意義上將是立刻執(zhí)行的意思,但是實(shí)際上它卻是在一個(gè)固定的階段才會(huì)執(zhí)行回調(diào),即poll階段之后。有趣的是,這個(gè)名字的意義和之前提到過(guò)的process.nextTick()方法才是最匹配的。node的開(kāi)發(fā)者們也清楚這兩個(gè)方法的命名上存在一定的混淆,他們表示不會(huì)把這兩個(gè)方法的名字調(diào)換過(guò)來(lái)—-因?yàn)橛写罅康膎doe程序使用著這兩個(gè)方法,調(diào)換命名所帶來(lái)的好處與它的影響相比不值一提。setTimeout()和不設(shè)置時(shí)間間隔的setImmediate()表現(xiàn)上及其相似。猜猜下面這段代碼的結(jié)果是什么?
setTimeout(() => {
console.log(‘timeout’);
}, 0);
setImmediate(() => {
console.log(‘immediate’);
});
實(shí)際上,答案是不一定。沒(méi)錯(cuò),就連node的開(kāi)發(fā)者都無(wú)法準(zhǔn)確的判斷這兩者的順序誰(shuí)前誰(shuí)后。這取決于這段代碼的運(yùn)行環(huán)境。運(yùn)行環(huán)境中的各種復(fù)雜的情況會(huì)導(dǎo)致在同步隊(duì)列里兩個(gè)方法的順序隨機(jī)決定。但是,在一種情況下能準(zhǔn)確判斷兩個(gè)方法回調(diào)的執(zhí)行順序,那就是在一個(gè)I/O事件的回調(diào)中。下面這段代碼的順序永遠(yuǎn)是固定的:
const fs = require(‘fs’);
fs.readFile(__filename, () => {
setTimeout(() => {
console.log(‘timeout’);
}, 0);
setImmediate(() => {
console.log(‘immediate’);
});
});
答案永遠(yuǎn)是:
immediate
timeout
因?yàn)樵贗/O事件的回調(diào)中,setImmediate方法的回調(diào)永遠(yuǎn)在timer的回調(diào)前執(zhí)行。尾聲
javascrit的事件循環(huán)是這門(mén)語(yǔ)言中非常重要切基礎(chǔ)的概念。清楚的了解了事件循環(huán)的執(zhí)行順序和每一個(gè)階段的特點(diǎn),可以使我們對(duì)一段異步代碼的執(zhí)行順序有一個(gè)清晰的認(rèn)識(shí),從而減少代碼運(yùn)行的不確定性。合理的使用各種延遲事件的方法,有助于代碼更好的按照其優(yōu)先級(jí)去執(zhí)行。這篇文章期望用最易理解的方式和語(yǔ)言準(zhǔn)確描述事件循環(huán)這個(gè)復(fù)雜過(guò)程,但由于作者自己水平有限,文章中難免出現(xiàn)疏漏。如果您發(fā)現(xiàn)了文章中的一些問(wèn)題,歡迎在留言中提出,我會(huì)盡量回復(fù)這些評(píng)論,及時(shí)把錯(cuò)誤更正。引用
JavaScript中執(zhí)行環(huán)境和棧
Macrotask 與 Microtask 核心概念
The Node.js Event Loop, Timers, and process.nextTick()