Como usar linhas de execução WebAssembly do C, C++ e Rust

Saiba como trazer aplicativos com várias linhas de execução escritos em outros idiomas para o WebAssembly.

A compatibilidade com as linhas de execução do WebAssembly é uma das adições de desempenho mais importantes no WebAssembly. Ela permite que você execute partes do seu código em paralelo em núcleos separados ou o mesmo código em partes independentes dos dados de entrada, escalonando-os para os núcleos que o usuário tiver e reduzindo significativamente o tempo total de execução.

Neste artigo, você vai aprender a usar as linhas de execução do WebAssembly para disponibilizar aplicativos com várias linhas de execução escritos em linguagens como C, C++ e Rust para a Web.

Como as linhas de execução do WebAssembly funcionam

As linhas de execução do WebAssembly não são um recurso separado, mas uma combinação de vários componentes que permite que os apps WebAssembly usem paradigmas tradicionais de multithreading na Web.

Web workers

O primeiro componente são os Workers normais que você conhece e adora do JavaScript. As linhas de execução do WebAssembly usam o construtor new Worker para criar novas linhas de execução subjacentes. Cada linha de execução carrega um agrupador de JavaScript e, em seguida, a linha de execução principal usa o método Worker#postMessage para compartilhar o WebAssembly.Module compilado, além de um WebAssembly.Memory compartilhado (confira abaixo) com essas outras linhas de execução. Isso estabelece a comunicação e permite que todas essas linhas de execução executem o mesmo código WebAssembly na mesma memória compartilhada sem passar pelo JavaScript novamente.

Os Web Workers existem há mais de uma década, são amplamente suportados e não exigem nenhuma sinalização especial.

SharedArrayBuffer

A memória do WebAssembly é representada por um objeto WebAssembly.Memory na API JavaScript. Por padrão, WebAssembly.Memory é um wrapper em torno de um ArrayBuffer, um buffer de bytes bruto que pode ser acessado somente por uma única linha de execução.

> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer { … }

Para oferecer suporte ao uso de várias linhas de execução, WebAssembly.Memory também ganhou uma variante compartilhada. Quando criada com uma flag shared usando a API JavaScript ou pelo próprio binário WebAssembly, ela se torna um wrapper em torno de um SharedArrayBuffer. Ela é uma variação do ArrayBuffer que pode ser compartilhada com outras linhas de execução e lida ou modificada simultaneamente de ambos os lados.

> new WebAssembly.Memory({ initial:1, maximum:10, shared:true }).buffer
SharedArrayBuffer { … }

Ao contrário do postMessage, normalmente usado para comunicação entre a linha de execução principal e os Web Workers, o SharedArrayBuffer não exige a cópia de dados nem a espera do loop de eventos para enviar e receber mensagens. Em vez disso, todas as alterações são vistas por todas as linhas de execução quase instantaneamente, o que o torna um destino de compilação muito melhor para primitivos de sincronização tradicionais.

O histórico de SharedArrayBuffer é complicado. Ele foi inicialmente enviado para vários navegadores em meados de 2017, mas teve que ser desativado no início de 2018 devido à descoberta de vulnerabilidades Spectre (em inglês). O motivo específico era que a extração de dados no Spectre depende de ataques de marcação de tempo, que medem o tempo de execução de um código específico. Para tornar esse tipo de ataque mais difícil, os navegadores reduziram a precisão das APIs de tempo padrão, como Date.now e performance.now. No entanto, a memória compartilhada, combinada com um simples contador de loop em execução em uma linha de execução separada, também é uma maneira muito confiável de conseguir tempo de alta precisão e é muito mais difícil de mitigar sem limitar significativamente o desempenho do ambiente de execução.

Em vez disso, o Chrome 68 (meados de 2018) reativou o SharedArrayBuffer novamente usando o Isolamento de sites, um recurso que coloca diferentes sites em diferentes processos e dificulta muito o uso de ataques de canal secundário, como o Spectre. No entanto, essa mitigação ainda era limitada apenas ao Chrome para computadores, já que o isolamento de sites é um recurso bastante caro e não podia ser ativado por padrão para todos os sites em dispositivos móveis com pouca memória, nem ainda foi implementado por outros fornecedores.

