Como usar APIs Web assíncronas do WebAssembly

As APIs de E/S na Web são assíncronas, mas síncronas na maioria das linguagens do sistema. Ao compilar o código para o WebAssembly, você precisa transmitir um tipo de API a outro, e essa ponte é Asyncify. Nesta postagem, você vai aprender quando e como usar o Asyncify e como ele funciona nos bastidores.

E/S nos idiomas do sistema

Vou começar com um exemplo simples em C. Digamos que você queira ler o nome de um usuário em um arquivo e recebê-lo com uma 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 para o mundo externo. Toda essa interação com o mundo externo acontece por meio de algumas funções de entrada e 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 recuperar os dados, use outra função de E/S printf para mostrar o resultado no console.

Essas funções parecem bastante simples à primeira vista e você não precisa pensar duas vezes sobre as máquinas envolvidas para ler ou gravar dados. No entanto, dependendo do ambiente, pode haver muita coisa acontecendo lá dentro:

  • 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 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.
  • O arquivo de entrada pode estar 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 possíveis tentativas para cada operação.
  • Por fim, não há garantia de que printf exibirá itens no console e poderá ser redirecionado para um arquivo ou um local de rede. Nesse caso, ele teria que seguir as mesmas etapas acima.

Resumindo, a E/S pode ser lenta e não é possível prever quanto tempo uma chamada específica levará se você olhar rapidamente o código. Enquanto essa operação está em execução, o aplicativo inteiro parecerá congelado e não responderá ao usuário.

Isso também não se limita a C ou C++. A maioria das linguagens do sistema apresenta todas as E/S em uma forma de APIs síncronas. Por exemplo, se você traduzir o exemplo para o Rust, a API poderá 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 ela executa todas as operações caras e 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 para o WebAssembly e traduzi-las para a Web? Ou, para fornecer um exemplo específico, em que uma operação de "leitura de arquivo" poderia ser traduzida? Ele precisaria 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 na memória (objetos JS), localStorage, IndexedDB, armazenamento do lado do servidor e uma nova API File System Access.

No entanto, apenas duas dessas APIs, o armazenamento na memória e a localStorage, podem ser usadas de forma síncrona, e ambas são as opções mais limitadas quanto ao que é possível armazenar e por quanto tempo. Todas as outras opções oferecem apenas APIs assíncronas.

Essa é uma das principais propriedades da execução de código na Web: qualquer operação demorada, que inclui qualquer E/S, precisa ser assíncrona.

O motivo é que, historicamente, a Web tem uma linha de execução única, e qualquer código de usuário que toque na interface precisa ser executado na mesma linha de execução que ela. Ela precisa competir com outras tarefas importantes, como layout, renderização e processamento de eventos, pelo tempo de CPU. Você não gostaria que um fragmento de JavaScript ou WebAssembly pudesse iniciar uma operação de "leitura de arquivo" e bloquear todo o restante (a guia inteira ou, antes, o navegador inteiro) por um período de milissegundos a alguns segundos, até acabar.

Em vez disso, o código só tem permissão para programar uma operação de E/S com um callback para ser executado depois da conclusão. Essas chamadas de retorno são executadas 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 nos bastidores, confira Tarefas, microtarefas, filas e programações, que explica esse tópico em mais detalhes.

A versão abreviada é que o navegador executa todas as partes do código em uma espécie de loop infinito, tirando-as da fila uma a uma. Quando algum evento é acionado, o navegador coloca o gerenciador correspondente na fila e, na próxima iteração de loop, ele é retirado da fila e executado. Esse mecanismo permite simular a simultaneidade e executar muitas operações paralelas usando apenas uma linha de execução.

O importante a se lembrar sobre esse mecanismo é que, enquanto o código JavaScript personalizado (ou WebAssembly) é executado, o loop de eventos é bloqueado e, embora esteja, não há como reagir a manipuladores externos, eventos, E/S etc. A única maneira de recuperar os resultados de E/S é registrar um callback, terminar de executar o código e devolver o controle ao navegador para que ele continue processando as tarefas pendentes. Quando a E/S for concluída, seu gerenciador se tornará uma dessas tarefas e será executado.

