Utilizzo dei thread WebAssembly da C, C++ e Rust

Scopri come portare in WebAssembly applicazioni multi-thread scritte in altri linguaggi.

Ingvar Stepanyan
Ingvar Stepanyan

Il supporto dei thread di WebAssembly è uno dei miglioramenti più importanti in termini di prestazioni di WebAssembly. Consente di eseguire parti del codice in parallelo su core separati o lo stesso codice su parti indipendenti dei dati di input, scalandolo al numero di core dell'utente e riducendo notevolmente il tempo di esecuzione complessivo.

In questo articolo imparerai a utilizzare i thread WebAssembly per portare sul web applicazioni con multi-threading scritte in linguaggi come C, C++ e Rust.

Come funzionano i thread di WebAssembly

I thread WebAssembly non sono una funzionalità separata, ma una combinazione di diversi componenti che consente alle app WebAssembly di utilizzare paradigmi di multithreading tradizionali sul web.

Web worker

Il primo componente è costituito dai normali Worker che conosci e apprezzi di JavaScript. I thread WebAssembly utilizzano il costruttore new Worker per creare nuovi thread sottostanti. Ogni thread carica un collante JavaScript e poi il thread principale utilizza il metodo Worker#postMessage per condividere il WebAssembly.Module compilato e uno condiviso WebAssembly.Memory (vedi sotto) con gli altri thread. Questo stabilisce la comunicazione e consente a tutti i thread di eseguire lo stesso codice WebAssembly sulla stessa memoria condivisa senza dover utilizzare di nuovo JavaScript.

I web worker esistono da oltre un decennio, sono ampiamente supportati e non richiedono segnalazioni speciali.

SharedArrayBuffer

La memoria WebAssembly è rappresentata da un oggetto WebAssembly.Memory nell'API JavaScript. Per impostazione predefinita, WebAssembly.Memory è un wrapper attorno a un ArrayBuffer, un buffer di byte non elaborati a cui è possibile accedere solo da un singolo thread.

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

Per supportare il multithreading, WebAssembly.Memory ha ottenuto anche una variante condivisa. Quando viene creato con un flag shared tramite l'API JavaScript o dal programma binario WebAssembly stesso, diventa invece un wrapper attorno a un SharedArrayBuffer. È una variante di ArrayBuffer che può essere condivisa con altri thread e letto o modificato contemporaneamente da entrambi i lati.

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

A differenza di postMessage, normalmente utilizzato per la comunicazione tra il thread principale e i web worker, SharedArrayBuffer non richiede la copia dei dati o neanche l'attesa del loop di eventi per inviare e ricevere messaggi. Al contrario, le modifiche vengono visualizzate da tutti i thread quasi istantaneamente, il che lo rende un target di compilazione molto migliore per le primitive di sincronizzazione tradizionali.

SharedArrayBuffer ha una storia complicata. Inizialmente è stato distribuito in diversi browser a metà 2017, ma è stato disabilitato all'inizio del 2018 a causa del rilevamento delle vulnerabilità di Spectre. Il motivo particolare è che l'estrazione dei dati di Spectre si basa sulla tempistica degli attacchi, ovvero sulla misurazione del tempo di esecuzione di una determinata porzione di codice. Per rendere più difficile questo tipo di attacco, i browser hanno ridotto la precisione delle API con tempi standard come Date.now e performance.now. Tuttavia, la memoria condivisa, combinata con un semplice contatore loop in esecuzione in un thread separato, è anche un modo molto affidabile per ottenere tempi di alta precisione ed è molto più difficile da mitigare senza limitare significativamente le prestazioni di runtime.

Chrome 68 (metà 2018) ha invece riattivato SharedArrayBuffer sfruttando l'isolamento dei siti, una funzionalità che inserisce diversi siti web in processi diversi e rende molto più difficile l'utilizzo di attacchi laterali come Spectre. Tuttavia, questa mitigazione era ancora limitata solo alla versione desktop di Chrome, poiché l'isolamento dei siti è una funzionalità piuttosto costosa e non poteva essere attivata per impostazione predefinita per tutti i siti su dispositivi mobili con memoria ridotta né era stata ancora implementata da altri fornitori.

Nel 2020, Chrome e Firefox hanno implementato l'isolamento dei siti e un modo standard per attivare la funzionalità nei siti web con le intestazioni COOP e COEP. Un meccanismo di attivazione consente di utilizzare l'isolamento dei siti anche su dispositivi a bassa potenza, laddove l'attivazione per tutti i siti web sarebbe troppo costosa. A tale scopo, aggiungi le seguenti intestazioni al documento principale nella configurazione del server:

Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

