Usa APIs web asíncronas desde WebAssembly

Las APIs de E/S en la Web son asíncronas, pero lo son en la mayoría de los lenguajes del sistema. Cuando compilas código para WebAssembly, debes conectar un tipo de API a otro, y este puente es asíncrono. En esta publicación, aprenderás cuándo y cómo usar Asyncify y cómo funciona de forma interna.

E/S en idiomas del sistema

Comenzaré con un ejemplo sencillo en C. Supongamos que deseas leer el nombre del usuario desde un archivo y saludarlo con el mensaje “Hello, (username)!”:

#include <stdio.h>

int main() {
    FILE *stream = fopen("name.txt", "r");
    char name[20+1];
    size_t len = fread(&name, 1, 20, stream);
    name[len] = '\0';
    fclose(stream);
    printf("Hello, %s!\n", name);
    return 0;
}

Si bien el ejemplo no sirve de mucho, ya demuestra algo que encontrarás en una aplicación de cualquier tamaño: lee algunas entradas del mundo externo, las procesa de forma interna y escribe salidas de vuelta al mundo externo. Esta interacción con el mundo exterior ocurre a través de algunas funciones que se suelen llamar funciones de entrada y salida, también abreviadas como E/S.

Para leer el nombre de C, necesitas al menos dos llamadas de E/S cruciales: fopen, para abrir el archivo, y fread para leer los datos desde él. Una vez que recuperes los datos, puedes usar otra función de E/S printf para imprimir el resultado en la consola.

Esas funciones parecen bastante simples a primera vista y no tienes que pensar dos veces en la maquinaria necesaria para leer o escribir datos. Sin embargo, según el entorno, puede haber mucha actividad en el interior:

  • Si el archivo de entrada se encuentra en una unidad local, la aplicación necesita realizar una serie de accesos a la memoria y al disco para ubicar el archivo, verificar los permisos, abrirlo para su lectura y, luego, leer bloque a bloque hasta que se recupere la cantidad solicitada de bytes. Esto puede ser bastante lento según la velocidad de tu disco y el tamaño solicitado.
  • O bien, el archivo de entrada podría estar ubicado en una ubicación de red activada, en cuyo caso, la pila de red ahora también participará, lo que aumentará la complejidad, la latencia y la cantidad de reintentos potenciales para cada operación.
  • Por último, no se garantiza que incluso printf imprima elementos en la consola y podría redireccionarse a un archivo o a una ubicación de red, en cuyo caso tendría que seguir los mismos pasos anteriores.

En pocas palabras, las E/S pueden ser lentas, y no puedes predecir cuánto tiempo llevará una llamada en particular con solo un vistazo al código. Mientras se ejecute esa operación, toda la aplicación aparecerá bloqueada y no responderá al usuario.

Esto tampoco se limita a C o C++. La mayoría de los lenguajes de sistema presentan toda la E/S en forma de APIs síncronas. Por ejemplo, si traduces el ejemplo a Rust, es posible que la API parezca más simple, pero se aplican los mismos principios. Solo debes hacer una llamada y esperar de manera síncrona a que muestre el resultado, mientras realiza todas las operaciones costosas y, al final, muestra el resultado en una sola invocación:

fn main() {
    let s = std::fs::read_to_string("name.txt");
    println!("Hello, {}!", s);
}

Sin embargo, ¿qué sucede cuando intentas compilar cualquiera de esas muestras en WebAssembly y traducirlas a la Web? O, para proporcionar un ejemplo específico, ¿a qué se podría traducir la operación de “lectura de archivos”? Necesitaría leer datos de algún almacenamiento.

Modelo asíncrono de la Web

La Web tiene una variedad de opciones de almacenamiento a las que puedes asignar, como almacenamiento en memoria (objetos JS), localStorage, IndexedDB, almacenamiento del servidor y una nueva API de File System Access.

Sin embargo, solo dos de esas APIs (el almacenamiento en memoria y localStorage) se pueden usar de forma síncrona, y ambas son las opciones más limitantes en cuanto a lo que puedes almacenar y por cuánto tiempo. Todas las demás opciones solo proporcionan APIs asíncronas.

Esta es una de las propiedades principales de la ejecución de código en la Web: cualquier operación que requiera mucho tiempo, lo que incluye cualquier E/S, debe ser asíncrona.