Por exemplo, se você quiser reescrever as amostras acima em JavaScript moderno e decidir ler um nome de um URL remoto, use 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, internamente cada await é essencialmente açúcar de sintaxe para callbacks:

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

Neste exemplo de simplificação, que é um pouco mais claro, uma solicitação é iniciada e as respostas são inscritas no primeiro callback. Depois que 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 de recuperar todo o conteúdo, fetch 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 estiver em execução em segundo plano.

Por 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 fazer a tradução de uma maneira muito direta para bloquear 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 é muito ineficiente, bloqueia toda a interface e não permite que nenhum outro evento seja processado. Geralmente, não faça isso no código de produção.

Em vez disso, uma versão mais idiomática de "sleep" em JavaScript envolveria chamar setTimeout() e assinar com um gerenciador:

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 na linguagem 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, você precisa transformar esses dois modelos de execução, e o WebAssembly ainda não tem recursos integrados para fazer isso.

Uma ponte com o Asyncify

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

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

Uso em C / C++ com Emscripten

Se você quiser usar o Asyncify para implementar uma suspensão assíncrona no último exemplo, faça o seguinte:

#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 dela, use uma função Asyncify.handleSleep() que instrui o Emscripten a suspender o programa e fornece um gerenciador wakeUp() que será chamado assim que 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, é possível chamar async_sleep() em qualquer lugar que você quiser, como a sleep() normal ou qualquer outra API síncrona.

Ao compilar esse código, você precisa pedir ao Emscripten para ativar o recurso "Asyncify". Para fazer isso, transmita -s ASYNCIFY e -s ASYNCIFY_IMPORTS=[func1, func2] com uma lista de funções semelhantes a matrizes que podem ser assíncronas.

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

Isso informa ao Emscripten que qualquer chamada para essas funções pode exigir o salvamento e a restauração do estado. Assim, o compilador injeta um código de suporte em torno dessas chamadas.

Agora, ao executar esse código no navegador, você verá um registro de saída contínuo, conforme o esperado, com B após um pequeno atraso após A.

A
B

Também é possível retornar valores de funções Asyncify. Basta retornar o resultado de handleSleep() e transmiti-lo ao callback wakeUp(). Por exemplo, se, em vez de ler em um arquivo, você quiser buscar um número de um recurso remoto, use um snippet como o mostrado abaixo para emitir uma solicitação, suspender o código C e retomar quando o corpo da resposta for recuperado. Tudo isso sem problemas, 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 até combinar o Asyncify com o recurso de async-await 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(), é possível 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

No entanto, este exemplo ainda limita os 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 de JavaScript e C++. Ele também é compatível com o Asyncify, portanto, você pode chamar await() em Promises externas e ele funcionará 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, já que ela já está incluída por padrão.

Ok, tudo isso funciona muito bem no Emscripten. E os outros conjuntos de ferramentas e linguagens?

Uso em 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. Acontece que você também pode fazer isso!

Primeiro, você precisa definir essa função como uma importação regular por meio do bloco extern (ou a sintaxe da linguagem escolhida para funções estrangeiras).

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. No caso de C/C++, o Emscripten faria isso, mas como não é usado aqui, o processo é um pouco mais manual.

Felizmente, a transformação Asyncify é completamente independente de conjunto de ferramentas. Ele pode transformar arquivos WebAssembly arbitrários, seja qual for o compilador dele. A transformação é fornecida separadamente como parte do otimizador wasm-opt do conjunto de ferramentas binário e pode ser invocada desta forma:

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 será suspenso e depois retomado.

Só falta fornecer o código de ambiente de execução de suporte que fará isso: suspender e retomar o código do WebAssembly. Novamente, no caso do C / C++, isso seria incluído pelo Emscripten, mas agora você precisa do código agrupador JavaScript personalizado que lidaria com arquivos WebAssembly arbitrários. Criamos uma biblioteca só para isso.

Ele está disponível no GitHub em https://github.com/GoogleChromeLabs/asyncify ou npm com o nome asyncify-wasm (link em inglês).

