徹底弄懂瀏覽器端的Event-Loop

長可發表於2019-01-05

前言

寫這篇文章的起因是在群裡看到了各位再討論這部分的內容,這一塊自己也不太懂,一時手癢就寫了這篇文章這一塊很多初學者也是一知半懂,學到一半發現又麻煩又複雜,索性放棄了。 本來打算考完作業系統就寫完的,結果又遇到了 CPU 課設...所以這篇文章斷斷續續寫了很多天


Event Loop

簡單點講 event loop 就是對 JS 程式碼執行順序的一個規定(任務排程演算法)

先看看兩幅圖

JS engine

via: sessionstack

徹底弄懂瀏覽器端的Event-Loop

JS runtime

via: sessionstack

徹底弄懂瀏覽器端的Event-Loop

NOTE:

一個 web worker 或者一個跨域的 iframe 都有自己的棧,堆和訊息佇列。兩個不同的執行時只能通過 postMessage 方法進行通訊。如果另一執行時偵聽 message 事件,則此方法會向其新增訊息。

HTML Eventloop

via: livebook.manning.com/#!/book/sec…

這幅圖就是對 whatwg 組織制定 HTML 規範中的 event loop 的視覺化

徹底弄懂瀏覽器端的Event-Loop

我們通常在編寫 web 程式碼的時候,都是和JS runtime打交道

同步程式碼

毫無疑問是按順序執行

console.log(2); // 非非同步程式碼
console.log(3); // 非非同步程式碼
複製程式碼

顯然結果是 2 3

非阻塞程式碼

一般分為兩種任務,macroTasks 和 microTasks

event loop 裡面有維護了兩個不同的非同步任務佇列 macroTasks(Tasks) 的佇列 microTasks 的佇列

  • 巨集任務包括:setTimeout, setInterval, setImmediate, I/O, UI rendering

  • 微任務包括: 原生 Promise(有些實現的 Promise 將 then 方法放到了巨集任務中), Object.observe(已廢棄), MutationObserver, MessageChannel

每次開始執行一段程式碼(一個 script 標籤)都是一個 macroTask

1、event-loop start

2、從 macroTasks 佇列抽取一個任務,執行

3、microTasks 清空佇列執行,若有任務不可執行,推入下一輪 microTasks

4、結束 event-loop

值得一提的是,在 HTML 標準中提到了一個 compound microtasks 當它執行時可能會去執行一個 subTask,執行 compound microTasks 是一件很複雜的事情,在 whatwg 我也沒找到這部分具體的執行流程

const p = Promise.resolve();
p.then(() => {
  Promise.resolve().then(() => {
    console.log('subTask');
  });
}).then(() => {
  console.log('compound microTasks');
});
// subTask
// compound microTasks
複製程式碼

按理說 p 的兩個 then 先執行,在執行 then 函式回撥的時候又發現了 microTask,那應該是下一輪 eventLoop 執行了,但是結果確是相反的

瀏覽器執行程式碼的真正過程是下面整個流程,而我們編寫程式碼感知的過程是紅框裡面的(所以以後要是有人再問起你 macroTask 和 microTask 哪個先執行,可別再說 microTask 了)

徹底弄懂瀏覽器端的Event-Loop
例:
setTimeout(() => {
  console.log(123);
});

const p = Promise.resolve(
  new Promise(resolve => {
    setTimeout(() => {
      resolve('p');
      console.log(55);
    }, 1000);
    new Promise(resolve => {
      resolve('p1');
    }).then(r => console.log(r));
  })
);

setTimeout(() => {
  console.log(456);
});

p.then(r => console.log(r));
複製程式碼

大家可以先猜猜這段程式碼的執行順序,相信如果沒有上面的介紹,我覺得很多人在這就暈了 不過有了上面的介紹加上我們們一步一步的分析,你一定會明白的

  • 第一步,程式碼執行到第一個 setTimeout 列印 123 的函式推入巨集任務佇列
  • 第二部,程式碼執行到 Promse.resolve 裡面的 new Promise,啥也沒幹...繼續執行下面的程式碼
  • 第三步,程式碼執行到 new Promise 裡面的 setTimeout,列印 55 的函式推入巨集任務佇列
  • 第四步,程式碼執行到 new Promise 裡面的 new Promise,執行建構函式,再把 then 函式推入微任務佇列
  • 第五步,程式碼執行到第一個 setTimeout 列印 456 的函式推入巨集任務佇列
  • 第六步,程式碼執行到最後一個 p.then,推入微任務佇列

函式名後面的數字或者變數,是這個函式列印的東西,藉此區分函式

掃描完這些程式碼,各任務佇列的情況如下圖(注意此時由瀏覽器提供的 setTimeout 會檢查各定時任務是否到時間,如果到了則推入任務佇列,所以此時定時 1000ms 的回撥函式並未出現在 macroTask 中) 然後執行完同步程式碼,開始按上面介紹的情況開始執行 macro Task 和 micro Task

