天天看點

為何說前端要大力發展wasm-worker?

大家好,很高興又見面了,我是"進階前端‬進階‬",由我帶着大家一起關注前端前沿、深入前端底層技術,大家一起進步,也歡迎大家關注、點贊、收藏、轉發,您的支援是我不斷創作的動力。

為何說前端要大力發展wasm-worker?

前言

在建構 Web 應用程式時,編寫 CPU 密集型代碼一直存在諸多困難,比如:難以獲得跨浏覽器和 JavaScript 引擎的可預測運作時間、以不同的方式優化代碼路徑、不幹擾使用者體驗。

2010 年,Web Workers 橫空出世,其允許将 CPU 密集型的程式轉移到單獨的線程上,進而保持主線程空閑。 這幾年随着 WebAssembly (WASM) 的快速發展,這種新的 Web 代碼類型也備受關注。

為何說前端要大力發展wasm-worker?

WebAssembly 提供了緊湊的二進制編譯目标格式,允許開發人員從選擇 C/C++ 和 Rust 等強類型語言以及 Go 和 TypeScript 等語言開始 Web 開發。 WebAssembly 解決了第一個核心問題,即跨浏覽器和環境來獲得可預測的、接近本機的性能。

同時,開發者還可以将 Web Workers 和 WebAssembly 結合起來,以獲得 WebAssembly 的一緻性和潛在性能優勢,以及與 Web Workers 在單獨線程中工作的優勢。

1.為什麼将 WebAssembly 代碼放入 Web Worker 中

将 WebAssembly 子產品放入 Web Worker 的關鍵收益是:消除了從主線程 fetch、編譯和初始化 WebAssembly 子產品以及調用子產品中給定函數的開銷, 進而主線程可以專心渲染和處理使用者互動。 考慮到 WebAssembly 通常用于處理 CPU 密集型代碼,将其與 Web Workers 結合可以獲得不錯的收益。

為何說前端要大力發展wasm-worker?

當然,任何事情都有兩面性,将 WebAssembly 代碼移到 Web Worker 也是如此。比如:

  • 将資料從主線程傳輸到 Web Worker 的成本可能會很高,具體取決于相關資料的大小
  • 在 Web Worker 中使用 WebAssembly 時,還需要處理額外的複雜性和邏輯。
  • 将 WebAssembly 子產品放置在 Web Worker 中會使得主線程與 WASM 子產品互動方式變成異步,因為消息傳遞機制利用了事件偵聽器和回調。而且,Web Worker 也無法通路 DOM 等。

當然,這些都不是本文的讨論重點,本文将重點關注 WebAssembly 和 Web Worker 技術的結合。

2.将 WebAssembly 與 Web Worker 結合使用

假設有一個簡單的 WebAssembly 電腦子產品,可以使用輸入執行基本數學運算。 主線程和 Web Worker 之間的通信是通過傳遞消息進行,如主線程通過消息将資料傳遞給 Worker,在本例中是要操作的數字,然後将結果傳回到主線程。

為何說前端要大力發展wasm-worker?

在用戶端,主線程和工作線程都使用 postMessage 方法來傳遞消息。 下面是初始化和使用工作線程來托管 WebAssembly 子產品的代碼:

// worker.js
// 為缺少instantiateStreaming方法的浏覽器添加Polyfill
if (!WebAssembly.instantiateStreaming) {
  WebAssembly.instantiateStreaming = async (resp, importObject) => {
    const source = await (await resp).arrayBuffer();
    return await WebAssembly.instantiate(source, importObject);
  };
}
// 建立 Promise 來處理 Worker 調用
// 子產品仍在初始化
let wasmResolve;
let wasmReady = new Promise((resolve) => {
  wasmResolve = resolve;
});
// 處理消息
self.addEventListener(
  "message",
  function (event) {
    const { eventType, eventData, eventId } = event.data;
    if (eventType === "INITIALISE") {
      WebAssembly.instantiateStreaming(fetch(eventData), {}).then(
        (instantiatedModule) => {
          // instantiatedModule包含兩個屬性
          // 1)module:表示已編譯的 WebAssembly 子產品的 WebAssembly.Module 對象, 該子產品可以再次執行個體化或通過 postMessage() 共享
          // 2)instance:包含所有導出的 WebAssembly 函數的 WebAssembly.Instance 對象
          const wasmExports = instantiatedModule.instance.exports;
          // 解決消息何時導出的問題
          // 執行函數過來
          wasmResolve(wasmExports);
          // 将初始化消息發送回主線程
          self.postMessage({
            eventType: "INITIALISED",
            eventData: Object.keys(wasmExports),
          });
        }
      );
    } else if (eventType === "CALL") {
      wasmReady
        .then((wasmInstance) => {
          const method = wasmInstance[eventData.method];
          const result = method.apply(null, eventData.arguments);
          self.postMessage({
            eventType: "RESULT",
            eventData: result,
            eventId: eventId,
          });
        })
        .catch((error) => {
          self.postMessage({
            eventType: "ERROR",
            eventData:
              "An error occured executing WASM instance function: " +
              error.toString(),
            eventId: eventId,
          });
        });
    }
  },
  false
);           

下面對代碼中的關鍵方法進行說明:

  • WebAssembly.instantiate(bufferSource, importObject) :允許開發者編譯和執行個體化 WebAssembly 代碼。
  • WebAssembly.instantiateStreaming(source, importObject):直接從流式底層源編譯并執行個體化 WebAssembly 子產品,是加載 Wasm 代碼的最有效、最優化的方式。其中 source 表示想要流式傳輸、編譯和執行個體化的 Wasm 子產品的底層源。

在第一個代碼塊中為 instantiateStreaming 提供了一個基本的 polyfill,這是推薦的擷取、編譯和初始化 WebAssembly 程式的方法。 然後,繼續為工作線程添加一個事件監聽器,它監聽兩個事件 INITIALISE 和 CALL。 INITIALISE 運作 WASM 初始化步驟,CALL 運作帶有參數的給定函數。

現在對于主線程,假設它包含在 main.js 中。接着發送一條 INITIALISE 消息并偵聽一條 RESULT 消息,同時在相應的 Promise 中解析該消息:

// main.js
function wasmWorker(modulePath) {
  // 建立一個稍後與之互動的對象
  const proxy = {};
  // 跟蹤正在發送的消息
  // 這樣我們就可以正确地解決它們
  let id = 0;
  let idPromises = {};
  return new Promise((resolve, reject) => {
    const worker = new Worker("worker.js");
    worker.postMessage({ eventType: "INITIALISE", eventData: modulePath });
    // 發送INITIALISE消息,modulePath為子產品位址
    worker.addEventListener("message", function (event) {
      const { eventType, eventData, eventId } = event.data;
      if (eventType === "INITIALISED") {
        const methods = event.data.eventData;
        // wasm導出的所有方法
        methods.forEach((method) => {
          proxy[method] = function () {
            return new Promise((resolve, reject) => {
              worker.postMessage({
                eventType: "CALL",
                eventData: {
                  method: method,
                  arguments: Array.from(arguments),
                  // arguments is not an array
                },
                eventId: id,
              });
              idPromises[id] = { resolve, reject };
              id++;
            });
          };
        });
        resolve(proxy);
        return;
      } else if (eventType === "RESULT") {
        if (eventId !== undefined && idPromises[eventId]) {
          idPromises[eventId].resolve(eventData);
          delete idPromises[eventId];
        }
      } else if (eventType === "ERROR") {
        if (eventId !== undefined && idPromises[eventId]) {
          idPromises[eventId].reject(event.data.eventData);
          delete idPromises[eventId];
        }
      }
    });
    worker.addEventListener("error", function (error) {
      reject(error);
    });
  });
}           

主線程代碼的目的是處理來自正在處理 WASM 代碼的 Worker 的消息發送和接收。 是通過一個從主線程互動的代理對象,而不是直接與 WASM 執行個體對象互動。 而 ID 用于跟蹤請求和響應,以確定對正确 Promise 的調用。 這個抽象公開了一個可以與之互動的對象,就像原始導出對象的異步版本一樣。