A partir de 2020, o Chrome e o Firefox têm implementações de isolamento de sites e uma maneira padrão para os sites ativarem o recurso com cabeçalhos COOP e COEP. Um mecanismo de ativação permite usar o isolamento de sites mesmo em dispositivos de baixa potência, em que a ativação para todos os sites seria muito cara. Para aceitar, adicione os seguintes cabeçalhos ao documento principal na configuração do seu servidor:

Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

Depois de ativar, você terá acesso ao SharedArrayBuffer (incluindo WebAssembly.Memory com suporte de um SharedArrayBuffer), timers precisos, medição de memória e outras APIs que exigem uma origem isolada por motivos de segurança. Confira Como tornar seu site "isolado de origem cruzada" usando COOP e COEP para mais detalhes.

Atômicas do WebAssembly

Embora SharedArrayBuffer permita que cada linha de execução leia e grave na mesma memória, para uma comunicação correta, é importante garantir que elas não realizem operações conflitantes ao mesmo tempo. Por exemplo, é possível que uma linha de execução comece a ler dados de um endereço compartilhado, enquanto outra linha de execução está gravando nele, de modo que a primeira linha agora terá um resultado corrompido. Essa categoria de bugs é conhecida como disputas. Para evitar disputas, é preciso sincronizar esses acessos de alguma forma. É aqui que entram as operações atômicas.

As atômicas do WebAssembly (link em inglês) são uma extensão do conjunto de instruções do WebAssembly que permitem ler e gravar pequenas células de dados (geralmente números inteiros de 32 e 64 bits) "atomicamente". Ou seja, de modo a garantir que não haja duas linhas de execução lendo ou gravando na mesma célula ao mesmo tempo, evitando esses conflitos em um nível baixo. Além disso, as atômicas do WebAssembly contêm mais dois tipos de instrução, "wait" e "notify", que permitem que um thread seja suspenso ("espera") em um determinado endereço em uma memória compartilhada até que outro thread o ative via "notificar".

Todos os primitivos de sincronização de nível superior, incluindo canais, mutexes e bloqueios de leitura/gravação, são baseados nessas instruções.

Como usar as linhas de execução do WebAssembly

Detecção de recursos

As atômicas do WebAssembly e o SharedArrayBuffer são recursos relativamente novos e ainda não estão disponíveis em todos os navegadores compatíveis com o WebAssembly. Saiba quais navegadores oferecem suporte aos novos recursos do WebAssembly no roteiro do webassembly.org (link em inglês).

Para garantir que todos os usuários possam carregar seu aplicativo, implemente o aprimoramento progressivo criando duas versões diferentes do Wasm: uma com suporte a várias linhas de execução e outra sem. Em seguida, carregue a versão com suporte, dependendo dos resultados da detecção do recurso. Para detectar a compatibilidade com as linhas de execução do WebAssembly durante a execução, use a biblioteca Wasm-feature-detect (link em inglês) e carregue o módulo desta forma:

import { threads } from 'wasm-feature-detect';

const hasThreads = await threads();

const module = await (
  hasThreads
    ? import('./module-with-threads.js')
    : import('./module-without-threads.js')
);

// …now use `module` as you normally would

Agora vamos conferir como criar uma versão com várias linhas de execução do módulo WebAssembly.

C

Na linguagem C, principalmente em sistemas do tipo Unix, a maneira comum de usar linhas de execução é por meio de Threads POSIX fornecidos pela biblioteca pthread. A Emscripten fornece uma implementação compatível com a API da biblioteca pthread criada com base em Web Workers, memória compartilhada e atômicas, para que o mesmo código possa funcionar na Web sem mudanças.

Vamos conferir um exemplo:

example.c:

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

void *thread_callback(void *arg)
{
    sleep(1);
    printf("Inside the thread: %d\n", *(int *)arg);
    return NULL;
}

int main()
{
    puts("Before the thread");

    pthread_t thread_id;
    int arg = 42;
    pthread_create(&thread_id, NULL, thread_callback, &arg);

    pthread_join(thread_id, NULL);

    puts("After the thread");

    return 0;
}