Dopo l'attivazione, avrai accesso a SharedArrayBuffer (incluso WebAssembly.Memory supportato da SharedArrayBuffer), timer precisi, misurazione della memoria e altre API che richiedono un'origine isolata per motivi di sicurezza. Per ulteriori dettagli, consulta Rendere il tuo sito web "isolato multiorigine" utilizzando COOP e COEP.

atomico WebAssembly

Mentre SharedArrayBuffer consente a ogni thread di leggere e scrivere sulla stessa memoria, per una comunicazione corretta vuoi assicurarti che non eseguano operazioni in conflitto contemporaneamente. Ad esempio, è possibile che un thread inizi a leggere i dati da un indirizzo condiviso e vi scrive un altro thread, quindi il primo thread ora avrà un risultato danneggiato. Questa categoria di bug è nota come racecondition. Per prevenire le racecondition, è necessario sincronizzare in qualche modo questi accessi. È qui che entrano in gioco le operazioni atomiche.

WebAssembly atomics è un'estensione del set di istruzioni WebAssembly che consente di leggere e scrivere piccole celle di dati (di solito numeri interi a 32 e 64 bit) "a livello atomico". In altre parole, in modo da garantire che nessun thread possa leggere o scrivere nella stessa cella contemporaneamente, impedendo tali conflitti a basso livello. Inoltre, gli elementi atomici di WebAssembly contengono altri due tipi di istruzione, "wait" e "notify", che consentono a un thread di rimanere in modalità di sospensione ("attesa") su un determinato indirizzo in una memoria condivisa fino a quando un altro thread non lo riattiva tramite "notify".

Tutte le primitive di sincronizzazione di livello superiore, inclusi canali, mutex e blocchi di lettura-scrittura, si basano su queste istruzioni.

Come utilizzare i thread WebAssembly

Rilevamento delle funzionalità

Gli elementi atomici di WebAssembly e SharedArrayBuffer sono funzionalità relativamente nuove e non sono ancora disponibili in tutti i browser con supporto di WebAssembly. Puoi trovare i browser che supportano le nuove funzionalità di WebAssembly nella tabella di marcia webassembly.org.

Per garantire che tutti gli utenti possano caricare la tua applicazione, dovrai implementare il miglioramento progressivo creando due versioni diverse di Wasm: una con supporto del multi-threading e una senza. Dopodiché carica la versione supportata in base ai risultati del rilevamento delle funzionalità. Per rilevare il supporto dei thread WebAssembly in fase di runtime, utilizza la libreria Wasm-feature-detect e carica il modulo in questo modo:

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

Ora diamo un'occhiata a come creare una versione multithread del modulo WebAssembly.

C

In C, in particolare nei sistemi Unix, il modo più comune di utilizzare i thread è tramite i Thread POSIX forniti dalla libreria pthread. Emscripten fornisce un'implementazione compatibile con l'API della libreria pthread basata su web worker, memoria condivisa e componenti atomici, in modo che lo stesso codice possa funzionare sul web senza modifiche.

Vediamo un esempio:

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;
}

Qui le intestazioni per la libreria pthread sono incluse tramite pthread.h. Puoi anche vedere un paio di funzioni cruciali per la gestione dei thread.

pthread_create creerà un thread in background. Occorre una destinazione per archiviare l'handle di un thread, alcuni attributi per la creazione dei thread (qui senza passare, quindi è solo NULL), il callback da eseguire nel nuovo thread (qui thread_callback) e un puntatore all'argomento facoltativo da passare a quel callback nel caso in cui tu voglia condividere alcuni dati del thread principale. In questo esempio condividiamo un puntatore a una variabile arg.

Puoi chiamare pthread_join in qualsiasi momento per attendere che il thread termini l'esecuzione e ottenere il risultato restituito dal callback. Accetta l'handle del thread assegnato in precedenza, nonché un puntatore per archiviare il risultato. In questo caso, non ci sono risultati, quindi la funzione prende un NULL come argomento.

Per compilare il codice utilizzando thread con Emscripten, devi richiamare emcc e passare un parametro -pthread, come quando si compila lo stesso codice con Clang o GCC su altre piattaforme:

emcc -pthread example.c -o example.js

Tuttavia, quando provi a eseguirlo in un browser o in Node.js, viene visualizzato un avviso e il programma si blocca:

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…]

