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

Obtén información para 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 sobre partes independientes de los datos de entrada, escalarlo a tantos núcleos como el usuario tenga y reducir significativamente 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 permiten que las apps de WebAssembly usen paradigmas de multiprocesamiento tradicionales en la Web.

Trabajadores web

El primer componente son los Workers normales que conoces y te encantan de JavaScript. Los subprocesos de WebAssembly usan el constructor new Worker para crear subprocesos subyacentes nuevos. Cada subproceso carga una unión de JavaScript y, luego, el subproceso principal usa el método Worker#postMessage para compartir el WebAssembly.Module compilado y un WebAssembly.Memory compartido (ver 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 trabajadores web existen desde hace más de una década, tienen una amplia compatibilidad 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 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 mediante el objeto binario WebAssembly, se convierte en un wrapper alrededor de una SharedArrayBuffer. Es una variación de ArrayBuffer que se puede compartir con otros subprocesos y leer o modificar simultáneamente desde ambos lados.

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

A diferencia de postMessage, que por lo general se usa 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 subprocesos 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.

El historial de SharedArrayBuffer es complicado. Inicialmente, se envió en varios navegadores a mediados de 2017, pero tuvo que inhabilitarse a principios de 2018 debido al descubrimiento de vulnerabilidades de Spectre. El motivo en particular era que la extracción de datos en Spectre se basa en ataques de tiempo, lo que mide 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 simple bucle de contador 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 limitar significativamente el rendimiento del tiempo 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 distintos procesos y hace que sea mucho más difícil usar ataques de canal lateral como Spectre. Sin embargo, esta mitigación se limitaba solo a computadoras de escritorio de Chrome, 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 se había implementado aún por otros proveedores.

En el futuro hasta 2020, Chrome y Firefox tienen implementaciones de aislamiento de sitios y una forma estándar para que los sitios web habiliten la función con encabezados COOP y COEP. Este mecanismo permite utilizar 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 la función, obtendrás acceso a SharedArrayBuffer (incluido WebAssembly.Memory respaldado por un SharedArrayBuffer), temporizadores precisos, medición de memoria y otras APIs que requieren un origen aislado por motivos de seguridad. Para obtener más información, consulta Cómo hacer que tu sitio web esté “aislado con orígenes cruzados” con COOP y COEP.

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 conflictivas al mismo tiempo. Por ejemplo, es posible que un subproceso comience a leer datos desde una dirección compartida mientras otro subproceso esté escribiendo en ella; por lo tanto, el primer subproceso obtendrá un resultado dañado. Esta categoría de errores se conoce como condiciones de carrera. Para evitar condiciones de carrera, debes sincronizar de alguna manera esos accesos. Aquí es donde entran en juego las operaciones atómicas.

La atómica de WebAssembly es una extensión del conjunto de instrucciones de WebAssembly que permite leer y escribir celdas pequeñas de datos (por lo general, números enteros de 32 y 64 bits) "de forma atómica". Es decir, de una manera que se garantice que no dos subprocesos lean ni escriban en la misma celda al mismo tiempo, lo que evita tales conflictos en un nivel bajo. Además, las atómicos de WebAssembly contienen dos tipos de instrucciones más, "esperar" y "notificar", que permiten que un subproceso se suspenda ("esperar") en una dirección determinada en una memoria compartida hasta que otro subproceso se active a través de "notificar".

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 funciones

Las funciones atómicas de WebAssembly y SharedArrayBuffer son funciones relativamente nuevas y aún no están disponibles en todos los navegadores compatibles con WebAssembly. Puedes ver qué navegadores admiten las nuevas funciones de WebAssembly en la hoja de ruta de web casi.org.

Para asegurarte de que todos los usuarios puedan cargar tu aplicación, deberás implementar la mejora progresiva. Para ello, compila dos versiones diferentes de Wasm: una compatible con varios 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 en el tiempo de ejecución, usa la biblioteca de 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 multiproceso del módulo WebAssembly.

C

En C, particularmente en sistemas similares a Unix, la forma común de usar subprocesos es a través de Threads POSIX, que proporciona la biblioteca pthread. Emscripten proporciona una implementación compatible con la API de la biblioteca pthread compilada sobre 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 un par de funciones críticas para trabajar con subprocesos.

pthread_create creará un subproceso en segundo plano. Toma un destino para almacenar un controlador de subproceso, algunos atributos de creación de subprocesos (aquí no pasamos ninguno, por lo que solo es NULL), la devolución de llamada que se ejecutará en el nuevo subproceso (aquí thread_callback) y un puntero de argumento opcional para pasar a esa devolución de llamada en caso de que quieras compartir algunos datos del subproceso principal. En este ejemplo, compartimos un puntero para 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 obtenga el resultado que muestra la devolución de llamada. Acepta el controlador 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 elemento NULL como argumento.

