Como usar APIs Web assíncronas do WebAssembly

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. Nesta postagem, você aprenderá quando e como usar o Asyncify e como ele funciona em segundo plano.

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 a mensagem "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;
}

Embora o exemplo não faça muito, ele já demonstra algo que você encontrará em um aplicativo de qualquer tamanho: ele lê algumas entradas do mundo externo, as processa internamente e grava as saídas de volta para o 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 de bytes solicitado 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 estará 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. Você apenas faz uma chamada e espera de forma síncrona que ela retorne o resultado. Enquanto isso, ela realiza 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 uma dessas amostras no WebAssembly e convertê-las para a Web? Ou, para fornecer um exemplo específico, no que a operação de "leitura de arquivo" poderia se traduzir? 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 em 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 tratamento de eventos para o tempo da CPU. Não é recomendável 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ó tem permissão para programar uma operação de E/S com um callback a ser executado após a conclusão. Esses retornos de chamada são executados como parte do loop de eventos do navegador. Não entrarei em detalhes aqui, mas se você estiver interessado em saber como funciona o loop de eventos nos bastidores, confira Tarefas, microtarefas, filas e programações, que explica esse tópico em detalhes.

Na versão abreviada, o navegador executa todas as partes do código em uma espécie de loop infinito, extraindo-as da fila uma a 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 é lembrar 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 esse callback de forma assíncrona. O callback começa a ler o corpo como texto usando response.text() e se inscreve no resultado com outro callback. Por fim, depois que fetch recupera todo o conteúdo, ele invoca o último callback, que mostra "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 último exemplo, até mesmo APIs simples, como "sleep", que faz um aplicativo aguardar 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.

Preenchendo lacunas com o Asyncify

É aí que entra o Asyncify. O Asyncify é um recurso de tempo de compilação compatível com o Emscripten que permite pausar todo o programa e retomá-lo de forma assíncrona mais tarde.

Um gráfico de chamadas
que descreve uma invocação de tarefa assíncrona JavaScript -> WebAssembly -> API da Web ->, em que o Asyncify conecta
o resultado da tarefa assíncrona de volta à WebAssembly.

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. É necessário retornar o resultado de handleSleep() e transmiti-lo ao 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 Promises 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ê não precisa transmitir ASYNCIFY_IMPORTS como uma flag de compilação, porque ela já está incluída por padrão.

Certo, tudo isso funciona muito bem no Emscripten. E os outros conjuntos 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 o 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 nenhum toolchain. Ela pode transformar arquivos WebAssembly arbitrários, independentemente do compilador em que é produzido. 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.

Agora, só falta fornecer um código de execução que faça 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.

Ele está disponível no GitHub em https://github.com/GoogleChromeLabs/asyncify ou no npm com o nome asyncify-wasm.

Ele simula uma API de instanciação da 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();

Depois de tentar chamar uma função assíncrona (como get_answer() no exemplo acima) do lado do WebAssembly, a biblioteca detectará o Promise retornado, suspenderá e salvará o estado do aplicativo WebAssembly, assinará a conclusão da promessa e, depois, restaurar sem interrupções a pilha e o estado de chamadas e continuar 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 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 ao mesmo lugar e com o mesmo estado, como se o programa nunca tivesse parado.

Isso é bastante semelhante ao recurso de espera assíncrona 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 recebe esse código e o transforma de forma semelhante ao seguinte (pseudocódigo, a transformação real é mais envolvida 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 mudará mode para REWINDING e chamará a função novamente. Desta vez, a ramificação de "execução normal" é ignorada, já que ela já fez o job da última vez e quero evitar a exibição de "A" duas vezes. Em vez disso, ela vai direto para a ramificação "retrocedendo". Quando esse ponto é alcançado, todos os locais armazenados são restaurados, o modo é alterado de volta para "normal" e a execução é continuada 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.

Um gráfico que mostra o overhead
do tamanho do código para vários comparativos de mercado, de quase 0% em condições otimizadas a mais de 100% nos piores
casos

Isso não é o ideal, mas, em muitos casos, é aceitável quando a alternativa é não ter todas as funcionalidades ou ter que fazer substituições significativas 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, existe um padrão real 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? Assim, seria possível compilar qualquer aplicativo em qualquer linguagem de origem, com qualquer conjunto de ferramentas compatível com o destino WASI, e executá-lo em um sandbox na Web, permitindo que ele funcionasse em arquivos de usuários reais. Com o Asyncify, você pode fazer exatamente isso.

Nesta demonstração, compilei a caixa coreutils do Rust com alguns patches secundários para o WASI, transmitido pela transformação do Asyncify e implementei vinculações assíncronas do WASI para a API File System Access no lado do JavaScript. Quando combinado com o componente de terminal Xterm.js, ele fornece um shell realista executado na guia 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 APIs mais específicas 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 mapeados e compilados, consegui executar testes e exemplos padrão do libusb nos dispositivos escolhidos diretamente no sandbox de uma página da Web.

Captura de tela da saída de depuração do libusb
em uma página da Web, mostrando informações sobre a câmera Canon conectada

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.