Использование потоков WebAssembly из C, C++ и Rust

Узнайте, как перенести в 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 только планирует создание нового потока Worker при следующем запуске цикла событий, но затем pthread_join немедленно блокирует цикл событий, чтобы дождаться этого Worker, и тем самым предотвращает его создание. Это классический пример тупика .

Один из способов решения этой проблемы — заранее создать пул воркеров, еще до запуска программы. Когда вызывается 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, где представлена ​​комплексная демонстрация:

Реальные варианты использования

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