JavaScript – 單線程 與 執行機制 (event loop)

前言

因爲在寫 RxJS 系列, 有一篇要介紹 Scheduler. 它需要對 JS 執行機制有點了解, 於是就有了這裏篇. 

 

參考

知乎 – 詳解JavaScript中的Event Loop(事件循環)機制

掘金 – 徹底搞懂JavaScript事件循環

關於JavaScript單線程的一些事

 

遊覽器與 JavaScript 的線程

遊覽器是多線程的

但是呢, 負責執行 JavaScript 的卻只有一條 JS 線程.

UI 渲染則是 GUI 線程負責. 雖然是分開兩條線程. 但是它們關係又很密切,

JS 阻塞渲染

document.querySelector('h1')!.textContent = 'Hello World';
for (let i = 0; i < 5_000_000_000; i++) {} // 耗時 6 秒

遊覽器渲染是有周期的, 第一句代碼雖然更新了 DOM, 但遊覽器並不會馬上去渲染 UI.

它會等到所有 JS 執行完畢纔去渲染. 所以下面的 for loop 執行了 6 秒. 那麼 6 秒後用戶纔會看見 h1 變成 'Hello World'.

這就是 JS 阻塞渲染.

JS 不阻塞渲染

h1 {
  animation: moving 1s ease infinite;
}
@keyframes moving {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(100%);
  }
}

JS

for (let i = 0; i < 5_000_000_000; i++) {} // 耗時 6 秒

在這 6 秒中, CSS animation 依然可以跑的順順. 因爲 JS 和 UI 是不同線程負責的, 它們是可以同時工作的.

 

JavaScript 的執行機制

first JS code

遊覽器從 URL 下載到 HTML 後, 開始解析, 當遇到 <script> 標籤後去獲取 JS 代碼 (inline or src)

然後依據 defer or async 決定什麼時候執行. 

JS code to 執行棧 (execution stack)

當要執行時, 遊覽器會把 JS 代碼放入 exec stack (想象它是一個 box)

exec stack 接獲代碼後就開始執行. 我們先用簡單的同步代碼爲例子.

const value = '';
for (const number of [1, 2, 3, 4, 5]) {
  console.log(number);
}

執行完以後, JS 線程休息, 輪到 UI 線程去渲染. 這樣就算完成了一個週期 (執行 JS + 渲染 = 1 週期)

執行異步代碼

上面我們以同步代碼爲例, 這裏我們換成異步代碼 (Ajax). 

執行 JS...遇到 Ajax...發送請求....這時就遇到一個等待的問題.

請求發送以後, 需要等待 server response, 這可能是一個漫長的過程. 如果 JS 線程就傻傻的等. 那麼就有可能阻塞 UI 渲染 (爲了不阻塞, JS 線程一定要儘快的執行完, 完成一個週期).

於是就有了異步這個概念, 我們把要等待 response 才能繼續執行的代碼叫 callback, 不需要等待 response 依然能繼續執行的代碼叫同步代碼.

當 exec stack 遇到異步代碼後, 它會把 callback 存起來, 然後繼續執行後續的同步代碼. 這樣 JS 線程就不需要傻傻等了. 

執行完同步代碼後, 就渲染 UI. 這樣一個週期就結束了.

callback to event queue

當 response 回來以後, 遊覽器會找出剛纔保存的 callback 代碼. 然後把它放進 event queue (想象它是另一個 box). 

然後等待 exec stack 完成當前的週期, 再把 event queue 的代碼放進 exec stack, 然後週而復始.

其它異步代碼

除了上面提到的 Ajax 以外. SetTimeout, Event Listenner, Promise 這些都是異步代碼, 都有 callback.

Macro Task vs Micro Task

異步代碼中還有細分 Macro Task 和 Micro Task.

SetTimeout, Event Listenner 屬於 Macro Task 

Promise.resolve, MutaionObserver 屬於 Micro Task

它們的執行時機不同.

exec stack 執行完 JS 代碼後, 會先看 event queue 有沒有 Micro Task 可以執行. 如果有就立刻執行 (before UI render). 沒有的話就完成這次週期.

然後去看 event queue 有沒有 Macro Task 可以執行 (after UI render)

Promise, SetTimeout, requestAnimationFrame 觸發時機

requestAnimationFrame(() => {
  console.log('4. async requestAnimationFrame – next next cycle');
});

setTimeout(() => {
  console.log('3. async setTimeout – next cycle');
}, 4);

Promise.resolve().then(() => {
  console.log('2. async Promise – this cycle');
});

