При создании веб-приложений написание кода с интенсивной обработкой может стать проблемой. Одной из проблем является получение предсказуемого времени работы в браузерах и движках JavaScript, которые по-разному оптимизируют различные пути кода, а также создание кода, который не мешает работе пользователя. С 2010 года у нас есть стандартизированный способ управления интерактивностью для длинных задач, не связанных с DOM.
Веб-воркеры позволяют переносить обработку в отдельный поток, оставляя основной поток свободным. В последнее время мы наблюдаем рост другой спецификации, WebAssembly, нового типа кода для Интернета. WebAssembly предоставляет компактный двоичный целевой формат компиляции, позволяющий разработчикам начать с выбора строго типизированных языков, таких как C/C++ и Rust, а также таких языков, как Go и TypeScript. WebAssembly решает первую основную проблему, которая становится предсказуемой, близкой к естественной производительности в браузерах и средах. Здесь мы объединяем Web Workers и WebAssembly, чтобы получить согласованность и потенциальные преимущества производительности WebAssembly, наряду с преимуществами работы в отдельном потоке с Web Workers.
Зачем размещать код WebAssembly в Web Worker?
Критический аспект размещения модуля WebAssembly в Web Worker заключается в том, что он устраняет накладные расходы на выборку, компиляцию и инициализацию модуля WebAssembly вне основного потока и, в свою очередь, на вызов заданных функций в модуле. Это позволяет основному потоку браузера продолжать рендеринг и обработку взаимодействия с пользователем. Учитывая, что WebAssembly часто используется для обработки интенсивного кода, сочетание его с Web Workers может стать отличной комбинацией.
Однако у этого подхода есть некоторые недостатки. Передача данных из основного потока в рабочий поток может быть дорогостоящей в зависимости от размера данных, о которых идет речь. При использовании WebAssembly в Web Worker также возникает дополнительная сложность и логика. Размещение модуля WebAssembly в Web Worker делает взаимодействие с кодом модуля WASM асинхронным, поскольку механизм передачи сообщений использует прослушиватели событий и обратные вызовы.
Использование WebAssembly с Web Worker
В этом разделе мы продемонстрируем использование WebAssembly в Web Worker. Предположим, у нас есть простой модуль калькулятора WebAssembly, который выполняет основные математические операции с входными данными. Связь между основным потоком и Web Worker происходит путем передачи сообщений. Мы будем передавать наши данные через сообщение Worker, в данном случае числа, с которыми мы хотим работать, а затем возвращать результат обратно в основной поток. На стороне клиента мы используем метод postMessage
как в основном потоке, так и в рабочем потоке для передачи сообщений. Вот наш код для инициализации и использования воркера для размещения нашего модуля WebAssembly:
1 // worker.js 2 3 // Polyfill instantiateStreaming for browsers missing it 4 if (!WebAssembly.instantiateStreaming) { 5 WebAssembly.instantiateStreaming = async (resp, importObject) => { 6 const source = await (await resp).arrayBuffer(); 7 return await WebAssembly.instantiate(source, importObject); 8 }; 9 } 10 11 // Create promise to handle Worker calls whilst 12 // module is still initialising 13 let wasmResolve; 14 let wasmReady = new Promise((resolve) => { 15 wasmResolve = resolve; 16 }) 17 18 // Handle incoming messages 19 self.addEventListener('message', function(event) { 20 21 const { eventType, eventData, eventId } = event.data; 22 23 if (eventType === "INITIALISE") { 24 WebAssembly.instantiateStreaming(fetch(eventData), {}) 25 .then(instantiatedModule => { 26 const wasmExports = instantiatedModule.instance.exports; 27 28 // Resolve our exports for when the messages 29 // to execute functions come through 30 wasmResolve(wasmExports); 31 32 // Send back initialised message to main thread 33 self.postMessage({ 34 eventType: "INITIALISED", 35 eventData: Object.keys(wasmExports) 36 }); 37 38 }); 39 } else if (eventType === "CALL") { 40 wasmReady 41 .then((wasmInstance) => { 42 const method = wasmInstance[eventData.method]; 43 const result = method.apply(null, eventData.arguments); 44 self.postMessage({ 45 eventType: "RESULT", 46 eventData: result, 47 eventId: eventId 48 }); 49 }) 50 .catch((error) => { 51 self.postMessage({ 52 eventType: "ERROR", 53 eventData: "An error occured executing WASM instance function: " + error.toString(), 54 eventId: eventId 55 }); 56 }) 57 } 58 59 }, false);
В первом блоке кода мы предоставили базовый полифилл для instantiateStreaming
, который в настоящее время является рекомендуемым способом извлечения, компиляции и инициализации вашей программы WebAssembly за один шаг. Этот полифилл требуется для неподдерживающих постоянно обновляемых браузеров (в настоящее время это Safari, Safari iOS и Samsung Internet). Затем мы добавляем прослушиватель событий для рабочего процесса, который прослушивает два события INITIALISE
и CALL
. INITIALISE
запускает этап инициализации WASM, а CALL
запускает заданную функцию с аргументами против нее.
Теперь для кода основного потока предположим, что он содержится в main.js
. Здесь мы собираемся отправить сообщение INITIALISE
и прослушать сообщение RESULT
, которое мы разрешаем в соответствующем Promise
:
1 // main.js 2 3 function wasmWorker(modulePath) { 4 5 // Create an object to later interact with 6 const proxy = {}; 7 8 // Keep track of the messages being sent 9 // so we can resolve them correctly 10 let id = 0; 11 let idPromises = {}; 12 13 return new Promise((resolve, reject) => { 14 const worker = new Worker('worker.js'); 15 worker.postMessage({eventType: "INITIALISE", eventData: modulePath}); 16 worker.addEventListener('message', function(event) { 17 18 const { eventType, eventData, eventId } = event.data; 19 20 if (eventType === "INITIALISED") { 21 const methods = event.data.eventData; 22 methods.forEach((method) => { 23 proxy[method] = function() { 24 return new Promise((resolve, reject) => { 25 worker.postMessage({ 26 eventType: "CALL", 27 eventData: { 28 method: method, 29 arguments: Array.from(arguments) // arguments is not an array 30 }, 31 eventId: id 32 }); 33 34 idPromises[id] = { resolve, reject }; 35 id++ 36 }); 37 } 38 }); 39 resolve(proxy); 40 return; 41 } else if (eventType === "RESULT") { 42 if (eventId !== undefined && idPromises[eventId]) { 43 idPromises[eventId].resolve(eventData); 44 delete idPromises[eventId]; 45 } 46 } else if (eventType === "ERROR") { 47 if (eventId !== undefined && idPromises[eventId]) { 48 idPromises[eventId].reject(event.data.eventData); 49 delete idPromises[eventId]; 50 } 51 } 52 53 }); 54 55 worker.addEventListener("error", function(error) { 56 reject(error); 57 }); 58 }) 59 60 }
Целью этого основного кода потока является обработка отправки и получения сообщений от Worker, который обрабатывает наш код WASM. У нас есть прокси-объект, с которым мы взаимодействуем из основного потока, а не напрямую из экземпляра WASM. Идентификаторы используются для отслеживания запросов и ответов, чтобы убедиться, что мы разрешаем правильный вызов правильного Promise
. Эта абстракция предоставляет объект, с которым мы можем взаимодействовать, как с асинхронной версией исходного объекта exports
. Помимо асинхронности, мы также допускаем, что в данном случае доступ к свойствам осуществляется как вызовы функций, а не напрямую.
Затем мы могли бы продолжить использовать нашу новую абстракцию:
1 // main.js 2 3 wasmWorker("./calculator.wasm").then((wasmProxyInstance) => { 4 wasmProxyInstance.add(2, 3) 5 .then((result) => { 6 console.log(result); // 5 7 }) 8 .catch((error) => { 9 console.error(error); 10 }); 11 12 wasmProxyInstance.divide(100, 10) 13 .then((result) => { 14 console.log(result); // 10 15 }) 16 .catch((error) => { 17 console.error(error); 18 }); 19 });
Использование встроенных веб-воркеров
Еще одна интересная особенность Web Workers заключается в том, что, немного поработав, они могут быть созданы встроенными. Встроенные веб-воркеры используют функции API браузера URL.createObjectURL
и Blob
и позволяют нам создавать рабочих без необходимости использования внешнего ресурса. Blob
принимает тело функции, которую мы пытаемся создать, в виде строки (используя toString
), которую мы, в свою очередь, можем передать методу createObjectURL
. Давайте возьмем приведенный выше код и попробуем встроить его. Обратите внимание, что цель здесь не в том, чтобы написать встроенные веб-воркеры производственного уровня, а в том, чтобы продемонстрировать, как они работают!
1 function wasmWorker(modulePath) { 2 3 let worker; 4 const proxy = {}; 5 let id = 0; 6 let idPromises = {}; 7 8 // Polyfill instantiateStreaming for browsers missing it 9 if (!WebAssembly.instantiateStreaming) { 10 WebAssembly.instantiateStreaming = async (resp, importObject) => { 11 const source = await (await resp).arrayBuffer(); 12 return await WebAssembly.instantiate(source, importObject); 13 }; 14 } 15 16 return new Promise((resolve, reject) => { 17 18 worker = createInlineWasmWorker(inlineWasmWorker, modulePath); 19 worker.postMessage({eventType: "INITIALISE", data: modulePath}); 20 21 worker.addEventListener('message', function(event) { 22 23 const { eventType, eventData, eventId } = event.data; 24 25 if (eventType === "INITIALISED") { 26 const props = eventData; 27 props.forEach((prop) => { 28 proxy[prop] = function() { 29 return new Promise((resolve, reject) => { 30 worker.postMessage({ 31 eventType: "CALL", 32 eventData: { 33 prop: prop, 34 arguments: Array.from(arguments) 35 }, 36 eventId: id 37 }); 38 idPromises[id] = { resolve, reject }; 39 id++ 40 }) 41 42 } 43 }) 44 resolve(proxy); 45 return; 46 } else if (eventType === "RESULT") { 47 if (eventId !== undefined && idPromises[eventId]) { 48 idPromises[eventId].resolve(eventData); 49 delete idPromises[eventId]; 50 } 51 } else if (eventType === "ERROR") { 52 if (eventId !== undefined && idPromises[eventId]) { 53 idPromises[eventId].reject(event.data.eventData); 54 delete idPromises[eventId]; 55 } 56 } 57 }); 58 worker.addEventListener('error', function(error) { 59 reject(error) 60 }) 61 }) 62 63 function createInlineWasmWorker(func, wasmPath) { 64 if (!wasmPath.startsWith("http")) { 65 if (wasmPath.startsWith("/")) { 66 wasmPath = window.location.href + wasmPath 67 } else if (wasmPath.startsWith("./")) { 68 wasmPath = window.location.href + wasmPath.substring(1); 69 } 70 } 71 72 // Make sure the wasm path is absolute and turn into IIFE 73 func = `(${func.toString().trim().replace("WORKER_PATH", wasmPath)})()`; 74 const objectUrl = URL.createObjectURL(new Blob([func], { type: "text/javascript" })); 75 const worker = new Worker(objectUrl); 76 URL.revokeObjectURL(objectUrl); 77 78 return worker; 79 } 80 81 function inlineWasmWorker() { 82 83 let wasmResolve; 84 const wasmReady = new Promise((resolve) => { 85 wasmResolve = resolve; 86 }) 87 88 self.addEventListener('message', function(event) { 89 const { eventType, eventData, eventId } = event.data; 90 91 if (eventType === "INITIALISE") { 92 WebAssembly.instantiateStreaming(fetch('WORKER_PATH'), {}) 93 .then(instantiatedModule => { 94 const wasmExports = instantiatedModule.instance.exports; 95 wasmResolve(wasmExports); 96 self.postMessage({ 97 eventType: "INITIALISED", 98 eventData: Object.keys(wasmExports) 99 }); 100 }) 101 .catch((error) => { 102 console.error(error); 103 }) 104 105 } else if (eventType === "CALL") { 106 wasmReady.then((wasmInstance) => { 107 const prop = wasmInstance[eventData.prop]; 108 const result = typeof prop === 'function' ? prop.apply(null, eventData.arguments) : prop; 109 self.postMessage({ 110 eventType: "RESULT", 111 eventData: result, 112 eventId: eventId 113 }); 114 }) 115 } 116 117 }, false); 118 } 119 120 }
Этот подход работает, и вы можете использовать тот же код, что и выше, для использования этой абстракции (т.е. интерфейс не изменился). Если вы ищете что-то более надежное в этой области, wasm-worker library от Matteo Basso использует немного более гибкий подход к передаче функции, которая (после преобразования в строку и обратно) выполняется. в контексте модуля, чтобы он мог получить к нему доступ. wasm-worker
имеет некоторые дополнительные функции, которые могут быть полезны, такие как поддержка Transferables
, которые представляют собой способ передачи типов с низкими издержками, таких как ArrayBuffers и ImageBitmaps. Он более расширяем, позволяя использовать определенный importObject
, который является частью интерфейса создания WebAssembly, и позволяет импортировать значения в экземпляр WebAssembly, такие как функции. В следующем примере используется wasm-worker
:
1 import wasmWorker from 'wasm-worker'; 2 3 wasmWorker('calculator.wasm') 4 .then(module => { 5 // We can write code that operates on the WASM module 6 return module.exports.add(1, 2); 7 }) 8 .then(sum => { 9 console.log('1 + 2 = ' + sum); 10 }) 11 .catch(exception => { 12 // exception is a string 13 console.error(exception); 14 });
Вывод
Теперь стало просто использовать программы WebAssembly внутри Web Worker и использовать их из основного потока. Мы показали, как это сделать как традиционным способом с использованием отдельного файла JavaScript, так и с помощью встроенного подхода Web Worker. Наконец, мы показали использование wasm-worker
, библиотеки, которую вы можете использовать в своих проектах для использования встроенных рабочих процессов в своем проекте. Вы можете найти полный код этих примеров wasm-workers на GitHub.
Преимущество размещения вашей логики WASM в рабочем процессе заключается в улучшении взаимодействия с пользователем за счет освобождения основного потока. Это позволяет браузеру продолжать отображать и обрабатывать пользовательский ввод, что, в свою очередь, делает пользователей счастливыми. Вы можете заплатить накладные расходы за передачу любых данных здесь, если они большие, но в зависимости от ваших типов данных Transferables может позволить вам компенсировать это. Наконец, важно помнить, что Web Workers и в настоящее время WebAssembly не поддерживают прямые операции с DOM, что ограничивает их работу, не связанную с DOM. Даже с этим ограничением есть еще много отличных вариантов использования этой комбинации, например, посмотрите, как eBay создал сканер штрих-кода, который использует обе технологии!
Если вам нужна помощь в создании приложения, обеспечивающего оптимальное взаимодействие с конечным пользователем с использованием современных веб-технологий, пожалуйста, свяжитесь с нами, чтобы обсудить, как мы можем помочь!
Подпишитесь на SitePen в Twitter, Facebook и LinkedIn.
Первоначально опубликовано на https://www.sitepen.com 22 июля 2019 г.