[Перевод] Использование потоков WebAssembly из C, C++ и Rust
Поддержка многопоточности стала одним из важнейших апгрейдов производительности в WebAssembly. Она позволяет выполнять либо части кода на разных ядрах параллельно, либо один код для независимых элементов входных данных, масштабируя его на максимально доступное пользователю число ядер. Все это значительно сокращает общее время выполнения.
В этой статье вы узнаете, как использовать потоки WebAssembly для переноса многопоточных приложений, написанных на языках C, C++ и Rust, в веб-среду.
Как работают потоки WebAssembly
В WebAssembly они представляют не отдельную возможность, а комбинацию нескольких компонентов, позволяющих приложениям wasm (сокращенно от WebAssembly) использовать в веб традиционные многопоточные парадигмы.
▍Веб-воркеры
Первым компонентом выступают стандартные воркеры, которых мы все знаем и ценим еще по JavaScript. Потоки wasm используют конструктор new Worker
для создания новых внутренних потоков. Каждый поток загружает связующий JS-код, после чего основной поток через метод Worker#postMessage
предоставляет другим потокам общий доступ к скомпилированному WebAssembly.Module, а также общему WebAssembly.Memory
(см. ниже). Таким образом устанавливается связь и все потоки могут выполнять один код wasm в общей области памяти, не проходя повторно через JS.
Веб-воркеры используются уже больше десяти лет, широко поддерживаются и не требуют указания специальных флагов.
▍SharedArrayBuffer
Память wasm представлена в JavaScript API объектом WebAssembly.Memory
. По умолчанию WebAssembly.Memory
является оберткой вокруг ArrayBuffer
— буфера необработанных байтов, к которым может обращаться только один поток.
> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer { … }
С целью поддержки многопоточности для WebAssembly.Memory
также появилась совместно используемая версия. При создании с флагом shared
через JavaScript API или самим бинарником WebAssembly он становится оберткой вокруг SharedArrayBuffer
. Это вариация ArrayBuffer
, которую могут совместно использовать и другие потоки, и которая позволяет одновременное считывание и изменение с любой стороны.
> new WebAssembly.Memory({ initial:1, maximum:10, shared:true }).buffer
SharedArrayBuffer { … }
В отличие от postMessage
, типично используемого для связи между основным потоком и веб-воркерами, SharedArrayBuffer
не требует копирования данных и ожидания отправки/получения сообщений циклом событий. Вместо этого любые изменения обнаруживаются всеми потоками практически мгновенно, что делает его намного более удачной целью компиляции, чем традиционные примитивы синхронизации.
История SharedArrayBuffer
достаточно сложна. Изначально его поддержка появилась в ряде браузеров в середине 2017 года, но в связи с обнаружением уязвимостей Spectre ее пришлось прекратить. Причиной, в частности, стало то, что извлечение данных через Spectre опирается на атаку по времени — измерение времени выполнения конкретного фрагмента кода. Чтобы усложнить реализацию подобных атак, в браузерах снизили точность стандартных API тайминга, таких как Date.now
и performance.now
. Однако общая память, совмещенная с простым циклом счетчика, выполняющимся в отдельном потоке, также является очень надежным способом получения высокоточного тайминга. Причем данную проблему гораздо сложнее устранить без существенного влияния на скорость выполнения.
Вместо этого в Chrome 68 (середина 2018) снова активировали SharedArrayBuffer
, задействовав изоляцию сайтов — возможность, которая помещает разные сайты в разные процессы и намного усложняет использование одноканальных атак вроде Spectre.
Тем не менее это противодействие по-прежнему ограничивалось только настольными системами Chrome, поскольку изоляция сайтов является весьма требовательной функцией и по умолчанию не могла быть активирована для всех сайтов на мобильных устройствах с ограниченной памятью, плюс она еще не была реализована другими вендорами.
Теперь перенесемся в 2020 год. Chrome и Firefox уже внедрили изоляцию сайтов, предоставив им стандартный способ подключения к этой возможности через заголовки COOP (Cross-Origin Opener Policy) и COEP (Cross-Origin-Embedder-Policy). Механизм подключения позволяет использовать ее даже на маломощных устройствах, где применение изоляции для всех сайтов оказалось бы слишком дорогостоящим. Для подключения нужно добавить следующие заголовки в основной файл конфигурации сервера:
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
После подключения вы получите доступ к SharedArrayBuffer
(включая WebAssembly.Memory
с поддержкой SharedArrayBuffer
), точным таймерам, измерению памяти и другим API, из соображений безопасности требующим изолированности источника. Более подробно об этом можете узнать из статьи Making your website «cross-origin isolated» using COOP and COEP.
▍Атомарные операции WebAssembly
При том, что SharedArrayBuffer
позволяет каждому потоку производить чтение/запись одной области памяти, для корректного взаимодействия нужно исключить возможность одновременного выполнения ими конфликтующих операций. К примеру, один поток может начать чтение данных из общего адреса, в то время как другой поток туда записывает, что приведет к получению первым поврежденного результата. Эта категория багов описывается как состояние гонки. Для того, чтобы его избежать, нужно подобные обращения потоков синхронизировать. Здесь и пригождаются атомарные операции.
Эти операции расширяют набор инструкций wasm, позволяя считывать и записывать небольшие участки памяти данных (обычно 32- и 64-битные целые числа) атомарно. Это в некотором смысле гарантирует, что два потока не будут считывать/записывать одну и ту же ячейку памяти одновременно, предотвращая возникновение подобных конфликтов на низком уровне. Кроме того, атомарные операции содержат еще два вида инструкций — wait
и notify
— которые позволяют одному потоку находиться в ожидании (wait) заданного адреса памяти, пока другой поток его не уведомит (notify) о том, что доступ свободен.
Все высокоуровневые примитивы синхронизации, включая каналы, мьютексы и блокировки чтения-записи, строятся на основе этих инструкций.
Как использовать потоки WebAssembly
▍Обнаружение возможностей
Атомарные операции и SharedArrayBuffer
являются относительно новыми возможностями и пока еще доступны не во всех браузерах с поддержкой WebAssembly. Уточнить, в каких именно браузерах доступно их использование, можно в плане развития на сайте webassembly.org.
Для обеспечения возможности скачивания вашего приложения всеми пользователями нужно будет реализовать прогрессивное улучшение, создав две версии wasm — одну с поддержкой многопоточности и вторую без нее. Затем загружать поддерживаемую версию в зависимости от результатов обнаружения возможностей. Для определения при запуске наличия поддержки потоков используйте библиотеку 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')
);
// …теперь можно использовать `module` как обычно
Далее рассмотрим процесс создания многопоточной версии модуля WebAssembly.
▍C
В Си, в частности в Unix-системах, обычно потоки используются через стандарт POSIX Threads, предлагаемый библиотекой pthread
. Emscripten предоставляет API-совместимую реализацию pthread
, созданную на базе веб-воркеров, совместно используемой памяти и атомарных операций, в результате чего один и тот же код может работать в веб без изменений.
Взглянем на пример:
example.c:
#include
#include
#include
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 в веб являются асинхронными, и их выполнение опирается на цикл событий. Это ограничение представляет важное отличие от традиционных сред, где приложения обычно выполняют процессы ввода/вывода синхронно с блокированием. Если хотите узнать об этом больше, почитайте Using asynchronous web APIs from WebAssembly.
В этом же случае код синхронно вызывает pthread_create()
для создания фонового потока, а затем выполняет также синхронный вызов pthread_join()
, которая ожидает завершения выполнения задачи фоновым потоком.
Тем не менее веб-воркеры, используемые внутренне при компиляции кода Emscripten, работают асинхронно. Поэтому pthread_create()
только планирует создание нового потока воркера при очередном выполнении цикла событий, но затем pthread_join()
сразу же блокирует цикл событий в ожидании этого воркера, тем самым не позволяя его даже создать.
Это классический пример взаимной блокировки.
Один из вариантов решения этой проблемы заключается в создании пула воркеров заранее до запуска программы. При вызове pthread_create()
может сразу получать из пула готового воркера, выполнять предоставленный обратный вызов в фоновом потоке и возвращать воркера обратно в пул. Все это может выполняться асинхронно, не вызывая взаимных блокировок при условии достаточного размера пула.
Именно такую возможность и дает 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-кода в браузере он заблокирует поток UI на одну секунду, пока обратный вызов потока не вернет результат. В итоге страдает пользовательский опыт.
Для этого есть несколько решений:
pthread_detach
-s PROXY_TO_PTHREAD
- Собственный воркер и Comlink
▍pthread_detach
Если вам нужно просто выполнять задачи вне основного потока без необходимости дожидаться результатов, то вместо pthread_detach()
можно использовать pthread_join()
. Это оставит обратный вызов потока выполняющимся в фоновом режиме. Если вы используете этот вариант, то можете отключить предупреждение, указав -s PTHREAD_POOL_SIZE_STRICT=0
.
▍PROXY_TO_PTHREAD
Если вы компилируете приложение Си, а не библиотеку, то можете использовать опцию -s PROXY_TO_PTHREAD
, которая выгрузит основной код приложения в отдельный поток дополнительно к любым вложенным потокам, созданным самим приложением. Таким образом, основной код сможет в любой момент блокироваться безопасно, не тормозя UI.
Иногда при использовании этой опции вам не придется предварительно создавать пул потоков — вместо этого Emscripten сможет создать новых воркеров с помощью основного потока, после чего блокировать вспомогательный поток в pthread_join()
без зависания.
▍Comlink
Если вы работаете над библиотекой и при этом нуждаетесь в блокировке, то можете создать собственного воркера, импортировать сгенерированный Emscripten код и представить его основному потоку с помощью Comlink. Основной поток сможет вызывать любые экспортированные методы как асинхронные функции, избегая таким образом блокировки UI.
В простом приложении, наподобие приведенного в примере выше, наилучшим вариантом будет -s PROXY_TO_PTHREAD
:
emcc -pthread -s PROXY_TO_PTHREAD example.c -o example.js
▍C++
Все описанное также применимо и к С++. Единственное, что вы получаете дополнительно — это доступ к высокоуровневым API, таким как std:thread
и std:async
, которые внутренне используют описанную ранее библиотеку pthread
.
Поэтому пример выше можно более идиоматическим образом переписать на C++ так:
example.cpp:
#include
#include
#include
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.
▍Rust
В отличие от Emscripten в Rust нет специализированной сквозной веб-цели сборки, вместо этого он предоставляет цель wasm32-unknown-unknown
для обобщенного вывода wasm.
Если wasm планируется использовать в веб-среде, то любое взаимодействие с JavaScript API передается внешним библиотекам и инструментам вроде wasm-bingden
и wasm-pack
. К сожалению, это означает, что стандартная библиотека не знает о веб-воркерах, и стандартные API, такие как std:thread
, при компиляции в wasm работать не будут.
Выручает то, что большая часть экосистемы опирается на выполнение многопоточности более высокоуровневыми библиотеками. Это позволяет гораздо легче абстрагировать все отличия платформ.
Что касается Rust, то самым популярным выбором для параллельной обработки данных в нем является библиотека Rayon. Она позволяет брать цепочки методов в стандартных итераторах и, как правило путем изменения всего одной строки, преобразовывать их так, чтобы они выполнялись на всех доступных потоках параллельно, а не последовательно. К примеру:
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
numbers
.par_iter()
.map(|x| x * x)
.sum()
}
После этого небольшого изменения код разделит входные данные, вычислит x * x
и частичные суммы в параллельных потоках, а в конце сложит эти частичные суммы вместе.
Для функционирования с платформами без рабочего std:thread
Rayon предоставляет хуки, которые позволяют определять собственную логику для порождения и завершения потоков.
wasm-bindgen-rayon
подключается к этим хукам для создания потоков wasm в виде веб-воркеров. Чтобы использовать эту библиотеку, добавьте ее в качестве зависимости и следуйте настройке, описанной в документации. Пример выше получится таким:
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';
// Стандартная инициализация wasm-bindgen.
await init();
// Инициализация пула потоков с их заданным числом
// (если хотите использовать все ядра, передайте `navigator.hardwareConcurrency`).
await initThreadPool(navigator.hardwareConcurrency);
// ...теперь можно вызывать любые экспортированные функции как обычно
console.log(sum_of_squares(new Int32Array([1, 2, 3]))); // 14
Обратите внимание, что рассмотренные ранее нюансы с блокировкой основного потока также актуальны и здесь. Даже пример sum_of_squares
должен блокировать основной поток, чтобы дождаться частичных результатов от остальных потоков.
Ожидание может быть как очень коротким, так и весьма длинным, что будет зависеть от сложности итераторов и количества доступных потоков. Однако из соображений безопасности движки браузеров активно предотвращают блокирование основного потока, и такой код будет выдавать ошибку. Вместо этого следует создать воркера, импортировать в него код, сгенерированный wasm-bindgen
, и представить его API основному потоку с помощью библиотеки вроде Comlink.
Посмотрите на GitHub пример wasm-bindgen-rayon, где продемонстрировано:
Реальные случаи использования
Мы активно используем потоки wasm в Squoosh.app для сжатия изображений на стороне клиента — в частности, для форматов вроде AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) и WebP v2 (C++). Благодаря одной только многопоточности мы наблюдаем устойчивый прирост скорости в 1.5–3 раза (конкретный показатель зависит от кодека) и смогли достичь даже больших успехов, совместив потоки WebAssembly с WebAssembly SIMD.
Еще одним примечательным сервисом, использующим многопоточность WebAssembly в своей веб-версии, является Google Earth.
Также можно назвать FFMPEG.WASM, которая представляет WebAssembly-версию популярной цепочки мультимедиа инструментов FFmpeg, задействующую многопоточность для эффективного кодирования видео прямо в браузере.
Есть и много других прекрасных примеров использования потоков WebAssembly. Рекомендую ознакомиться с предложенным примером с GitHub и ожидаю, что вы также дополните веб-мир своими многопоточными приложениями и библиотеками.