然後可以繼續使用上面定義的 wasmWorker 方法:

// main.js
wasmWorker("./calculator.wasm").then((wasmProxyInstance) => {
  wasmProxyInstance
    .add(2, 3)
    .then((result) => {
      console.log(result);
      // 輸出 5
    })
    .catch((error) => {
      console.error(error);
    });
  wasmProxyInstance
    .divide(100, 10)
    .then((result) => {
      console.log(result);
      // 輸出 10
    })
    .catch((error) => {
      console.error(error);
    });
});           

3.使用 Inline Web Workers

Inline Web Workers 表示一種方法,其可以聲明 Web Worker 代碼,而無需将該代碼放入單獨的腳本檔案中。 本質是通過建立 Blob 對象來實作的,是一種将 JavaScript 代碼轉換為虛拟 URL 的方法,然後在建立 Worker 對象時可以使用該虛拟 URL 來代替 URL 參數。

const blob = new Blob([
  `onmessage = function(e) {
        console.log('Message received from main script: ' + JSON.stringify(e.data));
    }`,
]);
const blobURL = window.URL.createObjectURL(blob);
const myInlineWorker = new Worker(blobURL);
myInlineWorker.postMessage({ name: "test1", text: "hello inline worker" });           

Blob 方法嘗試将建立的函數體作為字元串(使用 toString),開發者可以将其傳遞給 createObjectURL 方法。接下來讓我們嘗試内聯上面的示例代碼, 請注意:本示例的目标不是編寫生産級内聯 Web Worker,而是示範它們是如何工作的!

function wasmWorker(modulePath) {
  let worker;
  const proxy = {};
  let id = 0;
  let idPromises = {};
  // 為缺少instantiateStreaming的浏覽器添加Polyfill
  if (!WebAssembly.instantiateStreaming) {
    WebAssembly.instantiateStreaming = async (resp, importObject) => {
      const source = await (await resp).arrayBuffer();
      return await WebAssembly.instantiate(source, importObject);
    };
  }

  return new Promise((resolve, reject) => {
    worker = createInlineWasmWorker(inlineWasmWorker, modulePath);
    // 建立inline worker
    worker.postMessage({ eventType: "INITIALISE", data: modulePath });
    worker.addEventListener("message", function (event) {
      const { eventType, eventData, eventId } = event.data;
      if (eventType === "INITIALISED") {
        const props = eventData;
        props.forEach((prop) => {
          proxy[prop] = function () {
            return new Promise((resolve, reject) => {
              worker.postMessage({
                eventType: "CALL",
                eventData: {
                  prop: prop,
                  arguments: Array.from(arguments),
                },
                eventId: id,
              });
              idPromises[id] = { resolve, reject };
              id++;
            });
          };
        });
        resolve(proxy);
        return;
      } else if (eventType === "RESULT") {
        if (eventId !== undefined && idPromises[eventId]) {
          idPromises[eventId].resolve(eventData);
          delete idPromises[eventId];
        }
      } else if (eventType === "ERROR") {
        if (eventId !== undefined && idPromises[eventId]) {
          idPromises[eventId].reject(event.data.eventData);
          delete idPromises[eventId];
        }
      }
    });
    worker.addEventListener("error", function (error) {
      reject(error);
    });
  });

  function createInlineWasmWorker(func, wasmPath) {
    if (!wasmPath.startsWith("http")) {
      if (wasmPath.startsWith("/")) {
        wasmPath = window.location.href + wasmPath;
      } else if (wasmPath.startsWith("./")) {
        wasmPath = window.location.href + wasmPath.substring(1);
      }
    }
    // 確定wasm路徑是絕對路徑并轉為IIFE
    func = `(${func.toString().trim().replace("WORKER_PATH", wasmPath)})()`;
    const objectUrl = URL.createObjectURL(
      new Blob([func], { type: "text/javascript" })
    );
    const worker = new Worker(objectUrl);
    URL.revokeObjectURL(objectUrl);
    return worker;
  }

  // inlineWasmWorker方法定義
  function inlineWasmWorker() {
    let wasmResolve;
    const wasmReady = new Promise((resolve) => {
      wasmResolve = resolve;
    });
    self.addEventListener(
      "message",
      function (event) {
        const { eventType, eventData, eventId } = event.data;

        if (eventType === "INITIALISE") {
          WebAssembly.instantiateStreaming(fetch("WORKER_PATH"), {})
            .then((instantiatedModule) => {
              const wasmExports = instantiatedModule.instance.exports;
              wasmResolve(wasmExports);
              self.postMessage({
                eventType: "INITIALISED",
                eventData: Object.keys(wasmExports),
              });
            })
            .catch((error) => {
              console.error(error);
            });
        } else if (eventType === "CALL") {
          wasmReady.then((wasmInstance) => {
            const prop = wasmInstance[eventData.prop];
            const result =
              typeof prop === "function"
                ? prop.apply(null, eventData.arguments)
                : prop;
            self.postMessage({
              eventType: "RESULT",
              eventData: result,
              eventId: eventId,
            });
          });
        }
      },
      false
    );
  }
}           

