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. Quando compilando código com o WebAssembly, você precisa conectar um tipo de API a outro, e essa ponte é Async. Nesta postagem, você aprenderá quando e como usar o Asyncify e como ele funciona em segundo plano.

E/S nos 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 um "Hello, (username)!" mensagem:

#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 pode ser encontrado em um aplicativo de qualquer tamanho: ele lê algumas entradas do mundo externo, as processa internamente e grava e seus resultados para o mundo externo. Toda essa interação com o mundo exterior acontece por meio de alguns funções comumente chamadas de funções de entrada/saída, também abreviadas para 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 os dados dele. Depois de extrair os dados, use 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 o máquinas virtuais envolvidas para ler ou gravar dados. No entanto, dependendo do ambiente, pode haver muita coisa acontecendo:

  • Se o arquivo de entrada estiver em uma unidade local, o aplicativo precisará executar uma série de de memória e disco para localizar o arquivo, verificar permissões, abri-lo para leitura e ler bloco por bloco até que o número solicitado de bytes seja recuperado. Isso pode ser bem 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 rede também será envolvida, aumentando a complexidade, a latência e o número de novas tentativas de cada operação.
  • Por fim, nem mesmo printf tem garantia de imprimir itens no console e pode ser redirecionado. a um arquivo ou local de rede. Nesse caso, ele precisaria seguir as mesmas etapas acima.

Para resumir, a E/S pode ser lenta e não é possível prever quanto tempo uma chamada específica levará por um uma olhada rápida no código. Enquanto essa operação estiver em execução, todo o aplicativo aparecerá congelado e não responde ao usuário.

Isso não se limita a C ou C++. A maioria das linguagens do sistema apresenta todas as E/S em forma de APIs síncronas. Por exemplo, se você converter o exemplo para Rust, a API poderá parecer mais simples, mas aplicam-se os mesmos princípios. Você apenas faz uma chamada e espera de forma síncrona que ela retorne o resultado, enquanto 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 para o WebAssembly e convertê-las para na Web? Ou, para fornecer um exemplo específico, o que poderia "ler arquivo" tradução? precisam 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 (JS objetos), 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 que limitam o que é possível armazenar e por quanto tempo. Todos As outras opções fornecem apenas APIs assíncronas.

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

O motivo é que, historicamente, a Web tem uma única linha de execução, e qualquer código de usuário que toque na interface precisa ser executado na mesma linha de execução que a interface. Ela tem que competir com outras tarefas importantes, o layout, a renderização e a manipulação de eventos para o tempo de CPU. Você não gostaria que um pedaço de JavaScript ou WebAssembly para poder iniciar uma "leitura de arquivo" operação e bloquear todo o resto, a guia inteira, ou, no passado, todo o navegador, por um intervalo de milésimos de segundo a alguns segundos, até terminar.

Em vez disso, o código só pode programar uma operação de E/S com um callback a ser executado depois de concluído. Esses retornos de chamada são executados como parte do loop de eventos do navegador. Não vou estar vamos entrar em detalhes aqui, mas se você estiver interessado em aprender como funciona o loop de eventos nos bastidores, confira Tarefas, microtarefas, filas e programações que explica esse assunto em detalhes.

A versão resumida é que o navegador executa todos os fragmentos de código em uma espécie de loop infinito, retirando-os da fila um por um. Quando algum evento é acionado, o navegador coloca os gerenciador correspondente e, na próxima iteração de loop, ele é removido da fila e executado. Esse mecanismo permite simular a simultaneidade e executar muitas operações paralelas usando apenas um único thread.

O importante a ser lembrado sobre esse mecanismo é que, embora seu JavaScript personalizado (ou WebAssembly) é executado, o loop de eventos é bloqueado e, enquanto estiver, não há como reagir ao manipuladores externos, eventos, E/S, etc. A única maneira de obter os resultados de E/S é registrar um , conclua a execução do código e devolva o controle ao navegador para que ele possa manter para processar as tarefas pendentes. Quando a E/S for concluída, o gerenciador se tornará uma dessas tarefas e serão executados.

Por exemplo, se você quiser reescrever os exemplos acima em JavaScript moderno e decidiu ler um nome de um URL remoto, você 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, em segundo plano cada await é essencialmente uma açúcar de sintaxe para retornos de chamada:

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 com o primeiro callback. Assim que o navegador receber a resposta inicial, apenas o HTTP cabeçalhos. 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, assim que fetch recuperou todo o conteúdo, ele invoca o último retorno de chamada, que imprime "Hello, (username)!" ao no console do Google Cloud.