Esto se debe a que, históricamente, la Web tiene un solo subproceso, y cualquier código de usuario que toque la IU debe ejecutarse en el mismo subproceso que esta. Tiene que competir con las otras tareas importantes, como el diseño, la renderización y el control de eventos por el tiempo de CPU. No querrías que un fragmento de JavaScript o WebAssembly pueda iniciar una operación de "lectura de archivos" y bloquear todo lo demás (la pestaña completa, o, en el pasado, todo el navegador) durante un rango de milisegundos a algunos segundos, hasta que finalice.

En cambio, el código solo puede programar una operación de E/S junto con una devolución de llamada para que se ejecute una vez que finalice. Estas devoluciones de llamada se ejecutan como parte del bucle de eventos del navegador. No entraré en detalles, pero si te interesa saber cómo funciona el bucle de eventos de forma interna, consulta Tareas, microtareas, colas y programas, en el que se explica este tema en profundidad.

La versión corta es que el navegador ejecuta todos los fragmentos de código en una especie de bucle infinito, ya que los toma de la cola uno por uno. Cuando se activa algún evento, el navegador pone en cola el controlador correspondiente y, en la siguiente iteración del bucle, se quita de la cola y se ejecuta. Este mecanismo permite simular la simultaneidad y ejecutar muchas operaciones paralelas mientras se usa un solo subproceso.

Lo importante que debes recordar sobre este mecanismo es que, mientras se ejecuta tu código JavaScript (o WebAssembly) personalizado, el bucle de eventos se bloquea y, mientras lo esté, no hay forma de reaccionar a los controladores externos, eventos, E/S, etc. La única forma de recuperar los resultados de E/S es registrar una devolución de llamada, terminar de ejecutar el código y devolver el control al navegador para que pueda continuar procesando las tareas pendientes. Una vez que finalice la E/S, tu controlador se convertirá en una de esas tareas y se ejecutará.

Por ejemplo, si deseas volver a escribir las muestras anteriores en JavaScript moderno y decides leer un nombre desde una URL remota, debes usar la API de Fetch y la sintaxis async-await:

async function main() {
  let response = await fetch("name.txt");
  let name = await response.text();
  console.log("Hello, %s!", name);
}

Aunque parece síncrono, de forma interna, cada await es esencialmente sintaxis edulcorada para devoluciones de llamada:

function main() {
  return fetch("name.txt")
    .then(response => response.text())
    .then(name => console.log("Hello, %s!", name));
}

En este ejemplo con expansión de sintaxis, que es un poco más claro, se inicia una solicitud y las respuestas se suscriben con la primera devolución de llamada. Una vez que el navegador recibe la respuesta inicial (solo los encabezados HTTP), invoca esta devolución de llamada de forma asíncrona. La devolución de llamada comienza a leer el cuerpo como texto mediante response.text() y se suscribe al resultado con otra devolución de llamada. Por último, una vez que fetch recupera todo el contenido, invoca la última devolución de llamada, que muestra "Hello, (username)!" en la consola.

Gracias a la naturaleza asíncrona de esos pasos, la función original puede devolver el control al navegador en cuanto se programe la E/S y dejar toda la IU responsiva y disponible para otras tareas, incluidas la renderización, el desplazamiento, etc., mientras la E/S se ejecuta en segundo plano.

Como ejemplo final, incluso las APIs simples como "sleep", que hace que una aplicación espere una cantidad específica de segundos, también son una forma de operación de E/S:

#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");

Por supuesto, podrías traducirlo de una manera muy directa que bloquee el subproceso actual hasta que venza el tiempo:

console.log("A");
for (let start = Date.now(); Date.now() - start < 1000;);
console.log("B");

De hecho, eso es exactamente lo que Emscripten hace en su implementación predeterminada de "sleep", pero es muy ineficiente, bloquea toda la IU y no permite que se controlen ningún otro evento mientras tanto. Generalmente, no lo hagas en código de producción.

En su lugar, una versión más idiomática de "sleep" en JavaScript implicaría llamar a setTimeout() y suscribirse con un controlador:

console.log("A");
setTimeout(() => {
    console.log("B");
}, 1000);

¿Qué es lo que tienen en común todos estos ejemplos y APIs? En cada caso, el código idiomático en el lenguaje original de los sistemas usa una API de bloqueo para la E/S, mientras que un ejemplo equivalente para la Web usa una API asíncrona. Cuando compilas en la Web, de alguna manera necesitas transformar entre esos dos modelos de ejecución, y WebAssembly no tiene la capacidad integrada para hacerlo aún.

Cómo cerrar la brecha con Asyncify

Aquí es donde entra en juego Asyncify. Asyncify es una función de tiempo de compilación compatible con Emscripten que permite pausar todo el programa y reanudarlo de forma asíncrona más tarde.