Para compilar código usando subprocesos con Emscripten, debes invocar a 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 en Node.js, verás una advertencia y, luego, el programa se colgará:

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 requieren 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 E/S de forma síncrona y de bloqueo. Consulta la entrada de blog sobre Cómo usar APIs web asíncronas de WebAssembly si deseas obtener más información.

En este caso, el código invoca de forma síncrona pthread_create para crear un subproceso en segundo plano y sigue con otra llamada síncrona a pthread_join que espera a que finalice el subproceso en segundo plano. Sin embargo, los Web Workers, que se usan en segundo plano cuando este código se compila con Emscripten, son asíncronos. Entonces, pthread_create solo programa la creación de un subproceso de Worker nuevo en la próxima ejecución del bucle de eventos, pero pthread_join bloquea inmediatamente el bucle de evento para esperar a ese Worker y, al hacerlo, 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, incluso antes de que comience el programa. Cuando se invoca pthread_create, puede tomar del grupo un trabajador listo para usar, ejecutar la devolución de llamada proporcionada en su subproceso en segundo plano y devolver el trabajador de vuelta al grupo. Todo esto se puede realizar de forma síncrona, por lo que no habrá ningún interbloqueo, 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 determinada 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, basta 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: ¿puedes ver ese 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 no debería haber problemas, ¿no? 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, suspendida durante 1 segundo), el subproceso principal también tendrá que bloquearse por el mismo tiempo hasta que regresen los resultados. Cuando se ejecute este JS en el navegador, se bloqueará el subproceso de IU durante 1 segundo hasta que se muestre la devolución de llamada del subproceso. Esto perjudica la experiencia del usuario.

Existen algunas soluciones para esto:

  • pthread_detach
  • -s PROXY_TO_PTHREAD
  • Trabajador personalizado y Comlink

pthread_detach

Primero, 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. De esta manera, la devolución de llamada del subproceso se ejecutará 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 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 que creó la propia aplicación. De esta manera, el código principal puede bloquear de forma segura en cualquier momento sin congelar la IU. Por cierto, cuando usas esta opción, no tienes que crear previamente el conjunto de subprocesos; en su lugar, Emscripten puede aprovechar el subproceso principal para crear nuevos trabajadores subyacentes y, luego, bloquear el subproceso de ayuda en pthread_join sin interbloqueo.

En tercer lugar, si trabajas en una biblioteca y aún necesitas bloquearla, puedes crear tu propio trabajador, importar el código generado por Emscripten y exponerlo con Comlink al subproceso principal. El subproceso principal podrá invocar cualquier método exportado como funciones asíncronas y, de esa manera, también evitará el bloqueo de la IU.

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

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

C++

Se aplican todas las mismas advertencias y lógica de la misma manera a C++. Lo único que obtendrás es acceso a APIs de nivel superior, como std::thread y std::async, que usan de forma interna la biblioteca pthread analizada con anterioridad.

Por lo tanto, el ejemplo anterior se puede reescribir en C++ más idiomáticos 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 compila y se ejecuta 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 objetivo wasm32-unknown-unknown genérico para el resultado genérico de WebAssembly.

Si Wasm está diseñado para usarse en un entorno web, cualquier interacción con las APIs de JavaScript depende de bibliotecas y herramientas externas, como wasm-bindgen y wasm-pack. Lamentablemente, esto significa que la biblioteca estándar no conoce los Web Workers, y las APIs estándar como std::thread no funcionarán cuando se compilen en WebAssembly.

Afortunadamente, la mayor parte del ecosistema depende de bibliotecas de nivel superior para encargarse de los multiprocesamientos. En ese nivel, es mucho más fácil abstraer todas las diferencias de las plataformas.

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 regulares y, generalmente, con un solo cambio de línea, convertirlos de una manera en la que se ejecuten en paralelo en todos los subprocesos disponibles, en lugar de secuencialmente. 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 plataformas sin std::thread, Rayon proporciona hooks que permiten definir la lógica personalizada para los subprocesos de generación y salida.

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 los docs. 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 hecho esto, el JavaScript generado exportará una función initThreadPool adicional. Esta función creará un grupo de trabajadores y los reutilizará durante la vida útil del programa para cualquier operación multiproceso realizada por Rayon.

Este mecanismo de agrupación es similar a la opción -s PTHREAD_POOL_SIZE=... en Emscripten explicada 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 en este caso también se aplican las mismas advertencias sobre el bloqueo del subproceso principal. Incluso el ejemplo de sum_of_squares aún necesita 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, por precaución, los motores del navegador evitan de manera activa el bloqueo del subproceso principal y este código arrojará un error. En cambio, 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 de manera activa subprocesos de WebAssembly 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 a los subprocesos múltiples, pudimos combinar esas velocidades de código de 1.5x-3x por combinación de velocidad de código de 1.5x-3x

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

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

Hay muchos ejemplos más interesantes en los que se usan subprocesos de WebAssembly. Asegúrate de revisar las demostraciones y de llevar tus propias aplicaciones multiproceso y bibliotecas a la Web.