Che cosa è successo? Il problema è che la maggior parte delle API dispendiose in termini di tempo sul web sono asincrone e si basano sul loop di eventi per essere eseguite. Questa limitazione è una distinzione importante rispetto agli ambienti tradizionali, in cui le applicazioni normalmente eseguono l'I/O in modo sincrono e bloccato. Per saperne di più, leggi il post del blog sull'utilizzo delle API web asincrone di WebAssembly.

In questo caso, il codice richiama in modo sincrono pthread_create per creare un thread in background e segue un'altra chiamata sincrona a pthread_join che attende che il thread in background termini l'esecuzione. Tuttavia, i web worker, utilizzati dietro le quinte quando questo codice viene compilato con Emscripten, sono asincroni. Quindi, pthread_create pianifica solo la creazione di un nuovo thread worker alla successiva esecuzione del loop di eventi, ma pthread_join blocca immediatamente il loop di eventi di attesa per quel worker, impedendone la creazione. È un classico esempio di deadlock.

Un modo per risolvere questo problema è creare un pool di worker in anticipo, prima che il programma sia iniziato. Quando pthread_create viene richiamato, può recuperare un worker pronto all'uso dal pool, eseguire il callback fornito sul thread in background e restituire il worker al pool. Tutto questo può essere fatto in modo sincrono, quindi non ci saranno deadlock purché il pool sia sufficientemente grande.

Questo è esattamente ciò che Emscripten consente con l'opzione -s PTHREAD_POOL_SIZE=.... Consente di specificare un numero di thread, un numero fisso o un'espressione JavaScript come navigator.hardwareConcurrency per creare un numero illimitato di thread quanti sono i core nella CPU. La seconda opzione è utile quando il codice può scalare fino a un numero arbitrario di thread.

Nell'esempio precedente, è stato creato un solo thread, quindi, anziché prenotare tutti i core, è sufficiente utilizzare -s PTHREAD_POOL_SIZE=1:

emcc -pthread -s PTHREAD_POOL_SIZE=1 example.c -o example.js

Questa volta, quando la esegui, l'operazione funziona correttamente:

Before the thread
Inside the thread: 42
After the thread
Pthread 0x701510 exited.

Tuttavia, c'è un altro problema: vedi sleep(1) nell'esempio di codice? Viene eseguito nel callback del thread, cioè al di fuori del thread principale, quindi non ci sono problemi. In realtà non è così.

Quando viene chiamato pthread_join, deve attendere il completamento dell'esecuzione del thread, il che significa che se il thread creato esegue attività a lunga esecuzione (in questo caso, 1 secondo), anche il thread principale dovrà bloccare per lo stesso periodo di tempo fino a quando i risultati non vengono restituiti. Quando questo codice JS viene eseguito nel browser, bloccherà il thread dell'interfaccia utente per 1 secondo fino a quando non restituisce il callback del thread. Questo determina un'esperienza utente scadente.

Le soluzioni a questo problema sono diverse:

  • pthread_detach
  • -s PROXY_TO_PTHREAD
  • Worker personalizzato e Comlink

pthread_detach

Innanzitutto, se devi eseguire solo alcune attività dal thread principale, ma non devi attendere i risultati, puoi utilizzare pthread_detach anziché pthread_join. Il callback del thread rimarrà in esecuzione in background. Se utilizzi questa opzione, puoi disattivare l'avviso con -s PTHREAD_POOL_SIZE_STRICT=0.

PROXY_TO_PTHREAD

Secondariamente, se stai compilando un'applicazione C anziché una libreria, puoi utilizzare l'opzione -s PROXY_TO_PTHREAD, che trasferisce il codice principale dell'applicazione in un thread separato, oltre a eventuali thread nidificati creati dall'applicazione stessa. In questo modo, il codice principale può bloccare in sicurezza in qualsiasi momento senza bloccare l'interfaccia utente. Per inciso, quando utilizzi questa opzione, non è necessario precreare il pool di thread; tuttavia, Emscripten può sfruttare il thread principale per creare nuovi worker sottostanti, quindi bloccare il thread di supporto in pthread_join senza deadlock.

Terzo, se lavori su una libreria e devi ancora bloccare i file, puoi creare il tuo worker, importare il codice generato da Emscripten ed esporlo con Comlink al thread principale. Il thread principale potrà richiamare tutti i metodi esportati come funzioni asincrone, evitando anche di bloccare l'UI.

