微任務、巨集任務與Event-Loop

来源:https://www.cnblogs.com/jiasm/archive/2018/08/15/9482443.html
-Advertisement-
Play Games

首先,JavaScript是一個單線程的腳本語言。所以就是說在一行代碼執行的過程中,必然不會存在同時執行的另一行代碼,就像使用alert()以後進行瘋狂console.log,如果沒有關閉彈框,控制台是不會顯示出一條log信息的。亦或者有些代碼執行了大量計算,比方說在前端暴力破解密碼之類的鬼操作,這 ...


首先,JavaScript是一個單線程的腳本語言。
所以就是說在一行代碼執行的過程中,必然不會存在同時執行的另一行代碼,就像使用alert()以後進行瘋狂console.log,如果沒有關閉彈框,控制台是不會顯示出一條log信息的。
亦或者有些代碼執行了大量計算,比方說在前端暴力破解密碼之類的鬼操作,這就會導致後續代碼一直在等待,頁面處於假死狀態,因為前邊的代碼並沒有執行完。

所以如果全部代碼都是同步執行的,這會引發很嚴重的問題,比方說我們要從遠端獲取一些數據,難道要一直迴圈代碼去判斷是否拿到了返回結果麽?就像去飯店點餐,肯定不能說點完了以後就去後廚催著人炒菜的,會被揍的。
於是就有了非同步事件的概念,註冊一個回調函數,比如說發一個網路請求,我們告訴主程式等到接收到數據後通知我,然後我們就可以去做其他的事情了。
然後在非同步完成後,會通知到我們,但是此時可能程式正在做其他的事情,所以即使非同步完成了也需要在一旁等待,等到程式空閑下來才有時間去看哪些非同步已經完成了,可以去執行。
比如說打了個車,如果司機先到了,但是你手頭還有點兒事情要處理,這時司機是不可能自己先開著車走的,一定要等到你處理完事情上了車才能走。

微任務與巨集任務的區別

這個就像去銀行辦業務一樣,先要取號進行排號。
一般上邊都會印著類似:“您的號碼為XX,前邊還有XX人。”之類的字樣。

因為櫃員同時職能處理一個來辦理業務的客戶,這時每一個來辦理業務的人就可以認為是銀行櫃員的一個巨集任務來存在的,當櫃員處理完當前客戶的問題以後,選擇接待下一位,廣播報號,也就是下一個巨集任務的開始。
所以多個巨集任務合在一起就可以認為說有一個任務隊列在這,裡邊是當前銀行中所有排號的客戶。
任務隊列中的都是已經完成的非同步操作,而不是說註冊一個非同步任務就會被放在這個任務隊列中,就像在銀行中排號,如果叫到你的時候你不在,那麼你當前的號牌就作廢了,櫃員會選擇直接跳過進行下一個客戶的業務處理,等你回來以後還需要重新取號

而且一個巨集任務在執行的過程中,是可以添加一些微任務的,就像在櫃臺辦理業務,你前邊的一位老大爺可能在存款,在存款這個業務辦理完以後,櫃員會問老大爺還有沒有其他需要辦理的業務,這時老大爺想了一下:“最近P2P爆雷有點兒多,是不是要選擇穩一些的理財呢”,然後告訴櫃員說,要辦一些理財的業務,這時候櫃員肯定不能告訴老大爺說:“您再上後邊取個號去,重新排隊”。
所以本來快輪到你來辦理業務,會因為老大爺臨時添加的“理財業務”而往後推。
也許老大爺在辦完理財以後還想 再辦一個信用卡?或者 再買點兒紀念幣
無論是什麼需求,只要是櫃員能夠幫她辦理的,都會在處理你的業務之前來做這些事情,這些都可以認為是微任務。

這就說明:你大爺永遠是你大爺
在當前的微任務沒有執行完成時,是不會執行下一個巨集任務的。

所以就有了那個經常在面試題、各種博客中的代碼片段:

setTimeout(_ => console.log(4))

