JS中EventLoop、巨集任務與微任務的個人理解

FakeStone發表於2021-03-31

 為什麼要EventLoop?

  JS 作為瀏覽器指令碼語言,為了避免複雜的同步問題(例如使用者操作事件以及操作DOM),這就決定了被設計成單執行緒語言,而且也將會一直保持是單執行緒的。而在單執行緒中若是遇到了耗時的操作(IO,定時器,網路請求)將會一直等待,CPU利用率將會大打折扣,時間大量浪費。所以需要設計一種方案讓一些耗時的操作放在一邊等待,讓後面的函式先執行,於是有了EventLoop的設計。

  將任務分為兩種:

  • 同步任務
  • 非同步任務
  1. 定時器都是非同步操作
  2. 事件繫結都是非同步操作
  3. AJAX中一般採取的非同步操作(雖然也可以同步)
  4. 回撥函式(不嚴謹的非同步)

 阮一峰老師《JavaScript 執行機制詳解:再談Event Loop》

(1)所有同步任務都在主執行緒上執行,形成一個執行棧(execution context stack)。

(2)主執行緒之外,還存在一個"任務佇列"(task queue)。只要非同步任務有了執行結果,就在"任務佇列"之中放置一個事件。

(3)一旦"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務佇列",看看裡面有哪些事件。那些對應的非同步任務,於是結束等待狀態,進入執行棧,開始執行。

(4)主執行緒不斷重複上面的第三步。

  任務都會按順序進入呼叫棧(call stack),即圖1-1的stack,然後按棧的順序依次執行。若全是同步任務,就會正常地順序執行。當遇到非同步任務時(其實就是執行到了一個耗時的任務,它發起後,需要它的回撥函式等待拿到結果之後才繼續進行)將會放到WebAPIs中(圖1-1),等待這個耗時操作返回結果,也有網友把這個 WebAPIs 稱之為 Event Table。如果非同步任務在WebAPIs中等待有了結果(比如setTimeout的時間截止了,xhr得到響應結果了,使用者click事件發生了),就會將這個結果作為一個事件置於任務佇列中。 【或者稱之為:註冊回撥函式】

  那麼任務佇列又是什麼?個人認為就是圖中的callback queue,或稱之為 Event Queue 。就是存放了各種耗時操作最後響應結果的各個事件(說白了,就是已經拿到結果的,就會從WebAPIs放到任務佇列裡來)

圖 1-1 轉自Philip Roberts的演講《Help, I'm stuck in an event-loop》

  搞懂上面兩段話後,就可以談EventLoop的作用了:

  • 在呼叫棧和任務佇列之間進行“輪詢”
  • 但輪詢的規則是:只有每當呼叫棧為空,才能去“詢問”任務佇列中是否有事件需要處理
  • 若任務佇列存在事件,則會將該事件相應的回撥函式(非同步操作)結束等待,置於呼叫棧中開始執行
  • 如果呼叫棧一直不為空,那就一直不會“詢問”任務佇列

  以上過程是不斷迴圈的,js引擎中,存在一個叫monitoring process的程式,這個程式會不斷的檢查主執行緒的執行情況,一旦為空,就會去任務佇列檢查有哪些待執行的函式。這裡的整個過程可以參考 一個工具 loupe 對整個呼叫過程進行檢視。

  

圖 1-2 loupe, 也是從其他地方發現的這個東西,很直觀

  針對call stack呼叫棧多說一句:通俗地講,將呼叫棧比喻為程式設計師,各個任務比喻為需求,任務佇列比喻為總監。當總監提需求時,程式設計師就要交接需求過來,然後完成它。如果沒有需求,就一直等待總監給需求。給了就做,不給就等。

  搞懂同步任務與非同步任務的具體執行流程後,再談談為什麼要設計巨集任務和微任務。

 為什麼有巨集任務、微任務?

  頁面渲染事件,各種IO的完成事件等隨時被新增到任務佇列中,一直會保持先進先出的原則執行,我們不能準確地控制這些事件被新增到任務佇列中的位置。但是這個時候突然有高優先順序的任務需要儘快執行,那麼若只有一種型別的任務就不合適了,所以引入了微任務佇列。

  至此,任務佇列已被分為:

  • 巨集任務佇列,即上文說的任務佇列,callback queue,用於存放巨集任務
  • 微任務佇列,再開闢一個佇列,用於存放微任務

