При создании веб-приложений написание кода с интенсивной обработкой может стать проблемой. Одной из проблем является получение предсказуемого времени работы в браузерах и движках 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 г.