new Promise(resolve => {
  resolve()
  console.log(1)
}).then(_ => {
  console.log(3)
})

console.log(2)

 

setTimeout就是作為巨集任務來存在的,而Promise.then則是具有代表性的微任務,上述代碼的執行順序就是按照序號來輸出的。

所有會進入的非同步都是指的事件回調中的那部分代碼
也就是說new Promise在實例化的過程中所執行的代碼都是同步進行的,而then中註冊的回調才是非同步執行的。
在同步代碼執行完成後才回去檢查是否有非同步任務完成,並執行對應的回調,而微任務又會在巨集任務之前執行。
所以就得到了上述的輸出結論1、2、3、4

+部分表示同步執行的代碼

+setTimeout(_ => {
-  console.log(4)
+})

+new Promise(resolve => {
+  resolve()
+  console.log(1)
+}).then(_ => {
-  console.log(3)
+})

+console.log(2)

 

本來setTimeout已經先設置了定時器(相當於取號),然後在當前進程中又添加了一些Promise的處理(臨時添加業務)。

所以進階的,即便我們繼續在Promise中實例化Promise,其輸出依然會早於setTimeout的巨集任務:

setTimeout(_ => console.log(4))

new Promise(resolve => {
  resolve()
  console.log(1)
}).then(_ => {
  console.log(3)
  Promise.resolve().then(_ => {
    console.log('before timeout')
  }).then(_ => {
    Promise.resolve().then(_ => {
      console.log('also before timeout')
    })
  })
})

console.log(2)

 

當然了,實際情況下很少會有簡單的這麼調用Promise的,一般都會在裡邊有其他的非同步操作,比如fetchfs.readFile之類的操作。
而這些其實就相當於註冊了一個巨集任務,而非是微任務。

P.S. 在Promise/A+的規範中,Promise的實現可以是微任務,也可以是巨集任務,但是普遍的共識表示(至少Chrome是這麼做的),Promise應該是屬於微任務陣營的

所以,明白哪些操作是巨集任務、哪些是微任務就變得很關鍵,這是目前業界比較流行的說法:

巨集任務

#瀏覽器Node
I/O
setTimeout
setInterval
setImmediate
requestAnimationFrame

有些地方會列出來UI Rendering,說這個也是巨集任務,可是在讀了HTML規範文檔以後,發現這很顯然是和微任務平行的一個操作步驟
requestAnimationFrame姑且也算是巨集任務吧,requestAnimationFrameMDN的定義為,下次頁面重繪前所執行的操作,而重繪也是作為巨集任務的一個步驟來存在的,且該步驟晚於微任務的執行

微任務

#瀏覽器Node
process.nextTick
MutationObserver
Promise.then catch finally

Event-Loop是個啥

上邊一直在討論 巨集任務、微任務,各種任務的執行。
但是回到現實,JavaScript是一個單進程的語言,同一時間不能處理多個任務,所以何時執行巨集任務,何時執行微任務?我們需要有這樣的一個判斷邏輯存在。

每辦理完一個業務,櫃員就會問當前的客戶,是否還有其他需要辦理的業務。(檢查還有沒有微任務需要處理)
而客戶明確告知說沒有事情以後,櫃員就去查看後邊還有沒有等著辦理業務的人。(結束本次巨集任務、檢查還有沒有巨集任務需要處理)
這個檢查的過程是持續進行的,每完成一個任務都會進行一次,而這樣的操作就被稱為Event Loop(這是個非常簡易的描述了,實際上會複雜很多)

而且就如同上邊所說的,一個櫃員同一時間只能處理一件事情,即便這些事情是一個客戶所提出的,所以可以認為微任務也存在一個隊列,大致是這樣的一個邏輯:

const macroTaskList = [
  ['task1'],
  ['task2', 'task3'],
  ['task4'],
]