Aqui, os cabeçalhos da biblioteca pthread são incluídos via pthread.h. Você também pode ver algumas funções cruciais para lidar com linhas de execução.

pthread_create vai criar uma linha de execução em segundo plano. É necessário um destino para armazenar um identificador de linha de execução, alguns atributos de criação de linha de execução (sem transmitir nenhum, então é apenas NULL), o callback a ser executado na nova linha de execução (aqui thread_callback) e um ponteiro de argumento opcional para transmitir a esse callback, caso você queira compartilhar alguns dados da linha de execução principal. Neste exemplo, estamos compartilhando um ponteiro para uma variável arg.

O pthread_join pode ser chamado mais tarde a qualquer momento para aguardar que a linha de execução termine a execução e receba o resultado retornado do callback. Ele aceita o identificador da linha de execução atribuído anteriormente, bem como um ponteiro para armazenar o resultado. Nesse caso, não há resultados. Portanto, a função usa um NULL como argumento.

Para compilar o código usando linhas de execução com o Emscripten, você precisa invocar emcc e transmitir um parâmetro -pthread, como ao compilar o mesmo código com o Clang ou GCC em outras plataformas:

emcc -pthread example.c -o example.js

No entanto, ao tentar executá-lo em um navegador ou Node.js, você verá um aviso e o programa travará:

Before the thread
Tried to spawn a new thread, but the thread pool is exhausted.
This might result in a deadlock unless some threads eventually exit or the code
explicitly breaks out to the event loop.
If you want to increase the pool size, use setting `-s PTHREAD_POOL_SIZE=...`.
If you want to throw an explicit error instead of the risk of deadlocking in those
cases, use setting `-s PTHREAD_POOL_SIZE_STRICT=2`.
[…hangs here…]

o que aconteceu? O problema é que a maioria das APIs demoradas na Web são assíncronas e dependem da repetição de eventos para serem executadas. Essa limitação é uma distinção importante em comparação com ambientes tradicionais, em que os aplicativos normalmente executam E/S de maneira síncrona e com bloqueio. Confira a postagem do blog sobre Como usar APIs da Web assíncronas do WebAssembly se quiser saber mais.

Nesse caso, o código invoca pthread_create de forma síncrona para criar uma linha de execução em segundo plano e segue outra chamada síncrona para pthread_join, que aguarda até que a linha de execução em segundo plano termine a execução. No entanto, os Web Workers, usados em segundo plano quando esse código é compilado com o Emscripten, são assíncronos. O que acontece é que pthread_create apenas programa uma nova linha de execução de worker a ser criada na próxima execução de loop de evento, mas, em seguida, pthread_join bloqueia imediatamente o loop de evento para aguardar esse worker e, ao fazer isso, impede que ele seja criado. É um exemplo clássico de um impasse.

Uma maneira de resolver esse problema é criar um pool de workers com antecedência, antes mesmo de o programa começar. Quando pthread_create é invocado, ele pode pegar um worker pronto para uso do pool, executar o callback fornecido na linha de execução em segundo plano e retornar o worker de volta ao pool. Tudo isso pode ser feito de maneira síncrona, para que não haja impasses, desde que o pool seja suficientemente grande.

É exatamente isso que o Emscripten permite com a opção -s PTHREAD_POOL_SIZE=.... Ele permite especificar uma série de linhas de execução, seja um número fixo ou uma expressão JavaScript, como navigator.hardwareConcurrency, para criar tantas linhas de execução quanto as de núcleos na CPU. A última opção é útil quando seu código pode ser escalonado para um número arbitrário de linhas de execução.

No exemplo acima, há apenas uma linha de execução sendo criada, então, em vez de reservar todos os núcleos, é suficiente usar -s PTHREAD_POOL_SIZE=1:

emcc -pthread -s PTHREAD_POOL_SIZE=1 example.c -o example.js

Desta vez, ao executá-lo, as coisas funcionam bem:

Before the thread
Inside the thread: 42
After the thread
Pthread 0x701510 exited.

