Узнайте, как перенести в WebAssembly многопоточные приложения, написанные на других языках.
Поддержка потоков WebAssembly — одно из наиболее важных дополнений производительности WebAssembly. Это позволяет вам либо запускать части вашего кода параллельно на отдельных ядрах, либо один и тот же код над независимыми частями входных данных, масштабируя его до такого количества ядер, которое есть у пользователя, и значительно сокращая общее время выполнения.
В этой статье вы узнаете, как использовать потоки WebAssembly для вывода в Интернет многопоточных приложений, написанных на таких языках, как C, C++ и Rust.
Как работают потоки WebAssembly
Потоки WebAssembly — это не отдельная функция, а комбинация нескольких компонентов, которая позволяет приложениям WebAssembly использовать традиционные парадигмы многопоточности в Интернете.
Веб-работники
Первый компонент — это обычные Workers, которые вы знаете и любите по JavaScript. Потоки WebAssembly используют new Worker
для создания новых базовых потоков. Каждый поток загружает связку JavaScript, а затем основной поток использует метод Worker#postMessage
для совместного использования скомпилированного WebAssembly.Module
, а также общего WebAssembly.Memory
(см. ниже) с этими другими потоками. Это устанавливает связь и позволяет всем этим потокам запускать один и тот же код WebAssembly в одной и той же общей памяти без повторного использования JavaScript.
Web Workers существуют уже более десяти лет, широко поддерживаются и не требуют каких-либо специальных флагов.
SharedArrayBuffer
Память WebAssembly представлена объектом WebAssembly.Memory
в API JavaScript. По умолчанию WebAssembly.Memory
представляет собой оболочку ArrayBuffer
— необработанного байтового буфера, доступ к которому возможен только из одного потока.
> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer { … }
Для поддержки многопоточности WebAssembly.Memory
также получил общий вариант. При создании с shared
флагом через API JavaScript или самим двоичным файлом WebAssembly он вместо этого становится оболочкой SharedArrayBuffer
. Это вариант ArrayBuffer
, который можно использовать совместно с другими потоками и читать или изменять одновременно с обеих сторон.
> new WebAssembly.Memory({ initial:1, maximum:10, shared:true }).buffer
SharedArrayBuffer { … }
В отличие от postMessage
, обычно используемого для связи между основным потоком и веб-воркерами, SharedArrayBuffer
не требует копирования данных или даже ожидания цикла событий для отправки и получения сообщений. Вместо этого любые изменения воспринимаются всеми потоками почти мгновенно, что делает его гораздо лучшей целью компиляции для традиционных примитивов синхронизации.
У SharedArrayBuffer
сложная история. Первоначально он был выпущен в нескольких браузерах в середине 2017 года, но в начале 2018 года его пришлось отключить из-за обнаружения уязвимостей Spectre . Конкретная причина заключалась в том, что извлечение данных в Spectre основано на атаках по времени — измерении времени выполнения определенного фрагмента кода. Чтобы усложнить этот вид атаки, браузеры снизили точность стандартных API-интерфейсов синхронизации, таких как Date.now
и performance.now
. Однако общая память в сочетании с простым циклом счетчиков, выполняющимся в отдельном потоке , также является очень надежным способом получения высокоточного времени , и его гораздо сложнее смягчить без значительного регулирования производительности во время выполнения.
Вместо этого в Chrome 68 (середина 2018 г.) снова был включен SharedArrayBuffer
, используя изоляцию сайта — функцию, которая помещает разные веб-сайты в разные процессы и значительно затрудняет использование атак по побочным каналам, таких как Spectre. Однако это смягчение по-прежнему ограничивалось только настольными компьютерами Chrome, поскольку изоляция сайтов — довольно дорогая функция, и ее нельзя было включить по умолчанию для всех сайтов на мобильных устройствах с низким объемом памяти, и она еще не была реализована другими поставщиками.
Перенесемся в 2020 год: и в Chrome, и в Firefox реализована изоляция сайта, а также есть стандартный способ для веб-сайтов подключить эту функцию с помощью заголовков COOP и COEP . Механизм подписки позволяет использовать изоляцию сайта даже на устройствах с низким энергопотреблением, где включение ее для всех веб-сайтов было бы слишком дорого. Чтобы подписаться, добавьте следующие заголовки в основной документ конфигурации вашего сервера:
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
После того как вы согласитесь, вы получите доступ к SharedArrayBuffer
(включая WebAssembly.Memory
поддерживаемый SharedArrayBuffer
), точным таймерам, измерению памяти и другим API, для которых требуется изолированный источник по соображениям безопасности. Для получения более подробной информации ознакомьтесь с разделом «Как сделать ваш веб-сайт изолированным от перекрестного происхождения» с помощью COOP и COEP .
Атомика WebAssembly
Хотя SharedArrayBuffer
позволяет каждому потоку читать и записывать в одну и ту же память, для корректной связи необходимо убедиться, что они не выполняют конфликтующие операции одновременно. Например, один поток может начать читать данные с общего адреса, в то время как другой поток записывает на него данные, поэтому первый поток теперь получит поврежденный результат. Эта категория ошибок известна как состояния гонки. Чтобы предотвратить условия гонки, вам необходимо каким-то образом синхронизировать эти доступы. Здесь на помощь приходят атомарные операции.
Атомика WebAssembly — это расширение набора инструкций WebAssembly, которое позволяет «атомарно» читать и записывать небольшие ячейки данных (обычно 32- и 64-битные целые числа). То есть таким образом, который гарантирует, что никакие два потока не читают и не записывают в одну и ту же ячейку одновременно, предотвращая такие конфликты на низком уровне. Кроме того, атомы WebAssembly содержат еще два типа инструкций — «ожидание» и «уведомление», которые позволяют одному потоку спать («ожидать») по заданному адресу в общей памяти, пока другой поток не разбудит его с помощью «уведомления».
Все примитивы синхронизации более высокого уровня, включая каналы, мьютексы и блокировки чтения-записи, основаны на этих инструкциях.
Как использовать потоки WebAssembly
Обнаружение функций
WebAssembly Atomics и SharedArrayBuffer
— относительно новые функции, которые пока доступны не во всех браузерах с поддержкой WebAssembly. Вы можете узнать, какие браузеры поддерживают новые функции WebAssembly, в дорожной карте webassembly.org .
Чтобы гарантировать, что все пользователи смогут загружать ваше приложение, вам необходимо реализовать прогрессивное улучшение, создав две разные версии Wasm — одну с поддержкой многопоточности, а другую — без нее. Затем загрузите поддерживаемую версию в зависимости от результатов обнаружения функций. Чтобы обнаружить поддержку потоков WebAssembly во время выполнения, используйте библиотеку wasm-feature-detect и загрузите модуль следующим образом:
import { threads } from 'wasm-feature-detect';
const hasThreads = await threads();
const module = await (
hasThreads
? import('./module-with-threads.js')
: import('./module-without-threads.js')
);
// …now use `module` as you normally would
Теперь давайте посмотрим, как собрать многопоточную версию модуля WebAssembly.
С
В C, особенно в Unix-подобных системах, обычным способом использования потоков является использование потоков POSIX, предоставляемых библиотекой pthread
. Emscripten предоставляет API-совместимую реализацию библиотеки pthread
, построенную на основе веб-воркеров, разделяемой памяти и атомарности, так что один и тот же код может работать в Интернете без изменений.
Давайте посмотрим на пример:
пример.с:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
void *thread_callback(void *arg)
{
sleep(1);
printf("Inside the thread: %d\n", *(int *)arg);
return NULL;
}
int main()
{
puts("Before the thread");
pthread_t thread_id;
int arg = 42;
pthread_create(&thread_id, NULL, thread_callback, &arg);
pthread_join(thread_id, NULL);
puts("After the thread");
return 0;
}
Здесь заголовки библиотеки pthread
включаются через pthread.h
. Вы также можете увидеть пару важных функций для работы с потоками.
pthread_create
создаст фоновый поток. Требуется место назначения для хранения дескриптора потока, некоторые атрибуты создания потока (здесь они не передаются, поэтому это просто NULL
), обратный вызов, который будет выполнен в новом потоке (здесь thread_callback
), и необязательный указатель аргумента для передачи этому обратный вызов на случай, если вы захотите поделиться некоторыми данными из основного потока — в этом примере мы передаем указатель на переменную arg
.
pthread_join
можно вызвать позже в любое время, чтобы дождаться завершения выполнения потока и получить результат обратного вызова. Он принимает назначенный ранее дескриптор потока, а также указатель для сохранения результата. В этом случае результатов нет, поэтому функция принимает в качестве аргумента значение NULL
.
Чтобы скомпилировать код с использованием потоков с помощью Emscripten, вам необходимо вызвать emcc
и передать параметр -pthread
, как при компиляции того же кода с помощью Clang или GCC на других платформах:
emcc -pthread example.c -o example.js
Однако когда вы попытаетесь запустить его в браузере или Node.js, вы увидите предупреждение, а затем программа зависнет:
Before the thread
Tried to spawn a new thread, but the thread pool is exhausted.
This might result in a deadlock unless some threads eventually exit or the code
explicitly breaks out to the event loop.
If you want to increase the pool size, use setting `-s PTHREAD_POOL_SIZE=...`.
If you want to throw an explicit error instead of the risk of deadlocking in those
cases, use setting `-s PTHREAD_POOL_SIZE_STRICT=2`.
[…hangs here…]
Что случилось? Проблема в том, что большинство трудоемких API в сети являются асинхронными и для выполнения используют цикл событий. Это ограничение является важным отличием от традиционных сред, где приложения обычно выполняют ввод-вывод синхронно и с блокировкой. Если вы хотите узнать больше, ознакомьтесь с публикацией в блоге об использовании асинхронных веб-API из WebAssembly .
В этом случае код синхронно вызывает pthread_create
для создания фонового потока, а затем выполняется еще один синхронный вызов pthread_join
, который ожидает завершения выполнения фонового потока. Однако веб-воркеры, которые используются «за кулисами» при компиляции этого кода с помощью Emscripten, являются асинхронными. Итак, происходит следующее: pthread_create
только планирует создание нового рабочего потока при следующем запуске цикла событий, но затем pthread_join
немедленно блокирует цикл событий, чтобы дождаться этого рабочего потока, и тем самым предотвращает его создание. Это классический пример тупика .
Один из способов решения этой проблемы — заранее создать пул воркеров, еще до запуска программы. Когда вызывается pthread_create
, он может взять готовый к использованию Worker из пула, запустить предоставленный обратный вызов в своем фоновом потоке и вернуть Worker обратно в пул. Все это можно делать синхронно, поэтому взаимоблокировок не будет, пока пул достаточно велик.
Это именно то, что Emscripten позволяет с помощью опции -s PTHREAD_POOL_SIZE=...
Он позволяет указать количество потоков — либо фиксированное число, либо выражение JavaScript, например navigator.hardwareConcurrency
для создания столько потоков, сколько ядер имеется в процессоре. Последний вариант полезен, когда ваш код может масштабироваться до произвольного количества потоков.
В приведенном выше примере создается только один поток, поэтому вместо резервирования всех ядер достаточно использовать -s PTHREAD_POOL_SIZE=1
:
emcc -pthread -s PTHREAD_POOL_SIZE=1 example.c -o example.js
На этот раз, когда вы его выполните, все работает успешно:
Before the thread
Inside the thread: 42
After the thread
Pthread 0x701510 exited.
Однако есть еще одна проблема: видите, что в примере кода есть sleep(1)
? Он выполняется в обратном вызове потока, то есть вне основного потока, так что все должно быть в порядке, верно? Ну, это не так.
Когда вызывается pthread_join
, он должен дождаться завершения выполнения потока. Это означает, что если созданный поток выполняет длительные задачи (в данном случае спит 1 секунду), то основной поток также должен будет заблокироваться на ту же сумму. времени, пока результаты не вернутся. Когда этот JS выполняется в браузере, он блокирует поток пользовательского интерфейса на 1 секунду, пока не вернется обратный вызов потока. Это приводит к ухудшению пользовательского опыта.
Есть несколько решений этой проблемы:
-
pthread_detach
-
-s PROXY_TO_PTHREAD
- Custom Worker и Комлинк
pthread_detach
Во-первых, если вам нужно запустить только некоторые задачи из основного потока, но не нужно ждать результатов, вы можете использовать pthread_detach
вместо pthread_join
. Это оставит обратный вызов потока в фоновом режиме. Если вы используете эту опцию, вы можете отключить предупреждение с помощью -s PTHREAD_POOL_SIZE_STRICT=0
.
PROXY_TO_PTHREAD
Во-вторых, если вы компилируете приложение C, а не библиотеку, вы можете использовать опцию -s PROXY_TO_PTHREAD
, которая выгрузит основной код приложения в отдельный поток в дополнение к любым вложенным потокам, созданным самим приложением. Таким образом, основной код может безопасно блокироваться в любое время, не замораживая пользовательский интерфейс. Кстати, при использовании этой опции вам также не нужно предварительно создавать пул потоков — вместо этого Emscripten может использовать основной поток для создания новых базовых рабочих процессов, а затем заблокировать вспомогательный поток в pthread_join
без взаимоблокировки.
Комлинк
В-третьих, если вы работаете над библиотекой и вам все равно необходимо заблокировать, вы можете создать свой собственный Worker, импортировать сгенерированный Emscripten код и предоставить его с помощью Comlink основному потоку. Основной поток сможет вызывать любые экспортированные методы как асинхронные функции, что также позволит избежать блокировки пользовательского интерфейса.
В простом приложении, таком как предыдущий пример, лучшим вариантом будет -s PROXY_TO_PTHREAD
:
emcc -pthread -s PROXY_TO_PTHREAD example.c -o example.js
С++
Все те же предостережения и логика применимы и к C++. Единственное новое, что вы получаете, — это доступ к API более высокого уровня, таким как std::thread
и std::async
, которые под капотом используют ранее обсуждавшуюся библиотеку pthread
.
Таким образом, приведенный выше пример можно переписать на более идиоматическом C++ следующим образом:
пример.cpp:
#include <iostream>
#include <thread>
#include <chrono>
int main()
{
puts("Before the thread");
int arg = 42;
std::thread thread([&]() {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Inside the thread: " << arg << std::endl;
});
thread.join();
std::cout << "After the thread" << std::endl;
return 0;
}
При компиляции и выполнении с аналогичными параметрами он будет вести себя так же, как пример C:
emcc -std=c++11 -pthread -s PROXY_TO_PTHREAD example.cpp -o example.js
Выход:
Before the thread
Inside the thread: 42
Pthread 0xc06190 exited.
After the thread
Proxied main thread 0xa05c18 finished with return code 0. EXIT_RUNTIME=0 set, so
keeping main thread alive for asynchronous event operations.
Pthread 0xa05c18 exited.
Ржавчина
В отличие от Emscripten, Rust не имеет специализированной сквозной веб-цели, а вместо этого предоставляет общую цель wasm32-unknown-unknown
для общего вывода WebAssembly.
Если Wasm предназначен для использования в веб-среде, любое взаимодействие с API-интерфейсами JavaScript оставлено на усмотрение внешних библиотек и инструментов, таких как wasm-bindgen и wasm-pack . К сожалению, это означает, что стандартная библиотека не поддерживает веб-воркеры, а стандартные API, такие как std::thread
не будут работать при компиляции в WebAssembly.
К счастью, большая часть экосистемы зависит от библиотек более высокого уровня, обеспечивающих многопоточность. На этом уровне гораздо проще абстрагироваться от всех различий платформ.
В частности, Rayon — самый популярный выбор для параллелизма данных в Rust. Это позволяет вам брать цепочки методов на обычных итераторах и, обычно с помощью одной замены строки, преобразовывать их таким образом, чтобы они выполнялись параллельно во всех доступных потоках, а не последовательно. Например:
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
numbers
.iter()
.par_iter()
.map(|x| x * x)
.sum()
}
С этим небольшим изменением код разделит входные данные, вычислит x * x
и частичные суммы в параллельных потоках и, в конце концов, сложит эти частичные результаты вместе.
Чтобы адаптироваться к платформам, не работающим std::thread
, Rayon предоставляет хуки, которые позволяют определять собственную логику для создания и выхода потоков.
Wasm-bindgen-rayon использует эти крючки для создания потоков WebAssembly в качестве веб-воркеров. Чтобы использовать его, вам необходимо добавить его как зависимость и выполнить шаги настройки, описанные в документации . Пример выше в конечном итоге будет выглядеть так:
pub use wasm_bindgen_rayon::init_thread_pool;
#[wasm_bindgen]
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
numbers
.par_iter()
.map(|x| x * x)
.sum()
}
После этого сгенерированный JavaScript экспортирует дополнительную функцию initThreadPool
. Эта функция создаст пул воркеров и будет повторно использовать их на протяжении всей жизни программы для любых многопоточных операций, выполняемых Rayon.
Этот механизм пула аналогичен параметру -s PTHREAD_POOL_SIZE=...
в Emscripten, описанному ранее, и его также необходимо инициализировать перед основным кодом, чтобы избежать взаимоблокировок:
import init, { initThreadPool, sum_of_squares } from './pkg/index.js';
// Regular wasm-bindgen initialization.
await init();
// Thread pool initialization with the given number of threads
// (pass `navigator.hardwareConcurrency` if you want to use all cores).
await initThreadPool(navigator.hardwareConcurrency);
// ...now you can invoke any exported functions as you normally would
console.log(sum_of_squares(new Int32Array([1, 2, 3]))); // 14
Обратите внимание, что здесь применимы те же предостережения относительно блокировки основного потока. Даже в примере sum_of_squares
по-прежнему необходимо блокировать основной поток, чтобы дождаться частичных результатов от других потоков.
Ожидание может быть очень коротким или долгим, в зависимости от сложности итераторов и количества доступных потоков, но на всякий случай механизмы браузера активно предотвращают полную блокировку основного потока, и такой код выдает ошибку. Вместо этого вам следует создать Worker, импортировать туда код, сгенерированный wasm-bindgen
, и предоставить его API с помощью такой библиотеки, как Comlink, в основной поток.
Посмотрите пример wasm-bindgen-rayon , где представлена комплексная демонстрация:
- Функция обнаружения потоков.
- Создание одно- и многопоточных версий одного и того же приложения Rust.
- Загрузка JS+Wasm, созданного wasm-bindgen, в Worker.
- Использование wasm-bindgen-rayon для инициализации пула потоков.
- Использование Comlink для предоставления API Worker основному потоку .
Реальные варианты использования
Мы активно используем потоки WebAssembly в Squoosh.app для сжатия изображений на стороне клиента — в частности, для таких форматов, как AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) и WebP v2 (C++). Только благодаря многопоточности мы увидели стабильное увеличение скорости в 1,5–3 раза (точное соотношение зависит от кодека) и смогли еще больше увеличить эти цифры, объединив потоки WebAssembly с WebAssembly SIMD !
Google Earth — еще один известный сервис, использующий потоки WebAssembly для своей веб-версии .
FFMPEG.WASM — это версия WebAssembly популярного набора мультимедийных инструментов FFmpeg , который использует потоки WebAssembly для эффективного кодирования видео непосредственно в браузере.
Есть еще много интересных примеров использования потоков WebAssembly. Обязательно ознакомьтесь с демо-версиями и разместите в Интернете свои собственные многопоточные приложения и библиотеки!