宏任務(wù)和微任務(wù)的一個(gè)小事

作者:Ivan

本文根據(jù) JavaScript 規(guī)范入手,闡述了JS執(zhí)行過程在考慮時(shí)效性和效率權(quán)衡中的演變,并通過從JS代碼運(yùn)行的基礎(chǔ)機(jī)制事件隊(duì)列入手,分析了JS不同任務(wù)類型(宏任務(wù)、微任務(wù))的差別,通過這些差別給出了詳細(xì)分析不同任務(wù)嵌套的復(fù)雜 JS 代碼執(zhí)行的分析流程。

一、事件隊(duì)列與回調(diào)

在使用JavaScript編程時(shí),需要用到大量的回調(diào)編程?;卣{(diào),單純的理解,其實(shí)就是一種設(shè)置的狀態(tài)通知,當(dāng)某個(gè)語(yǔ)句模塊執(zhí)行完后,進(jìn)行一個(gè)通知到對(duì)應(yīng)方法執(zhí)行的動(dòng)作。

最常見的setTimeout等定時(shí)器,AJAX請(qǐng)求等。這是由于JavaScript單線程設(shè)計(jì)導(dǎo)致的,作為腳本語(yǔ)言,在運(yùn)行的時(shí)候,語(yǔ)言設(shè)計(jì)人員需要考慮的兩件重要的事情,就是執(zhí)行的實(shí)時(shí)性和效率。

實(shí)時(shí)性,就是指在代碼執(zhí)行過程中,代碼執(zhí)行的實(shí)效性,當(dāng)前執(zhí)行語(yǔ)句任務(wù)是否在當(dāng)前的實(shí)效下發(fā)揮作用。效率,在這里指的是代碼執(zhí)行過程中,每個(gè)語(yǔ)句執(zhí)行的造成后續(xù)執(zhí)行的延遲率。

由于JavaScript單線程特性,想要在完成復(fù)雜的邏輯執(zhí)行情況下而不阻塞后續(xù)執(zhí)行,也就是保證效率,回調(diào)看似是不可避免的選擇。

早期瀏覽器的實(shí)現(xiàn)和現(xiàn)在可能有許多不同,但是并不會(huì)影響我們用其來理解回調(diào)過程。

早期瀏覽器設(shè)計(jì)時(shí),比如IE6,一般都讓頁(yè)面內(nèi)相關(guān)內(nèi)容,比如渲染、事件監(jiān)聽、網(wǎng)絡(luò)請(qǐng)求、文件處理等,都運(yùn)行于一個(gè)單獨(dú)的線程。此時(shí)要引入JavaScript控制文件,那JavaScript也會(huì)運(yùn)行在于頁(yè)面相同的線程上。

當(dāng)觸發(fā)某個(gè)事件時(shí),有單線程線性執(zhí)行,這時(shí)不僅僅可能是線程中正在執(zhí)行其他任務(wù),使得當(dāng)前事件不能立即執(zhí)行,更可能是考慮到直接執(zhí)行當(dāng)前事件導(dǎo)致的線程阻塞影響執(zhí)行效率的原因。這時(shí)事件觸發(fā)的執(zhí)行流程,比如函數(shù)等,將會(huì)進(jìn)入回調(diào)的處理過程,而為了實(shí)現(xiàn)不同回調(diào)的實(shí)現(xiàn),瀏覽器提供了一個(gè)消息隊(duì)列。

當(dāng)主線上下文內(nèi)容都程執(zhí)行完成后,會(huì)將消息隊(duì)列中的回調(diào)邏輯一一取出,將其執(zhí)行。這就是一個(gè)最簡(jiǎn)單的事件機(jī)制模型。

image

瀏覽器的事件回調(diào),其實(shí)就是一種異步的回調(diào)機(jī)制。常見的異步過程有兩種典型代表。一種是setTimeout定時(shí)器作為代表的,觸發(fā)后直接進(jìn)入事件隊(duì)列等待執(zhí)行;一種是XMLHTTPRequest代表的,觸發(fā)后需要調(diào)用去另一個(gè)線程執(zhí)行,執(zhí)行完成后封裝返回值進(jìn)入事件隊(duì)列等待。在這里并不進(jìn)行深入討論。

由此,我們得到了JavaScript設(shè)計(jì)的基礎(chǔ)線程框架。而宏任務(wù)和微任務(wù)的差異實(shí)現(xiàn)正是為了解決特定問題而在此基礎(chǔ)上衍生出來的。而在沒有微任務(wù)的時(shí)代,JavaScript的執(zhí)行中并沒有所謂異步執(zhí)行的概念,異步執(zhí)行是在宿主環(huán)境中實(shí)現(xiàn)的,也就是瀏覽器提供了。直至實(shí)現(xiàn)了微任務(wù),才可以認(rèn)為JavaScript的代碼執(zhí)行存在了異步過程。

(由于目前廣泛使用的JavaScript引擎是V8,在此我們已V8作為解釋對(duì)象)

二、(宏)任務(wù)和微任務(wù)

我們常在文章中看到,macroTask(宏任務(wù))和microTask(微任務(wù))的說法。但其實(shí)在MDN[鏈接]中查看的時(shí)候,macroTask(宏任務(wù))這一說法對(duì)應(yīng)于microTask(微任務(wù))而言的,而統(tǒng)一區(qū)分于microTask其實(shí)就是普通的Task任務(wù)。在此我們可以粗略的認(rèn)為普通的Task任務(wù)其實(shí)都是macroTask。

任務(wù)的定義:

A task is any JavaScript code which is scheduled to be run by the standard mechanisms such as initially starting to run a program, an event callback being run, or an interval or timeout being fired. These all get scheduled on the task queue.

(任何按標(biāo)準(zhǔn)機(jī)制調(diào)度進(jìn)行執(zhí)行的JavaScript代碼,都是任務(wù),比如執(zhí)行一段程序、執(zhí)行一個(gè)事件回調(diào)或interval/timeout觸發(fā),這些都在任務(wù)隊(duì)列上被調(diào)度。)

微任務(wù)存在的區(qū)別定義:

First, each time a task exits, the event loop checks to see if the task is returning control to other JavaScript code. If not, it runs all of the microtasks in the microtask queue. The microtask queue is, then, processed multiple times per iteration of the event loop, including after handling events and other callbacks.

Second, if a microtask adds more microtasks to the queue by calling queueMicrotask(), those newly-added microtasks execute before the next task is run.

(當(dāng)一個(gè)任務(wù)存在,事件循環(huán)都會(huì)檢查該任務(wù)是否正把控制權(quán)交給其他 JavaScript 代碼。如果不交予執(zhí)行,事件循環(huán)就會(huì)運(yùn)行微任務(wù)隊(duì)列中的所有微任務(wù)。接下來微任務(wù)循環(huán)會(huì)在事件循環(huán)的每次迭代中被處理多次,包括處理完事件和其他回調(diào)之后。其次,如果一個(gè)微任務(wù)通過調(diào)用 queueMicrotask(), 向隊(duì)列中加入了更多的微任務(wù),則那些新加入的微任務(wù)會(huì)早于下一個(gè)任務(wù)運(yùn)行 。)

根據(jù)定義,可以簡(jiǎn)單地作出以下理解。

(宏)任務(wù),其實(shí)就是標(biāo)準(zhǔn)JavaScript機(jī)制下的常規(guī)任務(wù),或者簡(jiǎn)單的說,就是指消息隊(duì)列中的等待被主線程執(zhí)行的事件。在宏任務(wù)執(zhí)行過程中,v8引擎都會(huì)建立新棧存儲(chǔ)任務(wù),宏任務(wù)中執(zhí)行不同的函數(shù)調(diào)用,棧隨執(zhí)行變化,當(dāng)該宏任務(wù)執(zhí)行結(jié)束時(shí),會(huì)清空當(dāng)前的棧,接著主線程繼續(xù)執(zhí)行下一個(gè)宏任務(wù)。

微任務(wù),看定義中與(宏)任務(wù)的區(qū)別其實(shí)比較復(fù)雜,但是根據(jù)定義就可以知道,其中很重要的一點(diǎn)是,微任務(wù)必須是一個(gè)異步的執(zhí)行的任務(wù),這個(gè)執(zhí)行的時(shí)間需要在主函數(shù)執(zhí)行之后,也就是微任務(wù)建立的函數(shù)執(zhí)行后,而又需要在當(dāng)前宏任務(wù)結(jié)束之前。

由此可以看出,微任務(wù)的出現(xiàn)其實(shí)就是語(yǔ)言設(shè)計(jì)中的一種實(shí)時(shí)性和效率的權(quán)衡體現(xiàn)。當(dāng)宏任務(wù)執(zhí)行時(shí)間太久,就會(huì)影響到后續(xù)任務(wù)的執(zhí)行,而此時(shí)因?yàn)槟承┬枨?,編程人員需要讓某些任務(wù)在宿主環(huán)境(比如瀏覽器)提供的事件循環(huán)下一輪執(zhí)行前執(zhí)行完畢,提高實(shí)時(shí)性,這就是微任務(wù)存在的意義。

