Cómo usar subprocesos de WebAssembly desde C, C++ y Rust

Aprende a incorporar aplicaciones multiproceso escritas en otros lenguajes a WebAssembly.

La compatibilidad con subprocesos de WebAssembly es una de las incorporaciones de rendimiento más importantes de WebAssembly. Te permite ejecutar partes de tu código en paralelo en núcleos separados o el mismo código en partes independientes de los datos de entrada, escalarlo a tantos núcleos como tenga el usuario y reducir de forma significativa el tiempo de ejecución general.

En este artículo, aprenderás a usar subprocesos de WebAssembly para llevar a la Web aplicaciones multiproceso escritas en lenguajes como C, C++ y Rust.

Cómo funcionan los subprocesos de WebAssembly

Los subprocesos de WebAssembly no son una función independiente, sino una combinación de varios componentes que permite que las apps de WebAssembly usen paradigmas de subprocesos múltiples tradicionales en la Web.

Web Workers

El primer componente son los trabajadores normales que conoces y que te encantan de JavaScript. Los subprocesos de WebAssembly usan el constructor new Worker para crear subprocesos subyacentes nuevos. Cada subproceso carga un elemento de unión de JavaScript y, luego, el subproceso principal usa el método Worker#postMessage para compartir el WebAssembly.Module compilado, así como un WebAssembly.Memory compartido (consulta a continuación) con esos otros subprocesos. Esto establece la comunicación y permite que todos esos subprocesos ejecuten el mismo código de WebAssembly en la misma memoria compartida sin volver a pasar por JavaScript.

Los Web Workers existen desde hace más de una década, son ampliamente compatibles y no requieren ninguna marca especial.

SharedArrayBuffer

La memoria de WebAssembly se representa con un objeto WebAssembly.Memory en la API de JavaScript. De forma predeterminada, WebAssembly.Memory es un wrapper alrededor de un ArrayBuffer, un búfer de bytes sin procesar al que solo puede acceder un solo subproceso.

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

Para admitir varios subprocesos, WebAssembly.Memory también obtuvo una variante compartida. Cuando se crea con una marca shared a través de la API de JavaScript o por el binario de WebAssembly, se convierte en un wrapper alrededor de un SharedArrayBuffer. Es una variación de ArrayBuffer que se puede compartir con otros subprocesos y leer o modificar de forma simultánea desde cualquier lado.

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

A diferencia de postMessage, que se usa normalmente para la comunicación entre el subproceso principal y los trabajadores web, SharedArrayBuffer no requiere copiar datos ni esperar a que el bucle de eventos envíe y reciba mensajes. En cambio, todos los cambios ven los cambios casi al instante, lo que lo convierte en un objetivo de compilación mucho mejor para las primitivas de sincronización tradicionales.

SharedArrayBuffer tiene un historial complicado. Inicialmente, se envió en varios navegadores a mediados de 2017, pero se tuvo que inhabilitar a principios de 2018 debido al descubrimiento de vulnerabilidades de Spectre. El motivo particular era que la extracción de datos en Spectre se basa en ataques de tiempo, que miden el tiempo de ejecución de un fragmento de código en particular. Para dificultar este tipo de ataque, los navegadores redujeron la precisión de las APIs de tiempo estándar, como Date.now y performance.now. Sin embargo, la memoria compartida, combinada con un bucle de contador simple que se ejecuta en un subproceso independiente también es una forma muy confiable de obtener tiempos de alta precisión y es mucho más difícil de mitigar sin reducir significativamente el rendimiento del entorno de ejecución.

En cambio, Chrome 68 (mediados de 2018) volvió a habilitar SharedArrayBuffer aprovechando el aislamiento de sitios, una función que coloca diferentes sitios web en diferentes procesos y dificulta mucho más el uso de ataques de canal lateral como Spectre. Sin embargo, esta mitigación aún se limitaba solo a Chrome para computadoras, ya que el aislamiento de sitios es una función bastante costosa y no se podía habilitar de forma predeterminada para todos los sitios en dispositivos móviles con poca memoria, ni otros proveedores la habían implementado.

En la actualidad, Chrome y Firefox implementan el aislamiento de sitios y tienen una forma estándar para que los sitios web habiliten la función con los encabezados COOP y COEP. Un mecanismo de habilitación voluntaria permite usar el aislamiento de sitios incluso en dispositivos de baja potencia, en los que habilitarlo para todos los sitios web sería demasiado costoso. Para habilitarlo, agrega los siguientes encabezados al documento principal en la configuración del servidor:

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