for (let macroIndex = 0; macroIndex < macroTaskList.length; macroIndex++) {
  const microTaskList = macroTaskList[macroIndex]

  for (let microIndex = 0; microIndex < microTaskList.length; microIndex++) {
    const microTask = microTaskList[microIndex]

    // 添加一個微任務
    if (microIndex === 1) microTaskList.push('special micro task')

    // 執行任務
    console.log(microTask)
  }

  // 添加一個巨集任務
  if (macroIndex === 2) macroTaskList.push(['special macro task'])
}

// > task1
// > task2
// > task3
// > special micro task
// > task4
// > special macro task

 

之所以使用兩個for迴圈來表示,是因為在迴圈內部可以很方便的進行push之類的操作(添加一些任務),從而使迭代的次數動態的增加。

以及還要明確的是,Event Loop只是負責告訴你該執行那些任務,或者說哪些回調被觸發了,真正的邏輯還是在進程中執行的。

在瀏覽器中的表現

在上邊簡單的說明瞭兩種任務的差別,以及Event Loop的作用,那麼在真實的瀏覽器中是什麼表現呢?
首先要明確的一點是,巨集任務必然是在微任務之後才執行的(因為微任務實際上是巨集任務的其中一個步驟)

I/O這一項感覺有點兒籠統,有太多的東西都可以稱之為I/O,點擊一次button,上傳一個文件,與程式產生交互的這些都可以稱之為I/O

假設有這樣的一些DOM結構:

<style>
  #outer {
    padding: 20px;
    background: #616161;
  }

  #inner {
    width: 100px;
    height: 100px;
    background: #757575;
  }
</style>
<div id="outer">
  <div id="inner"></div>
</div>

 

const $inner = document.querySelector('#inner')
const $outer = document.querySelector('#outer')

function handler () {
  console.log('click') // 直接輸出

  Promise.resolve().then(_ => console.log('promise')) // 註冊微任務

  setTimeout(_ => console.log('timeout')) // 註冊巨集任務

  requestAnimationFrame(_ => console.log('animationFrame')) // 註冊巨集任務

  $outer.setAttribute('data-random', Math.random()) // DOM屬性修改,觸發微任務
}

new MutationObserver(_ => {
  console.log('observer')
}).observe($outer, {
  attributes: true
})

$inner.addEventListener('click', handler)
$outer.addEventListener('click', handler)

 

如果點擊#inner,其執行順序一定是:click -> promise -> observer -> click -> promise -> observer -> animationFrame -> animationFrame -> timeout -> timeout

因為一次I/O創建了一個巨集任務,也就是說在這次任務中會去觸發handler
按照代碼中的註釋,在同步的代碼已經執行完以後,這時就會去查看是否有微任務可以執行,然後發現了PromiseMutationObserver兩個微任務,遂執行之。
因為click事件會冒泡,所以對應的這次I/O會觸發兩次handler函數(一次在inner、一次在outer),所以會優先執行冒泡的事件(早於其他的巨集任務),也就是說會重覆上述的邏輯。
在執行完同步代碼與微任務以後,這時繼續向後查找有木有巨集任務。
需要註意的一點是,因為我們觸發了setAttribute,實際上修改了DOM的屬性,這會導致頁面的重繪,而這個set的操作是同步執行的,也就是說requestAnimationFrame的回調會早於setTimeout所執行。

一些小驚喜

使用上述的示例代碼,如果將手動點擊DOM元素的觸發方式變為$inner.click(),那麼會得到不一樣的結果。
Chrome下的輸出順序大致是這樣的:
click -> click -> promise -> observer -> promise -> animationFrame -> animationFrame -> timeout -> timeout

與我們手動觸發click的執行順序不一樣的原因是這樣的,因為並不是用戶通過點擊元素實現的觸發事件,而是類似dispatchEvent這樣的方式,我個人覺得並不能算是一個有效的I/O,在執行了一次handler回調註冊了微任務、註冊了巨集任務以後,實際上外邊的$inner.click()並沒有執行完。
所以在微任務執行之前,還要繼續冒泡執行下一次事件,也就是說觸發了第二次的handler
所以輸出了第二次click,等到這兩次handler都執行完畢後才會去檢查有沒有微任務、有沒有巨集任務。