常見的創(chuàng)建宏任務(wù)的方法有setTimeout定時(shí)器,而常見的屬于微任務(wù)延伸出的技術(shù)有Promise、Generator、async/await等。而無(wú)論是宏任務(wù)還是微任務(wù)依賴的都是基礎(chǔ)的執(zhí)行棧和消息隊(duì)列的機(jī)制而運(yùn)行。根據(jù)定義,宏任務(wù)和微任務(wù)存在于不同的任務(wù)隊(duì)列,而微任務(wù)的任務(wù)隊(duì)列應(yīng)該在宏任務(wù)執(zhí)行棧完成前清空。

這正是分析和編寫類似以下復(fù)雜邏輯代碼所根據(jù)的基本原理,并且做到對(duì)事件循環(huán)的充分利用。

三、根據(jù)定義得出的分析實(shí)例

function taskOne() {
    console.log('task one ...')
    setTimeout(() => {
        Promise.resolve().then(() => {
            console.log('task one micro in macro ...')
        })
        setTimeout(() => {
            console.log('task one macro ...')
        }, 0)
    }, 0)
    taskTwo()
}
 
 
function taskTwo() {
    console.log('task two ...')
    Promise.resolve().then(() => {
        setTimeout(() => {
            console.log('task two macro in micro...')
        }, 0)
    })
 
    setTimeout(() => {
        console.log('task two macro ...')
    }, 0)
}
 