Una vez que habilites esta opción, obtendrás acceso a SharedArrayBuffer (incluida WebAssembly.Memory respaldada por una SharedArrayBuffer), temporizadores precisos, medición de memoria y otras APIs que requieren un origen aislado por motivos de seguridad. Consulta Cómo hacer que tu sitio web sea "aislado de varios orígenes" con COOP y COEP para obtener más información.

Funciones atómicas de WebAssembly

Si bien SharedArrayBuffer permite que cada subproceso lea y escriba en la misma memoria, para una comunicación correcta, debes asegurarte de que no realicen operaciones contradictorias al mismo tiempo. Por ejemplo, es posible que un subproceso comience a leer datos de una dirección compartida mientras otro subproceso le escribe, por lo que el primer subproceso obtendrá un resultado dañado. Esta categoría de errores se conoce como condiciones de carrera. Para evitar las condiciones de carrera, debes sincronizar de alguna manera esos accesos. Aquí es donde entran en juego las operaciones atómicas.

Las atómicas de WebAssembly son una extensión del conjunto de instrucciones de WebAssembly que permite leer y escribir pequeñas celdas de datos (por lo general, números enteros de 32 y 64 bits) "de forma atómica". Es decir, de una manera que garantice que no haya dos subprocesos que lean o escriban en la misma celda al mismo tiempo, lo que evita esos conflictos a un nivel bajo. Además, los elementos atómicos de WebAssembly contienen dos tipos de instrucciones más: "wait" y "notify", que permiten que un subproceso entre en suspensión ("wait") en una dirección determinada en una memoria compartida hasta que otro subproceso lo active a través de "notify".

Todas las primitivas de sincronización de nivel superior, incluidos los canales, las exclusiones mutuas y los bloqueos de lectura y escritura, se basan en esas instrucciones.

Cómo usar subprocesos de WebAssembly

Detección de atributos

Los elementos atómicos de WebAssembly y SharedArrayBuffer son funciones relativamente nuevas y aún no están disponibles en todos los navegadores compatibles con WebAssembly. Puedes encontrar qué navegadores admiten las nuevas funciones de WebAssembly en el plan de trabajo de webassembly.org.

Para garantizar que todos los usuarios puedan cargar tu aplicación, deberás implementar la mejora progresiva compilando dos versiones diferentes de Wasm: una con compatibilidad con subprocesos y otra sin ella. Luego, carga la versión compatible según los resultados de la detección de funciones. Para detectar la compatibilidad con subprocesos de WebAssembly durante el tiempo de ejecución, usa la biblioteca wasm-feature-detect y carga el módulo de la siguiente manera:

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

Ahora, veamos cómo compilar una versión de subprocesos múltiples del módulo WebAssembly.

C

En C, en particular en sistemas similares a Unix, la forma común de usar subprocesos es a través de los subprocesos POSIX que proporciona la biblioteca pthread. Emscripten proporciona una implementación compatible con la API de la biblioteca pthread compilada en Web Workers, memoria compartida y funciones atómicas, de modo que el mismo código pueda funcionar en la Web sin cambios.

Veamos un ejemplo:

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;
}

Aquí, los encabezados de la biblioteca pthread se incluyen a través de pthread.h. También puedes ver algunas funciones cruciales para controlar los subprocesos.

pthread_create creará un subproceso en segundo plano. Toma un destino para almacenar un identificador de subproceso, algunos atributos de creación de subprocesos (aquí no se pasa ninguno, por lo que es solo NULL), la devolución de llamada que se ejecutará en el subproceso nuevo (aquí thread_callback) y un puntero de argumento opcional para pasar a esa devolución de llamada en caso de que desees compartir algunos datos del subproceso principal. En este ejemplo, compartimos un puntero a una variable arg.

Se puede llamar a pthread_join más adelante en cualquier momento para esperar a que el subproceso finalice la ejecución y obtener el resultado que devuelve la devolución de llamada. Acepta el identificador de subproceso asignado anteriormente, así como un puntero para almacenar el resultado. En este caso, no hay resultados, por lo que la función toma un NULL como argumento.

Para compilar código con subprocesos con Emscripten, debes invocar emcc y pasar un parámetro -pthread, como cuando compilas el mismo código con Clang o GCC en otras plataformas:

emcc -pthread example.c -o example.js

Sin embargo, cuando intentes ejecutarlo en un navegador o Node.js, verás una advertencia y, luego, el programa se suspenderá:

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…]