兩點需要註意的:

  1. .click()的這種觸發事件的方式個人認為是類似dispatchEvent,可以理解為同步執行的代碼
document.body.addEventListener('click', _ => console.log('click'))

document.body.click()
document.body.dispatchEvent(new Event('click'))
console.log('done')

// > click
// > click
// > done

 

  1. MutationObserver的監聽不會說同時觸發多次,多次修改只會有一次回調被觸發。
new MutationObserver(_ => {
  console.log('observer')
  // 如果在這輸出DOM的data-random屬性,必然是最後一次的值,不解釋了
}).observe(document.body, {
  attributes: true
})

document.body.setAttribute('data-random', Math.random())
document.body.setAttribute('data-random', Math.random())
document.body.setAttribute('data-random', Math.random())

// 只會輸出一次 ovserver

 

這就像去飯店點餐,服務員喊了三次,XX號的牛肉麵,不代表她會給你三碗牛肉麵。
上述觀點參閱自Tasks, microtasks, queues and schedules,文中有動畫版的講解

在Node中的表現

Node也是單線程,但是在處理Event Loop上與瀏覽器稍微有些不同,這裡是Node官方文檔的地址。

就單從API層面上來理解,Node新增了兩個方法可以用來使用:微任務的process.nextTick以及巨集任務的setImmediate

setImmediate與setTimeout的區別

在官方文檔中的定義,setImmediate為一次Event Loop執行完畢後調用。
setTimeout則是通過計算一個延遲時間後進行執行。

但是同時還提到瞭如果在主進程中直接執行這兩個操作,很難保證哪個會先觸發。
因為如果主進程中先註冊了兩個任務,然後執行的代碼耗時超過XXs,而這時定時器已經處於可執行回調的狀態了。
所以會先執行定時器,而執行完定時器以後才是結束了一次Event Loop,這時才會執行setImmediate

setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))

 

有興趣的可以自己試驗一下,執行多次真的會得到不同的結果。

但是如果後續添加一些代碼以後,就可以保證setTimeout一定會在setImmediate之前觸發了:

setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))

let countdown = 1e9

while(countdonn--) { } // 我們確保這個迴圈的執行速度會超過定時器的倒計時,導致這輪迴圈沒有結束時,setTimeout已經可以執行回調了,所以會先執行`setTimeout`再結束這一輪迴圈,也就是說開始執行`setImmediate`

 

如果在另一個巨集任務中,必然是setImmediate先執行:

require('fs').readFile(__dirname, _ => {
  setTimeout(_ => console.log('timeout'))
  setImmediate(_ => console.log('immediate'))
})

// 如果使用一個設置了延遲的setTimeout也可以實現相同的效果

 

process.nextTick

就像上邊說的,這個可以認為是一個類似於PromiseMutationObserver的微任務實現,在代碼執行的過程中可以隨時插入nextTick,並且會保證在下一個巨集任務開始之前所執行。

在使用方面的一個最常見的例子就是一些事件綁定類的操作:

class Lib extends require('events').EventEmitter {
  constructor () {
    super()

    this.emit('init')
  }
}

const lib = new Lib()

lib.on('init', _ => {
  // 這裡將永遠不會執行
  console.log('init!')
})

 

因為上述的代碼在實例化Lib對象時是同步執行的,在實例化完成以後就立馬發送了init事件。
而這時在外層的主程式還沒有開始執行到lib.on('init')監聽事件的這一步。
所以會導致發送事件時沒有回調,回調註冊後事件不會再次發送。

我們可以很輕鬆的使用process.nextTick來解決這個問題:

class Lib extends require('events').EventEmitter {
  constructor () {
    super()

    process.nextTick(_ => {
      this.emit('init')
    })

    // 同理使用其他的微任務
    // 比如Promise.resolve().then(_ => this.emit('init'))
    // 也可以實現相同的效果
  }
}

 