Un gráfico de llamadas en el que se describe JavaScript -> WebAssembly -> API web -> invocación de tarea asíncrona, donde Asyncify conecta el resultado de la tarea asíncrona de nuevo a WebAssembly.

Uso en C / C++ con Emscripten

Si quisieras usar Asyncify para implementar una suspensión asíncrona en el último ejemplo, podrías hacerlo de la siguiente manera:

#include <stdio.h>
#include <emscripten.h>

EM_JS(void, async_sleep, (int seconds), {
    Asyncify.handleSleep(wakeUp => {
        setTimeout(wakeUp, seconds * 1000);
    });
});
…
puts("A");
async_sleep(1);
puts("B");

EM_JS es una macro que permite definir fragmentos de JavaScript como si fueran funciones de C. Adentro, usa una función Asyncify.handleSleep() que le indique a Emscripten que suspenda el programa y que proporcione un controlador wakeUp() al que se debe llamar una vez que finalice la operación asíncrona. En el ejemplo anterior, el controlador se pasa a setTimeout(), pero se puede usar en cualquier otro contexto que acepte devoluciones de llamada. Por último, puedes llamar a async_sleep() en cualquier lugar que desees, como sleep() normal o cualquier otra API síncrona.

Cuando compiles este código, debes indicarle a Emscripten que active la función Asyncify. Para ello, pasa -s ASYNCIFY y -s ASYNCIFY_IMPORTS=[func1, func2] con una lista de funciones similar a un array que puedan ser asíncronas.

emcc -O2 \
    -s ASYNCIFY \
    -s ASYNCIFY_IMPORTS=[async_sleep] \
    ...

Esto le permite a Emscripten saber que cualquier llamada a esas funciones puede requerir que se guarde y restablezca el estado, por lo que el compilador insertará código de compatibilidad alrededor de esas llamadas.

Ahora, cuando ejecutes este código en el navegador, verás un registro de resultados fluido, como esperabas, con B después de una breve demora después de A.

A
B

También puedes mostrar valores de funciones Asyncify. Lo que debes hacer es mostrar el resultado de handleSleep() y pasarlo a la devolución de llamada wakeUp(). Por ejemplo, si en lugar de leer desde un archivo deseas recuperar un número desde un recurso remoto, puedes usar un fragmento como el que se muestra a continuación para emitir una solicitud, suspender el código C y reanudarlo una vez que se recupere el cuerpo de la respuesta; todo ello sin problemas, como si la llamada fuera síncrona.

EM_JS(int, get_answer, (), {
     return Asyncify.handleSleep(wakeUp => {
        fetch("answer.txt")
            .then(response => response.text())
            .then(text => wakeUp(Number(text)));
    });
});
puts("Getting answer...");
int answer = get_answer();
printf("Answer is %d\n", answer);

De hecho, para las APIs basadas en promesas, como fetch(), incluso puedes combinar Asyncify con la función async-await de JavaScript en lugar de usar la API basada en devoluciones de llamada. Para eso, en lugar de Asyncify.handleSleep(), llama a Asyncify.handleAsync(). Luego, en lugar de tener que programar una devolución de llamada wakeUp(), puedes pasar una función async de JavaScript y usar await y return en el interior, lo que hace que el código parezca aún más natural y síncrono, sin perder ninguno de los beneficios de la E/S asíncrona.

EM_JS(int, get_answer, (), {
     return Asyncify.handleAsync(async () => {
        let response = await fetch("answer.txt");
        let text = await response.text();
        return Number(text);
    });
});

int answer = get_answer();

Esperando valores complejos

Sin embargo, este ejemplo te limita solo a números. ¿Qué sucede si quieres implementar el ejemplo original, en el que intenté obtener el nombre de un usuario de un archivo como una cadena? Bueno, ¡tú también puedes hacerlo!

Emscripten proporciona una función llamada Embind que te permite controlar conversiones entre valores de JavaScript y C++. También es compatible con Asyncify, por lo que puedes llamar a await() en Promise externos, y funcionará como await en código JavaScript async-await:

val fetch = val::global("fetch");
val response = fetch(std::string("answer.txt")).await();
val text = response.call<val>("text").await();
auto answer = text.as<std::string>();

Si usas este método, ni siquiera necesitas pasar ASYNCIFY_IMPORTS como una marca de compilación, dado que ya se incluye de forma predeterminada.

Todo esto funciona muy bien en Emscripten. ¿Qué ocurre con otros lenguajes y cadenas de herramientas?

Uso de otros idiomas

