Utilizzo delle API web asincrone di WebAssembly

Ingvar Stepanyan
Ingvar Stepanyan

Le API di I/O sul web sono asincrone, ma sono sincrone nella maggior parte dei linguaggi di sistema. Quando compili il codice in WebAssembly, devi collegare un tipo di API a un altro e questo bridge è Asyncify. In questo post scoprirai quando e come utilizzare Asyncify e come funziona in dettaglio.

I/O nelle lingue di sistema

Inizierò con un semplice esempio in C. Supponiamo che tu voglia leggere il nome dell'utente da un file e salutarlo con il messaggio "Ciao (nome utente)!".

#include <stdio.h>

int main() {
    FILE *stream = fopen("name.txt", "r");
    char name[20+1];
    size_t len = fread(&name, 1, 20, stream);
    name[len] = '\0';
    fclose(stream);
    printf("Hello, %s!\n", name);
    return 0;
}

Anche se non è molto utile, l'esempio dimostra già qualcosa che puoi trovare in un'applicazione di qualsiasi dimensione: legge alcuni input dal mondo esterno, li elabora internamente e scrive gli output al mondo esterno. Queste interazioni con il mondo esterno avvengono tramite alcune funzioni comunemente chiamate funzioni di input-output, abbreviate in I/O.

Per leggere il nome da C, sono necessarie almeno due chiamate I/O cruciali: fopen, per aprire il file e fread per leggerne i dati. Dopo aver recuperato i dati, puoi utilizzare un'altra funzione di I/O printf per stampare il risultato sulla console.

Queste funzioni a prima vista sembrano piuttosto semplici e non devi preoccuparti dei macchinari per leggere o scrivere dati. Tuttavia, a seconda dell'ambiente, possono esserci moltissime cose da fare:

  • Se il file di input si trova su un'unità locale, l'applicazione deve eseguire una serie di accessi alla memoria e al disco per individuare il file, verificare le autorizzazioni, aprirlo per la lettura e quindi leggere blocco per blocco fino a quando non viene recuperato il numero richiesto di byte. L'operazione può essere piuttosto lenta, a seconda della velocità del disco e delle dimensioni richieste.
  • Oppure, il file di input potrebbe trovarsi in una posizione di rete montata. In questo caso, sarà coinvolto anche lo stack di rete, aumentando la complessità, la latenza e il numero di potenziali nuovi tentativi per ogni operazione.
  • Infine, anche per printf non è garantito che stampi qualcosa sulla console e potrebbe essere reindirizzato a un file o a un percorso di rete, nel qual caso dovrebbe seguire gli stessi passaggi riportati sopra.

Per farla breve, l'I/O può essere lento e non puoi prevedere quanto tempo richiederà una determinata chiamata dando una rapida occhiata al codice. Mentre l'operazione è in esecuzione, l'intera applicazione risulterà bloccata e non risponderà all'utente.

Ciò non si limita a C o C++. La maggior parte dei linguaggi di sistema presenta tutti gli I/O sotto forma di API sincrone. Ad esempio, se traduci l'esempio in Rust, l'API potrebbe sembrare più semplice, ma si applicano gli stessi principi. È sufficiente effettuare una chiamata e attendere in modo sincrono il risultato, mentre esegue tutte le operazioni più costose e alla fine restituisce il risultato con una singola chiamata:

fn main() {
    let s = std::fs::read_to_string("name.txt");
    println!("Hello, {}!", s);
}

Ma cosa succede quando provi a compilare uno di questi esempi in WebAssembly e li traduci sul web? Oppure, per fornire un esempio specifico, in che cosa potrebbe tradurre l'operazione di "lettura file"? Dovrebbe leggere i dati da un po' di spazio di archiviazione.

Modello asincrono del web

Il web offre una varietà di opzioni di archiviazione a cui puoi eseguire la mappatura, come l'archiviazione in memoria (oggetti JS), localStorage, IndexedDB, l'archiviazione lato server e una nuova API File System Access.

Tuttavia, solo due di queste API, l'archiviazione in memoria e l'localStorage, possono essere utilizzate in modo sincrono ed entrambe sono le opzioni più limitate in termini di archiviazione e durata. Tutte le altre opzioni forniscono solo API asincrone.

Questa è una delle proprietà principali dell'esecuzione di codice sul web: qualsiasi operazione dispendiosa in termini di tempo, che include gli I/O, deve essere asincrona.

