使用Web Worker改善性能

開始之前,代碼在這裏。歡迎各位大神指導。

在 Web Worker 之前,解析 CSS,生成佈局,繪製界面以及運行 javascript 腳本都運行在瀏覽器的一個線程裏。如果一個 Web App 運行的 js 腳本一次運行時間過長,就會出現界面卡頓。這樣的用戶體驗是沒法用及格來評價的。

在多核普及的當下,瀏覽器也大多支持了 Web Worker。這讓前端開發有了更多的選擇,使用 web worker 來實現真正的多線程。所有阻礙、延遲用戶反饋的操作都可以移入一個後臺運行的線程中。

如何發現性能瓶頸

筆者主要使用 React 開發,所以首先聊一下 React 中如何發現性能出現問題的地方。在 React16.9 中新增了 Profiler API,使用起來也非常簡單,具體可以查看文檔((https://reactjs.org/docs/prof...

更通用一點的可以使用 chrome 的lighthouse。可以安裝插件 lighthouse 插件,或者也可以直接打開開發面板的audits點擊run audits,就能看到報表了。console.timeconsole.timeEnd組合。

但是,以上只適用於開發模式下使用。在生產上使用多多少少會給產品本身帶來額外的資源消耗。一般來說,app 都會有埋點,在埋點點時候如何順道達成性能消耗點記錄就需要具體問題具體分析了。

Web Worker

使用 Web Worke 讓阻塞代碼在後臺運行,自然不會阻塞 UI 線程(main thread)。在例子中所用到的是typescript版本的代碼,所有後面如果有必要會給出在 typescript 實現的代碼和相應的說明。配置一類的文件請直接移步到代碼目錄查看,這裏就不多說了。

在 Worker 的部分使用了webpack + worker-loader的方式。worker-loader的具體內容可以參考這裏

創建一個 Worker

創建一個 Worker 非常的簡單,只需要把一段命名腳本傳給Worker構造函數就可以。比如 MDN 的一段:

這是 Worker 腳本:

// worker.js
self.onmessage = event => {
  console.log("Message received", event.data);
  self.postMessage("Worker done");
};

在 typescript 裏,首先需要處理 Worker 的上下文的問題,否則tsc編譯不過。

const ctx: DedicatedWorkerGlobalScope = self as any;

ctx.onmessage = (event: MessageEvent) => {
  //...

  ctx.postMessage("done");

  // Close the worker when jobs done
  ctx.close();
};

ctx.onerror = (event: ErrorEvent): any => {
  console.error("Error in worker", event.message);
  ctx.close();
};

export default null as any;

注意:這裏需要使用DedicatedWorkerGlobalScope不能直接食慾哦那個Worker,因爲Worker的定義裏面沒有close方法。這是因爲close方法deprecated

TS2339: Property 'close' does not exist on type 'Worker'.

還有在創建 Worker 的最後,需要一個export語句:

export default null as any;

創建 Worker:

// Main thread
var myWorker = new Worker("worker.js");

myWorker.postMessage([first.value, second.value]);

myWorker.onmessage = function(e) {
  result.textContent = e.data;
  console.log("Message received from worker");
};

Typescript:

import SimpleWorker from "./simple.worker";
const worker = new SimpleWorker();

兩個線程之間(上例是 UI thread 和一個 worker)可以通過postMessageonmessage或者(addEventListener('message', () => {})的方式來傳遞消息。

線程之間的通信是基於事件的。那麼錯誤的處理也是同樣道理,例如:

// UI thread
var myWorker = new Worker("worker.js");

myWorker.onerror = function() {
  console.log("There is an error with your worker!");
};
// Inside worker
self.onerror = err => {
  console.error("Error in worker", err);
};

引入外部腳本

importScripts(); /* imports nothing */
importScripts("foo.js"); /* imports just "foo.js" */
importScripts("foo.js", "bar.js"); /* imports two scripts */
importScripts(
  "//example.com/hello.js"
); /* You can import scripts from other origins */

注意:下載順序可以是任意順序,但是執行的順序是按照腳本在importScripts方法裏出現的順序。

因爲使用了worker-loader,在引入外部代碼的時候,和一般的import差不多:

import { ab2str, str2ab } from "./lib/utils"; // 引入內部依賴
import * as _ from "lodash"; // 引入外部依賴

ctx.onmessage = (event: MessageEvent) => {
  // ...
  const dataStr = ab2str(dataBuff); // 使用內部依賴

  // ...
  const target = JSON.parse(dataStr || '[]');
  const v = _.get(target, 'a.b', 'N/A');  // 使用外部依賴

  // ...

關閉一個 Worker

Worker 也佔用和消耗資源,所以在不用的時候就要關閉它。

關閉一個 Worker 有兩種方法:一種是直接在 UI thread 裏面使用terminate方法,一種是在 Worker 的內部調用close方法。

// In main thread
const worker = new Worker("myworker.js");

// If it's the time to terminate a worker
worker.terminate();

在調用了terminate方法之後,Worker 會被立刻終止,即使是還在運行中的也是一樣。但是一般情況下還是希望在 Worker 執行完成之後纔去關閉。這個時候就要用到 Worker 的close方法。

// In a worker
self.onmessage = event => {
  self.close();
};

如上文所說,close方法就要被廢棄了,現在是在deprecated的狀態。具體看 MDN 的這裏

要被廢棄是因爲,在一個 worker 出了作用域之後就會被回收。所以有沒有close這個方法並沒有太大的必要。

Inline Worker

Worker 的創建需要得到腳本的 URL 地址。一般情況下,這段腳本是放在 server 上的。這就需要網絡的傳輸。如果只是一個簡單的需要放到後臺執行的腳本,如果可以打包到一起直接發佈到客戶瀏覽器會節省很多的時間。這個時候就需要 inline Worker。

它的創建也很簡單,並沒有什麼特別的地方。只是在獲得 URL 的時候使用了Blob這個工具,如:

// URL.createObjectURL
window.URL = window.URL || window.webkitURL;

// "Server response", used in all examples
var response = "self.onmessage=function(e){postMessage('Worker: '+e.data);}";

var blob;
try {
  blob = new Blob([response], { type: "application/javascript" });
} catch (e) {
  // Backwards-compatibility
  window.BlobBuilder =
    window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder;
  blob = new BlobBuilder();
  blob.append(response);
  blob = blob.getBlob();
}
var worker = new Worker(URL.createObjectURL(blob));

// Test, used in all examples:
worker.onmessage = function(e) {
  alert("Response: " + e.data);
};
worker.postMessage("Test");

在 react hook 和 Worker 結合的一個 npm 包裏就有過使用這種方法的代碼。簡單的把用戶的 task(一個方法)轉成字符串,之後通過Blob得到一個 URL 來創建出一個 Worker。其他使用 react hook 的工作機制通知 task 執行的結果。非常的簡單有效。代碼在這裏

節選部分代碼,以饗讀者:

const createWorker = func => {
  if (func instanceof Worker) return func;
  if (typeof func === "string" && func.endsWith(".js")) return new Worker(func);
  const code = [
    `self.func = ${func.toString()};`,
    "self.onmessage = async (e) => {",
    "  const r = self.func(e.data);",
    "  if (r[Symbol.asyncIterator]) {",
    "    for await (const i of r) self.postMessage(i)",
    "  } else if (r[Symbol.iterator]){",
    "    for (const i of r) self.postMessage(i)",
    "  } else {",
    "    self.postMessage(await r)",
    "  }",
    "};"
  ];
  const blob = new Blob(code, { type: "text/javascript" });
  const url = URL.createObjectURL(blob);
  return new Worker(url);
};

現在這部分代碼都交給 webpack 都插件來做了。

Web Worker 不能做什麼

首先 Web Worker 不能訪問 UI thread 的 UI,也就是 DOM。
如果一個 Web Worker 可以訪問 DOM,那加上 UI thread 就是兩個或者兩個以上的 Worker 可以訪問 DOM 了,那就會出現非常麻煩的多線程特有的問題,而且調試困難。所以 DOM 肯定是不能訪問的。

其他的還有很多限制可以參考這裏

但是,還是可以發出網絡請求,可以setTimeout, setInterval,還是可以使用CacheIndexedDB等等一些功能等。

Worker 雖好,也不能開的太多。Worker 是真正系統級的線程,要運行起來就需要有支撐的資源。在 Worker 之間傳輸的數據不能太大。爲了避免多個 Thread 共享內存而導致的多線程問題,WeW Worker 傳輸數據的時候使用了兩個方式:

  1. 在多個 Worker 之間傳輸的數據是拷貝傳輸的。開發者不需要考慮這段數據的鎖保護之類的事情。
  2. 以拷貝的方式傳輸數據,數據量過大的時候拷貝消耗的資源也會很大。這個時候就要考慮使用Transferable Object。這種類型的數據在傳輸的時候基本不存在複製的動作,可以認爲是 c++裏的引用傳遞。不同的是 Worker 的Transferable Object在傳遞出去之後就當前上下文裏即不可訪問。
// Create a 32MB "file" and fill it.
var uInt8Array = new Uint8Array(1024 * 1024 * 32); // 32MB
for (var i = 0; i < uInt8Array.length; ++i) {
  uInt8Array[i] = i;
}

worker.postMessage(uInt8Array.buffer, [uInt8Array.buffer]);

舉個栗子

我們來把一個字符串反轉多次來模擬 CPU “繁重”的任務。這個栗子分爲三部分一個是運行在 UI thread 上看看會有多卡,一個是運行在Promise裏,看看會有什麼不同的結果。數據全部都是基於我們的栗子來得到,對於讀者來說由於有些網絡、硬件等情況不同或者不完全可控會有不同,定量分析不會那麼準確,定性分析有一定的代表性。

同時,這個試驗和樣本的數量關係十分密切。在樣本足夠打的時候,試驗只會收到異常。

測試數據是怎麼來的

const ITERATE_COUNT = 1000;
const STR_LEN = 3;

let queue: TaskQueue | null = null;

function prepareData(count: number = 1000, length: number = 10) {
  const data: Array<DataType> = [];
  for (let i = 0; i < count; i++) {
    const item = RandomString.generate(length);
    data.push({ key: `Key - ${i}`, val: item });
  }

  return data;
}

const rawData = prepareData(ITERATE_COUNT, STR_LEN);
(window as any).rawData = rawData;

上面的方法生成了 1000 個長度是 10 的字符串。在後面的例子裏會把這些字符串全部反轉。以此來模擬某種業務場景下繁重的 CPU 任務。

例一、在主線程

代碼:

// Demo 1: execute reverse string in ui thread
function execTaskSync() {
  console.time("sync task in ui thread");

  const target = rawData;
  for (let el of target) {
    const { val } = el;
    reverseString(val);
  }

  console.timeEnd("sync task in ui thread");
}

(window as any).execTaskSync = execTaskSync;

這個任務量其實不夠大,只會產生一個和後面例子對比的效果。先運行一下看看結果:

ui thread

運行結果看起來很快,如果需要更慢一些只需要把字符串數量或者字符串的長度調大就可以。運行的結果基本都在 0.xx ms 的範圍內,只有一個是 2.27 ms。這也許只是一個現象,也許就很值得深究了。

在 Worker 運行

是時候讓這個功能在 worker 裏面運行一次了:

ctx.onmessage = (event: MessageEvent) => {
  console.time("worker timer");

  const { target } = event.data as { target: DataType[] };
  for (let el of target) {
    const { val } = el;
    reverseString(val);
  }

  console.timeEnd("worker timer");

  ctx.postMessage("done");

  // Close the worker when jobs done
  self.close();
};

數據全部傳過來之後,在 worker 連運行。結果是這樣的:
in a worker

在 Micro Queue 運行

看起來是一個 queue,不過是一個個 Promise 接連運行的。在本例中只有一個 Promise 運行。

Queue 是什麼樣的 Queue:

class Queue {
  private _startExec() {
    const task = this._queue.shift();
    if (task) task.run();
  }

  next() {
    if (this._queue.length === 0) {
      return;
    }

    this._startExec();
  }

  async addTask(
    fun: (param: any) => any,
    data: any,
    resolve: (val: any) => void,
    reject: (err: any) => void
  ) {
    const run = async () => {
      try {
        const ret = await fun(data);
        resolve(ret);
      } catch (e) {
        reject(e);
      }

      this.next();
    };

    this._queue.push({ run } as Task);
    this._startExec();
  }
}

這個是在 Queue 裏添加 task 的方法,在添加的時候就會在 task 運行完成之後調用 Queue 的 next 方法來開始下一個 task。

在數據量同樣的情況下運行的結果:
In queue

看起來和在主線程的運行結果相當的接近了。我們來把數據量加大看看會有什麼結果。

const ITERATE_COUNT = 100000;
const STR_LEN = 300;

先把數量級提升到這個程度。

多次運行之後,主線程和放在 Promise 裏的方式差別依然不大,只是在按鈕點擊之後明顯的增加了等待的時間。在 Worker 裏運行的花費時間比之主線程依然更多,但是按鈕點擊之後的等待時間並沒有相應的更多等待。

Run in Worker with Buffer

這就體現出 Worker 存在的意義了。相應用戶點擊的速度一定會快很多。這個時候就需要Buffer出場了。我們來測試一下使用了 Buffer 的 Worker 會出現什麼樣的驚喜。
worker-buffer

明顯在第一次消耗了很多時間之後,每次的調用都消耗了比直接調用 Worker 的postMessage更少的時間。使用 Buffer 來實現不同 Worker 之間傳輸數據就像是 C/C++的引用傳遞一樣,這裏不會涉及到數據的拷貝操作。所以節省了時間。

但是,在代碼裏:

const dataStr = JSON.stringify(data);
const dataBuff = str2ab(dataStr);

const worker = new CachedWorker();
worker.postMessage(dataBuff, [dataBuff]);

其實包含了數據->字符串(json)->buffer 的轉化過程。第一次花費的時間很多是在這些轉化的過程中消耗的。但是後面,筆者認爲是瀏覽器做了優化,還要繼續查一下資料,所以花費的時間只有直接傳輸 buffer 花費的時間,所以大量減少。

注意:使用 Buffer 傳輸數據可以很大,比如在 Google 的某個例子中是 30M 多。但是,上文的例子中,傳輸的數據的大小受到了很大的限制。主要是在把 Buffer 的數據轉化爲 Object 的時候會出現異常。有興趣的各位可以把數據的大小繼續往大調這個異常就會出現。_所以,如何使用需要看具體的場景,比如,上例可以改爲在 Worker 裏請求得到二進制數據再做處理。_

Transferable Object

傳遞 Buffer 的時候是按照 Transferable Object 傳遞的。這種數據是實現了Transferable接口的數據。這個接口就是一個標記的作用,表明實現了這個接口的數據可以如引用一般傳遞。

但是,此處的引用和 C/C++的引用是兩回事。Transferable object 在完成不同的執行上下文(execution context)傳輸之後就不再可用了。H5 委員會爲了 Worker 可以普及,默默的解決了多少使用多線程可能會出現的問題。

多次執行就不用說了,只執行一次的代碼緩存起來也存粹是浪費空間。緩衝的命中率是說緩存的結果會被用到。如果緩存不會再被多次執行的某個功能用到,那麼也是沒有意義的。

在本例中,緩存的作用基本上大打折扣。字符串是隨機生成的。用隨機字符串爲 Key 緩存的結果,基本上備用到的概率很小,而且隨機字符串的數量比較大(這裏是 1000)。那麼在查找緩存字符串的時候也要便利 map 的大部分 Key。反而造成了不必要的多餘計算。

所以,緩存需要根據代碼的執行邏輯和緩存的命中率來判斷是否需要。

Worker 的使用離不開特定的場景

使用 Worker 或者不使用 Worker 都是要看具體的某個場景。新技術的產生一定是解決某個特定的問題的。在使用這項新技術之前至少要儘量真實的模擬需要解決的場景,來驗證這個新的技術是否可行。比如,在本文使用的例子就是爲了模擬筆者想要解決的問題的場景設立的。遇到的最大的問題是如果數據量達到某個臨界值的時候,在 Worker 內部反序列化並組成 Object 的時候就會出現異常。而混存,因爲 Key 值極大的可能是重複的,所以混存的使用就非常的有必要。在以上各種場景的模擬之後可以使用的各種技術的結合必然是緩存和使用Buffer傳輸數據。但是,數據量需要控制,不能出現反序列化的問題。

或者,直接從 Worker 裏請求得到 JSON 的二進制串,比如發送和接收二進制數據

所以,各種技術都有在特定場合下使用的優劣。這就需要我們具體結合場景具體分析。

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