Scopri come portare in WebAssembly applicazioni multi-thread scritte in altri linguaggi.
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.
Comlink
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:
- Rilevamento delle funzionalità dei thread.
- Creazione di versioni a thread singolo e multi-thread della stessa app Rust.
- Caricamento del file JS+Wasm generato da wasm-bindgen in un Worker.
- Utilizzo di 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
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.