天天看点

为何说前端要大力发展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/

继续阅读