将WebAssembly与Web工作者一起使用的详细指南

1,231 阅读8分钟

在构建网络应用时,编写处理密集型代码可能是一个挑战。其中一个问题是在不同的浏览器和JavaScript引擎中获得可预测的运行时间,这些浏览器和引擎对不同的代码路径进行了不同的优化,同时也要产生不影响用户体验的代码。自2010年以来,我们已经有了一种标准化的方式来管理长期的、与DOM无关的任务的互动性。

Web Worker允许将处理过程卸载到一个单独的线程上,以保持主线程的自由。最近,我们看到了另一种规范的发展,即WebAssembly(WASM),一种用于网络的新代码类型。WebAssembly提供了一种紧凑的二进制编译目标格式,允许开发者从C/C++和Rust等强类型语言,以及GoTypeScript等语言开始。WebAssembly解决了第一个核心问题,即跨浏览器和环境获得可预测的、接近原生的性能。在这里,我们把Web Workers和WebAssembly结合起来,以获得WebAssembly的一致性和潜在的性能优势,以及Web Workers在独立线程中工作的好处。

为什么要把你的WebAssembly代码放在Web Worker中?

把WebAssembly模块放在Web Worker中的关键是,它消除了在主线程中获取、编译和初始化WebAssembly模块的开销,并反过来调用模块中的特定函数。这使得浏览器的主线程可以继续渲染和处理用户的互动。考虑到WebAssembly经常被用于处理密集型代码,将它与Web Workers配对可以是一个伟大的组合。

然而,这种方法也有一些缺点。根据相关数据的大小,将数据从主线程传输到工作线程可能会很昂贵。在Web Worker中使用WebAssembly时,也有额外的复杂性和逻辑需要处理。把WebAssembly模块放在Web Worker中,使得与WASM模块代码的交互是异步的,因为消息传递机制利用了事件监听器和回调。

在Web Worker中使用WebAssembly

在本节中,我们将演示在Web Worker中使用WebAssembly。让我们假设我们有一个简单的WebAssembly计算器模块,用输入来执行基本的数学运算。主线程和Web Worker之间的通信是通过传递消息进行的。我们将通过消息将我们的数据传递给Worker,在这种情况下,我们要对数字进行操作,然后将结果返回给主线程。在客户端,我们在主线程和工作线程上都使用postMessage 方法来传递消息。下面是我们初始化和使用工作者来承载我们的WebAssembly模块的代码:

    // worker.js

    // Polyfill instantiateStreaming for browsers missing it
    if (!WebAssembly.instantiateStreaming) {
        WebAssembly.instantiateStreaming = async (resp, importObject) => {
                const source = await (await resp).arrayBuffer();
                return await WebAssembly.instantiate(source, importObject);
        };
    }

    // Create promise to handle Worker calls whilst
    // module is still initialising
    let wasmResolve;
    let wasmReady = new Promise((resolve) => {
        wasmResolve = resolve;
    })

    // Handle incoming messages
    self.addEventListener('message', function(event) {

        const { eventType, eventData, eventId } = event.data;

        if (eventType === "INITIALISE") {
            WebAssembly.instantiateStreaming(fetch(eventData), {})
                .then(instantiatedModule => {
                    const wasmExports = instantiatedModule.instance.exports;

                    // Resolve our exports for when the messages
                    // to execute functions come through
                    wasmResolve(wasmExports);

                    // Send back initialised message to main thread
                    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);

在第一个代码块中,我们为instantiateStreaming 提供了一个基本的polyfill,这是目前推荐的在一个步骤中获取、编译和初始化WebAssembly程序的方法。对于不支持常青的浏览器(目前是Safari、Safari iOS和Samsung Internet),这个polyfill是必须的。然后,我们继续为工作者添加一个事件监听器,它监听两个事件INITIALISECALLINITIALISE 运行WASM初始化步骤,CALL 运行一个给定的函数与它的参数。

现在是主线程代码,让我们假设它包含在main.js 。在这里,我们要发送一个INITIALISE 消息,并监听一个RESULT 消息,我们在相应的Promise :

    // main.js

    function wasmWorker(modulePath) {

        // Create an object to later interact with 
        const proxy = {};

        // Keep track of the messages being sent
        // so we can resolve them correctly
        let id = 0;
        let idPromises = {};

        return new Promise((resolve, reject) => {
            const worker = new Worker('worker.js');
            worker.postMessage({eventType: "INITIALISE", eventData: modulePath});
            worker.addEventListener('message', function(event) {

                const { eventType, eventData, eventId } = event.data;

                if (eventType === "INITIALISED") {
                    const methods = event.data.eventData;
                    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 。这种抽象暴露了一个对象,我们可以像原始exports 对象的异步版本一样与之互动。除了是异步的,我们也做出了让步,在这种情况下,属性被作为函数调用而不是直接访问。

然后我们就可以继续使用我们的新抽象了:

    // 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);
            });
    });

使用内联Web Worker

Web Worker 的另一个有趣的特点是,通过一些工作,它们可以被内联创建。内联 Web Worker 使用URL.createObjectURLBlob 浏览器 API 函数,并允许我们在不需要外部资源的情况下创建一个 Worker。Blob 将我们试图创建的函数体作为一个字符串(使用toString ),我们可以反过来将其传递给createObjectURL 方法。让我们来看看上面的代码并尝试内联它。请注意,这里的目标不是编写生产级别的内联网络工作者,而是演示它们如何工作

function wasmWorker(modulePath) {

    let worker;
    const proxy = {};
    let id = 0;
    let idPromises = {};

    // Polyfill instantiateStreaming for browsers missing it
    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);
        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);
            }
        }

        // Make sure the wasm path is absolute and turn into 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;
    }

    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);
    }

}