Il motivo è che il web è storicamente a thread singolo e qualsiasi codice utente che tocca la UI deve essere eseguito sullo stesso thread della UI. Deve competere con le altre attività importanti come il layout, il rendering e la gestione degli eventi per il tempo di CPU. Non è consigliabile che parte di JavaScript o WebAssembly possa avviare un'operazione di "lettura file" e bloccare tutto il resto (l'intera scheda o, in passato, l'intero browser) per un intervallo che va da millisecondi a pochi secondi, fino alla fine.

Il codice può però pianificare un'operazione di I/O insieme a un callback da eseguire solo al termine. Questi callback vengono eseguiti come parte del loop di eventi del browser. Non parlerò in dettaglio di questo argomento, ma se ti interessa sapere come funziona il loop di eventi in dettaglio, dai un'occhiata a Attività, microattività, code e pianificazioni per una spiegazione approfondita di questo argomento.

Nella versione breve il browser esegue tutte le parti di codice in una sorta di loop infinito, prendendole dalla coda una per una. Quando viene attivato un evento, il browser mette in coda il gestore corrispondente e, all'iterazione del loop successiva, viene rimosso dalla coda ed eseguito. Questo meccanismo consente di simulare la contemporaneità ed eseguire molte operazioni parallele utilizzando un solo thread.

La cosa importante da ricordare di questo meccanismo è che, durante l'esecuzione del codice JavaScript personalizzato (o WebAssembly), il loop di eventi è bloccato e, mentre lo è, non c'è modo di reagire a gestori esterni, eventi, I/O e così via. L'unico modo per ottenere i risultati di I/O è registrare un callback, terminare l'esecuzione del codice e restituire il controllo al browser in modo che possa continuare a elaborare eventuali attività in sospeso. Al termine dell'I/O, il gestore diventerà una di queste attività e verrà eseguito.

Ad esempio, se volessi riscrivere gli esempi precedenti nel codice JavaScript moderno e decidessi di leggere un nome da un URL remoto, utilizza l'API Fetch e la sintassi async-await:

async function main() {
  let response = await fetch("name.txt");
  let name = await response.text();
  console.log("Hello, %s!", name);
}

Anche se sembra sincrono, in sostanza ogni await è essenzialmente una base di sintassi per i callback:

function main() {
  return fetch("name.txt")
    .then(response => response.text())
    .then(name => console.log("Hello, %s!", name));
}

In questo esempio meno chiaro, che è un po' più chiaro, viene avviata una richiesta e le risposte vengono registrate con il primo callback. Una volta che il browser riceve la risposta iniziale (solo le intestazioni HTTP) richiama in modo asincrono questo callback. Il callback inizia a leggere il corpo del testo come testo utilizzando response.text() e si iscrive al risultato con un altro callback. Infine, una volta che fetch ha recuperato tutti i contenuti, richiama l'ultimo callback, che visualizza "Hello, (username)!" nella console.

Grazie alla natura asincrona di questi passaggi, la funzione originale può restituire il controllo al browser non appena l'I/O è stato pianificato e lasciare l'intera UI reattiva e disponibile per altre attività, tra cui rendering, scorrimento e così via, durante l'esecuzione dell'I/O in background.

Come ultimo esempio, anche API semplici come "sleep", che fa attendere un'applicazione un numero specifico di secondi, sono anche una forma di un'operazione di I/O:

#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");

Certo, potresti tradurlo in un modo molto semplice e bloccare il thread corrente fino alla scadenza:

console.log("A");
for (let start = Date.now(); Date.now() - start < 1000;);
console.log("B");

In effetti, è esattamente ciò che fa Emscripten nella sua implementazione predefinita di "sleep", ma è molto inefficiente, bloccherà l'intera UI e non consentirà la gestione di altri eventi nel frattempo. In genere, non farlo nel codice di produzione.

Invece, una versione più idiomatica di "sleep" in JavaScript comporterebbe la chiamata a setTimeout() e l'iscrizione con un gestore:

console.log("A");
setTimeout(() => {
    console.log("B");
}, 1000);

Cosa c'è di comune tra tutti questi esempi e queste API? In ogni caso, il codice idiomatico nel linguaggio del sistema originale utilizza un'API di blocco per l'I/O, mentre un esempio equivalente per il web utilizza un'API asincrona. Quando compili sul web, devi in qualche modo trasformare tra questi due modelli di esecuzione, e WebAssembly non ha ancora la possibilità di farlo.

Colmare il divario con Asyncify

È qui che entra in gioco Asyncify. Asyncify è una funzionalità della fase di compilazione supportata da Emscripten che consente di mettere in pausa l'intero programma e di riprenderlo in modo asincrono in un secondo momento.

