Utilizzo delle API web asincrone di WebAssembly

Ingvar Stepanyan
Ingvar Stepanyan

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

I/O nelle lingue di sistema

Iniziamo con un semplice esempio in Do. Supponiamo che tu voglia leggere il nome dell'utente da un file e salutarlo con un messaggio "Hello, (username)!":

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

Sebbene l'esempio non faccia molto, dimostra già qualcosa che troverai in un'applicazione di qualsiasi dimensione: legge alcuni input dal mondo esterno, li elabora internamente e scrive nuovamente gli output nel mondo esterno. Tutta questa interazione con il mondo esterno avviene attraverso alcune funzioni comunemente chiamate funzioni di input-output, anch'esse abbreviate in I/O.

Per leggere il nome da C, sono necessarie almeno due chiamate I/O fondamentali: fopen per aprire il file e fread per leggere i dati al suo interno. Una volta recuperati i dati, puoi utilizzare un'altra funzione di I/O printf per stampare il risultato nella console.

Queste funzioni a prima vista sono molto semplici e non è necessario pensarci bene per leggere e scrivere i dati sui macchinari coinvolti. Tuttavia, a seconda dell'ambiente, all'interno può accadere molto:

  • 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, controllare le autorizzazioni, aprirlo per la lettura e poi leggere blocco per blocco fino a quando non viene recuperato il numero di byte richiesto. L'operazione può essere piuttosto lenta, a seconda della velocità del disco e delle dimensioni richieste.
  • In alternativa, il file di input potrebbe trovarsi in una posizione di rete montata, nel qual caso verrà coinvolto anche lo stack di rete, aumentando la complessità, la latenza e il numero di potenziali tentativi di nuovo per ogni operazione.
  • Infine, anche per printf non è garantito che i dati vengano stampati nella console e potrebbero essere reindirizzati a un file o a una posizione di rete, nel qual caso dovranno seguire gli stessi passaggi precedenti.

In breve, l'I/O può essere lenta e non puoi prevedere il tempo necessario per una determinata chiamata dando un'occhiata rapida al codice. Durante l'esecuzione dell'operazione, l'intera applicazione apparirà bloccata e non risponderà all'utente.

Né C né C++. La maggior parte dei linguaggi di sistema presenta tutte le operazioni di I/O sotto forma di API sincrone. Ad esempio, se traduci l'esempio in Rust, l'API potrebbe sembrare più semplice, ma valgono gli stessi principi. Devi solo effettuare una chiamata e attendere in modo sincrono che restituisca il risultato, mentre esegue tutte le operazioni dispendiose e alla fine restituisce il risultato in 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 a tradurlo sul web? In alternativa, per fornire un esempio specifico, a cosa potrebbe corrispondere l'operazione "lettura file"? Dovrebbe leggere i dati da un determinato spazio di archiviazione.

Modello asincrono del web

Il web offre una varietà di opzioni di archiviazione che puoi mappare, ad esempio archiviazione in memoria (oggetti JS), localStorage, IndexedDB, archiviazione lato server e una nuova API File System Access.

Tuttavia, solo due di queste API, l'archiviazione in memoria e localStorage, possono essere utilizzate in modo sincrono ed entrambe sono le opzioni più limitanti in ciò che puoi archiviare e per quanto tempo. Tutte le altre opzioni forniscono solo API asincrone.

Questa è una delle proprietà fondamentali dell'esecuzione di codice sul web: qualsiasi operazione che richiede tempo, che include qualsiasi I/O, deve essere asincrona.

Il motivo è che storicamente il web è a thread singolo e qualsiasi codice utente che tocca la UI deve essere eseguito sullo stesso thread della UI. Deve competere con altre attività importanti come il layout, il rendering e la gestione degli eventi per il tempo della CPU. Non vorresti che un codice JavaScript o WebAssembly potesse avviare un'operazione di "lettura del 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 al termine dell'operazione.

Al contrario, il codice può pianificare un'operazione di I/O insieme a un callback da eseguire al termine dell'operazione. Questi callback vengono eseguiti nell'ambito del loop di eventi del browser. Non entrerò nel dettaglio, ma se ti interessa scoprire come funziona il loop di eventi, consulta Attività, microattività, code e pianificazioni, che spiega questo argomento in modo approfondito.

Nella versione breve, il browser esegue tutte le parti di codice in una sorta di loop infinito, prendendole dalla coda uno alla volta. Quando viene attivato un evento, il browser mette in coda il gestore corrispondente e nell'iterazione successiva del ciclo lo estrae dalla coda ed esegue. Questo meccanismo consente di simulare la concorrenza ed eseguire molte operazioni in parallelo utilizzando solo un singolo thread.

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

Ad esempio, se volessi riscrivere gli esempi precedenti in JavaScript moderno e decidere di leggere un nome da un URL remoto, dovresti utilizzare 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, dietro le quinte, ogni await rappresenta essenzialmente lo zucchero della sintassi per i callback:

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