Há outro problema: você está vendo que sleep(1) no exemplo de código? Ela é executada no callback da linha de execução, ou seja, fora da linha de execução principal. Então fica tudo bem, certo? Bem, não é.

Quando pthread_join é chamado, ele precisa esperar a execução da linha de execução ser concluída, o que significa que, se a linha de execução criada estiver realizando tarefas de longa duração (neste caso, em suspensão de 1 segundo), a linha de execução principal também terá que ser bloqueada pela mesma quantidade de tempo até que os resultados retornem. Quando esse JS é executado no navegador, ele bloqueia a linha de execução de IU por um segundo até que o callback da linha de execução retorne. Isso resulta em uma experiência ruim para o usuário.

Existem algumas soluções para isso:

  • pthread_detach
  • -s PROXY_TO_PTHREAD
  • Worker personalizado e Comlink

pthread_detach

Primeiro, se você só precisar executar algumas tarefas fora da linha de execução principal, mas não precisar esperar pelos resultados, use pthread_detach em vez de pthread_join. Isso deixará o callback da linha de execução em segundo plano. Se você estiver usando essa opção, poderá desativar o aviso com -s PTHREAD_POOL_SIZE_STRICT=0.

PROXY_TO_PTHREAD

Se você estiver compilando um aplicativo C em vez de uma biblioteca, poderá usar a opção -s PROXY_TO_PTHREAD, que descarrega o código do aplicativo principal em uma linha de execução separada, além de quaisquer linhas de execução aninhadas criadas pelo próprio aplicativo. Dessa forma, o código principal pode ser bloqueado com segurança a qualquer momento sem congelar a interface. Aliás, ao usar essa opção, não é necessário criar previamente o pool de linhas de execução. Em vez disso, o Emscripten pode aproveitar a linha de execução principal para criar novos workers subjacentes e bloquear a linha de execução auxiliar em pthread_join sem impasse.

Terceiro, se você estiver trabalhando em uma biblioteca e ainda precisar fazer um bloqueio, crie seu próprio worker, importe o código gerado pelo Emscripten e o exponha com Comlink para a linha de execução principal. A linha de execução principal poderá invocar qualquer método exportado como funções assíncronas, e dessa forma também evitará o bloqueio da interface.

Em um aplicativo simples, como o exemplo anterior, -s PROXY_TO_PTHREAD é a melhor opção:

emcc -pthread -s PROXY_TO_PTHREAD example.c -o example.js

C++

Todas as ressalvas e lógicas se aplicam da mesma forma ao C++. A única novidade que você ganha é o acesso a APIs de nível superior, como std::thread e std::async, que usam a biblioteca pthread discutida anteriormente em segundo plano.

Assim, o exemplo acima pode ser reescrito em C++ mais idiomático, como este:

example.cpp:

#include <iostream>
#include <thread>
#include <chrono>

int main()
{
    puts("Before the thread");

    int arg = 42;
    std::thread thread([&]() {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::cout << "Inside the thread: " << arg << std::endl;
    });

    thread.join();

    std::cout << "After the thread" << std::endl;

    return 0;
}

Quando compilado e executado com parâmetros semelhantes, ele se comportará da mesma forma que o exemplo em C:

emcc -std=c++11 -pthread -s PROXY_TO_PTHREAD example.cpp -o example.js

Saída:

Before the thread
Inside the thread: 42
Pthread 0xc06190 exited.
After the thread
Proxied main thread 0xa05c18 finished with return code 0. EXIT_RUNTIME=0 set, so
keeping main thread alive for asynchronous event operations.
Pthread 0xa05c18 exited.

Rust

Ao contrário do Emscripten, o Rust não tem um destino da Web especializado de ponta a ponta. Em vez disso, ele fornece um destino wasm32-unknown-unknown genérico para a saída genérica do WebAssembly.

Se o Wasm for usado em um ambiente da Web, qualquer interação com APIs JavaScript será deixada para bibliotecas e ferramentas externas, como Wasm-bindgen e Wasm-pack. Infelizmente, isso significa que a biblioteca padrão não reconhece Web Workers e APIs padrão, como std::thread, não vão funcionar quando compiladas no WebAssembly.