Un grafico delle chiamate che descrive la chiamata di un&#39;attività asincrona, in JavaScript -> WebAssembly -> API web ->, in cui Asyncify connette di nuovo il risultato dell&#39;attività asincrona a WebAssembly.

Utilizzo in C / C++ con Emscripten

Se vuoi utilizzare Asyncify per implementare una fase di sospensione asincrona per l'ultimo esempio, puoi farlo nel seguente modo:

#include <stdio.h>
#include <emscripten.h>

EM_JS(void, async_sleep, (int seconds), {
    Asyncify.handleSleep(wakeUp => {
        setTimeout(wakeUp, seconds * 1000);
    });
});
…
puts("A");
async_sleep(1);
puts("B");

EM_JS è una macro che consente di definire gli snippet JavaScript come se fossero funzioni C. All'interno, utilizza una funzione Asyncify.handleSleep() che dica a Emscripten di sospendere il programma e fornisce un gestore wakeUp() che deve essere chiamato al termine dell'operazione asincrona. Nell'esempio precedente, il gestore viene passato a setTimeout(), ma potrebbe essere utilizzato in qualsiasi altro contesto che accetta i callback. Infine, puoi chiamare async_sleep() ovunque vuoi, proprio come il normale sleep() o qualsiasi altra API sincrona.

Quando compili questo codice, devi chiedere a Emscripten di attivare la funzionalità Asyncify. Per farlo, passa -s ASYNCIFY e -s ASYNCIFY_IMPORTS=[func1, func2] con un elenco di funzioni di tipo array di funzioni asincrone.

emcc -O2 \
    -s ASYNCIFY \
    -s ASYNCIFY_IMPORTS=[async_sleep] \
    ...

Ciò consente a Emscripten di sapere che qualsiasi chiamata a queste funzioni potrebbe richiedere il salvataggio e il ripristino dello stato, quindi il compilatore inserirà il codice di supporto relativo a queste chiamate.

Ora, quando esegui questo codice nel browser, vedrai un log di output semplice come previsto, con B dopo un breve ritardo A.

A
B

Puoi anche restituire valori dalle funzioni Asyncify. Devi restituire il risultato di handleSleep() e passare il risultato al callback wakeUp(). Ad esempio, se, invece di leggere da un file, vuoi recuperare un numero da una risorsa remota, puoi utilizzare uno snippet come quello riportato di seguito per inviare una richiesta, sospendere il codice C e riprendere una volta recuperato il corpo della risposta, tutto senza problemi come se la chiamata fosse sincrona.

EM_JS(int, get_answer, (), {
     return Asyncify.handleSleep(wakeUp => {
        fetch("answer.txt")
            .then(response => response.text())
            .then(text => wakeUp(Number(text)));
    });
});
puts("Getting answer...");
int answer = get_answer();
printf("Answer is %d\n", answer);

Infatti, per le API basate su Promise come fetch(), puoi anche combinare Asyncify con la funzionalità di attesa asincrona di JavaScript anziché utilizzare l'API basata su callback. In questo caso, invece di Asyncify.handleSleep(), chiama Asyncify.handleAsync(). Poi, invece di pianificare un callback wakeUp(), puoi passare una funzione JavaScript async e utilizzare await e return al suo interno, rendendo il codice ancora più naturale e sincrono senza perdere i vantaggi dell'I/O asincrono.

EM_JS(int, get_answer, (), {
     return Asyncify.handleAsync(async () => {
        let response = await fetch("answer.txt");
        let text = await response.text();
        return Number(text);
    });
});

int answer = get_answer();

In attesa di valori complessi

Tuttavia, questo esempio è limitato solo ai numeri. E se volessi implementare l'esempio originale, in cui ho provato a ottenere il nome di un utente da un file sotto forma di stringa? Beh, puoi farlo anche tu!

Emscripten fornisce una funzionalità chiamata Embind che ti consente di gestire le conversioni tra i valori JavaScript e C++. Supporta anche Asyncify, quindi puoi chiamare await() su Promise esterni e funzionerà come await nel codice JavaScript di attesa asincrona:

val fetch = val::global("fetch");
val response = fetch(std::string("answer.txt")).await();
val text = response.call<val>("text").await();
auto answer = text.as<std::string>();

Quando utilizzi questo metodo, non è necessario passare ASYNCIFY_IMPORTS come flag di compilazione, poiché è già incluso per impostazione predefinita.

Ok, quindi tutto funziona alla grande in Emscripten. E per quanto riguarda altre toolchain e linguaggi?

Utilizzo da altre lingue

Supponiamo che tu abbia una chiamata sincrona simile da qualche parte nel tuo codice Rust che vuoi mappare a un'API asincrona sul web. Ho scoperto che puoi farlo anche tu.

