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

Saiba como trazer aplicativos multithread escritos em outros idiomas para o WebAssembly.

O suporte para linhas de execução WebAssembly é uma das adições mais importantes de desempenho ao WebAssembly. Com ele, é possível executar partes do código em paralelo em núcleos separados ou o mesmo código em partes independentes dos dados de entrada, escalonando-o para quantos núcleos o usuário tiver e reduzindo significativamente o tempo de execução geral.

Neste artigo, você vai aprender a usar linhas de execução do WebAssembly para trazer aplicativos multilinha de execução escritos em linguagens como C, C++ e Rust para a Web.

Como funcionam as linhas de execução WebAssembly

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

Web Workers

O primeiro componente é o Worker que você conhece e ama do JavaScript. As linhas de execução da WebAssembly usam o construtor new Worker para criar novas linhas de execução subjacentes. Cada linha de execução carrega uma cola JavaScript e, em seguida, a linha de execução principal usa o método Worker#postMessage para compartilhar o WebAssembly.Module compilado, bem como um WebAssembly.Memory compartilhado (consulte 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 da WebAssembly na mesma memória compartilhada sem passar pelo JavaScript novamente.

Os Web Workers existem há mais de uma década, têm amplo suporte e não exigem sinalizações especiais.

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 só pode ser acessado 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 criado com uma flag shared pela API JavaScript ou pelo próprio binário do WebAssembly, ele se torna um wrapper em torno de um SharedArrayBuffer. É uma variação de 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 a comunicação entre a linha de execução principal e os Web Workers, o SharedArrayBuffer não requer a cópia de dados nem a espera para que o loop de eventos envie e receba mensagens. Em vez disso, todas as mudanças são vistas por todas as linhas de execução quase que instantaneamente, o que torna a compilação muito melhor para primitivas de sincronização tradicionais.

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

Em vez disso, o Chrome 68 (meados de 2018) reativou o SharedArrayBuffer usando o isolamento de sites, um recurso que coloca sites diferentes em processos diferentes e dificulta muito o uso de ataques de canal lateral, como o Spectre. No entanto, essa mitigação ainda era limitada apenas ao Chrome para computador, 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 de baixa memória nem implementado por outros fornecedores.

Em 2020, o Chrome e o Firefox implementam o 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 ativar esse recurso para todos os sites seria muito caro. Para ativar, adicione os cabeçalhos a seguir ao documento principal na configuração do servidor:

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

Depois de ativar, você terá acesso a 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 isolar seu site em várias origens usando COOP e COEP para mais detalhes.

Atômicos do WebAssembly

Embora SharedArrayBuffer permita que cada linha de execução leia e grave na mesma memória, para uma comunicação correta, você quer garantir que elas não executem 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 estiver gravando nele. Portanto, a primeira linha terá um resultado corrompido. Essa categoria de insetos é conhecida como condições de corrida. Para evitar condições de disputa, é necessário sincronizar esses acessos de alguma forma. É aqui que entram as operações atômicas.

Átomos do WebAssembly é uma extensão do conjunto de instruções do WebAssembly que permite ler e gravar pequenas células de dados (geralmente números inteiros de 32 e 64 bits) "atomicamente". Ou seja, garantindo que não haja duas linhas de execução lendo ou gravando na mesma célula ao mesmo tempo, evitando conflitos de baixo nível. Além disso, os atômicos da WebAssembly contêm mais dois tipos de instrução: "wait" e "notify", que permitem que uma linha de execução entre no modo de suspensão ("wait") em um determinado endereço em uma memória compartilhada até que outra linha de execução a ative usando "notify".

Todas as primitivas de sincronização de nível superior, incluindo canais, mutexes e bloqueios de leitura e gravação, são baseadas nessas instruções.

Como usar as linhas de execução do WebAssembly

Detecção de recursos

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

Para garantir que todos os usuários possam carregar seu aplicativo, você precisa implementar 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 de recursos. Para detectar o suporte a threads do WebAssembly no momento da execução, use a biblioteca wasm-feature-detect e carregue o módulo assim:

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ários threads do módulo do WebAssembly.

C

Em C, particularmente em sistemas semelhantes ao Unix, a maneira comum de usar linhas de execução é por meio de POSIX Threads fornecidas pela biblioteca pthread. O Emscripten oferece uma implementação compatível com a API da biblioteca pthread criada em cima de Web Workers, memória compartilhada e atômicas, para que o mesmo código possa funcionar na Web sem mudanças.

Confira 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 por pthread.h. Você também pode conferir algumas funções essenciais para lidar com encadeamentos.

