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. 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 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ê 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 ocorre por meio de algumas funções comumente chamadas de funções de entrada/saída, também conhecidas como E/S.

Para ler o nome de C, você precisa de pelo menos duas chamadas de E/S essenciais: fopen, para abrir o arquivo, e fread para ler 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 em uma unidade local, o aplicativo precisará executar uma série de acessos à memória e ao disco para localizar o arquivo, verificar permissões, abri-lo para leitura e fazer a leitura 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 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. Basta fazer uma chamada e aguardar o resultado de forma síncrona, 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 convertida? Ele precisa ler dados de algum armazenamento.

Modelo assíncrono da Web

A Web tem várias opções diferentes de armazenamento que podem ser mapeadas, como armazenamento na memória (objetos JS), localStorage, IndexedDB, armazenamento no lado do 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. Ela precisa competir com outras tarefas importantes, como layout, renderização e processamento de eventos pelo tempo de CPU. Você não quer que um pedaço de JavaScript ou WebAssembly possa iniciar uma operação de "leitura de arquivo" e bloquear todo o restante (a guia inteira ou, no passado, todo o navegador) por um intervalo de milissegundos a alguns segundos, até terminar.

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 uma espécie de 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 é 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 se tornará uma dessas tarefas e será executado.

Por exemplo, se você quisesse reescrever os exemplos acima em JavaScript moderno e decidiu 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);
}

Mesmo que pareça síncrono, nos bastidores, cada await é essencialmente uma facilidade de sintaxe 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, quando fetch recuperar todo o conteúdo, ele invocará o último callback, que vai imprimir "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 dos sistemas 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 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 que quiser, como a 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 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 retomar quando o corpo da resposta for recuperado. Tudo isso sem interrupções, 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 promessa, como fetch(), é possível até 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 E/S 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 conseguir o nome de um usuário de um arquivo como uma string? Bem, 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.

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 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 o 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 Asyncify é completamente independente do conjunto de ferramentas. 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 do conjunto de ferramentas 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 agrupador JavaScript personalizado que processe arquivos WebAssembly arbitrários. 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();

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 do WebAssembly, se inscreve na conclusão da promessa e, depois que ela é resolvida, restaura a pilha de chamadas e o estado e continua 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 nos bastidores?

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 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 quero evitar imprimir "A" duas vezes. Em vez disso, ela vai diretamente para a ramificação "rebobinamento". Ao ser alcançado, ele restaura todos os locais armazenados, muda o modo de volta para "normal" e continua a execução como se o código nunca tivesse sido interrompido.

Custos de transformação

Infelizmente, a transformação Asyncify não é completamente sem custo financeiro, já que ela precisa injetar bastante código de suporte para armazenar e restaurar todos esses locais, navegando pela pilha de chamadas em diferentes modos 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 é 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 File System Access assíncrona. Ele fornece acesso a um sistema de arquivos de host real a partir de 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? Em seguida, você pode compilar qualquer aplicativo em qualquer linguagem 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 opere 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 patches menores para o WASI, transmitidos pela transformação Asyncify e implementamos vinculações assíncronas do WASI à API File System Access no lado do JavaScript. Quando combinado com o componente de terminal Xterm.js, ele cria um shell realista em execução na guia do navegador e operando em arquivos de usuários reais, como um terminal real.

Confira ao vivo 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
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.