Innanzitutto, devi definire una funzione di questo tipo come un'importazione normale tramite il blocco extern (o la sintassi della lingua che hai scelto per le funzioni straniere).

extern {
    fn get_answer() -> i32;
}

println!("Getting answer...");
let answer = get_answer();
println!("Answer is {}", answer);

E compila il tuo codice in WebAssembly:

cargo build --target wasm32-unknown-unknown

Ora devi instrumentare il file WebAssembly con il codice per l'archiviazione/il ripristino dello stack. Per C/C++, Emscripten lo fa al posto nostro, ma non è usato in questo caso, quindi la procedura è un po' più manuale.

Fortunatamente, la trasformazione Asyncify stessa è completamente indipendente dalla toolchain. Può trasformare file WebAssembly arbitrari, indipendentemente dal compilatore da cui sono stati prodotti. La trasformazione viene fornita separatamente nell'ambito dell'ottimizzazione wasm-opt dalla Toolchain Binaryen e può essere richiamata nel seguente modo:

wasm-opt -O2 --asyncify \
      --pass-arg=asyncify-imports@env.get_answer \
      [...]

Passa --asyncify per abilitare la trasformazione, quindi usa --pass-arg=… per fornire un elenco separato da virgole di funzioni asincrone in cui lo stato del programma deve essere sospeso e poi ripristinato.

Non ti resta che fornire il codice di runtime di supporto per farlo: sospendi e riprendi il codice WebAssembly. Ancora una volta, nel caso C / C++ questo verrebbe incluso da Emscripten, ma ora hai bisogno di codice colla JavaScript personalizzato in grado di gestire file WebAssembly arbitrari. Abbiamo creato una libreria apposta per questo.

Lo puoi trovare su GitHub all'indirizzo https://github.com/GoogleChromeLabs/asyncify o npm sotto il nome asyncify-wasm.

Simula un'API di istanziazione WebAssembly standard, ma all'interno del proprio spazio dei nomi. L'unica differenza è che, con un'API WebAssembly standard, puoi fornire solo funzioni sincrone come importazioni, mentre con il wrapper Asyncify puoi fornire anche importazioni asincrone:

const { instance } = await Asyncify.instantiateStreaming(fetch('app.wasm'), {
    env: {
        async get_answer() {
            let response = await fetch("answer.txt");
            let text = await response.text();
            return Number(text);
        }
    }
});
…
await instance.exports.main();

Dopo aver tentato di chiamare una funzione asincrona di questo tipo, come get_answer() nell'esempio precedente, dal lato WebAssembly, la libreria rileverà l'elemento Promise restituito, sospenderà e salverà lo stato dell'applicazione WebAssembly, sottoscriverà la promessa di completamento e, in seguito, una volta risolta la promessa, ripristinerà senza problemi lo stack e lo stato delle chiamate e continuerà l'esecuzione come se non fosse successo nulla.

Poiché qualsiasi funzione del modulo potrebbe effettuare una chiamata asincrona, tutte le esportazioni diventano anche potenzialmente asincrone, quindi vengono anche sottoposte a wrapping. Nell'esempio precedente potresti aver notato che devi await il risultato di instance.exports.main() per sapere quando l'esecuzione è davvero completata.

Come funziona tutto questo in dettaglio?

Quando Asyncify rileva una chiamata a una delle funzioni ASYNCIFY_IMPORTS, avvia un'operazione asincrona, salva l'intero stato dell'applicazione, inclusi lo stack di chiamata e gli eventuali elementi locali temporanei, e, in seguito, al termine dell'operazione, ripristina tutta la memoria e lo stack di chiamate, riprendendo dalla stessa posizione e con lo stesso stato come se il programma non si fosse mai arrestato.

È molto simile alla funzionalità async-await di JavaScript che ho mostrato in precedenza, ma, a differenza di quella JavaScript, non richiede una sintassi particolare o il supporto di runtime dal linguaggio, al contrario funziona trasformando le semplici funzioni sincrone in fase di compilazione.

Durante la compilazione dell'esempio di sonno asincrono mostrato in precedenza:

puts("A");
async_sleep(1);
puts("B");

Asyncify prende questo codice e lo trasforma più o meno come il seguente (pseudocodice, la vera trasformazione è più complessa di questo):

if (mode == NORMAL_EXECUTION) {
    puts("A");
    async_sleep(1);
    saveLocals();
    mode = UNWINDING;
    return;
}
if (mode == REWINDING) {
    restoreLocals();
    mode = NORMAL_EXECUTION;
}
puts("B");

