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 compilando il codice in WebAssembly, devi collegare un tipo di API a un altro, e questo Asinc. In questo post, scoprirai quando e come utilizzare Asyncify e come funziona in background.

I/O nelle lingue di sistema

Iniziamo con un semplice esempio in Do. Supponiamo di voler leggere il nome dell'utente da un file e di salutare con il messaggio "Ciao, (nome utente)!". messaggio:

#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 sia molto utile, dimostra già qualcosa che si trova in un'applicazione di qualsiasi dimensione: legge alcuni input dal mondo esterno, li elabora internamente e scrive al mondo esterno. Tutte queste interazioni con il mondo esterno avvengono attraverso alcune comunemente chiamate funzioni input-output, 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 leggerne i dati. Dopo aver recuperato i dati, puoi utilizzare un'altra funzione I/O printf per stampare il risultato sulla console.

Queste funzioni sono molto semplici a prima vista e non è necessario preoccuparsi necessari per leggere o scrivere dati. Tuttavia, a seconda dell'ambiente, ci possono essere c'è molto da fare:

  • Se il file di input si trova su un'unità locale, l'applicazione deve eseguire una serie accessi a memoria e disco per individuare il file, controllare le autorizzazioni, aprirlo per la lettura e quindi legge blocco per blocco fino a quando non viene recuperato il numero di byte richiesto. Questa operazione può essere piuttosto lenta, a seconda della velocità del disco e delle dimensioni richieste.
  • Oppure, il file di input potrebbe trovarsi in un percorso di rete montato; in questo caso, la rete lo stack sarà coinvolto anche in questo momento, con un conseguente aumento della complessità, della latenza e del numero per ogni operazione.
  • Infine, non è garantito che anche printf stampi elementi sulla console e potrebbe essere reindirizzato a un file o a una posizione di rete, nel qual caso si dovrà seguire la stessa procedura descritta sopra.

Per farla breve, l'I/O può essere lento e non è possibile prevedere quanto tempo richieda una rapida occhiata al codice. Mentre l'operazione è in esecuzione, l'intera applicazione apparirà bloccata e che non risponde all'utente.

Non si limita a C o C++. La maggior parte delle lingue di sistema presenta l'I/O sotto forma di API sincrone. Se ad esempio traduci l'esempio in Rust, l'API potrebbe sembrare più semplice, ma si applicano gli stessi principi. Basta effettuare una chiamata e attendere in modo sincrono che restituisca il risultato eseguendo tutte le operazioni più costose e alla fine restituisce il risultato chiamata:

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

Ma cosa succede quando provi a compilare uno qualsiasi di questi esempi in WebAssembly e a tradurli in sul web? Oppure, per fornire un esempio specifico, cosa potrebbe essere "file letto" in cui eseguire l'operazione di traduzione? Dovrebbe per leggere i dati contenuti nello spazio di archiviazione.

Modello asincrono del web

Il web ha una varietà di opzioni di archiviazione diverse che puoi mappare, ad esempio l'archiviazione in memoria (JS oggetti), 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 è possibile archiviare e per quanto tempo. Tutti 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 qualsiasi I/O, deve essere asincrono.

Il motivo è che storicamente il web è a thread unico e qualsiasi codice utente che tocca l'interfaccia utente deve essere eseguito sullo stesso thread della UI. Deve competere con altre attività importanti come layout, rendering e gestione degli eventi per il tempo di CPU. Non è consigliabile aggiungere WebAssembly per poter avviare una "lettura file" operativa e bloccare tutto il resto: l'intera scheda oppure, in passato, tutto il browser, per un intervallo che va da millisecondi a pochi secondi, fino alla scadenza.

Al contrario, il codice può solo pianificare un'operazione di I/O insieme a un callback da eseguire una volta finito. Questi callback vengono eseguiti come parte del loop di eventi del browser. Non lo farò ma se ti interessa scoprire come funziona di più il ciclo di eventi, fare il check-out Attività, microattività, code e pianificazioni che spiega in dettaglio l'argomento.

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

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

Ad esempio, se volessi riscrivere gli esempi precedenti nel codice JavaScript moderno e decidi di leggere una nome da un URL remoto, dovresti usare 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 è essenzialmente lo zucchero della sintassi per di 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. Quando il browser riceve la risposta iniziale, solo il prompt : richiama in modo asincrono questo callback. Il callback inizia a leggere il corpo come testo utilizzando response.text() e si registra al risultato con un altro callback. Infine, una volta che fetch ha recupera tutti i contenuti, richiama l'ultimo callback, che stampa "Hello, (username)!". alle Google Cloud.

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

Come ultimo esempio, anche API semplici come "sleep", che fanno sì che un'applicazione attenda un di secondi, sono anche 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 attuale fino alla scadenza:

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

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