¿Qué pasó? El problema es que la mayoría de las APIs de la Web que consumen mucho tiempo son asíncronas y dependen del bucle de eventos para ejecutarse. Esta limitación es una distinción importante en comparación con los entornos tradicionales, en los que las aplicaciones suelen ejecutar operaciones de E/S de forma síncrona y de bloqueo. Consulta la entrada de blog sobre Cómo usar APIs web asíncronas desde WebAssembly si quieres obtener más información.

En este caso, el código invoca de forma síncrona a pthread_create para crear un subproceso en segundo plano y, luego, realiza otra llamada síncrona a pthread_join que espera a que el subproceso en segundo plano termine la ejecución. Sin embargo, los Web Workers, que se usan en segundo plano cuando este código se compila con Emscripten, son asíncronos. Lo que sucede es que pthread_create solo programa un nuevo subproceso de trabajador para que se cree en la siguiente ejecución del bucle de eventos, pero luego pthread_join bloquea de inmediato el bucle de eventos para esperar a ese trabajador y, de esta manera, evita que se cree. Es un ejemplo clásico de un interbloqueo.

Una forma de resolver este problema es crear un grupo de trabajadores con anticipación, antes de que el programa siquiera comience. Cuando se invoca pthread_create, puede tomar un trabajador listo para usar del grupo, ejecutar la devolución de llamada proporcionada en su subproceso en segundo plano y devolver el trabajador al grupo. Todo esto se puede hacer de forma síncrona, por lo que no habrá interbloqueos, siempre y cuando el grupo sea lo suficientemente grande.

Esto es exactamente lo que Emscripten permite con la opción -s PTHREAD_POOL_SIZE=.... Permite especificar una cantidad de subprocesos, ya sea un número fijo o una expresión de JavaScript como navigator.hardwareConcurrency, para crear tantos subprocesos como núcleos en la CPU. La última opción es útil cuando tu código puede escalar a una cantidad arbitraria de subprocesos.

En el ejemplo anterior, solo se crea un subproceso, por lo que, en lugar de reservar todos los núcleos, es suficiente con usar -s PTHREAD_POOL_SIZE=1:

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

Esta vez, cuando lo ejecutes, todo funcionará correctamente:

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

Sin embargo, hay otro problema: ¿ves sleep(1) en el ejemplo de código? Se ejecuta en la devolución de llamada del subproceso, es decir, fuera del subproceso principal, por lo que debería funcionar bien, ¿verdad? Bueno, no lo es.

Cuando se llama a pthread_join, debe esperar a que finalice la ejecución del subproceso, lo que significa que, si el subproceso creado realiza tareas de larga duración (en este caso, se suspende 1 segundo), el subproceso principal también deberá bloquearse durante la misma cantidad de tiempo hasta que se muestren los resultados. Cuando se ejecute este código JS en el navegador, bloqueará el subproceso de IU durante 1 segundo hasta que se devuelva la devolución de llamada del subproceso. Esto genera una experiencia del usuario deficiente.

Hay algunas soluciones para esto:

  • pthread_detach
  • -s PROXY_TO_PTHREAD
  • Custom Worker y Comlink

pthread_detach

En primer lugar, si solo necesitas ejecutar algunas tareas fuera del subproceso principal, pero no necesitas esperar los resultados, puedes usar pthread_detach en lugar de pthread_join. Esto dejará la devolución de llamada del subproceso en ejecución en segundo plano. Si usas esta opción, puedes desactivar la advertencia con -s PTHREAD_POOL_SIZE_STRICT=0.

PROXY_TO_PTHREAD

En segundo lugar, si compilas una aplicación de C en lugar de una biblioteca, puedes usar la opción -s PROXY_TO_PTHREAD, que descargará el código principal de la aplicación en un subproceso independiente, además de los subprocesos anidados creados por la aplicación. De esta manera, el código principal puede bloquearse de forma segura en cualquier momento sin inmovilizar la IU. Por cierto, cuando usas esta opción, tampoco tienes que crear previamente el grupo de subprocesos. En su lugar, Emscripten puede aprovechar el subproceso principal para crear nuevos trabajadores subyacentes y, luego, bloquear el subproceso auxiliar en pthread_join sin interbloqueos.

En tercer lugar, si estás trabajando en una biblioteca y aún necesitas bloquear, puedes crear tu propio Worker, importar el código generado por Emscripten y exponerlo con Comlink al subproceso principal. El subproceso principal podrá invocar cualquier método exportado como función asíncrona y, de esa manera, también evitará bloquear la IU.