Graças à natureza assíncrona dessas etapas, a função original pode retornar o controle ao do navegador assim que a E/S for agendada 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 sendo executada em segundo plano.

Como último exemplo, até mesmo APIs simples como “sleep”, que faz um aplicativo aguardar um determinado número de segundos, também são uma forma de uma operação de E/S:

#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");

É claro que você pode fazer uma tradução direta, o que bloquearia a conversa atual. até que o tempo expire:

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

Na verdade, é isso que o Emscripten faz na implementação padrão de “sono”, mas isso é muito ineficiente, bloqueia toda a interface e não permite que outros eventos sejam tratados enquanto isso. Em geral, não faça isso no código de produção.

Uma versão mais idiomática de “sleep” em JavaScript envolveria chamar setTimeout(). fazendo a assinatura 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 linguagem de 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 fazer alguma transformação entre esses dois arquivos modelos de execução, e o WebAssembly ainda não tem essa capacidade integrada.

Preenchendo lacunas com o Asyncify

É aqui que entra a Asyncify. O Asyncify é uma com suporte do Emscripten, que permite pausar todo o programa e e retomá-la de modo assíncrono 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 se conecta
o resultado da tarefa assíncrona de volta ao WebAssembly

Uso em C / C++ com Emscripten

Se você quisesse usar o Asyncify para implementar uma suspensão assíncrona no último exemplo, poderia fazer da seguinte forma:

#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 é um que permite definir snippets JavaScript como se fossem funções C. Dentro dela, use uma função Asyncify.handleSleep() que diz ao Emscripten para suspender o programa e fornece um gerenciador wakeUp() que deve ser chamado quando a operação assíncrona termina. No exemplo acima, o manipulador é passado para setTimeout(), mas pode ser usada em qualquer outro contexto que aceite callbacks. Por fim, você também pode chame async_sleep() em qualquer lugar que você quiser, como a sleep() normal ou qualquer outra API síncrona.

Ao compilar esse código, é necessário solicitar ao Emscripten a ativação do recurso Asyncify. Faça isso até transmitindo -s ASYNCIFY e -s ASYNCIFY_IMPORTS=[func1, func2] com um lista de funções do tipo matriz 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 exige que você salve e restaure a de modo que o compilador injetará o código de suporte em torno dessas chamadas.

Agora, quando você executar esse código no navegador, verá um registro de saída contínuo, como esperado. com B depois de um curto atraso após A.

A
B

Você pode retornar valores de Async também pode ser executada. O quê? necessário é retornar o resultado de handleSleep() e transmiti-lo ao wakeUp() o retorno de chamada. Por exemplo, se, em vez de ler de um arquivo, você quiser buscar um número de um você pode usar um snippet como o abaixo para emitir uma solicitação, suspender o código C e ser retomado assim que o corpo da resposta for recuperado — tudo feito 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 async-await em vez de usar a API baseada em callback. Para isso, em vez de Asyncify.handleSleep(), chame Asyncify.handleAsync(). Então, em vez de agendar wakeUp(), é possível transmitir uma função JavaScript async e usar await e return internamente, tornando o código ainda mais natural e síncrono, sem perder nenhum dos benefícios de a 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 este exemplo ainda limita você apenas a números. E se você quiser implementar a versão original exemplo, em que tentei obter o nome de um usuário de um arquivo como uma string? Bem, você também pode fazer isso.

O Emscripten fornece um recurso chamado Embind, que permite a gerenciar conversões entre valores JavaScript e C++. Ele também é compatível com o Asyncify, É possível chamar await() em Promises externas. Ele funcionará como await em async-await. Código 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>();

Ao usar esse método, você não precisa transmitir ASYNCIFY_IMPORTS como uma flag de compilação, já que ele é já são incluídas 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 e que queira mapear para uma assíncrona na Web. Acontece que você também pode fazer isso.