setTimeout(() => {
    console.log('running macro ...')
}, 0)
 
taskOne()
 
Promise.resolve().then(() => {
    console.log('running micro ...')
})

根據(jù)宏任務(wù)、微任務(wù)定義和調(diào)用棧執(zhí)行以及消息隊(duì)列就可以分析出console.log的輸出順序,即所代表的執(zhí)行順序。

首先,在執(zhí)行的第一步,全局上下文進(jìn)入調(diào)用棧,也屬于常規(guī)任務(wù),可以簡(jiǎn)單認(rèn)為此執(zhí)行也是執(zhí)行中的一個(gè)宏任務(wù)。

在全局上下文中,setTimeout觸發(fā)設(shè)置宏任務(wù),直接進(jìn)入消息隊(duì)列,而Promise.resolve().then()中的內(nèi)容進(jìn)入當(dāng)前宏任務(wù)執(zhí)行狀態(tài)下的微任務(wù)隊(duì)列。taskOne被壓入調(diào)用棧。當(dāng)然,因?yàn)槲⑷蝿?wù)隊(duì)列的存放位置,也是申請(qǐng)于環(huán)境對(duì)象中,可以認(rèn)為微任務(wù)擁有一個(gè)單獨(dú)的隊(duì)列。

image

此時(shí)當(dāng)前宏任務(wù)并沒有結(jié)束,taskOne函數(shù)上下文需要被執(zhí)行。函數(shù)內(nèi)部的console.log()立即執(zhí)行,其中的setTimeout觸發(fā)宏任務(wù),進(jìn)入消息隊(duì)列,taskTwo被壓入調(diào)用棧。