Supongamos que tienes una llamada síncrona similar en algún lugar de tu código de Rust que deseas asignar a una API asíncrona en la Web. Resulta que tú también puedes hacerlo.

Primero, debes definir esa función como una importación regular a través del bloque extern (o la sintaxis del lenguaje que elegiste para las funciones externas).

extern {
    fn get_answer() -> i32;
}

println!("Getting answer...");
let answer = get_answer();
println!("Answer is {}", answer);

Compila tu código en WebAssembly:

cargo build --target wasm32-unknown-unknown

Ahora debes instrumentar el archivo WebAssembly con código para almacenar/restablecer la pila. En el caso de C/C++, Emscripten haría esto por nosotros, pero no se usa aquí, por lo que el proceso es un poco más manual.

Afortunadamente, la transformación de Asyncify en sí es completamente independiente de la cadena de herramientas. Puede transformar archivos de WebAssembly arbitrarios, independientemente del compilador mediante el que los produzca. La transformación se proporciona por separado como parte del optimizador wasm-opt de la cadena de herramientas de Binaryen y se puede invocar de la siguiente manera:

wasm-opt -O2 --asyncify \
      --pass-arg=asyncify-imports@env.get_answer \
      [...]

Pasa --asyncify para habilitar la transformación y, luego, usa --pass-arg=… para proporcionar una lista separada por comas de funciones asíncronas, en la que el estado del programa debe suspenderse y reanudarse más tarde.

Todo lo que falta es proporcionar un código de entorno de ejecución compatible que lo haga: suspender y reanudar el código de WebAssembly. De nuevo, en el caso de C / C++ esto lo incluiría Emscripten, pero ahora necesitas un código de unión de JavaScript personalizado que controle archivos WebAssembly arbitrarios. Creamos una biblioteca solo para eso.

Puedes encontrarlo en GitHub en https://github.com/GoogleChromeLabs/asyncify o en npm con el nombre asyncify-wasm.

Simula una API de creación de instancias de WebAssembly estándar, pero con su propio espacio de nombres. La única diferencia es que, en una API normal de WebAssembly, solo puedes proporcionar funciones síncronas como importaciones, mientras que en el wrapper Asyncify, también puedes proporcionar importaciones asíncronas:

const { instance } = await Asyncify.instantiateStreaming(fetch('app.wasm'), {
    env: {
        async get_answer() {
            let response = await fetch("answer.txt");
            let text = await response.text();
            return Number(text);
        }
    }
});
…
await instance.exports.main();

Una vez que intentes llamar a una función asíncrona como get_answer() en el ejemplo anterior, desde WebAssembly, la biblioteca detectará el Promise que se muestra, suspenderá y guardará el estado de la aplicación de WebAssembly, se suscribirá a la finalización de la promesa y, una vez que se resuelva, restablecerá sin problemas el estado y la pila de llamadas, y continuará la ejecución como si nada hubiera sucedido.

Debido a que cualquier función en el módulo puede realizar una llamada asíncrona, todas las exportaciones también pueden ser asíncronas, por lo que también se unen. En el ejemplo anterior, quizás hayas notado que debes usar await en el resultado de instance.exports.main() para saber cuándo finaliza realmente la ejecución.

¿Cómo funciona todo esto de forma interna?

Cuando Asyncify detecta una llamada a una de las funciones ASYNCIFY_IMPORTS, inicia una operación asíncrona, guarda el estado completo de la aplicación, incluidos la pila de llamadas y los parámetros de configuración regionales temporales, y, más adelante, cuando finaliza la operación, restablece toda la memoria y la pila de llamadas y se reanuda desde el mismo lugar y con el mismo estado que si el programa nunca se hubiera detenido.

Es bastante similar a la función async-await en JavaScript que mostré antes, pero, a diferencia de JavaScript, no requiere ninguna sintaxis especial ni compatibilidad con el entorno de ejecución del lenguaje y, en su lugar, funciona transformando funciones síncronas simples en el tiempo de compilación.

Cuando compiles el ejemplo del sueño asíncrono que se mostró antes, ten en cuenta lo siguiente:

puts("A");
async_sleep(1);
puts("B");

Asyncify toma este código y lo transforma para que sea similar al siguiente (seudocódigo, la transformación real es más compleja que esto):

if (mode == NORMAL_EXECUTION) {
    puts("A");
    async_sleep(1);
    saveLocals();
    mode = UNWINDING;
    return;
}
if (mode == REWINDING) {
    restoreLocals();
    mode = NORMAL_EXECUTION;
}
puts("B");

