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

Перевод | Автор оригинала: Ingvar Stepanyan

Узнайте, как перенести в WebAssembly многопоточные приложения, написанные на других языках.

Поддержка потоков WebAssembly - одно из наиболее важных улучшений производительности WebAssembly. Это позволяет вам либо запускать части вашего кода параллельно на отдельных ядрах, либо один и тот же код над независимыми частями входных данных, масштабируя его до такого количества ядер, которое есть у пользователя, и значительно сокращая общее время выполнения.

В этой статье вы узнаете, как использовать потоки WebAssembly для переноса в Интернет многопоточных приложений, написанных на таких языках, как C, C++ и Rust.

Как работают потоки WebAssembly

Потоки WebAssembly - это не отдельная функция, а комбинация нескольких компонентов, которая позволяет приложениям WebAssembly использовать традиционные парадигмы многопоточности в Интернете.

Веб-воркеры

Первый компонент - это обычные воркеры, которых вы знаете и любите по JavaScript. Потоки WebAssembly используют конструктор new Worker для создания новых базовых потоков. Каждый поток загружает клей JavaScript, а затем основной поток использует метод Worker # postMessage для совместного использования скомпилированного WebAssembly.Module, а также общего WebAssembly.Memory (см. Ниже) с этими другими потоками. Это устанавливает связь и позволяет всем этим потокам запускать один и тот же код WebAssembly в одной и той же общей памяти без повторного использования JavaScript.

Веб-воркеры существуют уже более десяти лет, широко поддерживаются и не требуют каких-либо специальных флагов.

SharedArrayBuffer

Память WebAssembly представлена объектом WebAssembly.Memory в API JavaScript. По умолчанию WebAssembly.Memory является оболочкой для ArrayBuffer - буфера необработанных байтов, доступ к которому может получить только один поток.

> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer { … }

Для поддержки многопоточности WebAssembly.Memory также получил общий вариант. При создании с общим флагом через 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 atomics - это расширение набора инструкций 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

В C, особенно в Unix-подобных системах, общий способ использования потоков - это потоки POSIX, предоставляемые библиотекой pthread. Emscripten предоставляет API-совместимую реализацию библиотеки pthread, созданной поверх Web Workers, разделяемой памяти и атомики, так что один и тот же код может работать в сети без изменений.

Давайте посмотрим на пример:

// example.c

#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 #

Во-первых, если вам нужно запустить только некоторые задачи из основного потока, но не нужно ждать результатов, вы можете использовать pthread_detach вместо pthread_join. Это оставит обратный вызов потока в фоновом режиме. Если вы используете эту опцию, вы можете отключить предупреждение с помощью -s PTHREAD_POOL_SIZE_STRICT = 0.

PROXY_TO_PTHREAD

Во-вторых, если вы компилируете приложение C, а не библиотеку, вы можете использовать параметр -s PROXY_TO_PTHREAD, который выгружает основной код приложения в отдельный поток в дополнение к любым вложенным потокам, созданным самим приложением. Таким образом, основной код может безопасно блокироваться в любое время без зависания пользовательского интерфейса. Между прочим, при использовании этой опции вам также не нужно предварительно создавать пул потоков - вместо этого Emscripten может использовать основной поток для создания новых базовых Workers, а затем заблокировать вспомогательный поток в pthread_join без взаимоблокировки.

В-третьих, если вы работаете с библиотекой и вам все еще нужно блокировать, вы можете создать своего собственного Worker, импортировать сгенерированный Emscripten код и предоставить его с помощью Comlink в основной поток. Основной поток сможет вызывать любые экспортированные методы как асинхронные функции, что также позволит избежать блокировки пользовательского интерфейса.

В простом приложении, таком как предыдущий пример, -s PROXY_TO_PTHREAD - лучший вариант:

emcc -pthread -s PROXY_TO_PTHREAD example.c -o example.js

C++

Все те же предостережения и логика одинаково применимы и к C++. Единственное, что вы получаете, - это доступ к API более высокого уровня, таким как std::thread и std::async, которые используют ранее обсуждавшуюся библиотеку pthread под капотом.

Таким образом, приведенный выше пример можно переписать на более идиоматическом языке C++ следующим образом:

// example.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.

Rust

В отличие от Emscripten, Rust не имеет специализированной сквозной веб-цели, но вместо этого предоставляет общую цель wasm32-unknown-unknown для общего вывода WebAssembly.

Если Wasm предназначен для использования в веб-среде, любое взаимодействие с API JavaScript остается на усмотрение внешних библиотек и инструментов, таких как wasm-bindgen и wasm-pack. К сожалению, это означает, что стандартная библиотека не знает о веб-воркерах, а стандартные API, такие как std::thread, не будут работать при компиляции в WebAssembly.

К счастью, большая часть экосистемы зависит от высокоуровневых библиотек, которые заботятся о многопоточности. На этом уровне намного проще абстрагироваться от всех различий платформ.

В частности, наиболее популярным выбором для параллелизма данных в Rust является Rayon. Он позволяет вам использовать цепочки методов на обычных итераторах и, обычно с изменением одной строки, преобразовывать их таким образом, чтобы они выполнялись параллельно во всех доступных потоках, а не последовательно. Например:

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 Планета Земля - еще один примечательный сервис, использующий потоки WebAssembly для своей веб-версии.

FFMPEG.WASM - это WebAssembly-версия популярной мультимедийной цепочки инструментов FFmpeg, которая использует потоки WebAssembly для эффективного кодирования видео непосредственно в браузере.

Есть еще много интересных примеров использования потоков WebAssembly. Обязательно ознакомьтесь с демонстрациями и внесите в Интернет свои собственные многопоточные приложения и библиотеки!