Una versione più idiomatica del termine "sonno" in JavaScript comporterebbe la chiamata a setTimeout() e tramite un gestore:

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

Cosa c'è in comune a tutti questi esempi e API? In ogni caso, il codice idiomatico nel codice originale utilizza un'API di blocco per l'I/O, mentre un esempio equivalente per il web utilizza un'API asincrona. Quando li compili sul web, devi in qualche modo trasformare questi due aspetti di esecuzione e WebAssembly non dispone ancora di funzionalità integrate per farlo.

Colmare il divario con Asyncify

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

Grafico delle chiamate
che descrive un linguaggio JavaScript -> WebAssembly -> API web -> chiamata di attività asincrona, dove Asyncify si connette
il risultato dell&#39;attività asincrona di nuovo in WebAssembly.

Utilizzo in C / C++ con Emscripten

Se volessi utilizzare Asyncify per implementare una modalità di sospensione asincrona nell'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 è un che permette di definire 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 dovrebbe essere una volta terminata l'operazione asincrona. Nell'esempio precedente, il gestore viene passato setTimeout(), ma potrebbe essere utilizzato in qualsiasi altro contesto che accetta i callback. Infine, puoi chiama async_sleep() ovunque tu voglia, proprio come faresti con sleep() o qualsiasi altra API sincrona.

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

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

Questo consente a Emscripten di sapere che qualsiasi chiamata a queste funzioni potrebbe richiedere il salvataggio e il ripristino quindi il compilatore inserisce il codice di supporto attorno a queste chiamate.

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

A
B

Puoi restituire valori da Asincronizzare anche le funzioni. Cosa devi restituire il risultato di handleSleep() e passarlo a wakeUp() di Google. Ad esempio, se invece di leggere da un file vuoi recuperare un numero da un telecomando, puoi utilizzare uno snippet come quello riportato di seguito per inviare una richiesta, sospendere il codice C e riprendi 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 anche combinare Asyncify con JavaScript async-await invece di utilizzare l'API basata su callback. Per questo, invece di Asyncify.handleSleep(), chiama Asyncify.handleAsync(). Poi, invece di dover pianificare una wakeUp(), puoi passare una funzione JavaScript async e usare await e return al suo interno, rendendo il codice ancora più naturale e sincrono, senza rinunciare ai vantaggi della l'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

Ma questo esempio si limita ancora ai numeri. E se volessi implementare l'originale Ad esempio, dove ho cercato di ottenere il nome di un utente da un file come stringa? Puoi fare anche questo!

Emscripten offre una funzionalità chiamata Embind che 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 in attesa Codice JavaScript:

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 devi nemmeno passare ASYNCIFY_IMPORTS come flag di compilazione, sono già inclusi per impostazione predefinita.

Ok, quindi con Emscripten è tutto perfetto. E per quanto riguarda le altre catene di strumenti e i linguaggi?

Utilizzo da altre lingue

Supponi di avere una chiamata sincrona simile in qualche punto del codice Rust che vuoi mappare a un l'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 l'input la sintassi del linguaggio 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 instrumentare il file WebAssembly con il codice per archiviare/ripristinare lo stack. Per C / C++, Emscripten lo fa per noi, ma non viene usato qui, quindi il processo è un po' più manuale.

Fortunatamente, la trasformazione di Asyncify è del tutto indipendente dalla catena degli strumenti. Può trasformare arbitrarie File WebAssembly, indipendentemente dal compilatore da cui viene prodotto. La trasformazione è fornita separatamente nell'ambito dello strumento per l'ottimizzazione di wasm-opt di Binaryen toolchain e può essere richiamato in questo modo:

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

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

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

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

Simula un'istanza WebAssembly standard API, ma all'interno del proprio spazio dei nomi. L'unico differenza è che, in una normale API WebAssembly puoi fornire solo funzioni sincrone mentre nel 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, da lato WebAssembly, la libreria rileverà il valore Promise restituito, sospenderà e salva lo stato di l'applicazione WebAssembly, sottoscrivi la promessa e, una volta risolta, in un secondo momento ripristinare senza problemi lo stato e lo stack delle chiamate e continuare l'esecuzione come se non fosse successo nulla.

Poiché qualsiasi funzione nel modulo potrebbe effettuare una chiamata asincrona, tutte le esportazioni diventano potenzialmente asincroni, quindi anche questi vengono aggregati. Potresti aver notato nell'esempio precedente che devi await il risultato di instance.exports.main() per sapere quando l'esecuzione è davvero completato.

Come funziona tutto ciò?