這樣會在主進程的代碼執行完畢後,程式空閑時觸發Event Loop流程查找有沒有微任務,然後再發送init事件。

關於有些文章中提到的,迴圈調用process.nextTick會導致報警,後續的代碼永遠不會被執行,這是對的,參見上邊使用的雙重迴圈實現的loop即可,相當於在每次for迴圈執行中都對數組進行了push操作,這樣迴圈永遠也不會結束

多提一嘴async/await函數

因為,async/await本質上還是基於Promise的一些封裝,而Promise是屬於微任務的一種。所以在使用await關鍵字與Promise.then效果類似:

setTimeout(_ => console.log(4))

async function main() {
  console.log(1)
  await Promise.resolve()
  console.log(3)
}

main()

console.log(2)

 

async函數在await之前的代碼都是同步執行的,可以理解為await之前的代碼屬於new Promise時傳入的代碼,await之後的所有代碼都是在Promise.then中的回調

小節

JavaScript的代碼運行機制在網上有好多文章都寫,本人道行太淺,只能簡單的說一下自己對其的理解。
並沒有去生摳文檔,一步一步的列出來,像什麼查看當前棧、執行選中的任務隊列,各種balabala。
感覺對實際寫代碼沒有太大幫助,不如簡單的入個門,掃個盲,大致瞭解一下這是個什麼東西就好了。

推薦幾篇參閱的文章:


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 1 . Math.ceil() 向上取整 2. Math.floor() 向下取整 3. Math.round() 四捨五入取整 4. Math.random() 生成隨機數 生成n - m 的隨機整數 parseInt(n + Math.random()*(m-n+1)); parseInt是強制 ...
  • 經過 "《字元串的擴展》" 和 "《字元編碼的那些事》" 這兩篇文章的閱讀,大概瞭解js里codePointAt方法返回結果的含義。 那麼這個134071到底是這麼來的呢?我們可以根據這段話來理解。 在http://tool.chinaz.com/tools/unicode.aspx這個網站上可以將 ...
  • 經過測試,鬧心律師小程式第二期也即將上線了,而鬧心對於小程式有怎麼樣的開發實踐呢? 小程式在千呼萬喚出來之後,帶來了大量的開發的吐槽,但儘管我們再怎麼嫌棄小程式語法,我們也無法否認微信給小程式所帶來的流量以及收益,也必須看重小程式,也不得不去學習小程式 那麼我們開發小程式應該怎麼去開發呢,熟悉前端的 ...
  • $.map(data,function(item,index){return XXX}) 遍歷data數組中的每個元素,並按照return中的計算方式 形成一個新的元素,放入返回的數組中 輸出為 55 0 [55,1,2]是一個數組,按照return的條件,,,,function 中的item,為5 ...
  • 1 2 3 4 5 Title 6 73 74 75 76 77 78 請註冊 79 立即登陸&gt; 80 81 82 83 84 ... ...
  • 塊級元素:獨占一行,其寬度自動填滿父元素的寬度,可以容納行內元素和其他塊級元素,可以設置margin和padding值。 行內元素:不會獨占一行,與其他行內元素排成一行,直到其父元素拍不下,才會從新一行開始。可以容納文本及其他行內元素,設置padding和margin值無效,行內元素的水平方向的pa ...
  • 前段時間換了份工作,也經歷了很多面試,最終通常都會撲在演算法上 雖說前端是所有程式員中,對於演算法的要求最低的一個崗位,但演算法依舊是進階的必修課 於是決定記錄一下與演算法相關的面試題,以後拿去面別人 一、面試題 問:有一個一百層的高樓,現在給你兩個完全一樣的玻璃球,去測出在哪一層樓把球扔出去,剛好能把玻璃 ...
  • Jquery內容 <script type="text/javascript"> $(function () { var num = 0; $(".box ol li:eq(0)").click(function () { $(":text").each(function () { num += p ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...