這種方法非常有效,開發者可以使用與上面相同的代碼來使用此抽象(即接口沒有改變)。

4.wasm-worker 讓一切變得容易

wasm-worker 的目标是将 WebAssembly 子產品轉移到單獨的線程中,同時讓 WebAssembly 和 worker 的結合更加容易。

Move a WebAssembly module into its own thread

wasm-worker 目前僅支援浏覽器環境,因為它底層使用 fetch、Worker 和大量的 WebAssembly API,雖然受到大多數主流浏覽器的支援,但是對于舊版浏覽器依然需要使用 polyfill。

if (!window.fetch || !window.Worker || !window.WebAssembly) {
    ...
} else {
    ...
}           

同時,為了在 NodeJS 環境中使用 wasm-worker,Web Workers 必須使用像 node-webworker 這樣的 polyfill 庫進行填充。為了使用 wasm-worker,需要首先安裝它:

npm install --save wasm-worker           

如果沒有在項目中使用 npm,則可以使用 UMD build 将 wasmWorker 包含在帶有 <script> 标記的 dist 檔案夾中。安裝了 wasm-worker 後,假設有 CommonJS 環境,就可以按照下面的方式導入并使用:

import wasmWorker from "wasm-worker";
// 假設有一個“add.wasm”子產品導出單個函數“add”
wasmWorker("add.wasm")
  .then((module) => {
    return module.exports.add(1, 2);
  })
  .then((sum) => {
    console.log("1 + 2 = " + sum);
  })
  .catch((ex) => {
    // ex 是表示異常的字元串
    console.error(ex);
  });

// 也可以在worker内部運作js函數
// 例如:通路 importObject
wasmWorker("add.wasm")
  .then((module) => {
    return module.run(
      ({
        // module,
        // importObject,
        instance,
        params,
      }) => {
        // 同步運作
        const sum = instance.exports.add(...params);
        return "1 + 2 = " + sum;
      },
      [1, 2]
    );
  })
  .then((result) => {
    console.log(result);
  });           

5.本文總結

本文主要和大家介紹如何将 WebAssembly 和 Web Worker做結合。相信通過本文的閱讀,大家對 如何在 WebAssembly 場景使用 Web Worker 會有一個初步的了解,同時也會有自己的看法。

因為篇幅有限,文章并沒有過多展開,如果有興趣,可以在我的首頁繼續閱讀,同時文末的參考資料提供了大量優秀文檔以供學習。最後,歡迎大家點贊、評論、轉發、收藏!

參考資料

https://www.sitepen.com/blog/using-webassembly-with-web-workers

https://github.com/mbasso/wasm-worker

https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/instantiate

https://www.codemag.com/Article/2101071/Understanding-and-Using-Web-Workers

https://blog.csdn.net/weixin_33962621/article/details/88832260

https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/Instance

https://www.hongkiat.com/blog/web-workers-javascript-api/

https://soshace.com/web-workers-concurrency-in-java-script/

https://blog.cloudflare.com/webassembly-on-cloudflare-workers/

繼續閱讀