console.log('1. sync console – this cycle');

結果

Console 是同步代碼, 在當前週期執行.

Promise 是異步代碼, callback 進 event queue, 同時它是 Micro Task. exec stack 執行完後會馬上去執行 Micro Task 才結束週期. 所以它依然是當前週期.

SetTimeout 是異步代碼 Macro Task. 它的默認值是 4ms 後執行. 所以它會進入 event queue, 肯定不是當前週期執行.

requestAnimationFrame 是異步代碼 Macro Task, 它大約是 60ms 後執行 (這個不一定哦, 有時候甚至不到 4ms 它就執行了. 遊覽器有它的算法), 它也是進入 event queue, 肯定不是當前週期執行.

上面 4 行代碼, Macro Task 就會產生新的週期, 所以一定會有 3 個週期.

爲什麼 SetTimeout 觸發時機不精準?

timeout 的計數不是 JS 線程負責的, 遊覽器有一條計數的線程. 時間到的時候, callback 會被放入 event queue.

但是並不一定馬上執行. 如果 exec stack 正忙着, 時間自然就被耽擱了. 就不精準了.

所以, 不管有多少線程幫忙分工, 執行 JS 的始終只有一條 JS 線程. 還是有許多侷限的.

耗時的 CPU 操作導致阻塞

異步的解決思路是靠 callback, JS 線程不等待就不會阻塞.

但是如果我跑 for loop 100億次呢? JS 線程忙不過來, 還是會阻塞 UI 渲染.

所以就有了 Web Worker. Web Worker 和 SetTimeout 是一樣的概念 (setTimeout 遊覽器會給予多一條線程來幫忙計數, 於是 JS 主線程就 free 了)

當開啓 Web Worker 後, 遊覽器會創建一條線程去處理 JS 代碼 (比如 for loop 100億次), 這時 JS 主線程就 free 了. 

然後就等 callback 咯.

 

執行機制 FAQ

1. 遊覽器是單線程嗎?

不是, 遊覽器有很多線程, 比如 JS 線程, UI 線程, Timer 線程 等等等

 

2. JS 會影響 UI 渲染嗎? 

有些時候會, 雖然它們是不同的線程, 但是遊覽器渲染是有周期的, JS 一旦運行, 就必須等到它結束後才更新 DOM 做 UI 渲染 (獨立的 CSS animation, hover effect 這些就不受影響, 可以並行)

 

3. JS 是單線程嗎?

是也不是, JS 只有一條主線程. 主線程阻塞就會導致 UI 無法渲染. 但是 JS 可以通過 Web Worker 開多幾條子線程來幫忙處理耗時的 CPU 計算.

這樣就可以減輕主線程的負擔, 它就不會阻塞了.

 

4. 同步和異步代碼是什麼? 有什麼區別?

同步就是一鏡到底, 沒有中斷的執行. 

異步就是跑了個開頭, 然後等待 (可以等另一個線程, 等 IO, 等 Network, 等誰不重要), 等到時機到了, 再運行後續的代碼 (callback)

 

5. 爲什麼需要異步?

因爲 JS 主線程不能一直跑, 一直跑 UI 就一直不渲染, 用戶就感覺死機了. 所以要分段.

另一點是, 在等 IO, Network 時, JS 線程本來就沒事幹. 瞎等幹什麼呢, 倒不如去做點別的.

 

6. Promise, SetTimeout, requestAnimationFrame, UI render 誰先觸發?

sync JS > async Micro Task > UI Render > async Macro Task

首先三個都是異步, 一定後於同步代碼

Promise 是 Micro Task 先於 UI render, timeout 和 animation (Macro Task)

UI render 先於 timeout 和 animation

SetTimeout 和 requestAnimationFrame 誰先不好說, 因爲是遊覽器控制的. 一個是默認是 4ms, 另一個大約在 60ms.

 

7. 爲什麼 interval, timeout 時間不精準? 

timer 是準的, 只是到點的時候, 遊覽器只把 callback 放入了 event queue.

而 event queue 必須等到 exec stack 完成當前週期 (執行 + 渲染) 後, 纔會把 callback 交給 exec stack.

如果這時碰巧 exec stack 在忙, 那麼自然就耽誤了.

 

8. 什麼時候需要用 Web Worker? 

當你需要處理耗時的 CPU 操作時.

開啓 Web Worker 主要的目的不是爲了開更多線程分工, 從而提高計算速度. (這是目的, 但不一定時主要目的)

更重要的原因是不要讓 JS 主線程阻塞影響到 UI 渲染.

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章