Quando Asyncify rileva una chiamata a una delle funzioni ASYNCIFY_IMPORTS, avvia un processo dell'applicazione, salva l'intero stato dell'applicazione, compresi lo stack di chiamate e le locali e, in seguito, al termine dell'operazione, ripristina tutta la memoria e lo stack di chiamate riprende dalla stessa posizione e con lo stesso stato in cui il programma non è mai stato interrotto.

È molto simile alla funzione asincrona di attesa in JavaScript che ho mostrato prima, ma a differenza della JavaScript non richiede una sintassi speciale o un supporto di runtime dal linguaggio. trasformando le semplici funzioni sincrone al momento della 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 un modo più o meno simile al seguente (pseudo-codice, è più coinvolto di così):

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

Inizialmente il valore di mode è impostato su NORMAL_EXECUTION. Di conseguenza, la prima volta che un tale codice , verrà valutata solo la parte che porta a async_sleep(). Non appena il parametro viene pianificata l'operazione asincrona, Asyncify salva tutti gli utenti locali e annulla lo stack ritornando da ogni funzione fino all'inizio, in questo modo per restituire il controllo al browser loop di eventi.

Una volta risolto il problema async_sleep(), il codice di assistenza di Asyncify cambierà mode in REWINDING e richiama nuovamente la funzione. Questa volta, l'"esecuzione normale" ramo è ignorato, dato che lo ha già fatto l'ultima volta e non voglio stampare la lettera "A" due volte. Si arriva direttamente "riavvolgimento" ramo. Una volta raggiunto, ripristina tutti i dati locali archiviati e reimposta la modalità su "normale" e continua l'esecuzione come se il codice non fosse mai stato arrestato.

Costi di trasformazione

Purtroppo, la trasformazione Asyncify non è del tutto senza costi, dato che deve inserire un bel po' codice di supporto per l'archiviazione e il ripristino di tutti gli utenti locali, esplorando lo stack di chiamate diverse modalità e così via. Prova a modificare solo le funzioni contrassegnate come asincrone nel comando così come i potenziali chiamanti, ma l'overhead delle dimensioni del codice potrebbe comunque corrispondere a circa il 50% prima della compressione.

Grafico che mostra il codice
dimensioni overhead per vari benchmark, da quasi lo 0% in condizioni ottimizzate a oltre il 100% nel peggiore
casi

Non si tratta dell'ideale, ma in molti casi è accettabile quando l'alternativa non è avere la funzionalità completamente o dover apportare importanti riscritture al codice originale.

Assicurati di attivare sempre le ottimizzazioni per le build finali per evitare che aumentino ulteriormente. Puoi consulta anche l'articolo sull'ottimizzazione specifica di Asyncify opzioni per ridurre l'overhead limitando le trasformazioni solo a funzioni specificate e/o solo chiamate di funzione dirette. C'è anche un piccolo rispetto alle prestazioni di runtime, ma è limitato alle chiamate asincrone stesse. Tuttavia, rispetto del lavoro effettivo, di solito è trascurabile.

Demo reali

Dopo aver esaminato questi semplici esempi, passiamo a scenari più complicati.

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

Esiste invece uno standard di fatto chiamato WASI per WebAssembly I/O nella console e sul lato server. È stata progettata come destinazione di compilazione linguaggi di sistema ed espone tutti i tipi di file system e altre operazioni in un sincrona.

E se potessi eseguirne la mappatura l'uno all'altro? Poi puoi compilare qualsiasi applicazione in qualsiasi linguaggio di origine con qualsiasi toolchain che supporti il target WASI ed eseguirlo in una sandbox sul Web, consentendogli di operare su file reali degli utenti. Con Asyncify puoi farlo.

In questa demo ho compilato la cassa Rust coreutils con un poche patch minori a WASI, passate tramite la trasformazione Asyncify e implementate in modo asincrono bindings di WASI all'API File System Access sul lato JavaScript. Una volta combinata con Xterm.js, che fornisce una shell realistica in esecuzione una scheda del browser e operare su file di utenti reali, proprio come un terminale reale.

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

I casi d'uso di sincronizzazione non si limitano nemmeno a timer e file system. Puoi andare oltre e utilizzare più 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 Dispositivi USB: a un'API WebUSB, che fornisce accesso asincrono a questi dispositivi sul web. Una volta mappati e compilati, ho ottenuto dei test libusb standard e degli esempi da eseguire rispetto a quelli direttamente nella sandbox di una pagina web.

Screenshot di libusb
output di debug su una pagina web, che mostra informazioni sulla fotocamera Canon collegata

Probabilmente si tratta di una storia per un altro post del blog.

Questi esempi dimostrano l'efficacia di Asyncify per colmare le lacune e trasferire tutti diversi tipi di applicazioni sul web, consentendoti di avere accesso multipiattaforma, limitazione tramite sandbox e sicurezza, il tutto senza perdere funzionalità.