As APIs de E/S na Web são assíncronas, mas são síncronas na maioria dos idiomas do sistema. Ao compilar o código para o WebAssembly, é necessário fazer a ponte entre um tipo de API e outro, e essa ponte é o Asyncify. Neste post, você vai aprender quando e como usar o Asyncify e como ele funciona nos bastidores.
E/S em idiomas do sistema
Vou começar com um exemplo simples em C. Digamos que você queira ler o nome do usuário em um arquivo e cumprimentá-lo com uma mensagem "Olá, (nome de usuário)":
#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;
}
Embora o exemplo não faça muito, ele já demonstra algo que você vai encontrar em um aplicativo de qualquer tamanho: ele lê algumas entradas do mundo externo, as processa internamente e grava as saídas de volta ao mundo externo. Toda essa interação com o mundo externo acontece por meio de algumas funções comumente chamadas de funções de entrada/saída, também abreviadas para E/S.
Para ler o nome em C, você precisa de pelo menos duas chamadas de E/S cruciais: fopen
, para abrir o arquivo, e
fread
para ler os dados dele. Depois de extrair os dados, você pode usar outra função de E/S printf
para imprimir o resultado no console.
Essas funções parecem bastante simples à primeira vista, e você não precisa pensar duas vezes sobre a máquina envolvida para ler ou gravar dados. No entanto, dependendo do ambiente, pode haver muitas coisas acontecendo:
- Se o arquivo de entrada estiver localizado em uma unidade local, o aplicativo precisará realizar uma série de acessos à memória e ao disco para localizar o arquivo, verificar as permissões, abrir para leitura e ler bloco por bloco até que o número solicitado de bytes seja recuperado. Isso pode ser muito lento, dependendo da velocidade do disco e do tamanho solicitado.
- Ou o arquivo de entrada pode estar localizado em um local de rede montado. Nesse caso, a pilha de rede também será envolvida, aumentando a complexidade, a latência e o número de tentativas de cada operação.
- Por fim, mesmo o
printf
não tem garantia de impressão no console e pode ser redirecionado para um arquivo ou um local de rede. Nesse caso, ele precisa seguir as mesmas etapas acima.
Resumindo, a E/S pode ser lenta e você não pode prever quanto tempo uma chamada específica vai levar com uma rápida olhada no código. Enquanto essa operação estiver em execução, todo o aplicativo vai parecer congelado e não responder ao usuário.
Isso não se limita a C ou C++. A maioria das linguagens de sistema apresenta todas as E/S em uma forma de APIs síncronas. Por exemplo, se você traduzir o exemplo para Rust, a API pode parecer mais simples, mas os mesmos princípios se aplicam. Basta fazer uma chamada e aguardar de forma síncrona até que ela retorne o resultado, enquanto ela executa todas as operações caras e, por fim, retorna o resultado em uma única invocação:
fn main() {
let s = std::fs::read_to_string("name.txt");
println!("Hello, {}!", s);
}
Mas o que acontece quando você tenta compilar qualquer um desses exemplos para WebAssembly e traduzi-los para a Web? Ou, para dar um exemplo específico, em que a operação de "leitura de arquivo" poderia ser traduzida? Ele precisa ler dados de algum armazenamento.
Modelo assíncrono da Web
A Web tem várias opções de armazenamento diferentes que podem ser mapeadas, como armazenamento em memória (objetos
JS), localStorage
,
IndexedDB, armazenamento no servidor
e uma nova API File System Access.
No entanto, apenas duas dessas APIs, o armazenamento na memória e o localStorage
, podem ser usadas
simultaneamente, e ambas são as opções mais restritivas em termos do que você pode armazenar e por quanto tempo. Todas
as outras opções fornecem apenas APIs assíncronas.
Essa é uma das principais propriedades da execução de código na Web: qualquer operação que consome tempo, que inclui qualquer E/S, precisa ser assíncrona.
O motivo é que a Web é historicamente de linha única, e qualquer código do usuário que afeta a interface precisa ser executado na mesma linha de execução que a interface. Ele precisa competir com outras tarefas importantes, como layout, renderização e processamento de eventos para o tempo da CPU. Você não quer que um pedaço de JavaScript ou WebAssembly inicie uma operação de "leitura de arquivo" e bloqueie tudo o mais, como a guia inteira ou, no passado, o navegador inteiro, por um período de milissegundos a alguns segundos, até que termine.
Em vez disso, o código só pode programar uma operação de E/S com um callback para ser executado quando ela for concluída. Esses callbacks são executados como parte do loop de eventos do navegador. Não vou entrar em detalhes aqui, mas, se você quiser saber como o loop de eventos funciona, confira Tarefas, microtarefas, filas e programações, que explica esse tópico em detalhes.
Resumindo, o navegador executa todas as partes do código em um loop infinito, retirando-as da fila uma por uma. Quando algum evento é acionado, o navegador enfileira o gerenciador correspondente e, na próxima iteração do loop, ele é retirado da fila e executado. Esse mecanismo permite simular a simultaneidade e executar muitas operações paralelas usando apenas uma única linha de execução.
O importante sobre esse mecanismo é que, enquanto o código JavaScript (ou WebAssembly) personalizado é executado, o loop de eventos é bloqueado e, enquanto isso, não há como reagir a qualquer gerenciador externo, evento, E/S etc. A única maneira de receber os resultados de E/S é registrar um callback, terminar a execução do código e devolver o controle ao navegador para que ele possa continuar processando as tarefas pendentes. Quando a E/S for concluída, o gerenciador vai se tornar uma dessas tarefas e será executado.
Por exemplo, se você quisesse reescrever os exemplos acima em JavaScript moderno e decidisse ler um nome de um URL remoto, usaria a API Fetch e a sintaxe async-await:
async function main() {
let response = await fetch("name.txt");
let name = await response.text();
console.log("Hello, %s!", name);
}
Embora pareça síncrono, cada await
é essencialmente uma sintaxe mais simples para
callbacks:
function main() {
return fetch("name.txt")
.then(response => response.text())
.then(name => console.log("Hello, %s!", name));
}
Neste exemplo simplificado, que é um pouco mais claro, uma solicitação é iniciada e as respostas são registradas com o primeiro callback. Quando o navegador recebe a resposta inicial, apenas os cabeçalhos
HTTP, ele invoca assíncronamente esse callback. O callback começa a ler o corpo como texto usando
response.text()
e se inscreve no resultado com outro callback. Por fim, quando fetch
recupera todo o conteúdo, ele invoca o último callback, que imprime "Hello, (username)!" no
console.
Graças à natureza assíncrona dessas etapas, a função original pode retornar o controle ao navegador assim que a E/S for programada e deixar toda a interface responsiva e disponível para outras tarefas, incluindo renderização, rolagem e assim por diante, enquanto a E/S é executada em segundo plano.
Como exemplo final, até APIs simples, como "sleep", que fazem um aplicativo esperar um número especificado de segundos, também são uma forma de operação de E/S:
#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");
É possível traduzir de uma maneira muito simples que bloqueia a linha de execução atual até que o tempo expire:
console.log("A");
for (let start = Date.now(); Date.now() - start < 1000;);
console.log("B");
Na verdade, isso é exatamente o que o Emscripten faz na implementação padrão de "sleep", mas isso é muito ineficiente, bloqueia toda a interface e não permite que outros eventos sejam processados enquanto isso. Em geral, não faça isso no código de produção.
Em vez disso, uma versão mais idiomática de "sleep" no JavaScript envolveria chamar setTimeout()
e
se inscrever com um manipulador:
console.log("A");
setTimeout(() => {
console.log("B");
}, 1000);
O que é comum a todos esses exemplos e APIs? Em cada caso, o código idiomático no idioma original do sistema usa uma API de bloqueio para a E/S, enquanto um exemplo equivalente para a Web usa uma API assíncrona. Ao compilar para a Web, é necessário transformar de alguma forma entre esses dois modelos de execução, e o WebAssembly ainda não tem capacidade integrada para fazer isso.
Como usar o Asyncify para preencher a lacuna
É aí que entra o Asyncify. O Asyncify é um recurso de tempo de compilação com suporte do Emscripten que permite pausar todo o programa e retornar de forma assíncrona mais tarde.
Uso em C / C++ com Emscripten
Se você quisesse usar o Asyncify para implementar um sono assíncrono para o último exemplo, poderia fazer assim:
#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
é uma
macro que permite definir snippets JavaScript como se fossem funções C. Dentro, use uma função
Asyncify.handleSleep()
que informa ao Emscripten para suspender o programa e fornece um manipulador wakeUp()
que precisa ser
chamado quando a operação assíncrona for concluída. No exemplo acima, o gerenciador é transmitido para
setTimeout()
, mas pode ser usado em qualquer outro contexto que aceite callbacks. Por fim, você pode
chamar async_sleep()
em qualquer lugar, assim como sleep()
normal ou qualquer outra API síncrona.
Ao compilar esse código, você precisa informar ao Emscripten para ativar o recurso Asyncify. Para fazer isso,
transmita -s ASYNCIFY
e -s ASYNCIFY_IMPORTS=[func1,
func2]
com uma
lista semelhante a uma matriz de funções que podem ser assíncronas.
emcc -O2 \
-s ASYNCIFY \
-s ASYNCIFY_IMPORTS=[async_sleep] \
...
Isso permite que o Emscripten saiba que qualquer chamada para essas funções pode exigir o salvamento e a restauração do estado. Portanto, o compilador injeta o código de suporte em torno dessas chamadas.
Agora, quando você executar esse código no navegador, vai aparecer um registro de saída perfeito, com B aparecendo após um pequeno atraso após A.
A
B
Você também pode retornar valores de
funções do
Asyncify. O que
você precisa fazer é retornar o resultado de handleSleep()
e transmitir o resultado para o callback
wakeUp()
. Por exemplo, se, em vez de ler de um arquivo, você quiser buscar um número de um recurso
remoto, use um snippet como o abaixo para emitir uma solicitação, suspender o código C e
continuar assim que o corpo da resposta for recuperado. Tudo isso é feito de forma simples, como se a chamada fosse síncrona.
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);
Na verdade, para APIs baseadas em promessas, como fetch()
, é possível combinar o Asyncify com o recurso
de espera assíncrona do JavaScript em vez de usar a API baseada em callback. Para isso, em vez de
Asyncify.handleSleep()
, chame Asyncify.handleAsync()
. Em vez de programar um callback
wakeUp()
, você pode transmitir uma função JavaScript async
e usar await
e return
dentro, fazendo com que o código pareça ainda mais natural e síncrono, sem perder nenhum dos benefícios da
entrada/saída assíncrona.
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();
Aguardando valores complexos
Mas esse exemplo ainda limita você apenas a números. E se você quiser implementar o exemplo original, em que tentei extrair o nome de um usuário de um arquivo como uma string? Você também pode fazer isso.
O Emscripten oferece um recurso chamado
Embind, que permite
processar conversões entre valores JavaScript e C++. Ele também oferece suporte ao Asyncify, então
é possível chamar await()
em Promise
s externos, e ele vai funcionar exatamente como await
no código JavaScript
async-await:
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>();
Ao usar esse método, você nem precisa transmitir ASYNCIFY_IMPORTS
como uma flag de compilação, porque ela já
está incluída por padrão.
Tudo funciona muito bem no Emscripten. E quanto a outras cadeias de ferramentas e linguagens?
Uso de outros idiomas
Digamos que você tenha uma chamada síncrona semelhante em algum lugar do código Rust que você quer mapear para uma API assíncrona na Web. Você também pode fazer isso.
Primeiro, você precisa definir essa função como uma importação regular usando um bloco extern
(ou a sintaxe da linguagem escolhida para funções externas).
extern {
fn get_answer() -> i32;
}
println!("Getting answer...");
let answer = get_answer();
println!("Answer is {}", answer);
E compile seu código para o WebAssembly:
cargo build --target wasm32-unknown-unknown
Agora você precisa instrumentar o arquivo WebAssembly com código para armazenar/restaurar a pilha. Para C / C++, o Emscripten faria isso por nós, mas ele não é usado aqui, então o processo é um pouco mais manual.
Felizmente, a transformação do Asyncify não depende de nenhuma ferramenta. Ele pode transformar arquivos
WebAssembly arbitrários, não importa qual compilador os produz. A transformação é fornecida separadamente
como parte do otimizador wasm-opt
da ferramenta
Binaryen e pode ser invocada assim:
wasm-opt -O2 --asyncify \
--pass-arg=asyncify-imports@env.get_answer \
[...]
Transmita --asyncify
para ativar a transformação e use --pass-arg=…
para fornecer uma lista de funções assíncronas separadas por vírgulas, em que o estado do programa precisa ser suspenso e retomado mais tarde.
Tudo o que resta é fornecer suporte ao código de execução que vai fazer isso: suspender e retomar o código do WebAssembly. Novamente, no caso de C / C++, isso seria incluído pelo Emscripten, mas agora você precisa de um código de união JavaScript personalizado que processe arquivos arbitrários do WebAssembly. Criamos uma biblioteca para isso.
Você pode encontrá-lo no GitHub em
https://github.com/GoogleChromeLabs/asyncify ou no npm
com o nome asyncify-wasm
.
Ele simula uma API de instanciação de WebAssembly padrão, mas no próprio namespace. A única diferença é que, em uma API WebAssembly regular, você só pode fornecer funções síncronas como importações, enquanto no wrapper Asyncify, você também pode fornecer importações assíncronas:
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();
Quando você tenta chamar uma função assíncrona, como get_answer()
no exemplo acima,
do lado do WebAssembly, a biblioteca detecta o Promise
retornado, suspende e salva o estado do
aplicativo WebAssembly, assina a conclusão da promessa e, depois que ela é resolvida,
restaure a pilha de chamadas e o estado e continue a execução como se nada tivesse acontecido.
Como qualquer função no módulo pode fazer uma chamada assíncrona, todas as exportações também podem
ser assíncronas, então elas também são agrupadas. Você pode ter notado no exemplo acima que
é necessário await
o resultado de instance.exports.main()
para saber quando a execução realmente
terminou.
Como tudo isso funciona?
Quando o Asyncify detecta uma chamada para uma das funções ASYNCIFY_IMPORTS
, ele inicia uma operação
assíncrona, salva todo o estado do aplicativo, incluindo a pilha de chamadas e qualquer local
temporário. Depois, quando essa operação é concluída, ele restaura toda a memória e a pilha de chamadas e
retorna do mesmo lugar e com o mesmo estado, como se o programa nunca tivesse parado.
Isso é bastante semelhante ao recurso de async-await no JavaScript que mostrei anteriormente, mas, ao contrário do JavaScript, não requer nenhuma sintaxe especial ou suporte de execução da linguagem. Em vez disso, funciona transformando funções síncronas simples no momento da compilação.
Ao compilar o exemplo de suspensão assíncrona mostrado anteriormente:
puts("A");
async_sleep(1);
puts("B");
O Asyncify transforma esse código de forma semelhante ao seguinte (pseudocódigo, a transformação real é mais complexa do que isso):
if (mode == NORMAL_EXECUTION) {
puts("A");
async_sleep(1);
saveLocals();
mode = UNWINDING;
return;
}
if (mode == REWINDING) {
restoreLocals();
mode = NORMAL_EXECUTION;
}
puts("B");
Inicialmente, mode
é definido como NORMAL_EXECUTION
. Da mesma forma, na primeira vez que esse código transformado
é executado, apenas a parte que leva a async_sleep()
é avaliada. Assim que a
operação assíncrona é programada, o Asyncify salva todos os locais e desfaz a pilha
retornando de cada função até o topo, devolvendo o controle ao loop de eventos
do navegador.
Em seguida, quando async_sleep()
for resolvido, o código de suporte do Asyncify vai mudar mode
para REWINDING
e
chamar a função novamente. Desta vez, a ramificação "execução normal" é ignorada, já que ela já fez
o trabalho da última vez e eu quero evitar imprimir "A" duas vezes. Em vez disso, ela vai diretamente para a
ramificação "rebobinamento". Quando ele é alcançado, todos os locais armazenados são restaurados, o modo é alterado de volta para
"normal" e a execução continua como se o código nunca tivesse sido interrompido.
Custos de transformação
Infelizmente, a transformação do Asyncify não é totalmente sem custo financeiro, já que precisa injetar bastante código de suporte para armazenar e restaurar todos os locais, navegando pela pilha de chamadas em modos diferentes e assim por diante. Ele tenta modificar apenas as funções marcadas como assíncronas na linha de comando, bem como qualquer um dos possíveis autores da chamada, mas o overhead do tamanho do código ainda pode chegar a aproximadamente 50% antes da compactação.
Isso não é ideal, mas em muitos casos é aceitável quando a alternativa é não ter a funcionalidade ou ter que fazer reescritos significativos no código original.
Sempre ative as otimizações para os builds finais para evitar que o número aumente ainda mais. Você também pode conferir as opções de otimização específicas do Asyncify para reduzir a sobrecarga, limitando as transformações apenas a funções especificadas e/ou apenas a chamadas de função diretas. Há também um pequeno custo para o desempenho do tempo de execução, mas ele é limitado às próprias chamadas assíncronas. No entanto, em comparação com o custo do trabalho real, ele geralmente é insignificante.
Demonstrações reais
Agora que você analisou os exemplos simples, vamos passar para cenários mais complicados.
Como mencionado no início do artigo, uma das opções de armazenamento na Web é uma API de acesso ao sistema de arquivos assíncrona. Ele fornece acesso a um sistema de arquivos de host real em um aplicativo da Web.
Por outro lado, há um padrão de fato chamado WASI para E/S do WebAssembly no console e no lado do servidor. Ele foi projetado como um destino de compilação para línguas do sistema e expõe todos os tipos de sistema de arquivos e outras operações de uma forma síncrona tradicional.
E se você pudesse mapear um para o outro? Em seguida, você pode compilar qualquer aplicativo em qualquer idioma de origem com qualquer conjunto de ferramentas que ofereça suporte ao destino WASI e executá-lo em um sandbox na Web, permitindo que ele funcione em arquivos de usuários reais. Com o Asyncify, você pode fazer exatamente isso.
Nesta demonstração, compilei o pacote coreutils do Rust com alguns pequenos patches para o WASI, transmitidos pela transformação Asyncify e implementamos vinculações assíncronas do WASI à API File System Access do lado do JavaScript. Quando combinado com o componente de terminal Xterm.js, ele fornece um shell realista executado na aba do navegador e operando em arquivos de usuários reais, assim como um terminal real.
Confira em ação em https://wasi.rreverser.com/.
Os casos de uso do Asyncify não se limitam a timers e sistemas de arquivos. Você pode ir além e usar mais APIs de nicho na Web.
Por exemplo, com a ajuda do Asyncify, é possível mapear libusb, provavelmente a biblioteca nativa mais conhecida para trabalhar com dispositivos USB, para uma API WebUSB, que oferece acesso assíncrono a esses dispositivos na Web. Depois de mapear e compilar, consegui executar testes e exemplos padrão do libusb nos dispositivos escolhidos diretamente no sandbox de uma página da Web.
Mas provavelmente é uma história para outra postagem no blog.
Esses exemplos demonstram o poder do Asyncify para preencher a lacuna e portar todos os tipos de aplicativos para a Web, permitindo que você tenha acesso entre plataformas, sandbox e melhor segurança, tudo sem perder a funcionalidade.