Ele simula uma API de instanciação WebAssembly padrão, mas com o próprio namespace. A única diferença é que, em uma API WebAssembly normal, você só pode fornecer funções síncronas como importações, enquanto no wrapper Asyncify, também é possível 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 que você tentar chamar essa 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 de resolvida, restaure a pilha de chamadas e o estado 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 se tornam potencialmente assíncronas, portanto, elas também são agrupadas. Talvez você tenha notado no exemplo acima que precisa await o resultado de instance.exports.main() para saber quando a execução está realmente concluída.

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 locais temporários. Depois, quando a operação é concluída, restaura toda a memória e a pilha de chamadas e retoma do mesmo local e com o mesmo estado, como se o programa nunca tivesse sido interrompido.

Ele é bem parecido com o recurso async-await do JavaScript que mostramos anteriormente, mas, ao contrário do JavaScript, não exige nenhuma sintaxe especial ou suporte de execução da linguagem e, em vez disso, funciona transformando funções síncronas simples durante a compilação.

Ao compilar o exemplo de sono assíncrono mostrado anteriormente:

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

O Asyncify pega esse código e o transforma para mais ou menos como o 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 for executado, apenas a parte que leva a async_sleep() será avaliada. Assim que a operação assíncrona é programada, o Asyncify salva todos os locais e desenrola 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 "Asyncify" mudará mode para REWINDING e chamará a função novamente. Desta vez, a ramificação de "execução normal" é ignorada porque ele já fez o job da última vez e quero evitar exibir "A" duas vezes. Em vez disso, ela vai direto para a ramificação "retroceder". Quando ele é alcançado, ele restaura todos os locais armazenados, muda o modo de volta ao "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 um pouco de código de suporte para armazenar e restaurar todos esses locais, navegar pela pilha de chamadas em diferentes modos e assim por diante. Ela tenta modificar apenas funções marcadas como assíncronas na linha de comando, bem como qualquer um dos possíveis autores de chamadas, mas a sobrecarga do tamanho do código ainda pode aumentar aproximadamente 50% antes da compactação.

Um gráfico mostrando a sobrecarga
de tamanho do código para vários comparativos de mercado, de quase 0% em condições ajustadas a mais de 100% nos piores
casos

Isso não é o ideal, mas em muitos casos aceitável quando a alternativa não é ter a funcionalidade completamente ou ter que reescrever o código original de forma significativa.

Sempre ative as otimizações nas versões finais para evitar que o nível seja ainda mais alto. Também é possível marcar 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 chamadas de função diretas. Há também um custo menor para o desempenho do ambiente de execução, mas é limitado às próprias chamadas assíncronas. No entanto, comparado ao custo do trabalho real, geralmente é insignificante.

Demonstrações realistas

Agora que você analisou os exemplos simples, vou passar para cenários mais complicados.

Como mencionado no início do artigo, uma das opções de armazenamento na Web é a API File System Access assíncrona. Ele fornece acesso a um sistema de arquivos de host real por um aplicativo da Web.

Por outro lado, existe um padrão chamado WASI para E/S do WebAssembly no console e no lado do servidor. Ele foi projetado como um destino de compilação para linguagens do sistema e expõe todos os tipos de sistemas de arquivos e outras operações em um formato síncrono tradicional.

E se fosse possível mapear um ao outro? Assim, você pode 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, sem deixar de operar em arquivos de usuários reais. Com o Asyncify, é possível fazer exatamente isso.

Nesta demonstração, compilei a caixa coreutils do Rust com alguns patches secundários para o WASI, transmitidos pela transformação Asyncify e implementando vinculações assíncronas da WASI para a API File System Access no lado do JavaScript. Depois de combinado com o componente do terminal Xterm.js, ela fornece um shell realista em execução na guia do navegador e operando em arquivos de usuários reais, assim como um terminal real.

Confira ao vivo em https://wasi.rreverser.com/.

Os casos de uso do Asyncify não se limitam apenas a timers e sistemas de arquivos. Você pode ir além e usar APIs mais específicas na Web.

Por exemplo, também com a ajuda do Asyncify, é possível mapear a 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, recebi exemplos e testes libusb padrão para serem executados 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.

Provavelmente é a história de outra postagem do blog.

Esses exemplos demonstram como o Asyncify pode ser poderoso para preencher a lacuna e transferir todos os tipos de aplicativos para a Web, permitindo que você tenha acesso multiplataforma, sandbox e melhor segurança, tudo sem perder funcionalidade.