圖 2-1 微任務Microtask Queue的加入

  首先列舉一下哪些是巨集任務、哪些是微任務

  巨集任務

  • script(主程式碼)
  • setTimeout()
  • setInterval()
  • postMessage
  • I/O
  • UI互動事件
  • setImmediate(Node.js)
  • requestAnimationFrame(瀏覽器)

  微任務

  • new Promise().then(回撥)
  • MutationObserver(html5 新特性)
  • process.nextTick(Node.js)在當前"執行棧"的尾部----下一次Event Loop(主執行緒讀取"任務佇列")之前----觸發回撥函式。也就是說,它指定的任務總是發生在所有非同步任務之前

  緊接著第一節裡說的EventLoop,當時沒有考慮什麼巨集任務微任務,現在再加入微任務的概念再來考慮整個流程:

  1. 依舊是在呼叫棧和任務佇列中輪詢。(此時的任務佇列指的是巨集任務佇列)
  2. 呼叫棧為空後,優先檢查微任務佇列,如果微任務佇列中存在事件,則加入到呼叫棧中進行執行(為什麼先詢問的是微任務佇列而不是巨集任務佇列,在後面解釋)
    • 注:如果在執行微任務佇列中的函式時,產生了新的微任務(比如then函式巢狀),則會繼續在本次執行中執行(就是說如果期間一直有微任務產生,那就會永遠卡在微任務佇列執行)
  3. 如果微任務佇列為空,那就取巨集任務佇列中的事件加入到呼叫棧中進行執行
  4. 若在執行巨集任務的時候,產生了新的微任務,就會將該微任務加入到微任務佇列,該微任務佇列將會在下一次巨集任務執行之前執行,如圖2-2。
  5. 迴圈。

  依舊是:兩個任務佇列(巨集、微)只有有任務,那麼主程式的呼叫棧就會調過去執行,沒有任務的話,主程式就一直等著,直到又有任務。

圖 2-2 巨集任務與微任務的執行順序

  注意的是,圖2-2看起來是巨集任務先執行,微任務後執行,這僅僅是巨集任務與微任務的先後次序,但不代表巨集任務優先順序比微任務高。事實是微任務的優先順序是高於巨集任務的。因為微任務其實是產生於巨集任務的,不可能憑空產生微任務,也就不可能一開始就出現幾個微任務。在本次巨集任務產生微任務後,將會在下次巨集任務執行之前,優先執行這些微任務。自然也就映證了設計微任務的初衷:為了讓某些任務儘快執行。

  總結完整的EventLoop流程:

  1. 執行一個巨集任務(呼叫棧中沒有就從巨集、微任務佇列中獲取)
  2. 執行過程中如果遇到微任務,就將它新增到微任務的任務佇列中
  3. 巨集任務執行完畢後,立即執行當前微任務佇列中的所有微任務(依次執行)
  4. 當前微任務執行完畢,開始檢查渲染,然後GUI執行緒接管渲染
  5. 渲染完畢後,JS執行緒繼續接管,開始下一個巨集任務(從事件佇列中獲取)

  微任務在本次巨集任務之後執行,在本次渲染之前執行,在下次巨集任務之前執行。(巨集任務 -> 微任務 -> 渲染 -> 巨集任務)

 包含巨集任務、微任務的非同步程式碼分析:

// 知乎作者:Miku
// 連結:https://zhuanlan.zhihu.com/p/257069622
// 注意:程式碼中的process.netxTick 函式存在於Node.js中
console.log('1'); setTimeout(function() { console.log('2'); process.nextTick(function() { console.log('3'); }) new Promise(function(resolve) { console.log('4'); resolve(); }).then(function() { console.log('5'); }); }); process.nextTick(function() { console.log('6'); }); new Promise(function(resolve) { console.log('7'); resolve(); }).then(function() { console.log('8'); }); setTimeout(function() { console.log('9'); process.nextTick(function() { console.log('10'); }); new Promise(function(resolve) { console.log('11'); resolve(); }).then(function() { console.log('12'); }); });

第一輪迴圈:

1)、首先列印 1
2)、接下來是setTimeout是非同步任務且是巨集任務,加入巨集任務暫且記為 setTimeout1
3)、接下來是 process 微任務 加入微任務佇列 記為 process1
4)、接下來是 new Promise 裡面直接 resolve(7) 所以列印 7 後面的then是微任務 記為 then1
5)、setTimeout 巨集任務 記為 setTimeout2

第一輪迴圈列印出的是 1 7
當前巨集任務佇列:setTimeout1, setTimeout2
當前微任務佇列:process1, then1,

第二輪迴圈:

1)、執行所有微任務
2)、執行process1,列印出 6
3)、執行then1 列印出8
4)、微任務都執行結束了,開始執行第一個巨集任務
5)、執行 setTimeout1 也就是 第 3 - 14 行
6)、首先列印出 2
7)、遇到 process 微任務 記為 process2
8)、new Promise中resolve 列印出 4
9)、then 微任務 記為 then2

第二輪迴圈結束,當前列印出來的是 1 7 6 8 2 4
當前巨集任務佇列:setTimeout2
當前微任務佇列:process2, then2

第三輪迴圈:

1)、執行所有的微任務
2)、執行 process2 列印出 3
3)、執行 then2 列印出 5
4)、執行第一個巨集任務,也就是執行 setTimeout2 對應程式碼中的 25 - 36 行
5)、首先列印出 9
6)、process 微任務 記為 process3
7)、new Promise執行resolve 列印出 11
8)、then 微任務 記為 then3

第三輪迴圈結束,當前列印順序為:1 7 6 8 2 4 3 5 9 11
當前巨集任務佇列為空
當前微任務佇列:process3,then3

第四輪迴圈:

1)、執行所有的微任務
2)、執行process3 列印出 10
3)、執行then3 列印出 12

程式碼執行結束:
最終列印順序為:1 7 6 8 2 4 3 5 9 11 10 12

 參考

 

相關文章