Felizmente, a maior parte do ecossistema depende de bibliotecas de nível superior para cuidar de várias linhas de execução. Nesse nível, é muito mais fácil abstrair todas as diferenças entre as plataformas.

Em particular, o Rayon é a escolha mais conhecida de paralelismo de dados em Rust. Ele permite que você use cadeias de método em iteradores regulares e, geralmente, com uma única mudança de linha, converta-as de uma maneira em que elas fossem executadas em paralelo em todas as linhas de execução disponíveis, em vez de sequencialmente. Exemplo:

pub fn sum_of_squares(numbers: &[i32]) -> i32 {
  numbers
  .iter()
  .par_iter()
  .map(|x| x * x)
  .sum()
}

Com essa pequena mudança, o código dividirá os dados de entrada, calculará x * x e somas parciais em linhas de execução paralelas e, por fim, somará esses resultados parciais.

Para acomodar plataformas que não funcionam com std::thread, o Rayon fornece hooks que permitem definir a lógica personalizada para gerar e sair linhas de execução.

Wasm-bindgen-rayon usa esses ganchos para gerar linhas de execução do WebAssembly como Web Workers. Para usá-lo, você precisa adicioná-lo como uma dependência e seguir as etapas de configuração descritas nos docs. O exemplo acima terá esta aparência:

pub use wasm_bindgen_rayon::init_thread_pool;

#[wasm_bindgen]
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
  numbers
  .par_iter()
  .map(|x| x * x)
  .sum()
}

Depois disso, o JavaScript gerado exportará uma função initThreadPool extra. Essa função criará um pool de workers e os reutilizará durante todo o ciclo de vida do programa para qualquer operação com várias linhas de execução feita pelo Rayon.

Esse mecanismo de pool é semelhante à opção -s PTHREAD_POOL_SIZE=... em Emscripten, explicada anteriormente, e também precisa ser inicializado antes do código principal para evitar impasses:

import init, { initThreadPool, sum_of_squares } from './pkg/index.js';

// Regular wasm-bindgen initialization.
await init();

// Thread pool initialization with the given number of threads
// (pass `navigator.hardwareConcurrency` if you want to use all cores).
await initThreadPool(navigator.hardwareConcurrency);

// ...now you can invoke any exported functions as you normally would
console.log(sum_of_squares(new Int32Array([1, 2, 3]))); // 14

As mesmas advertências sobre o bloqueio da linha de execução principal também se aplicam aqui. Até o exemplo sum_of_squares ainda precisa bloquear a linha de execução principal para aguardar os resultados parciais de outras linhas de execução.

A espera pode ser muito curta ou longa, dependendo da complexidade dos iteradores e do número de linhas de execução disponíveis. No entanto, por segurança, os mecanismos dos navegadores impedem ativamente o bloqueio total da linha de execução principal, e esse código gera um erro. Em vez disso, é necessário criar um worker, importar o código gerado por wasm-bindgen para ele e expor a API com uma biblioteca como Comlink na linha de execução principal.

Confira o exemplo de wasm-bindgen-rayon para uma demonstração completa:

Casos de uso reais

Usamos ativamente as linhas de execução do WebAssembly no Squoosh.app para compactação de imagem do lado do cliente. Em especial, para formatos como AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) e WebP v2 (C++). Graças ao uso de multithreading, notamos que o envio por push de velocidades de 1,5x-3x com o codec da WebAssembly consistente (com uma proporção exata e com codec da WebAssemb é possível com que as linhas de execução de 1,5x a 3x são compatíveis, ou seja, com a proporção exata,

O Google Earth é outro serviço importante que usa as linhas de execução do WebAssembly na própria versão da Web.

O FFMPEG.WASM é uma versão WebAssembly de um conjunto de ferramentas multimídia FFmpeg (em inglês) conhecido que usa linhas de execução do WebAssembly para codificar vídeos de maneira eficiente diretamente no navegador.

Há muitos outros exemplos interessantes usando as linhas de execução do WebAssembly. Não deixe de conferir as demonstrações e trazer seus próprios aplicativos e bibliotecas com várias linhas de execução para a Web.