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