In un'applicazione semplice come l'esempio precedente, -s PROXY_TO_PTHREAD è l'opzione migliore:

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

C++

Le stesse avvertenze e logica si applicano allo stesso modo di C++. L'unica novità è l'accesso ad API di livello superiore come std::thread e std::async, che utilizzano la precedente libreria pthread.

Quindi l'esempio sopra può essere riscritto in C++ più idiomatico come questo:

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;
}

Una volta compilato ed eseguito con parametri simili, si comporterà come l'esempio C:

emcc -std=c++11 -pthread -s PROXY_TO_PTHREAD example.cpp -o example.js

Output:

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

A differenza di Emscripten, Rust non ha un target web end-to-end specializzato, ma fornisce un target wasm32-unknown-unknown generico per l'output di WebAssembly generico.

Se Wasm è destinato a essere utilizzato in un ambiente web, qualsiasi interazione con le API JavaScript viene lasciata a librerie e strumenti esterni come wasm-bindgen e wasm-pack. Purtroppo, ciò significa che la libreria standard non è a conoscenza dei web worker e le API standard come std::thread non funzionano quando vengono compilate in WebAssembly.

Fortunatamente, la maggior parte dell'ecosistema dipende da librerie di livello superiore per la gestione del multithreading. A quel livello è molto più facile astrare tutte le differenze della piattaforma.

In particolare, Rayon è la scelta più popolare per il parallelismo dei dati in Rust. Consente di utilizzare catene di metodi su iteratori regolari e, di solito, con una singola modifica di riga, convertirle in modo tale che vengano eseguite in parallelo su tutti i thread disponibili invece che in sequenza. Ad esempio:

pub fn sum_of_squares(numbers: &[i32]) -> i32 {
  numbers
  .iter()
  .par_iter()
  .map(|x| x * x)
  .sum()
}

Con questa piccola modifica, il codice suddividerà i dati di input, calcolerà x * x e le somme parziali nei thread paralleli e alla fine somma i risultati parziali.

Per adattarsi alle piattaforme senza usare std::thread, Rayon fornisce hook che permettono di definire una logica personalizzata per la generazione e l'uscita dei thread.

wasm-bindgen-rayon sfrutta questi ganci per generare thread di WebAssembly come worker web. Per utilizzarla, devi aggiungerla come dipendenza e seguire i passaggi di configurazione descritti nella docs. L'esempio riportato sopra avrà il seguente aspetto:

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()
}

Al termine, il codice JavaScript generato esporterà un'ulteriore funzione initThreadPool. Questa funzione creerà un pool di worker e li riutilizza per tutta la durata del programma per qualsiasi operazione multithread eseguita da Rayon.

Questo meccanismo del pool è simile all'opzione -s PTHREAD_POOL_SIZE=... di Emscripten spiegato in precedenza e deve inoltre essere inizializzato prima del codice principale per evitare deadlock:

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

Tieni presente che valgono anche le stesse avvertenze sul blocco del thread principale. Anche l'esempio sum_of_squares deve comunque bloccare il thread principale per attendere i risultati parziali di altri thread.

Potrebbe essere un'attesa molto breve o molto lunga, a seconda della complessità degli iteratori e del numero di thread disponibili. Tuttavia, per garantire la massima sicurezza, i motori del browser impediscono attivamente di bloccare completamente il thread principale e questo codice genererà un errore. Devi invece creare un worker, importare lì il codice generato wasm-bindgen ed esporre la relativa API con una libreria come Comlink al thread principale.

Dai un'occhiata all'esempio wasm-bindgen-rayon per una demo end-to-end che mostra:

Casi d'uso del mondo reale

Usiamo attivamente i thread WebAssembly in Squoosh.app per la compressione di immagini lato client, in particolare per formati come AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) e WebP v2 (C++). Grazie al solo multithreading, abbiamo osservato che i thread delle schede SIM Assembly hanno eseguito il push-up di velocità 1,5x-3 volte più volte rispetto ai codec WebAssembly che hanno consentito di

Google Earth è un altro servizio importante che utilizza i thread WebAssembly per la sua versione web.

FFMPEG.WASM è una versione WebAssembly di una popolare toolchain multimediale FFmpeg che utilizza i thread di WebAssembly per codificare in modo efficiente i video direttamente nel browser.

Esistono molti altri esempi interessanti utilizzando i thread di WebAssembly. Assicurati di guardare le demo e porta sul web le tue applicazioni e librerie multi-thread.