Saiba como trazer aplicativos multithread escritos em outros idiomas para o WebAssembly.
O suporte a threads do WebAssembly é uma das adições de desempenho mais importantes ao WebAssembly. Ele permite executar partes do código em paralelo em núcleos separados ou o mesmo código em partes independentes dos dados de entrada, dimensionando-o para tantos núcleos quanto 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 as linhas de execução do WebAssembly funcionam
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 WebAssembly na mesma memória compartilhada sem passar pelo JavaScript novamente.
Os workers da Web existem há mais de uma década, têm suporte amplo e não exigem flags 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 a 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 pelo loop de eventos para enviar e receber 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 SharedArrayBuffer
tem um histórico 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 contador simples em execução 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 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 têm implementações do isolamento de site e uma maneira padrão para que os sites ativem 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ários origens usando COOP
e COEP para mais detalhes.
Átomos do WebAssembly
Embora SharedArrayBuffer
permita que cada linha de execução leia e grave na mesma memória, para uma comunicação
correta, você precisa 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. Assim, a primeira linha de execução vai receber um resultado corrompido. Essa categoria de bugs é conhecida como condições
de disputa. 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, de uma maneira que garanta que nenhuma das linhas de execução esteja lendo ou gravando na mesma célula ao mesmo tempo, evitando esses conflitos em um nível baixo. 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/gravação, são baseadas nessas instruções.
Como usar 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. Confira quais navegadores oferecem suporte aos novos recursos do WebAssembly
no plano de ação do webassembly.org.
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 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
é seguido por outra chamada síncrona para pthread_join
que aguarda a linha de execução em segundo plano
concluir 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_create
só programa 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
iniciar. 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 forma síncrona, para que não haja deadlocks, 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 última 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
Desta 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. Isso significa que, se
a linha de execução criada estiver realizando tarefas de longa duração (neste caso, com 1 segundo de inatividade), a linha de execução
principal também precisará ser bloqueada pelo mesmo período até que os resultados sejam retornados. 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ê estiver
usando essa opção, poderá desativar o aviso 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 ser bloqueado com segurança a qualquer momento sem congelar a interface.
A propósito, ao usar essa opção, você não precisa 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 de segundo plano e, em seguida, bloquear a
linha de execução auxiliar em pthread_join
sem deadlock.
Comlink
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 e, assim, 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 completo especializado, mas fornece um
destino wasm32-unknown-unknown
genérico para saídas genéricas 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 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 abstrair todas as diferenças de 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 observações 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 uma demonstração completa que mostra:
- Detecção de recursos de linhas de execução.
- Criar versões de um e vários threads do mesmo app Rust.
- Como carregar o JS+Wasm gerado por wasm-bindgen em um Worker.
- Uso de wasm-bindgen-rayon para inicializar um pool de linhas de execução.
- Usar o Comlink para expor a API do worker para a linha de execução principal.
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 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 de uso de linhas de execução do WebAssembly. Confira as demonstrações e leve suas próprias bibliotecas e aplicativos multithread para a Web.