image

此時(shí)當(dāng)前宏任務(wù)還沒有結(jié)束,調(diào)用棧中taskTwo需要被執(zhí)行。函數(shù)內(nèi)部的console.log()立即執(zhí)行,其中的promise進(jìn)入微任務(wù)的隊(duì)列,setTimeout進(jìn)入消息隊(duì)列。taskTwo出棧執(zhí)行完畢。

image

此時(shí)當(dāng)前已沒有主邏輯執(zhí)行的代碼,而當(dāng)前宏任務(wù)將執(zhí)行結(jié)束,微任務(wù)會(huì)在當(dāng)前宏任務(wù)完成前執(zhí)行,所以微任務(wù)隊(duì)列會(huì)依次執(zhí)行,直到微任務(wù)隊(duì)列清空。首先執(zhí)行running micro,輸出打印,然后執(zhí)行taskTwo中的promise,setTimeout觸發(fā)宏任務(wù)進(jìn)入消息隊(duì)列。

image

此時(shí)已經(jīng)清空微任務(wù)隊(duì)列,當(dāng)前宏任務(wù)結(jié)束,主線程會(huì)到消息隊(duì)列進(jìn)行消費(fèi)。先執(zhí)行running macro 宏任務(wù),直接進(jìn)行打印,沒有對(duì)應(yīng)微任務(wù),當(dāng)前結(jié)束,繼續(xù)執(zhí)行taskOne setTimeout宏任務(wù),內(nèi)部執(zhí)行同理。

image

由于微任務(wù)隊(duì)列存在任務(wù),在上一個(gè)宏任務(wù)taskOne setTimeout執(zhí)行結(jié)束前,需要執(zhí)行微任務(wù)隊(duì)列中任務(wù)。

image

接下來所有的宏任務(wù)依次執(zhí)行。得到最終的輸出結(jié)果。

image

我們可以在Chrome里面進(jìn)行驗(yàn)證??雌饋聿]有問題。

image

四、Nodejs環(huán)境中的區(qū)別

這是在瀏覽器搭載v8引擎的情況下,我們驗(yàn)證了宏任務(wù)和微任務(wù)的執(zhí)行機(jī)理,那在Nodejs中運(yùn)行JavaScript代碼會(huì)有什么不同嗎?

使用命令行直接執(zhí)行JavaScript腳本文件,得到了以下結(jié)果。

image

與瀏覽器的執(zhí)行輸出結(jié)果有所不同。這里的one micro in macro 并沒有在一開始執(zhí)行。這是為什么呢?

雖然Nodejs的事件循環(huán)有不同于瀏覽器的六個(gè)階段,但是按照定義規(guī)范,這里的宏任務(wù)和微任務(wù)執(zhí)行,明顯沒有遵循微任務(wù)區(qū)分差別的第二點(diǎn),也就是微任務(wù)必須在宏任務(wù)執(zhí)行結(jié)束前執(zhí)行。

其實(shí)這個(gè)問題在之前的業(yè)務(wù)開發(fā)中遇到過。由于微任務(wù)執(zhí)行的時(shí)序與定義不符,導(dǎo)致數(shù)據(jù)出現(xiàn)了微小的差異。這里與Nodejs版本迭代中的實(shí)現(xiàn)有關(guān)。

