Scopri come portare le applicazioni multithread scritte in altri linguaggi su WebAssembly.
Il supporto dei thread WebAssembly è una delle aggiunte più importanti in termini di prestazioni a WebAssembly. Ti consente di eseguire parti del codice in parallelo su core separati o lo stesso codice su parti indipendenti dei dati di input, scalandolo in base 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 multithread scritte in linguaggi come C, C++ e Rust.
Come funzionano i thread WebAssembly
I thread WebAssembly non sono una funzionalità separata, ma una combinazione di diversi componenti che consente alle app WebAssembly di utilizzare i paradigmi di multithreading tradizionali sul web.
Web worker
Il primo componente è costituito dai Worker regolari che conosci e ami di JavaScript. I thread WebAssembly utilizzano il costruttore new Worker
per creare nuovi thread di base. Ogni thread carica un collegamento JavaScript, quindi il thread principale utilizza il metodo
Worker#postMessage
per condividere il codice compilato
WebAssembly.Module
nonché un WebAssembly.Memory
condiviso (vedi di seguito) con gli altri thread. In questo modo si stabilisce la comunicazione e consente a tutti i thread di eseguire lo stesso codice WebAssembly sulla stessa memoria condivisa senza ripetere JavaScript.
I web worker sono disponibili da oltre un decennio, sono ampiamente supportati e non richiedono parametri speciali.
SharedArrayBuffer
La memoria WebAssembly è rappresentata da un oggetto WebAssembly.Memory
nell'API JavaScript. Per impostazione predefinita, WebAssembly.Memory
è un wrapper di un
ArrayBuffer
, ovvero un
buffer di byte non elaborato a cui può accedere solo un singolo thread.
> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer { … }
Per supportare il multithreading, anche WebAssembly.Memory
ha ottenuto una variante condivisa. Se viene creato con un flag shared
tramite l'API JavaScript o dal codice binario WebAssembly stesso, diventa un wrapper attorno a SharedArrayBuffer
. È una variante di ArrayBuffer
che può essere condivisa con altri thread e letta o
modificata 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 worker web, SharedArrayBuffer
non richiede la copia dei dati né l'attesa che il loop di eventi invii e riceva i messaggi.
Le modifiche vengono invece 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 implementato in diversi browser
a metà 2017, ma ha dovuto essere disattivato all'inizio del 2018 a causa del rilevamento delle vulnerabilità Spectre. Il motivo particolare è che l'estrazione dei dati in Spectre si basa su attacchi di temporizzazione, ovvero sulla misurazione del tempo di esecuzione di un determinato codice. Per rendere questo tipo di attacco più difficile, i browser hanno ridotto la precisione delle API di tempistica standard come Date.now
e performance.now
. Tuttavia, la memoria condivisa, combinata con un semplice loop di contatori in esecuzione in un thread separato, è anche un modo molto affidabile per ottenere temporizzazioni di alta precisione ed è molto più difficile da mitigare senza limitare in modo significativo le prestazioni di runtime.
Invece, Chrome 68 (metà 2018) ha 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 lato canale come Spectre. Tuttavia, questa mitigazione era ancora limitata solo a Chrome per computer, 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 poca memoria, né era ancora stata implementata da altri fornitori.
Nel 2020, sia Chrome che Firefox hanno implementazioni dell'isolamento dei siti e un modo standard per consentire ai siti web di attivare la funzionalità con le intestazioni COOP e COEP. Un meccanismo di attivazione consente di utilizzare l'isolamento dei siti anche su dispositivi a bassa potenza in cui l'attivazione per tutti i siti web sarebbe troppo costosa. Per attivare questa funzionalità, aggiungi le seguenti intestazioni al documento principale nella configurazione del server:
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Una volta attivata la funzionalità, avrai accesso a SharedArrayBuffer
(incluso WebAssembly.Memory
supportato da un
SharedArrayBuffer
), timer precisi, misurazione della memoria e altre API che richiedono un'origine distinta per motivi di sicurezza. Per ulteriori dettagli, consulta la pagina Rendere il tuo sito web "isolato multiorigine" utilizzando COOP e COEP.
Atomici WebAssembly
Sebbene SharedArrayBuffer
consenta a ogni thread di leggere e scrivere nella stessa memoria, per una comunicazione corretta devi assicurarti che non eseguano contemporaneamente operazioni in conflitto. Ad esempio, è possibile che un thread inizi a leggere i dati da un indirizzo condiviso e che un altro thread vi scriva, quindi il primo thread ora riceverà un risultato danneggiato. Questa categoria di insetti è nota
come condizioni di gara. Per evitare le condizioni di gara, è necessario sincronizzare in qualche modo questi accessi.
È qui che entrano in gioco le operazioni atomiche.
WebAssembly atomics è un'estensione del set di istruzioni di WebAssembly che consente di leggere e scrivere piccole celle di dati (di solito numeri interi a 32 e 64 bit) "a livello atomico". In altri termini, viene garantito che non ci siano due thread che leggono o scrivono contemporaneamente nella stessa cella, evitando conflitti di basso livello. Inoltre, gli elementi atomici WebAssembly contengono altri due tipi di istruzioni, "wait" e "notify", che consentono a un thread di entrare in sospensione ("wait") su un determinato indirizzo in una memoria condivisa finché un altro thread non lo riattiva tramite "notify".
Tutte le primitive di sincronizzazione di livello superiore, inclusi canali, mutex e lock di lettura/scrittura, si basano su queste istruzioni.
Come utilizzare i thread WebAssembly
Rilevamento di funzionalità
Gli elementi atomici WebAssembly e SharedArrayBuffer
sono funzionalità relativamente nuove e non sono ancora disponibili in tutti i browser con supporto WebAssembly. Puoi scoprire quali browser supportano le nuove funzionalità WebAssembly
nella roadmap di webassembly.org.
Per garantire che tutti gli utenti possano caricare la tua applicazione, dovrai implementare un miglioramento progressivo creando due diverse versioni di Wasm, una con supporto del multithreading e una senza. Quindi 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 come segue:
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 vediamo come creare una versione multithread del modulo WebAssembly.
C
In C, in particolare sui sistemi simili a Unix, il modo comune per utilizzare i thread è tramite i thread POSIX forniti dalla libreria pthread
. Emscripten
fornisce un'implementazione compatibile con l'API della
libreria pthread
creata 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 fondamentali per gestire i thread.
pthread_create
creerà un
thread in background. Prende una destinazione per archiviare un handle del thread, alcuni attributi di creazione del thread (qui non ne vengono trasmessi, quindi è solo NULL
), il callback da eseguire nel nuovo thread (qui thread_callback
) e un puntatore di argomento facoltativo da passare a quel callback nel caso in cui tu voglia condividere alcuni dati dal thread principale. In questo esempio stiamo condividendo un puntatore a una variabile arg
.
pthread_join
può essere chiamato in un secondo momento
in qualsiasi momento per attendere il completamento dell'esecuzione del thread e ottenere il risultato restituito dal callback. Accetta l'handle del thread assegnato in precedenza e un puntatore per memorizzare il risultato. In questo caso non ci sono risultati, quindi la funzione accetta un NULL
come argomento.
Per compilare il codice utilizzando i thread con Emscripten, devi richiamare emcc
e passare un parametro -pthread
, come quando compili lo stesso codice con Clang o GCC su altre piattaforme:
emcc -pthread example.c -o example.js
Tuttavia, quando tenti di eseguirlo in un browser o su 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 che richiedono molto tempo sul web sono asincrone e si basano sul loop di eventi per l'esecuzione. Questa limitazione è una distinzione importante rispetto agli ambienti tradizionali, in cui le applicazioni eseguono normalmente l'I/O in modo sincrono e bloccante. Se vuoi saperne di più, consulta il post del blog sull'utilizzo delle API web asincrone da WebAssembly.
In questo caso, il codice invoca in modo sincrono pthread_create
per creare un thread in background, seguito da un'altra chiamata sincrona a pthread_join
che attende il completamento dell'esecuzione del thread in background. Tuttavia, i worker web, che vengono utilizzati dietro le quinte durante la compilazione di questo codice con Emscripten, sono asincroni. Ciò che accade è che pthread_create
pianifica solo la creazione di un nuovo ilo di lavoro nell'esecuzione successiva del loop di eventi, ma pthread_join
blocca immediatamente il loop di eventi in attesa di quel ilo di lavoro e, in questo modo, ne impedisce la creazione. È un
classico esempio di blocco.
Un modo per risolvere questo problema è creare un pool di worker in anticipo, prima che il programma sia stato avviato. Quando viene invocato, pthread_create
può prendere un worker pronto all'uso dal pool, eseguire il callback fornito nel thread in background e restituire il worker al pool. Tutto questo può essere eseguito 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 tanti thread quanti sono i core della CPU. Quest'ultima opzione è utile quando il codice può essere scalato a un numero arbitrario di thread.
Nell'esempio precedente viene creato un solo thread, quindi anziché riservare 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 lo esegui, tutto funziona correttamente:
Before the thread
Inside the thread: 42
After the thread
Pthread 0x701510 exited.
C'è però un altro problema: vedi sleep(1)
nell'esempio di codice? Viene eseguito nel callback del thread, ovvero al di fuori del thread principale, quindi dovrebbe andare bene, giusto? Non lo è.
Quando viene chiamato pthread_join
, deve attendere il completamento dell'esecuzione del thread, il che significa che se il thread creato sta eseguendo attività di lunga durata, in questo caso in attesa di 1 secondo, anche il thread principale dovrà bloccarsi per lo stesso periodo di tempo fino a quando non verranno restituiti i risultati. Quando questo codice JS viene eseguito nel browser, blocca il thread dell'interfaccia utente per 1 secondo fino al ritorno del callback del thread. Ciò si traduce in un'esperienza utente scadente.
Esistono alcune soluzioni a questo problema:
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
invece di 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
In secondo luogo, se stai compilando un'applicazione C anziché una libreria, puoi utilizzare l'opzione -s
PROXY_TO_PTHREAD
, che decaricherà il codice dell'applicazione principale in un thread separato, oltre a eventuali thread nidificati creati dall'applicazione stessa. In questo modo, il codice principale può bloccarsi in sicurezza in qualsiasi momento senza bloccare l'interfaccia utente.
A proposito, quando utilizzi questa opzione, non devi nemmeno pre creare il pool di thread. Emscripten può invece sfruttare il thread principale per creare nuovi worker sottostanti e bloccare il thread di supporto in pthread_join
senza blocchi di sistema.
Comlink
In terzo luogo, se stai lavorando su una libreria e devi ancora bloccare, puoi creare il tuo worker, importare il codice generato da Emscripten ed esporlo con Comlink al thread principale. Il thread principale potrà invocare eventuali metodi esportati come funzioni asincrone, evitando così di bloccare l'interfaccia utente.
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++
Tutte le stesse limitazioni e la stessa logica si applicano allo stesso modo a C++. L'unica novità è l'accesso
a API di livello superiore come std::thread
e
std::async
, che utilizzano la libreria pthread
discussa in precedenza.
Pertanto, l'esempio riportato sopra può essere riscritto in C++ più idiomatico come segue:
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;
}
Se viene compilato ed eseguito con parametri simili, si comporta nello stesso modo dell'esempio in 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 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. Sfortunatamente, ciò significa che la libreria standard non è a conoscenza dei web worker e le API standard come std::thread
non funzioneranno quando viene compilata in WebAssembly.
Fortunatamente, la maggior parte dell'ecosistema si basa su librerie di livello superiore per gestire il multithreading. A questo livello è molto più facile ignorare tutte le differenze tra le piattaforme.
In particolare, Rayon è la scelta più popolare per il parallelismo dei dati in Rust. Ti consente di prendere catene di metodi su iteratori regolari e, in genere con una singola modifica di riga, di convertirli in modo che vengano eseguiti in parallelo su tutti i thread disponibili anziché 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 suddivide i dati di input, calcola x * x
e le somme parziali in thread paralleli e alla fine somma i risultati parziali.
Per supportare le piattaforme senza std::thread
funzionante, Rayon fornisce hook che consentono di definire la logica personalizzata per la generazione e l'uscita dai thread.
wasm-bindgen-rayon attinge a questi hook per generare thread WebAssembly come web worker. Per utilizzarla, devi aggiungerla come dipendenza e seguire i passaggi di configurazione descritti nella documentazione. 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à una funzione initThreadPool
aggiuntiva. Questa funzione
crea un pool di worker e li riutilizza per tutta la durata del programma per qualsiasi
operazione multithread eseguita da Rayon.
Questo meccanismo di pool è simile all'opzione -s PTHREAD_POOL_SIZE=...
in Emscripten descritta
in precedenza e deve anche 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 qui valgono gli stessi limiti relativi al blocco del thread principale. Anche l'esempio sum_of_squares
deve bloccare il
thread principale per attendere i risultati parziali di altri thread.
L'attesa potrebbe essere molto breve o lunga, a seconda della complessità degli iteratori e del numero di thread disponibili, ma, per sicurezza, i motori dei browser impediscono attivamente di bloccare del tutto il thread principale e questo codice genererà un errore. Devi invece creare un worker, importare il codice generato da wasm-bindgen
lì ed esporre la relativa API con una libreria come Comlink nel thread principale.
Consulta l'esempio wasm-bindgen-rayon per una demo end-to-end che mostra:
- Rilevamento di elementi di discussione.
- Creazione di versioni mono e multithread della stessa app Rust.
- Caricamento di JS+Wasm generato da wasm-bindgen in un Worker.
- Utilizzare wasm-bindgen-rayon per inizializzare un pool di thread.
- Utilizzo di Comlink per esporre l'API del worker al thread principale.
Casi d'uso del mondo reale
Utilizziamo attivamente i thread WebAssembly in Squoosh.app per la compressione delle immagini lato client, in particolare per formati come AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) e WebP v2 (C++). Grazie al solo multithreading, abbiamo registrato un aumento costante della velocità di 1,5-3 volte (il rapporto esatto varia in base al codec) e siamo riusciti a migliorare ulteriormente questi numeri combinando i thread WebAssembly con WebAssembly SIMD.
Google Earth è un altro servizio notevole che utilizza thread WebAssembly per la sua versione web.
FFMPEG.WASM è una versione WebAssembly di una popolare toolchain multimediale FFmpeg che utilizza thread WebAssembly per codificare in modo efficiente i video direttamente nel browser.
Esistono molti altri esempi interessanti che utilizzano i thread WebAssembly. Non perderti le demo e porta le tue applicazioni e librerie multi-thread sul web.