JavaScript 架構

本章節, 從非同步程式的概念開始, 假設有個程式這樣寫:

const arr = [];
arr.push(1);
arr.push(2);
arr.push(3);
arr.push(4);
console.log(arr) // [1,2,3,4]

在這個例子中, 程式碼都是由上到下執行, 因此很好預測執行結果

但如果程式碼改成以下模式:

const arr = []
let fileA = download(urlA);

let fileB = download(urlB);

let fileC = download(urlC);

while( !(fileA.done && fileB.done && fileC.done) ) {
  if(fileA.done && !fileA.lock) {
    arr.push(fileA.content);
    fileA.writeLock();
  }
  if(fileB.done && !fileB.lock) {
    arr.push(fileB.content);
    fileB.writeLock();
  }
  if(fileC.done && !fileC.lock) {
    arr.push(fileC.content);
    fileC.writeLock();
  }
}

假設download程式碼, 可以下載某個URL提供的資料, 並且分別用 done 表示下載完成, 以及 content 表示下載的內容

此例進行下載三個網站的內容, 並不斷檢測是否下載完成;如果有任何網站還未下載完成, 就不會離開迴圈

迴圈會依序檢查A、B、C的內容, 若下載完成, 把資料放入arr中, 並且上鎖防止重複寫入

Question

arr 中資料的順序為何?

答案是不確定

可以改成: arr[0] = fileA.content 這種做法來確定資料的順序, 但是當資料列表變大的情境, 要處理的細節就會越來越多

或是操作順序有時序性:需要先登入銀行、然後才能操作帳戶

當操作的步驟越來越繁瑣, 卻又需要知道式健順序, 無法確定API回傳順序時 - 在網路世界中, 程式的執行順序, 不等同於資料的取得順序

JavaScript 引擎架構

v8

模擬一個情境:

在 C++ 中, 可能使用 cin 或是 scanf 來得到使用者的輸入, 程式碼如下:

cin >> num;
cout << "Hello, world" << endl;

cin 會嘗試取得使用者輸入, 然後輸出 "Hello, world"。但是使用者輸入之前, 畫面是不會繼續渲染的, 這在交互式的 command interface 不是問題

但是到了 GUI 卻相當嚴重, 比方說登入頁面, 在你輸入帳號、密碼之前, 畫面上其他部分都停止繪製, 這不是一個好的體驗

因此瀏覽器採用"事件驅動", 透過觀察者模式 (設計模式的一種, 不贅述), 等待用戶發出的事件, 並進行響應

<button onclick="alert('My name is Alex')">Click Me</button>

當點下按鈕後, 會push clickEvent 至事件佇列(Event Queue), 圖中的 EventLoop, 是一個無窮迴圈, 會不斷檢查Event Queue, 若裡頭有資料, 生成Task給後面的執行緒池

Task Queue 紀錄等待執行的工作, 由後方的Worker取出後執行, 完成後調用註冊的 Handler

這就是關鍵所在, 因為不知道使用者何時輸入/點擊畫面, 因此需要用某種機制來控制渲染的執行緒、網路請求執行緒、JS執行緒, 讓所有元件在正確的時間點運作

有了這個概念, 再追加補充:JavaScript有分成 main threadjob thread(或者說是 worker, task thread), 而所有的非同步事件會先扔至 job thread, 靜待瀏覽器調用

比方說 setTimeout(fn, ms), 接受一個function和毫秒的數值, 就會在 N 毫秒後調用該方法

setTimeout(() => console.log('test'), 1000) // 1秒後印出 'test'

可能實現的原理如下:

  1. 瀏覽器會初始化一個 timer, 紀錄經過的毫秒數
  2. 呼叫setTimeout時, 放置一個事件在 job thread
  3. 每次 Eventloop 的週期, 當main thread的工作清空後, 檢查job thread
  4. 如果job thread裡面有事件, 檢查註冊的時戳, 並比較current - reg_timestamp是否逾時
  5. 如果上述為真, 代表事件應該要執行了, 調用
  6. 回到步驟3

比方說設定了 setTimeout(fn, 1000), 當時戳相減超過 1000時 , 調用 fn

假設有個情境如下:要輸出1 ~ 5, 每一秒輸出一個數字, 程式設計如下:

setTimeout(() => console.log(1), 1000);
setTimeout(() => console.log(2), 2000);
setTimeout(() => console.log(3), 3000);
setTimeout(() => console.log(4), 4000);
setTimeout(() => console.log(5), 5000);

這時候, "好像"跟預期的一樣:

首先, 如何證明main thread結束後, 才會執行job thread的工作

證明如下:setTimeout會把事件放入job thread, 首先設定setTimeout(fn, 0);

let arr = [];
setTimeout(() => arr.push(1), 0); // Enqueue - job thread
setTimeout(() => arr.push(2), 0); // Enqueue - job thread
setTimeout(() => arr.push(3), 0); // Enqueue - job thread
arr.push(4) // main thread
console.log(arr) // [4, 1, 2, 3]

該程式碼揭露的:因為前面三次push是放在job thread的, 因此狀況就好像:

JobThread = [fn, fn, fn];
MainThread = [fn];

必須等到 MainThread 清空後, 才會依序執行 job thread 內的工作

Info

setTimeoutsetInterval

  1. setTimeout - 經過至少多少毫秒後, 應該調用 function
  2. setInterval - 每隔至少多少毫秒後, 應該調用 function

Example

下方的例子, 通過程式實驗, 揭露了 setTimeout 與 setInterval 的實現

首先定義基準時間:

timer

在筆者的電腦上, 執行 3 * 109次 空迴圈大約耗時 1500ms, 接下來分別推入5個事件到job thread中:

使用 performance.now用來取得分頁開啟後的累積時間, 並透過 setTimeout 放入 job thread

timer2

定義 diffTime:計算陣列前後項的時間差, 相當於是 setTimeout 放入時戳的間隔

首先定義五個 job, 都是放入一個時戳到timestamps[]中, 最後手動呼叫 diffTime

可以看到每一次的時間間隔大概是 90~100ms 左右

接下來設計一個實驗:

function diffTime(arr) {
  for(let i = 0; i < arr.length - 1 ; ++i) {
    console.log(`time pass: ${arr[i+1] - arr[i]} ms`);
  }
}

// 放入5個 Task 到 job thread 中
let timestamps = [];
setTimeout(() => { timestamps.push(performance.now())}, 100);
setTimeout(() => { timestamps.push(performance.now())}, 200);
setTimeout(() => { timestamps.push(performance.now())}, 300);
setTimeout(() => { timestamps.push(performance.now())}, 400);
setTimeout(() => { timestamps.push(performance.now())}, 500);

// 使用 for-loop, 阻塞 main thread 1500ms 左右
console.time("timer")
for(let i = 0 ; i < 3 * 1e9 ; ++i) {}
console.timeEnd("timer")

// diffTime(timestamps) 最後手動呼叫, 查看每個元素被放入的時間差

這個結果的意義:

  • 假定在 t0 的時候執行腳本
  • 分別設定事件:放入時戳到 timestamps 中, 分別在 t0 + (100 * i)ms 的5個時間點調用push(i 表示第 i 個事件)
  • 程式繼續執行繼續往下, 使用 for-loop 阻塞 main thread 1492ms
  • 對瀏覽器來說, 在 t0 + 1492ms 的時間點後, main thread 的所有任務結束
  • 此時檢查 job thread 中的 task, 5 個 Task 都逾時了
  • 依序執行 5 個 Task

Note

唯有當 main thread 執行結束後, 才會執行job thread 中的Task

另外還有兩個單純的測試方法:

首先執行, 會看到大約每間隔一秒, 畫面會輸出一個數字

setTimeout(() => console.log(1), 1000);
setTimeout(() => console.log(2), 2000);
setTimeout(() => console.log(3), 3000);
setTimeout(() => console.log(4), 4000);
setTimeout(() => console.log(5), 5000);

接下來改成執行:

setTimeout(() => console.log(1), 1000);
setTimeout(() => console.log(2), 2000);
setTimeout(() => console.log(3), 3000);
setTimeout(() => console.log(4), 4000);
setTimeout(() => console.log(5), 5000);
window.alert('block!');

alert 會跳出一個提示框, 他會強制阻塞main thread, 等到5秒後把提示框給點掉

alert

然後再看看 console.log 的輸出, 會發現1~5會同時輸出;

另一個簡單的作法:

while(true) {}
setTimeout(() => console.log(), 0); // 永不執行, 因為 main thread 被 while-loop 永遠阻塞

關於 setTimeout 或 setInterval

前述的例子中, 有點像是證明 main threadjob thread 的關係, 如果只是證明:setTimeout和setInterval 是至少 N 毫秒後執行

可以透過 console.time 和 console.timeEnd 兩組函數, 觀測瀏覽器回傳的時間差異

alert

如圖所示, 計時開始與結束的時間是 1019ms, 實際上與 1000ms 還是有一點誤差