通過命令可以看到當(dāng)前執(zhí)行的Nodejs版本為10.16.0。

image

我們使用nvm切換到更新一些的版本看看執(zhí)行結(jié)果如何。

image

然后再次使用Nodejs執(zhí)行上述腳本代碼。在11版本之上我們得到了和瀏覽器一致的結(jié)果。

image

從一開始瀏覽器端就是嚴(yán)格遵循了微任務(wù)和宏任務(wù)定義進(jìn)行執(zhí)行,也就是說,一個(gè)宏任務(wù)執(zhí)行完成過程中,就會(huì)去檢測(cè)微任務(wù)隊(duì)列是否有需要執(zhí)行的任務(wù),即使是微任務(wù)嵌套微任務(wù),也會(huì)將微任務(wù)執(zhí)行完成,再去執(zhí)行下一個(gè)宏任務(wù)。

而通過查看Nodejs版本日志發(fā)現(xiàn),在Nodejs環(huán)境中,在11版本之前,同源的任務(wù)放在一起進(jìn)行執(zhí)行,也就是宏任務(wù)隊(duì)列和微任務(wù)隊(duì)列只有清空一個(gè)后才會(huì)執(zhí)行另一個(gè)。

就算涉及到同源宏任務(wù)的嵌套代碼,任然會(huì)將宏任務(wù)一起執(zhí)行,但是內(nèi)部的任務(wù)則會(huì)放到下一個(gè)循環(huán)中去執(zhí)行。而在11版本后,Nodejs修改成了與瀏覽器一樣的遵循定義的執(zhí)行方式。

對(duì)于早于11版本的Nodejs的實(shí)現(xiàn),可能是由于嵌套任務(wù)存在的可能性。微任務(wù)嵌套微任務(wù)可能造成線程中一直處于當(dāng)前微任務(wù)隊(duì)列執(zhí)行狀態(tài)而走不下去,而宏任務(wù)的嵌套循環(huán)執(zhí)行,并不會(huì)造成內(nèi)存溢出的問題,因?yàn)槊總€(gè)宏任務(wù)的執(zhí)行都是新建的棧。這就是為什么下方的代碼會(huì)導(dǎo)致棧溢出,而加入setTimeout后就不會(huì)報(bào)錯(cuò)的原因。

既然如此,可能開發(fā)人員考慮這樣情景的時(shí)候,不如先把同源任務(wù)執(zhí)行完畢,以免在微任務(wù)餓死線程的時(shí)候,還有未執(zhí)行完成的宏任務(wù)。然而這不符合規(guī)范,也顯然不是很合理,這樣的操作甚至是失誤應(yīng)該交給JavaScript的開發(fā)者。

function run() {
    run()
}
run()
 
 
function run() {
    setTimeout(run, 0)
}
run()

這也許是早于11版本,Nodejs實(shí)現(xiàn)的一個(gè)考慮。但是這樣并不符合規(guī)范,所以我更愿意傾向于相信Nodejs團(tuán)隊(duì)在11版本之前的實(shí)現(xiàn)存在錯(cuò)誤,而在11版本后修復(fù)了這個(gè)錯(cuò)誤。畢竟如果使用同源執(zhí)行策略,嵌套中的微任務(wù)就已經(jīng)失去了時(shí)效性,在宏任務(wù)都執(zhí)行完成后的微任務(wù),與宏任務(wù)并沒有區(qū)別。

當(dāng)然了,目前大部分瀏覽器都傾向于去符合規(guī)范的實(shí)現(xiàn)方式,但是任然有一些區(qū)別。在使用的過程中,如果需要兼容不容的瀏覽器還是要更了解這些執(zhí)行過程,以免出現(xiàn)難以察覺和查找的問題。在IE高版本、FireFox和Safari不同版本中,執(zhí)行會(huì)有些不同,有興趣的可以動(dòng)手試試,并找出為何不同。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容