pthread_create vai criar uma linha de execução em segundo plano. Ele usa um destino para armazenar um identificador de linha de execução, alguns atributos de criação de linha de execução (aqui não é transmitido, 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.

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

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

emcc -pthread example.c -o example.js

No entanto, quando você tenta executá-lo em um navegador ou no Node.js, aparece um aviso e o programa trava:

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 que consomem tempo na Web são assíncronas e dependem do loop 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 bloqueada. Confira a postagem do blog sobre Como usar APIs 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 é seguida por outra chamada síncrona para pthread_join que aguarda a linha de execução em segundo plano terminar a execução. No entanto, os Web Workers, que são usados nos bastidores quando esse código é compilado com o Emscripten, são assíncronos. O que acontece é que pthread_createprograma uma nova linha de execução do worker para ser criada na próxima execução do loop de eventos, mas pthread_join bloqueia imediatamente o loop de eventos para aguardar esse worker e, ao fazer isso, impede que ele seja criado. É um exemplo clássico de bloqueio.

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

Isso é exatamente o que o Emscripten permite com a opção -s PTHREAD_POOL_SIZE=.... Ele permite especificar um número de linhas de execução, seja um número fixo ou uma expressão JavaScript como navigator.hardwareConcurrency para criar o mesmo número de linhas de execução que há núcleos na CPU. A segunda opção é útil quando o 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. Portanto, em vez de reservar todas as cores, basta usar -s PTHREAD_POOL_SIZE=1:

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

Dessa vez, quando você executar, as coisas vão funcionar:

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

Há outro problema: você viu o sleep(1) no exemplo de código? Ele é executado no callback da linha de execução, ou seja, fora da linha de execução principal. Isso não é um problema, certo? Não é.

Quando pthread_join é chamado, ele precisa aguardar a conclusão da execução da linha de execução, o que significa que, se a linha de execução criada estiver realizando tarefas de longa duração (neste caso, suspensa por um segundo), a linha de execução principal também precisará ser bloqueada pelo mesmo tempo até que os resultados voltem. Quando esse JS é executado no navegador, ele bloqueia a linha de execução da interface por 1 segundo até que o callback da linha de execução seja retornado. Isso leva a uma experiência ruim do usuário.

Há algumas soluções para isso:

  • pthread_detach
  • -s PROXY_TO_PTHREAD
  • Worker personalizado e Comlink

pthread_detach

Primeiro, se você só precisa executar algumas tarefas fora da linha de execução principal, mas não precisa esperar pelos resultados, use pthread_detach em vez de pthread_join. Isso deixa o callback da linha de execução em execução em segundo plano. Se você usa essa opção, é possível desativar o alerta com -s PTHREAD_POOL_SIZE_STRICT=0.

PROXY_TO_PTHREAD

Em segundo lugar, se você estiver compilando um aplicativo C em vez de uma biblioteca, use a opção -s PROXY_TO_PTHREAD, que vai descarregar o código principal do aplicativo em uma linha de execução separada, além de todas as linhas de execução aninhadas criadas pelo próprio aplicativo. Dessa forma, o código principal pode bloquear com segurança a qualquer momento sem congelar a interface. Ao usar essa opção, você também não precisa pré-criar 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, em seguida, bloquear a linha de execução auxiliar em pthread_join sem impasses.

Terceiro, se você estiver trabalhando em uma biblioteca e ainda precisar bloquear, crie seu próprio worker, importe o código gerado pelo Emscripten e o exponha com Comlink na linha de execução principal. A linha de execução principal poderá invocar todos os métodos exportados como funções assíncronas, o que 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 mesmas ressalvas e lógica se aplicam da mesma forma ao C++. A única novidade é o acesso a APIs de nível mais alto, como std::thread e std::async, que usam a biblioteca pthread discutida anteriormente.

O exemplo acima pode ser reescrito em C++ de forma mais idiomática, desta forma:

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 comporta 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 de ponta a ponta especializado, mas fornece um destino genérico wasm32-unknown-unknown para uma saída genérica do WebAssembly.

Se o Wasm for destinado ao uso 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 conhece os Web Workers, e APIs padrão, como std::thread, não vão funcionar quando compiladas para o WebAssembly.

Felizmente, a maioria do ecossistema depende de bibliotecas de nível mais alto para cuidar da multitarefa. Nesse nível, é muito mais fácil se abstrair de todas as diferenças da plataforma.

Em particular, Rayon é a escolha mais popular para 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 seriam 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 vai dividir os dados de entrada, calcular x * x e somas parciais em linhas de execução paralelas e, no final, somar esses resultados parciais.

Para acomodar plataformas sem std::thread, o Rayon oferece hooks que permitem definir uma lógica personalizada para gerar e sair de linhas de execução.

O wasm-bindgen-rayon usa esses hooks para gerar linhas de execução do WebAssembly como Web Workers. Para usá-lo, adicione-o como uma dependência e siga as etapas de configuração descritas nos documentos. O exemplo acima vai ficar assim:

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 vai exportar uma função initThreadPool extra. Essa função vai criar um pool de workers e reutilizá-los durante a vida útil do programa para qualquer operação multithread feita pelo Rayon.

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

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. Mesmo 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, para garantir a segurança, os mecanismos do navegador evitam ativamente o bloqueio da linha de execução principal, e esse código gera um erro. Em vez disso, crie um worker, importe o código gerado por wasm-bindgen e exponha a API dele com uma biblioteca como Comlink para a linha de execução principal.

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

Casos de uso reais

Usamos ativamente as linhas de execução do WebAssembly no Squoosh.app para compressão de imagens do lado do cliente, principalmente para formatos como AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) e WebP v2 (C++). Graças à multitarefa, observamos um aumento consistente de 1,5 a 3 vezes na velocidade (a proporção exata varia de acordo com o codec) e conseguimos aumentar ainda mais esses números combinando linhas de execução do WebAssembly com o SIMD do WebAssembly.

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

FFMPEG.WASM é uma versão do WebAssembly de uma cadeia de ferramentas multimídia FFmpeg que usa linhas de execução do WebAssembly para codificar vídeos diretamente no navegador de maneira eficiente.

Há muitos outros exemplos interessantes usando linhas de execução do WebAssembly. Confira as demonstrações e traga seus próprios aplicativos e bibliotecas com multithreading para a Web.