这种方法是有效的,你可以使用和上面一样的代码来使用这种抽象(即接口没有改变)。如果你想在这个领域寻找更强大的东西,Matteo Bassowasm-worker库采取了一种稍微灵活的方法,即传递一个函数,这个函数(在被转化为字符串并返回后)在模块的上下文中被执行,因此它可以访问它。wasm-worker 有一些额外的功能可能是有益的,比如支持 Transferables这是一种传输类型的低开销方式,如ArrayBuffers和ImageBitmaps。它在允许特定的importObject ,这是WebAssembly实例化接口的一部分,并允许向WebAssembly实例导入值,如函数,是更具有扩展性。下面的例子使用wasm-worker :


    import wasmWorker from 'wasm-worker';

    wasmWorker('calculator.wasm')
        .then(module => {
            // We can write code that operates on the WASM module
            return module.exports.add(1, 2);
        })
        .then(sum => {
            console.log('1 + 2 = ' + sum);
        })
        .catch(exception => {
            // exception is a string
            console.error(exception);
        });

结论

现在,在Web Worker中使用WebAssembly程序并从主线程中利用它们是很简单的。我们已经展示了如何通过使用单独的JavaScript文件的传统方式,以及使用内联Web Worker的方法来实现这一点。最后,我们展示了wasm-worker 的用法,这个库可以在你的项目中使用,以便在你的项目中使用内联工作者。你可以在GitHub上找到这些wasm-workers例子的完整代码。

将你的WASM逻辑放在worker中的好处是通过保持主线程的自由来改善用户体验。这使得浏览器能够继续渲染和处理用户输入,从而使用户感到满意。如果数据量较大,你可能要为在这里传输任何数据支付开销,但根据你的数据类型,Transferables可能会让你抵消这个开销。最后,重要的是要记住,Web Workers和目前的WebAssembly不支持直接的DOM操作,这限制了它们的非DOM绑定工作。即使有这样的限制,这种组合仍然有很多很好的用例,例如,看看eBay是如何利用这两种技术创建条形码扫描器的。

如果你需要帮助创建一个利用现代网络技术提供最佳终端用户体验的应用程序,请与我们联系,讨论我们可以如何提供帮助