JavaScript 使用 Web Worker 處理複雜任務


隨著數據變得越來越大,若應用程式保持在單線程中運行 UI 性能將受到越多的影響,處理效能瓶頸時的解決方式,透過 Browser Web Worker 讓 JavaScript 可以有 multi thread 而不會影響到原來的 main thread,讓網頁使用者體驗上,穩定畫面的流暢度。

單執行緒阻塞

首先,先提供範例來演示長時間影響 Web 應用程式的狀況:

See the Pen
demo
by Eric Wang (@wweitinio)
on CodePen.0

在上面的範例中,使用遊戲中人物的動畫效果來展示,點擊按鈕將執行下面這段程式碼來模擬資源密集型計算,將觀察到阻塞了 main thread 造成動畫凍結了幾秒鐘。

function sleep(milliseconds) {
    const currentTime = new Date().getTime();

    let i = 0;
    while (currentTime + milliseconds >= new Date().getTime()) {
        i++;
        if(i % 1000000 === 0){
            console.log('rows processed : ' + i);
        }
    }
}

我們來了解凍結動畫的原因,開啟 Chrome 開發者工具(F12 或 Ctrl + Shift + I),切換到 Performance 分頁,點選左上角的灰色圓圈 Record (Ctrl + E) 以啟動 JavaScript 分析,然後點擊範例應用程式中的按鈕執行頁面互動,最後按下 Stop 按鈕結束分析,此時就會跑出一張效能表:

上圖中可以看出在觸發按鈕事件時佔了 CPU 的多數時間, 這樣的時間花費會讓使用者感受到頁面嚴重的停頓,甚至使用者執行其他所觸發的事件瀏覽器也會沒辦法即時處理,如果網頁在停頓更長的時間沒有任何響應,瀏覽器還可能會提示用戶是否要停止關閉頁面。

可以想像它就是瀑布式的執行每一個動作,雖然可以利用 event callback 來完成 "非同步" 的工作,但終究是在同一個瀑布上面流動。

在開發案例中也常會遇到密集的計算,例如報表資料,從 server 端取回資料之後,在前端進行排序,或是從 API 中接收巨大的數據集並調用 JSON.parse(),JavaScript 的 main thread 就會阻塞,畫面就像當掉了一樣。

不可預測的性能問題

fps (Frame per second) 表示的是每秒畫面更新次,越高的 fps 代表每秒更新次數越多,人眼會感覺越來越流暢,對於使用者反應回饋也會更快,在開發更複雜邏輯時,除了測試資料邏輯處理運算之間經過的時間外,還需要確保運行設備的渲染刷新率。

在開發過程中我們可以在自己的設備上,專注處理可能會導致過度的運算,對整體性能產生負面影響,然而一段 JavaScript 需要多長時間才能完成,也取決於在設備上運行的速度。

過往可能的做法我們可以在定義開發介於廣泛的設備上,將過於複雜的工作分解為更小部分的任務,保持應用程式流暢且穩定以最小的延遲響應的使用者界面。

拆分同步邏輯,也並不是所有適用,可能遇到的問題:

  • 不是所有邏輯都可拆分

    數組排序、迴圈搜尋、圖像處理 ⋯⋯ 等,還有執行中需要維護當前狀態,也無法輕易地拆分爲子任務

  • 拆分的粒度難以掌控

    在其他低功率的設備上開發可能再拆分到更小,才能控制在 16ms 內讓瀏覽器完成每畫面的渲染

  • 拆分的邏輯難以維護

    對同步邏輯進行再拆分,每次改動業務邏輯,都需要去 review 子邏輯

使用 Worker 的改善

為了確保範例中的動畫效果不會受到執行計算的影響,我們將計算的邏輯移動到不同的線程,我們就可以長時間運行也不會影響 UI 線程 (main thread)

首先,要創建計算的線程,我們使用 Worker() 通過指定文件路徑來構建,讓文件在 main thread 中加載和運行:

const worker = new Worker("./worker.js");

如果使用的只是一段程式碼時則可以使用 Blob 搭配URL.createObjectURL() 產生的臨時網址來構建:

const code = `(function () {

})();`;

const createBlobObjectURL = (code: string) => {
  const blob = new Blob([`${code}`], { type: "text/javascript" });        
  const url = URL.createObjectURL(blob); 
  return url;
};

const worker = new Worker(createBlobObjectURL(code));

接著在 main thread 中,再增加一個按鈕並發送給 worker

$('#button2').click(function () {
    worker.postMessage('long task started');
});

worker script 裡,我們將原本計算的 sleep 函式移過來,使用 onmessage 事件偵聽接收 main thread 所發送過來的資料進行資料運算,在調用 postMessage 將計算結果發送回去:

const code = `(function () {
    function sleep(milliseconds) {
        const currentTime = new Date().getTime();

        let i = 0;
        while (currentTime + milliseconds >= new Date().getTime()) {
            i++;
            if(i % 1000000 === 0){
                console.log('rows processed : ' + i);
            }
        }
    }

    self.onmessage = function (event){
        console.log(event.data);

        sleep(10000);

        self.postMessage('long task completed');
                self.close();
    }
})();`;

最後要在 main thread 中接收 worker 計算的結果,我們也要在 main thread 使用 onmessage 事件偵聽接收 worker 發送過來的資料:

worker.onmessage = function (event) {
    console.log(event.data)
}

整理使用 Worker 改善的範例演示:

See the Pen
demo 2
by Eric Wang (@wweitinio)
on CodePen.0

在上面的範例中,點擊 “worker測試” 按鈕執行模擬運算,可以觀察到計算的結果仍在記錄到 Chrome Devtools Console,但不會影響頁面上人物的動畫效果。

再次使用 Chrome Performance 分析,確定 Web Worker 的性能影響,應該會看到類似於下圖的結果:

從上圖中,明顯的觀察是在 main thread 下方出現一個 worker thread,而模擬資源密集計算都在 worker thread 中計算不再發生在 main thread 上,因此改善了動畫效果的性能讓使用者體驗上穩定畫面的流暢度。

Worker 在開發時需要注意及思考的

終止 Web Worker

建立 Web Worker 會在使用者的設備上建構真正的線程,這也是會消耗系統資源。因此 worker 執行完成時終止釋放 worker thread:

// close main script
worker.terminate();

// close worker script
self.close();

Web Worker 的局限性

  • 沒有 DOM 訪問權限

    無法讀取 main thread 網頁的 DOM 元素,也不能取得 document、window 等對象,但是可以獲取 navigator、location(只允許讀取)、XMLHttpRequest、setTimeout ⋯⋯ 等瀏覽器 API。

  • 沒有共享狀態

    worker script 無法訪問 main script 的數據,對任何一個線程的雙方都使用 postMessage() 方法發送各自的資料,使用 onmessage() 事件接收處理資料。

Transferable Objects

在 worker 相互發送數據這個過程中,數據並不是被共享而是被複製,如果要傳遞一個 50MB 的大文件(File、Blob、ArrayBuffer、JSON),那麼會產生明顯的資源開銷。

為了解決這個問題,postMessage()方法也支持傳輸 Transferable 數據類型 (類似 C/C++ Pass by reference),使用 Transferable 傳輸時,會直接把數據從一個執行環境 (worker thread 或 main thread) 傳輸到另一個執行環境,這樣不會額外增加一份資源消耗,並且傳輸速度極快因為不需要數據拷貝。