Web Worker 性能優化初體驗

背景

近期在做用戶回放系統需求,其中有一環是從 indexedDB 中讀取日誌並做上報。然而,由於日誌的數據量太大,計算處理 indexedDB 的數據比較耗時,容易造成阻塞,導致用戶瀏覽器卡頓。爲了解決這個問題,我們想了幾種優化手段:

其中,由於沒有實踐的經驗,使用 Web Worker 的時候也踩了一些坑。在這裏對 Web Worker 的使用做一個小結。

基本介紹

我們都知道,JavaScript 是單線程的,也就是一次只能做一件事。所以,當一些低優先級但是耗時的任務 (日誌處理) 正在執行時,一些高優先級的任務 (業務相關) 就只能等着,可能導致 UI 交互不流暢,瀏覽器出現卡頓的情況,對於 CPU 來說,JS 單線程的帶來的不便就更加明顯了。

而 Web Worker 的出現,爲 JavaScript 創造了多線程的環境。(ps:這裏並不是說 JS 本身支持了多線程的能力,只是瀏覽器作爲宿主環境提供了 JS 一個多線程運行的環境)

W3C 定義:A web worker is a JavaScript that runs in the background, independently of other scripts, without affecting the performance of the page. You can continue to do whatever you want: clicking, selecting things, etc., while the web worker runs in the background.

在項目中,我們可以將一些複雜的計算任務分配給 Worker 運行,讓主線程專注於 UI 交互相關的任務,Worker 線程和主線程互不干擾,這樣用戶使用起來就會比較流暢,不會有卡頓之感。

使用方法

由於主線程和 Worker 線程不在同一個上下文中,他們使用數據通信的方式交互,通過 postMessage 發送消息、監聽 message 事件接收消息(可以通過 addEventListener 或 onmessage 這兩個 API)。

主線程

// 創建一個 Worker 線程,用於上報數據,傳入這個 Worker 對應的腳本文件
const worker = new Worker('reportWorker.ts');

// 主線程向 Worker 線程發送消息,讓 Worker 線程從 indexedDB 讀取 count 條數據
worker.postMessage({ type: WorkerReportType.ReadEventTblStart, data: count });

// 主線程監聽來自 Worker 的消息
worker.onmessage = (event: MessageEvent) => {
  const { type, data } = event.data;
  // 對不同類型的消息做不同處理
  switch (type) {
    case WorkerReportType.ReadEventTblFinish:
      console.log('從worker中接收的數據', data);
      // ...
      break;
    case ...
  }
}

Worker 線程

// Worker 監聽來自主線程的消息
self.onmessage = (event: MessageEvent) => {
  const { type, data } = event.data;
  // 對不同類型的消息做不同處理
  switch (type) {
    case WorkerReportType.ReadEventTblStart:
      // 讀取、處理日誌數據
      readIndexedDB();
      break;
    case ...:
    }
}

// Worker 向主線程發送消息
self.postMessage({ type: WorkerReportType.ReadEventTblFinish, data: result });

除了發送和接收消息這兩種最常用的 API,還可以監聽 Worker 線程的錯誤:

worker.onerror((event: MessageEvent) => {
  console.log('worker error');
})

在 Worker 使用完畢時,應及時關閉:

// 主線程
worker.terminate();

// worker線程
self.close();

數據通信

雖然在 Worker 線程進行一些複雜的運算不會對主線程有影響,但如果主線程和 Worker 之間通信時,傳輸的數據量太大(比如 5-10MB,甚至更大),會不會對主線程的性能有影響呢?

拷貝傳輸

首先,我們瞭解一下主線程和 Worker 之間的默認數據傳輸方式,當像剛剛提到的基本用法那樣使用 postMessage 時,數據的通信是一種拷貝的關係,瀏覽器內部會先將內容序列化,發送給接收方,接收方再將其還原。因此,當我們傳輸一個 100MB 的數據時,會由於拷貝而增加一份內存消耗,複製的時間也會隨數據量增加而增加。通過這樣一段代碼,我們模擬線性增大傳輸數據量:

// Worker 中發送數據
for (let i = 0; i <= 50; i += 5) {
  const mockData = new Uint8Array(1024 * 1024 * i);
  const start = Date.now();
  tasks.postMessage({ type: ReadEventTblFinish, data: { mockData, size: i, start } });
}
        
// 主線程接收數據
...
const now = Date.now();
const { size, start } = data;
const time = now - start;
console.log(`post message end, 大小:${size}MB, 耗時 ${time}ms`);

Chrome 瀏覽器輸出的結果如下:

可以看到,傳輸二進制數據時,傳輸時間基本是隨着傳輸數據大小線性增加的。

使用 Transferable 對象傳輸

爲了解決拷貝傳輸的問題,postMessage 這個還有第二個參數:transferableList,即一個可轉移對象的列表。JavaScript 與 Worker 通信的時候,直接將對象轉移給接收方,一旦轉移,發送方就再也無法使用這些二進制數據。

我們只需要在 postMessage 的時候指定一下可轉移對象:

tasks.postMessage({ type: ReadEventTblFinish, data: { mockData, size: i, start } }, [mockData.buffer]);

Chrome 瀏覽器輸出的結果如下:

可以看到通過這種方法,數據傳輸的耗時大大減少了。再打印一下 postMessage 之後的 mockData

數據爲空,說明控制權確實被轉移了,Worker 裏再也無法使用這份數據了。