In questo esempio senza zucchero, che è un po' più chiaro, viene avviata una richiesta e le risposte vengono sottoscritte con il primo callback. Una volta che il browser riceve la risposta iniziale, ovvero solo le intestazioni HTTP, richiama in modo asincrono questo callback. Il callback inizia a leggere il corpo come testo utilizzando response.text() e si iscrive al risultato con un altro callback. Infine, dopo che fetch ha recuperato tutti i contenuti, richiama l'ultimo callback, che stampa "Un saluto da (nome utente)" nella console.

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

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

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

Certo, potresti tradurlo in modo molto semplice bloccando il thread corrente fino alla scadenza del tempo:

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

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

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

Che cosa hanno in comune tutti questi esempi e API? In ogni caso, il codice idiomatico nel linguaggio di sistema originale utilizza un'API di blocco per l'I/O, mentre un esempio equivalente per il web utilizza invece un'API asincrona. Quando compili per il web, devi eseguire una trasformazione tra questi due modelli di esecuzione e WebAssembly non ha ancora la possibilità di farlo in modo integrato.

Colmare il divario con Asyncify

È qui che entra in gioco Asyncify. Asyncify è una funzionalità in 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 grafo di chiamate
che descrive un&#39;invocazione di attività asincrona JavaScript -> WebAssembly -> API web, in cui Asyncify ricollega
il risultato dell&#39;attività asincrona a WebAssembly

Utilizzo in C / C++ con Emscripten

Se volessi utilizzare Asyncify per implementare un sonno asincrono per l'ultimo esempio, potresti farlo così:

#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 indica 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 accetti i callback. Infine, puoi chiamare async_sleep() ovunque tu voglia, proprio come sleep() normale o qualsiasi altra API sincrona.

Quando compili questo codice, devi dire a Emscripten di attivare la funzionalità Asyncify. Per farlo, devi passare -s ASYNCIFY e -s ASYNCIFY_IMPORTS=[func1, func2] con un elenco di funzioni simili ad array che potrebbero essere asincrone.

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

In questo modo, Emscripten sa che eventuali chiamate a queste funzioni potrebbero richiedere il salvataggio e il ripristino dello stato, quindi il compilatore inietta codice di supporto intorno a queste chiamate.

Ora, quando esegui questo codice nel browser, vedrai un log di output senza interruzioni, come previsto, con B che viene visualizzato dopo un breve ritardo rispetto ad A.

A
B

Puoi anche restituire valori dalle funzioni Asyncify. Devi solo restituire il risultato di handleSleep() e passarlo al callback wakeUp(). Ad esempio, se anziché 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, il 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 persino combinare Asyncify con la funzionalità async-await di JavaScript anziché utilizzare l'API basata su callback. Per farlo, anziché chiamare Asyncify.handleSleep(), chiama Asyncify.handleAsync(). Poi, anziché dover pianificare un callbackwakeUp(), puoi passare una funzione JavaScript async e utilizzare await e return al suo interno, rendendo il codice ancora più naturale e sincrono, senza perdere nessuno dei vantaggi dell'I/O asincrona.

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

Valori complessi in attesa

Tuttavia, questo esempio ti limita ancora solo ai numeri. Che cosa succede se vuoi implementare l'esempio originale, in cui ho provato a recuperare il nome di un utente da un file come stringa? Beh, puoi farlo anche tu.

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

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 è nemmeno 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 codice Rust che vuoi mappare a un'API asincrona sul web. A quanto pare, puoi fare anche questo!

