單執行緒的 Javascript 為什麼可以非同步

cnguu發表於2020-10-24

前言

看下面一段程式碼:

console.log("start");

setTimeout(() => {
    console.log("children2");

    Promise.resolve().then(() => {
        console.log("children3");
    });
}, 0);

new Promise((resolve, reject) => {
    console.log("children4");

    setTimeout(() => {
        console.log("children5");

        resolve("children6");
    }, 0);
}).then(result => {
    console.log("children7");

    setTimeout(() => {
        console.log(result);
    }, 0);
});

列印結果:

start
chidlren4
chidlren2
chidlren3
chidlren5
chidlren7
chidlren6

疑問:既然 Javascript 是單執行緒的,為什麼不是從上到下的列印結果呢?

瀏覽器核心是多執行緒的

雖然 Javascript 是單執行緒的,但是瀏覽器卻不是,在核心控制下,各執行緒相互配合以保持同步,一個瀏覽器通常由以下常駐執行緒組成:

  • 瀏覽器
    • 瀏覽器程式
    • 渲染程式
      • Javascript 引擎執行緒
      • GUI 渲染執行緒
      • 定時觸發器執行緒
      • 事件觸發執行緒
      • 非同步 HTTP 請求執行緒
    • GPU 程式
    • 網路程式
    • 外掛程式

Javascript 引擎執行緒稱為主執行緒,其他可以稱為輔助執行緒,這些輔助執行緒便是 Javascript 實現非同步的關鍵

Javascript 引擎執行緒

Javascript 引擎,也叫 Javascript 核心,主要負責處理 Javascript 指令碼程式,解析 Javascript 指令碼,執行程式碼,例如 V8 引擎

GUI 渲染執行緒

GUI 渲染執行緒,負責渲染瀏覽器介面,解析 HTML、CSS,構建 DOM 樹和 RenderObject 樹,佈局和繪製等

當介面需要重繪(Repaint)或由於某種操作引發迴流(reflow)時,該執行緒就會執行

Javascript 引擎執行緒執行指令碼期間,GUI 渲染執行緒都是處於掛起狀態的,也就是說被“凍結”了

GUI 渲染執行緒Javascript 引擎執行緒互斥

由於 Javascript 是可操縱 DOM 的,如果在修改這些元素屬性同時渲染介面(即Javascript 引擎執行緒GUI 渲染執行緒同時執行),那麼GUI 渲染前後獲得的元素資料就可能不一致了;因此,為了防止渲染出現不可預期的結果,瀏覽器設定GUI 渲染執行緒Javascript 引擎執行緒為互斥的關係,當Javascript 引擎執行緒執行時,GUI 渲染執行緒會被掛起,GUI 更新會被儲存在一個佇列中,等到Javascript 引擎執行緒空閒時立即被執行。

Javascript 阻塞頁面載入

由於GUI 渲染執行緒Javascript 引擎執行緒是互斥的關係,當瀏覽器在執行 Javascript 程式的時候,GUI 渲染執行緒會被儲存在一個佇列中,直到 Javascript 程式執行完成,才會接著執行;因此,如果 Javascript 執行的時間過長,這樣就會造成頁面的渲染不連貫,導致頁面渲染載入阻塞的感覺。

定時觸發器執行緒

setIntervalsetTimeout所線上程,避免Javascript 引擎執行緒處於阻塞執行緒狀態影響計時的準確

事件觸發執行緒

將滿足觸發條件的事件放入任務佇列

當一個事件被觸發時,事件觸發執行緒會把事件新增到待處理佇列的隊尾,等待Javascript 引擎執行緒的處理;這些事件可以是當前執行的程式碼塊,如定時任務,也可來自瀏覽器核心的其他執行緒,如滑鼠點選、Ajax 非同步請求等,但由於 Javascript 的單執行緒關係,所有這些事件都得排隊,等待Javascript 引擎執行緒處理。

非同步 HTTP 請求執行緒

XMLHttpRequest 在連線後,透過瀏覽器新開一個執行緒請求,將檢測到狀態變更時,如果設定有回撥函式,非同步執行緒就產生狀態變更事件放到Javascript 引擎執行緒的處理佇列中等待處理。

任務佇列

任務佇列,即事件迴圈(Event Loop),Javascript 管理事件執行的一個流程

當任務加入到任務佇列後並不會立即執行,而是處於等候狀態,等主執行緒處理完了自己的事情後,才來執行任務佇列中任務

任務又分為兩種:

  • 宏任務(MacroTask 或者 Task)
    • script
    • setInterval/setTimeout
    • setImmediate(NodeJS)
    • requestAnimationFrame
    • I/O
    • ajax
    • 事件繫結
    • MessageChannel
    • postMessage
  • 微任務(MicroTask)
    • UI rendering
    • Promise
    • process.nextTick(NodeJS)
    • Object.observe
    • MutationObserve

微任務擁有更高的優先順序,可以插隊,當事件迴圈遍歷佇列時,先檢查微任務佇列,如果裡面有任務,就全部拿來執行,執行完之後再執行一個宏任務

需要注意的是:

  1. 一個事件迴圈可以有一個或多個事件佇列,但是隻有一個微任務佇列。
  2. 微任務佇列全部執行完會重新渲染一次
  3. 每個宏任務執行完都會重新渲染一次
  4. requestAnimationFrame 處於渲染階段,不在微任務佇列,也不在宏任務佇列

Web Worker

Web Worker 能夠同時執行兩段 Javascript,不代表 Javascript 實現了多執行緒,Web Worker 是向瀏覽器申請一個子執行緒,該子執行緒服務於主執行緒,完全受主執行緒控制

  • 同源限制
  • DOM 限制
  • 通訊限制
  • 指令碼限制
  • 檔案限制

同源限制

分配給 Worker 執行緒執行的指令碼檔案,必須和主執行緒的指令碼檔案同源

DOM 限制

Worker 執行緒所在的全域性物件與主執行緒不一樣,無法讀取主執行緒所在網頁的 DOM 物件,也無法使用 document、window、parent 這些物件,但是,Worker 執行緒可以使用 navigator 物件和 location 物件

通訊限制

Worker 執行緒和主執行緒不在同一個上下文環境,它們不能直接通訊,必須透過訊息完成

指令碼限制

Worker 執行緒不能執行 alter 和 confirm 方法,但可以使用 XMLHttpRequest 物件發出 Ajax 請求

檔案限制

Worker 執行緒無法讀取本地檔案,即不能開啟本機的檔案系統(file://),它所載入的指令碼必須來自網路

最後

回到前言,分析執行流程:

  1. 先執行宏任務 script 指令碼
  2. 執行 console.log("start")
  3. 遇到定時器,交由定時觸發器執行緒,等待時間到了加入佇列
  4. 遇到 Promise 直接執行 executor,列印 console.log("children4");遇到第二定時器,又交由定時觸發器執行緒管理,等待加入佇列;Promise.then等 resolve 之後加入微佇列;此時,第一輪任務執行完畢
  5. 第一定時器先進入佇列,取出任務執行 console.log("children2"),此時遇到 Promise 執行,並將 Promise.then 放入當前宏任務佇列中的微任務佇列,當前任務執行完畢;執行 then,列印 console.log("children3")
  6. 取出第二定時器,列印 console.log("children5"),並將 then 放入微任務中,當前宏任務執行完畢,取出 then 執行列印 console.log("children7")
  7. 又遇到定時器,由定時觸發器執行緒等待時間到了新增到宏任務中
  8. 取出定時器任務,列印 console.log("children6")
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章