En una aplicación simple, como el ejemplo anterior, -s PROXY_TO_PTHREAD es la mejor opción:

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

C++

Todas las mismas advertencias y lógicas se aplican de la misma manera a C++. Lo único nuevo que obtienes es acceso a APIs de nivel superior, como std::thread y std::async, que usan la biblioteca pthread que se analizó anteriormente.

Por lo tanto, el ejemplo anterior se puede volver a escribir en C++ más idiomático de la siguiente manera:

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;
}

Cuando se compile y ejecute con parámetros similares, se comportará de la misma manera que el ejemplo de C:

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

Resultado:

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

A diferencia de Emscripten, Rust no tiene un destino web especializado de extremo a extremo, sino que proporciona un destino wasm32-unknown-unknown genérico para la salida genérica de WebAssembly.

Si se pretende usar Wasm en un entorno web, cualquier interacción con las APIs de JavaScript se deja a las bibliotecas y herramientas externas, como wasm-bindgen y wasm-pack. Lamentablemente, esto significa que la biblioteca estándar no reconoce a los trabajadores web, y las APIs estándar, como std::thread, no funcionarán cuando se compilen en WebAssembly.

Por suerte, la mayoría del ecosistema depende de bibliotecas de nivel superior para controlar el procesamiento en varios subprocesos. En ese nivel, es mucho más fácil abstraer todas las diferencias de plataforma.

En particular, Rayon es la opción más popular para el paralelismo de datos en Rust. Te permite tomar cadenas de métodos en iteradores normales y, por lo general, con un cambio de una sola línea, convertirlos de una manera en la que se ejecuten en paralelo en todos los subprocesos disponibles en lugar de de forma secuencial. Por ejemplo:

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

Con este pequeño cambio, el código dividirá los datos de entrada, calculará x * x y sumas parciales en subprocesos paralelos y, al final, sumará esos resultados parciales.

Para adaptarse a las plataformas sin std::thread que funcione, Rayon proporciona hooks que permiten definir una lógica personalizada para generar subprocesos y salir de ellos.

wasm-bindgen-rayon aprovecha esos hooks para generar subprocesos de WebAssembly como Web Workers. Para usarlo, debes agregarlo como una dependencia y seguir los pasos de configuración que se describen en la documentación. El ejemplo anterior se verá de la siguiente manera:

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()
}

Una vez que termines, el código JavaScript generado exportará una función initThreadPool adicional. Esta función creará un grupo de trabajadores y los reutilizará durante el ciclo de vida del programa para cualquier operación multiproceso que realice Rayon.

Este mecanismo de grupo es similar a la opción -s PTHREAD_POOL_SIZE=... en Emscripten que se explicó antes y también debe inicializarse antes del código principal para evitar interbloqueos:

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

Ten en cuenta que las mismas advertencias sobre el bloqueo del subproceso principal se aplican aquí. Incluso el ejemplo de sum_of_squares aún debe bloquear el subproceso principal para esperar los resultados parciales de otros subprocesos.

Puede ser una espera muy corta o larga, según la complejidad de los iteradores y la cantidad de subprocesos disponibles, pero, para mayor seguridad, los motores de navegadores evitan de forma activa bloquear el subproceso principal por completo, y ese código arrojará un error. En su lugar, debes crear un trabajador, importar el código generado por wasm-bindgen allí y exponer su API con una biblioteca como Comlink al subproceso principal.

Consulta el ejemplo de wasm-bindgen-rayon para ver una demostración de extremo a extremo que muestra lo siguiente:

Casos de uso del mundo real

Usamos subprocesos de WebAssembly de forma activa en Squoosh.app para la compresión de imágenes del cliente, en particular, para formatos como AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) y WebP v2 (C++). Gracias solo al procesamiento en varios subprocesos, observamos aceleraciones coherentes de 1.5 a 3 veces (la proporción exacta difiere según el códec) y pudimos aumentar esas cifras aún más combinando subprocesos de WebAssembly con WebAssembly SIMD.

Google Earth es otro servicio notable que usa subprocesos de WebAssembly para su versión web.

FFMPEG.WASM es una versión de WebAssembly de una popular cadena de herramientas multimedia FFmpeg que usa subprocesos de WebAssembly para codificar videos de manera eficiente directamente en el navegador.

Hay muchos más ejemplos interesantes que usan subprocesos de WebAssembly. Asegúrate de ver las demos y llevar tus propias bibliotecas y aplicaciones de varios subprocesos a la Web.