Innanzitutto, devi definire una funzione di questo tipo come una normale importazione tramite il blocco extern (o la sintassi del linguaggio 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 codice in WebAssembly:

cargo build --target wasm32-unknown-unknown

Ora devi eseguire l'instrumentazione del file WebAssembly con il codice per l'archiviazione/il ripristino dello stack. Per C/C++, Emscripten lo farebbe per noi, ma non viene utilizzato qui, quindi la procedura è un po' più manuale.

Fortunatamente, la trasformazione di Asyncify è del tutto indipendente dalla catena degli strumenti. Può trasformare file WebAssembly arbitrari, indipendentemente dal compilatore che li ha generati. La trasformazione viene fornita separatamente come parte dell'ottimizzatore wasm-opt della toolchain Binaryen e può essere richiamata come segue:

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

Passa --asyncify per attivare la trasformazione, quindi utilizza --pass-arg=… per fornire un elenco di funzioni asincrone separate da virgola, in cui lo stato del programma deve essere sospeso e ripreso in un secondo momento.

Rimane solo per fornire il codice di runtime di supporto che effettivamente lo farà: sospendere e riprendere il codice WebAssembly. Anche in questo caso, nel caso C / C++ questo sarebbe incluso da Emscripten, ma ora hai bisogno di un codice glue JavaScript personalizzato che possa gestire file WebAssembly arbitrari. Abbiamo creato una libreria apposita.

Puoi trovarlo su GitHub all'indirizzo https://github.com/GoogleChromeLabs/asyncify o su npm con il nome asyncify-wasm.

Simula un'API di istanza WebAssembly standard, ma nel proprio spazio dei nomi. L'unica differenza è che, in una normale API WebAssembly, 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 provato a chiamare una funzione asincrona di questo tipo, come get_answer() nell'esempio precedente, dal lato WebAssembly, la libreria rileverà il valore Promise restituito, sospenderà e salva lo stato dell'applicazione WebAssembly, sottoscriverà il completamento della promessa e, una volta risolta, ripristina senza problemi lo stack e lo stato delle chiamate e continua l'esecuzione come se non fosse successo nulla.

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

Come funziona tutto questo?

Quando Asyncify rileva una chiamata a una delle funzioni ASYNCIFY_IMPORTS, avvia un'operazione asincrona, salva l'intero stato dell'applicazione, incluso lo stack delle chiamate e eventuali variabili locali temporanee e, in seguito, al termine dell'operazione, ripristina tutta la memoria e lo stack delle chiamate e riprende dallo stesso punto e con lo stesso stato come se il programma non si fosse mai interrotto.

È molto simile alla funzionalità asincrona di attesa in JavaScript che ho mostrato prima, ma, a differenza di quella JavaScript, non richiede alcuna sintassi speciale o un supporto del runtime dal linguaggio. Funziona invece trasformando le semplici funzioni sincrone in fase di compilazione.

Quando compili l'esempio di sonno asincrono mostrato in precedenza:

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

Asyncify prende questo codice e lo trasforma in modo approssimativo in quello seguente (pseudocodice, la trasformazione reale è più complessa):

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 viene eseguito questo codice trasformato, verrà valutata solo la parte che precede async_sleep(). Non appena l'operazione asincrona viene pianificata, Asyncify salva tutte le variabili locali e srotola lo stack tornando da ogni funzione fino in cima, restituendo così il controllo al loop di eventi del browser.

Poi, una volta risolto async_sleep(), il codice di supporto di Asyncify cambierà mode in REWINDING e chiamerà di nuovo la funzione. Questa volta, il ramo "Esecuzione normale" viene saltato, poiché lo ha già fatto la volta scorsa e voglio evitare di stampare "A" due volte, e passa direttamente al ramo "Risvoltamento". Una volta raggiunto, ripristina tutti i locali memorizzati, ripristina la modalità "normale" e continua l'esecuzione come se il codice non fosse mai stato interrotto.

Costi di trasformazione

Purtroppo, la trasformazione Asyncify non è del tutto senza costi, dato che deve inserire una notevole quantità di codice di supporto per l'archiviazione e il ripristino di tutti gli utenti locali, spostandosi nello stack di chiamate in modalità diverse e così via. Cerca di modificare solo le funzioni contrassegnate come asincrone sulla riga di comando, nonché i relativi potenziali chiamanti, ma l'overhead delle dimensioni del codice potrebbe comunque aumentare fino a circa il 50% prima della compressione.

Un grafico che mostra il sovraccarico
delle dimensioni del codice per vari benchmark, da quasi lo 0% in condizioni di ottimizzazione fine a oltre il 100% nei casi peggiori

Non è l'ideale, ma in molti casi è accettabile quando l'alternativa è non avere affatto la funzionalità o dover apportare riscritture significative al codice originale.

Assicurati di attivare sempre le ottimizzazioni per le build finali per evitare che aumentino ulteriormente. Puoi anche controllare le opzioni di ottimizzazione specifiche di Asyncify per ridurre il sovraccarico limitando le trasformazioni solo a funzioni specificate e/o solo a chiamate di funzioni dirette. Inoltre, il rendimento in fase di esecuzione è leggermente inferiore, ma è limitato alle chiamate asincrone stesse. Tuttavia, rispetto al costo del lavoro effettivo, di solito è trascurabile.

Demo reali

Ora che hai esaminato gli esempi semplici, passeremo a scenari più complicati.

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

D'altra parte, esiste uno standard de facto chiamato WASI per l'I/O di WebAssembly nella console e lato server. È stato progettato come target di compilazione per le lingue di sistema ed espone tutti i tipi di operazioni del file system e di altro tipo in una forma sincrona tradizionale.

E se potessi mappare uno all'altro? Quindi puoi compilare qualsiasi applicazione in qualsiasi linguaggio di origine con qualsiasi toolchain che supporti la destinazione WASI ed eseguirla in una sandbox sul web, pur consentendole di funzionare su file degli utenti reali. Con Asyncify puoi farlo.

In questa demo ho compilato il crate coreutils di Rust con alcune piccole patch a WASI, trasmesse tramite la trasformazione Asyncify e implementato collegamenti asincroni da WASI all'API File System Access lato JavaScript. Se combinato con il componente del terminale Xterm.js, fornisce una shell realistica in esecuzione nella scheda del browser e che opera su file utente reali, proprio come un terminale reale.

Dai un'occhiata in tempo reale all'indirizzo https://wasi.rreverser.com/.

I casi d'uso di Asyncify non si limitano solo a timer e file system. Puoi fare di più e utilizzare API più di nicchia sul web.

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

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

Probabilmente è una storia per un altro post del blog.

Questi esempi dimostrano quanto sia potente Asyncify per colmare il divario e eseguire il porting di ogni tipo di applicazione sul web, consentendoti di ottenere accesso multipiattaforma, sandboxing e una maggiore sicurezza, il tutto senza perdere funzionalità.