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

Scopri come portare le applicazioni multithread scritte in altri linguaggi su WebAssembly.

Ingvar Stepanyan
Ingvar Stepanyan

Il supporto dei thread WebAssembly è una delle aggiunte più importanti al rendimento di 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 scoprirai come utilizzare i thread WebAssembly per portare sul web applicazioni multithread scritte in linguaggi come C, C++ e Rust.

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 viene stabilita la comunicazione e tutti i thread possono eseguire lo stesso codice WebAssembly nella stessa memoria condivisa senza dover passare di nuovo da 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 per un SharedArrayBuffer. Si tratta di 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 del ciclo di eventi per inviare e ricevere 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 più difficile questo tipo di attacco, i browser hanno ridotto la precisione delle API di misurazione del tempo 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, Chrome e Firefox hanno implementato l'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

Dopo l'attivazione, 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 sezione Rendere il sito web "isolato da origini diverse" 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 mentre un altro thread vi scrive, quindi il primo thread otterrà un risultato danneggiato. Questa categoria di bug è nota come condizioni di gara. Per evitare condizioni di gara, devi sincronizzare in qualche modo questi accessi. È qui che entrano in gioco le operazioni atomiche.

WebAssembly atomics è un'estensione dell'insieme di istruzioni WebAssembly che consente di leggere e scrivere piccole celle di dati (in genere interi a 32 e 64 bit) "in modo atomico". In altre parole, in modo da garantire che nessun thread legga o scriva contemporaneamente nella stessa cella, evitando così conflitti a livello basso. Inoltre, gli elementi atomici WebAssembly contengono altri due tipi di istruzioni, "wait" e "notify", che consentono a un thread di rimanere inattivo ("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 assicurarti che tutti gli utenti possano caricare la tua applicazione, devi implementare il miglioramento progressivo generando due versioni diverse 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 su sistemi Unix-like, il modo comune per utilizzare i thread è tramite POSIX Threads fornito dalla libreria pthread. Emscripten fornisce un'implementazione compatibile con l'API della libreria pthread basata su Web Workers, memoria condivisa e 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 della 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. Richiede una destinazione in cui memorizzare un handle del thread, alcuni attributi di creazione del thread (qui non ne viene passato nessuno, quindi è solo NULL), il callback da eseguire nel nuovo thread (qui thread_callback) e un puntatore all'argomento facoltativo da passare al callback nel caso in cui tu voglia condividere alcuni dati dal thread principale. In questo esempio condividiamo 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 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 che richiedono molto tempo sul web è asincrona e si basa 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 consente Emscripten 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 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 anziché pthread_join. In questo modo, il callback del thread continuerà a essere eseguito 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 scaricherà 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.

In terzo luogo, se stai lavorando a una libreria e devi comunque 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. Purtroppo, ciò significa che la libreria standard non è a conoscenza dei worker web e le API standard come std::thread non funzioneranno quando vengono compilate 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 sfrutta questi hook per generare thread WebAssembly come worker web. Per utilizzarlo, devi aggiungerlo 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 creerà un pool di worker e li riutilizzerà 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 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 e 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

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. Assicurati di dare un'occhiata alle demo e di portare le tue applicazioni e librerie multithread sul web.