Primeiro, defina essa função, como uma importação regular, usando o bloco extern (ou o método sintaxe de uma linguagem de programação 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 no 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 / O C++, o Emscripten, faria isso, mas 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 objetos arquivos WebAssembly, independentemente do compilador que eles produziram. A transformação é fornecida separadamente como parte do otimizador wasm-opt da classe Binaryen conjunto de ferramentas e podem ser invocados desta forma:

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

Transmita --asyncify para ativar a transformação e, em seguida, use --pass-arg=… para fornecer um valor lista de funções assíncronas, em que o estado do programa deve ser suspenso e retomado posteriormente.

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

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

Ele simula uma instanciação WebAssembly padrão API, mas com o próprio namespace dela. O único a diferença é que, em uma API WebAssembly normal, só é possível fornecer funções síncronas, importações. 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();

Ao tentar chamar essa função assíncrona, como get_answer() no exemplo acima, de no lado do WebAssembly, a biblioteca detectará o Promise retornado, suspenderá e salvará o estado do o aplicativo WebAssembly, assinar a conclusão da promessa e, depois, quando o problema for resolvido, restaurar perfeitamente 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 podem se tornar assíncronas também, então elas também são encapsuladas. Talvez você tenha notado no exemplo acima que É necessário await o resultado de instance.exports.main() para saber quando a execução foi realmente realizada. concluído.

Como tudo isso funciona nos bastidores?

Quando o Asyncify detecta uma chamada para uma das funções ASYNCIFY_IMPORTS, ele inicia uma salva todo o estado do aplicativo, incluindo a pilha de chamadas e quaisquer locais e, mais tarde, quando a operação for concluída, restaura toda a memória e a pilha de chamadas voltam do mesmo local e com o mesmo estado, como se o programa nunca tivesse parado.

Isso é bastante semelhante ao recurso async-await do JavaScript que mostrei anteriormente, mas, ao contrário do JavaScript um, não requer nenhuma sintaxe especial ou suporte de tempo de execução da linguagem e, em vez funciona transformando funções síncronas simples em tempo de compilação.

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

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

O Asyncify recebe esse código e o transforma de modo semelhante ao seguinte (pseudocódigo, real a transformação é 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. A primeira vez que o código transformado for executado, apenas a parte que leva a async_sleep() será avaliada. Assim que o uma operação assíncrona é programada, o Asyncify salva todos os locais e desconecta a pilha retornando de cada função até o topo, devolvendo o controle ao navegador loop de eventos.

Em seguida, quando async_sleep() for resolvido, o código de suporte do Asyncify vai mudar mode para REWINDING. chamar a função novamente. Desta vez, a "execução normal" a ramificação é ignorada, já que o job da última vez e quero evitar a impressão de "A" duas vezes e, em vez disso, vem direto "retrocedendo" ramificação. 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 do Asyncify não é totalmente sem custo financeiro, já que precisa injetar uma boa quantidade de 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 no comando , bem como qualquer um de seus autores de chamada em potencial, mas a sobrecarga de tamanho do código ainda pode totalizar cerca de 50% antes da compactação.

Um gráfico mostrando o código
sobrecarga de tamanho para vários comparativos de mercado, de quase 0% em condições ajustadas a mais de 100% nas piores condições
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 muito no código original.

Sempre ative as otimizações nos builds finais para evitar que eles fiquem ainda mais altos. Você pode marque também Otimização específica de Asyncify para reduzir a sobrecarga em limitar transformações apenas para funções especificadas e/ou apenas chamadas de função diretas. Também há um menor custo para o desempenho do tempo de execução, mas se limita às próprias chamadas assíncronas. No entanto, em comparação ao custo do trabalho real, geralmente é insignificante.

Demonstrações reais

Agora que você analisou os exemplos simples, passaremos 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 dá acesso do sistema de arquivos real de um aplicativo da Web.

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

E se fosse possível mapear um para o outro? Assim, era 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, enquanto ainda permitindo que ele opere em arquivos de usuários reais. Com o Asyncify, é possível fazer exatamente isso.

Nesta demonstração, eu compilei a caixa coreutils do Rust com uma alguns pequenos patches para o WASI, transmitidos pela transformação Asyncify e implementados bindings do WASI à API File System Access no lado do JavaScript. Depois de combinado com Xterm.js (em inglês), fornece um shell realista em execução na 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 de Asyncify não são limitados 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 libusb, provavelmente a biblioteca nativa mais conhecida para trabalhar com dispositivos USB, para uma API WebUSB, que dá acesso assíncrono a esses dispositivos na Web. Depois de mapeado e compilado, tenho testes e exemplos libusb padrão para executar dispositivos no sandbox de uma página da Web.

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

Mas provavelmente é uma história para outra postagem do blog.

Esses exemplos demonstram o quão poderoso pode ser o Asyncify para preencher a lacuna e transferir todos diversos tipos de aplicativos à Web, permitindo que você obtenha acesso multiplataforma, sandbox e melhores a segurança da rede sem perder a funcionalidade.