然而,使用 transferableList 有兩個需要注意的地方:

  1. 目前,實現了 Transferable 接口的只有:ArrayBufferMessagePort、 ImageBitmap。也就是說,如果我們傳輸的是 JS 對象,需先將其轉換爲 ArrayBuffer,否則會報錯。而如果對象本身很龐大,數據格式轉換的時間也會隨之增大,是否有必要爲了減少 Worker 通信時間而增加數據格式轉換時間還需要權衡。

  2. 當我們使用 TransferableList 傳輸對象時,瀏覽器會幫我們完成 Transferable 對象到對應的數據成員(postMessage 的第一個參數中)之間的映射。因此,如果我們的數據集中於少數變量中,那麼可以放心地使用 Transferable 來傳輸。但如果 transferable 數據分散於成百上千個元素中,這個解析映射的時間就會比較久,使用 Transferable 對象傳輸反而會有比較明顯的性能問題。

Shared Array Buffers

默認情況下,Worker 之間、主線程與 Worker 都不會共享內存,但使用 SharedArrayBuffer,兩個線程都可以在同一塊內存中讀寫數據。共享內存,也就意味着沒有傳輸延遲和開銷。

然而,這也會帶來衝突和競爭的問題,而且當前瀏覽器對這個特性的支持情況也比較差,因此建議不要使用這種方式。

使用 Promise 封裝 Worker 通信

目前,使用 postMessage 和 onmessage 這兩個 API,我們確實能實現通信的目的。但看看代碼結構:

主線程向 Worker 發送消息:

  1. 首先找到主線程的入口,主線程 postMessage 發送了 ReadEventTblStart 的信號;

  2. 到 Worker 對應的代碼中找到 onmessage 時對應的處理方法;

  3. Worker 處理完後給主線程發了一個 ReadEventTblFinish 的信號;

  4. 回到主線程對應的代碼,找到 onmessage 時對應事件的處理;

class PromiseWorker {
  private worker: Worker;
    
  constructor(worker: Worker) {
    this.worker = worker;
  }
}

由於我們只能通過 postMessage 和 onmessage 發送和接收信息,所以我們需要一個 map 將發送消息和收到消息後回調映射起來:

// 這裏我用number類型的type變量作爲key值,實際上這個key值只要唯一即可
private handlerMap: Map<number, Function> = newMap();

封裝 postMessage,每次發送消息時,在 map 中添加一條映射,以供返回時轉換 Promise 的狀態:

postMessage(message: WorkerMessage) {
  const { type } = message;
  returnnewPromise((resolve) => {
    this.worker.postMessage(message);
    this.handlerMap.set(type, resolve);
  });
}

接收消息時,根據和發送消息對應的 type 值,取出 resolve 函數:

this.worker.onmessage = (event: MessageEvent) => {
  const { type, data } = event.data;
  const resolve = this.handlerMap.get(type);
  if (!resolve) {
    return;
  }
  resolve(data);
  this.handlerMap.delete(type);
};

一個完整的 PromiseWorker 類:

exportdefaultclass PromiseWorker {
  private handlerMap: Map<number, Function> = newMap();

  private worker: Worker;

  constructor(worker: Worker) {
    this.worker = worker;
    this.worker.onmessage = (event: MessageEvent) => {
      const { type, data } = event.data;
      const resolve = this.handlerMap.get(type);
      if (!resolve) {
        return;
      }
      resolve(data);
      this.handlerMap.delete(type);
    };
  }

  postMessage(message: WorkerMessage) {
    const { type } = message;
    returnnewPromise((resolve) => {
      this.worker.postMessage(message);
      this.handlerMap.set(type, resolve);
    });
  }
}

使用方式:

/** 主線程 */
// 實例化一個PromiseWorker
const reportWorker = new PromiseWorker(new ReportWorker());
// 調用封裝好的postMessage
reportWorker.postMessage({ type: WorkerReportType.ReadEventTbl, data: count }).then((data) => {
  console.log('read event table finish', data);
});

/** worker線程 */
// 收到消息,計算處理完畢後,發送同一個type即可
self.onmessage = async (event: MessageEvent) => {
  const { type, data } = event.data;
  // 對不同類型的消息做不同處理
  switch (type) {
    case WorkerReportType.ReadEventTbl:
      // 讀取、處理日誌數據
      const result = await ...
      // 回覆(發送同樣的type)
      self.postMessage({ type, data: result });
      break;
    case ...
  }
}

這樣簡單的實現一個 Promise 化的 Worker,在主線程上,我們就能專注於業務實現,而不必關心發送消息和接收消息的對應關係。

Web Worker 的侷限性

  1. DOM 操作限制 Worker 線程和主線程的 window 是不在一個全局上下文中運行的,因此我們無法在 Worker 中訪問到 document、window、parent 這些對象,也不能訪問 DOM 元素。但是,可以獲取 navigator、location 對象。這跟 JavaScript 被設計成單線程也是有關係的,試想多個線程同時對同一個 DOM 操作,就會出現衝突。

  2. 數據通信限制 Worker 和主線程的通信可以傳遞對象和數組,他們是通過拷貝的形式傳遞的,這意味着,我們不能傳遞不能被序列化的數據,比如說函數,否則會報錯。

  3. 無法訪問 localStorage。

  4. 同源限制 分配給 Worker 線程運行的腳本文件,需要和主線程的腳本文件同源。

  5. 腳本限制 Worker 線程不能執行 alert、confirm,但是可以獲取 setTimeout、XMLHttpRequest 等瀏覽器 API。

  6. 文件限制 爲了安全,Worker 線程無法讀取本地文件,即不能打開本機的文件系統( file:// ),它所加載的腳本必須來自網絡,且需要與主線程的腳本同源。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/IJHI9JB3nMQPi46b6yGVWw