Inizialmente, mode è impostato su NORMAL_EXECUTION. Di conseguenza, la prima volta che questo codice trasformato viene eseguito, verrà valutata solo la parte che porta a async_sleep(). Una volta pianificata l'operazione asincrona, Asyncify salva tutti i dati locali e sblocca lo stack tornando da ogni funzione all'inizio, in questo modo restituendo il controllo al loop di eventi del browser.

Quindi, una volta risolto il problema async_sleep(), il codice di assistenza di Asyncify cambierà mode in REWINDING e richiamerà di nuovo la funzione. Questa volta il ramo "normal pending" è stato saltato perché ha già fatto il lavoro l'ultima volta e voglio evitare di stampare due volte "A" e invece arriva direttamente al ramo di "riavvolgimento". Una volta raggiunto, ripristina tutti i dati locali archiviati, torna alla modalità "normale" e continua l'esecuzione come se il codice non fosse mai stato arrestato.

Costi di trasformazione

Sfortunatamente, la trasformazione di Asyncify non è completamente senza costi, poiché deve inserire un bel po' di codice di supporto per l'archiviazione e il ripristino di tutti gli utenti locali, navigando nello stack di chiamate in diverse modalità e così via. Prova a modificare solo le funzioni contrassegnate come asincrone nella riga di comando, nonché i potenziali chiamanti, ma il sovraccarico delle dimensioni del codice potrebbe comunque arrivare al 50% circa prima della compressione.

Un grafico che mostra l&#39;overhead delle dimensioni del codice per vari benchmark, da quasi lo 0% in condizioni perfezionate a oltre il 100% nei casi peggiori

Questo non è l'ideale, ma in molti casi è accettabile quando l'alternativa non è avere completamente la funzionalità o devono apportare modifiche significative al codice originale.

Assicurati di abilitare sempre le ottimizzazioni per le build finali per evitare che aumenteranno ulteriormente. Puoi anche selezionare Opzioni di ottimizzazione specifiche per asincroni per ridurre il sovraccarico, limitando le trasformazioni solo a funzioni specificate e/o solo a chiamate di funzione dirette. Anche le prestazioni di runtime hanno un costo minore, ma è limitato alle chiamate asincrone stesse. Tuttavia, rispetto al costo del lavoro effettivo, di solito è trascurabile.

Demo reali

Ora che abbiamo seguito questi semplici esempi, possiamo passare a scenari più complessi.

Come indicato all'inizio dell'articolo, una delle opzioni di archiviazione sul web è un'API File System Access asincrona. Fornisce l'accesso a un file system host reale da un'applicazione web.

D'altra parte, esiste uno standard di fatto chiamato WASI per WebAssembly I/O nella console e sul lato server. È stata progettata come destinazione di compilazione per i linguaggi di sistema ed espone tutti i tipi di file system e altre operazioni in una forma tradizionale sincrona.

E se fosse possibile mappare l'uno all'altro? Poi puoi compilare qualsiasi applicazione in qualsiasi linguaggio di origine con qualsiasi toolchain che supporti il target WASI ed eseguirla in una sandbox sul web, pur consentendole di operare su file utente reali. Con Asyncify, è possibile farlo.

In questa demo, ho compilato la cassa coreutils di Rust con alcune patch minori a WASI, ho passato tramite la trasformazione Asyncify e ho implementato associazioni asincrone da WASI all'API File System Access sul lato JavaScript. Se combinata con il componente del terminale Xterm.js, fornisce una shell realistica in esecuzione nella scheda del browser e su file utente reali, proprio come un vero terminale.

Guardalo in diretta all'indirizzo https://wasi.rreverser.com/.

I casi d'uso di Asyncify non si limitano solo a timer e file system. Puoi andare oltre e utilizzare altre API di nicchia sul web.

Ad esempio, anche con l'aiuto di Asyncify, è possibile mappare libusb, probabilmente la libreria nativa più popolare per lavorare con i dispositivi USB, a un'API WebUSB, che fornisce accesso asincrono a questi dispositivi sul web. Una volta mappato e compilato, ho ottenuto test ed esempi libusb standard da eseguire su dispositivi scelti direttamente nella sandbox di una pagina web.

Screenshot dell&#39;output di debug
libusb su una pagina web, che mostra informazioni sulla fotocamera Canon collegata

Probabilmente è una storia per un altro post del blog, però.

Questi esempi dimostrano quanto Asyncify può essere potente per colmare il divario e trasferire tutti i tipi di applicazioni sul web, consentendoti di ottenere accesso multipiattaforma, limitazione tramite sandbox e una maggiore sicurezza, il tutto senza perdere funzionalità.