徹底弄懂瀏覽器端的Event-Loop

先執行 micro Task,拿出 p.then p1 發現可執行,列印 p1;然後拿出 p.then p 發現不可執行,即 status 為“pending”, 這一輪 micro Task 執行完畢 開始執行 macro Task,拿出 setTimeout 123,發現可執行(此時同步程式碼已執行完畢),列印 123,檢查執行 micro Task, p.then p 依舊不可執行 等到 macro Task 執行完一段時間,發現 micro Task 裡面的 p.then p 可執行了,列印,結束 event loop

所以這一段程式碼的列印結果是

5
p1
123
456
55
p
複製程式碼

你有做對嗎,這只是小 case,還沒加上 async 函式呢,接下來看看 async 函式

async/await

當一個 async 函式裡面執行 await 的時候,其實是標誌這個 async 函式要讓出執行緒了(我個人覺得這就像執行 一個特殊的 函式一樣,該函式會推進第一輪微任務佇列末尾),當 async 函式裡面的 await 語句後面的函式或者表示式執行完,該函式立馬退出執行,呼叫棧也會撤銷, 當本輪事件迴圈完畢的時候又會回來執行剩下的程式碼

再來看看 MDN 咋說的

An async function can contain an await expression that pauses the execution of the async function and waits for the passed Promise's resolution, and then resumes the async function's execution and returns the resolved value.

翻譯過來就是 async 函式可以包含一個 await 表示式,該表示式暫停執行 async 函式並等待返回的 Promise resovle/reject 完成,然後恢復 async 函式的執行並返回已解析的值

看完你應該知道為啥 await 表示式會讓 async 函式讓出執行緒了吧?(如果不讓出執行緒,還不如寫同步程式碼了,阻塞後面所有程式碼), 結合前面的 Event Loop,可以確定,await 表示式需要等待 Promise 解析完成,await 恢復 async 函式執行需要等待執行完第一輪微任務以後,畢竟不是每個 async 函式都是直接返回一個非 Promise 的值或者立即解析的 Promise,所以等 mainline JS 執行完還需要等待一輪 event loop

await 阻塞什麼程式碼的執行

await 阻塞的是當前屬於 async 函式作用域下後面的程式碼

什麼時候恢復被阻塞的程式碼的執行?

答案是當每一輪 microTask 執行完畢後恢復,具體哪一輪,看返回的 Promise 什麼時候解析完成

進入正題,看看 async/await

async function b() {
  console.log('1');
}

async function c() {
  console.log('7');
}

async function a() {
  console.log('2');
  await b();
  //console.log(3);
  await c();
  console.log(8);
}

a();
console.log(5);
Promise.resolve()
  .then(() => {
    console.log(4);
  })
  .then(() => {
    console.log(6);
  });

new Promise(resolve => {
  setTimeout(() => resolve(), 1000);
}).then(() => console.log(55555555));

setTimeout(() => {
  console.log(123);
});
複製程式碼

有了上面的解釋,加上下面這個 GIF,上面這段程式碼執行過程一目瞭然了 我就不再贅述了,大家直接看我單步執行這些程式碼順序應該就懂了(使用了定時器可能單步除錯列印的資訊可能會和正常執行不一樣)

徹底弄懂瀏覽器端的Event-Loop

總結

  • macroTask 和 microTask 哪個先執行

macroTask 先執行(畢竟標準就是這麼定的),至於為什麼,我個人認為是因為 macroTask 都是和使用者互動有關的事件,所以需要及時響應

  • async 函式做了什麼

    async 函式裡面可以使用 await 表示式,async 函式的返回值會被 Promise.resolve 包裹(返回值是一個 Promise 物件就直接返回該物件)
// 驗證
const p = new Promise(resolve => resolve());
console.log(p === Promise.resolve(p)); // true
複製程式碼
  • await 語句做了什麼

await 語句會先執行其後面的表示式,(如果該表示式是函式且該函式裡面遇到 await,則會按同樣的套路執行),然後阻塞屬於當前 async 函式作用域下後面的程式碼

  • 什麼時候恢復 await 語句後面程式碼的執行

當執行完 await 語句之後的 某一輪 eventloop 結束後恢復執行(它需要等待它右側的返回的 Promise 解析完成,而 Promise 解析可能是同步的(new Promise),也可能是非同步的(.then),而 then 回撥需要等到 eventloop 最後去執行)

參考資料

來源 連結
IMWeb 前端部落格 imweb.io/
MDN developer.mozilla.org/en-US/
前端精讀週刊 github.com/dt-fe/weekl…
sessionstack blog.sessionstack.com/
v8 部落格 fastasync(中文版) v8.js.cn/blog/fast-a…
Tasks, microtasks, queues and schedules jakearchibald.com/2015/tasks-…
Secrets of the JavaScript Ninja livebook.manning.com/#!/book/sec…

相關文章