Inicialmente, mode se configura como NORMAL_EXECUTION. En consecuencia, la primera vez que se ejecuta este código transformado, solo se evalúa la parte que conduce a async_sleep(). En cuanto se programa la operación asíncrona, Asyncify guarda todos los locales y desenrolla la pila cuando regresa desde cada función hasta la parte superior, lo que devuelve el control al bucle de eventos del navegador.

Luego, una vez que se resuelve async_sleep(), el código de asistencia de Asyncify cambiará mode a REWINDING y volverá a llamar a la función. Esta vez, se omite la rama de "ejecución normal", dado que ya hizo el trabajo la última vez y quiero evitar imprimir "A" dos veces, y, en su lugar, va directamente a la rama "rebobinado". Una vez que se alcanza, restablece todos los datos locales almacenados, vuelve a cambiar el modo a "normal" y continúa la ejecución como si nunca se hubiera detenido el código.

Costos de transformación

Desafortunadamente, la transformación de Asyncify no es completamente gratuita, ya que debe inyectar bastante código de respaldo para almacenar y restablecer todos esos locales, navegar por la pila de llamadas en diferentes modos y así sucesivamente. Intenta modificar solo las funciones marcadas como asíncronas en la línea de comandos, así como cualquiera de sus posibles llamadores, pero la sobrecarga del tamaño del código podría sumar alrededor del 50% antes de la compresión.

Un gráfico que muestra la sobrecarga del tamaño del código para varias comparativas, desde casi el 0% en condiciones definidas hasta más del 100% en el peor de los casos.

Esto no es ideal, pero es aceptable en muchos casos cuando la alternativa no es tener la funcionalidad completa o tener que realizar reescrituras significativas en el código original.

Asegúrate de habilitar siempre optimizaciones para las compilaciones finales para evitar que aumenten aún más. También puedes verificar las opciones de optimización específicas de Asyncify para reducir la sobrecarga mediante la limitación de las transformaciones solo a funciones específicas o solo a las llamadas directas a funciones. Además, el rendimiento del entorno de ejecución tiene un costo menor, pero se limita a las llamadas asíncronas. Sin embargo, en comparación con el costo del trabajo real, no suele tener importancia.

Demostraciones del mundo real

Ahora que viste los ejemplos simples, pasaremos a situaciones más complicadas.

Como se mencionó al comienzo del artículo, una de las opciones de almacenamiento en la Web es una API de Acceso al sistema de archivos asíncrona. Proporciona acceso a un sistema de archivos del host real desde una aplicación web.

Por otro lado, existe un estándar de facto llamado WASI para WebAssembly E/S en la consola y en el servidor. Se diseñó como un destino de compilación para los lenguajes del sistema y expone todo tipo de sistema de archivos y otras operaciones en una forma síncrona tradicional.

¿Y si pudieras mapear uno a otro? Luego, puedes compilar cualquier aplicación en cualquier lenguaje de origen con cualquier cadena de herramientas que admita el destino WASI y ejecutarla en una zona de pruebas en la Web, a la vez que permites que funcione en archivos de usuarios reales. Con Asyncify, puedes hacer justamente eso.

En esta demostración, compilé el contenedor coreutils de Rust con algunos parches menores a WASI, lo pasamos a través de la transformación Asyncify y, además, implementamos vinculaciones asíncronas de WASI a la API de Acceso al sistema de archivos en JavaScript. Cuando se combina con el componente de la terminal Xterm.js, proporciona un shell realista que se ejecuta en la pestaña del navegador y opera en archivos de usuarios reales, como una terminal real.

Míralo en vivo en https://wasi.rreverser.com/.

Los casos de uso del modo asíncrono no se limitan solo a los temporizadores y los sistemas de archivos. Puedes ir más allá y usar APIs más de nicho en la Web.

Por ejemplo, también con la ayuda de Asyncify, es posible asignar libusb (probablemente la biblioteca nativa más popular para trabajar con dispositivos USB) a una API de WebUSB, que brinda acceso asíncrono a esos dispositivos en la Web. Después de realizar la asignación y la compilación, obtuve pruebas libusb y ejemplos estándar para ejecutar en los dispositivos elegidos en la zona de pruebas de una página web.

Captura de pantalla del resultado de depuración de libusb en una página web, que muestra información sobre la cámara Canon conectada

Probablemente sea una historia para otra entrada de blog.

Esos ejemplos demuestran lo potente que puede ser Asyncify para cerrar la brecha y transferir todo tipo de aplicaciones a la Web, lo que te permite obtener acceso multiplataforma, zonas de pruebas y mejor seguridad, todo sin perder funcionalidad.