Usa APIs web asíncronas desde WebAssembly

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

E/S en idiomas del sistema

Comenzaré con un ejemplo simple en C. Supongamos que quieres leer el nombre del usuario desde un archivo y saludarlo con un mensaje que diga “Hola, (nombre de usuario)”:

#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 hace 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 en el mundo externo. Toda esa interacción con el mundo exterior se realiza a través de algunas funciones comúnmente llamadas funciones de entrada y salida, también abreviadas como E/S.

Para leer el nombre desde C, necesitas al menos dos llamadas de E/S cruciales: fopen, para abrir el archivo, y fread, para leer datos de é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 el mecanismo involucrado para leer o escribir datos. Sin embargo, según el entorno, puede haber mucho sucediendo dentro:

  • Si el archivo de entrada se encuentra en una unidad local, la aplicación debe realizar una serie de accesos a la memoria y al disco para ubicar el archivo, verificar los permisos, abrirlo para su lectura y, luego, leerlo bloque por bloque hasta que se recupere la cantidad solicitada de bytes. Esto puede ser bastante lento, según la velocidad del 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 se involucrará, lo que aumentará la complejidad, la latencia y la cantidad de reintentos potenciales para cada operación.
  • Por último, incluso printf no garantiza que se impriman elementos en la consola y podría redireccionarse a un archivo o una ubicación de red, en cuyo caso tendría que seguir los mismos pasos anteriores.

En pocas palabras, la E/S puede ser lenta y no se puede predecir cuánto tiempo tomará una llamada en particular con solo mirar el código. Mientras se ejecuta esa operación, toda la aplicación aparecerá inmovilizada y no responderá al usuario.

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

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

Pero ¿qué sucede cuando intentas compilar cualquiera de esos ejemplos en WebAssembly y traducirlos a la Web? O, para dar un ejemplo específico, ¿a qué se traduciría la operación de "lectura de archivos"? Debería leer datos de algún almacenamiento.

Modelo asíncrono de la Web

La Web tiene una variedad de opciones de almacenamiento diferentes a las que puedes asignar, como el almacenamiento en memoria (objetos JS), localStorage, IndexedDB, el almacenamiento del servidor y una nueva API de acceso al sistema de archivos.

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, que incluya cualquier E/S, debe ser asíncrona.

El motivo es que, históricamente, la Web tiene un solo subproceso, y cualquier código de usuario que afecte a la IU debe ejecutarse en el mismo subproceso que esta. Debe competir con las otras tareas importantes, como el diseño, la renderización y el manejo de eventos, por el tiempo de la CPU. No querrás que un fragmento de JavaScript o WebAssembly pueda iniciar una operación de "lectura de archivo" y bloquear todo lo demás (la pestaña completa o, en el pasado, todo el navegador) durante un período de milisegundos a unos segundos, hasta que finalice.

En su lugar, 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 aquí, pero si te interesa saber cómo funciona el bucle de eventos en segundo plano, consulta Tasks, microtasks, queues and schedules, en el que se explica este tema en detalle.

En resumen, el navegador ejecuta todas las partes de código en una especie de bucle infinito, tomándolas de la cola una por una. Cuando se activa un 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 en paralelo mientras se usa solo un subproceso.

Lo importante de este mecanismo es que, mientras se ejecuta tu código personalizado de JavaScript (o WebAssembly), el bucle de eventos se bloquea y, mientras lo hace, no hay forma de reaccionar a ningún controlador externo, evento, E/S, etcétera. 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 seguir procesando las tareas pendientes. Una vez finalizada la E/S, tu controlador se convertirá en una de esas tareas y se ejecutará.

Por ejemplo, si quisieras reescribir las muestras anteriores en JavaScript moderno y decidiste leer un nombre de una URL remota, usarías 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, en realidad cada await es, en esencia, sintaxis enriquecida para callbacks:

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

En este ejemplo sin sintaxis, que es un poco más claro, se inicia una solicitud y se suscriben respuestas con la primera devolución de llamada. Una vez que el navegador recibe la respuesta inicial (solo los encabezados HTTP), invoca de forma asíncrona esta devolución de llamada. La devolución de llamada comienza a leer el cuerpo como texto con 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 imprime "Hola, (nombre de usuario)" 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 programó la E/S y dejar toda la IU responsiva y disponible para otras tareas, incluida la renderización, el desplazamiento, etcétera, mientras la E/S se ejecuta en segundo plano.

Como último ejemplo, incluso las APIs simples, como “sleep”, que hacen que una aplicación espere una cantidad especificada 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, puedes traducirlo de una manera muy directa que bloquearía 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 hace Emscripten en su implementación predeterminada de “sleep”, pero eso es muy ineficiente, bloqueará toda la IU y no permitirá que se manejen otros eventos mientras tanto. Por lo general, no lo hagas en el código de producción.

En cambio, 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é tienen en común todos estos ejemplos y estas APIs? En cada caso, el código idiomático en el lenguaje de sistemas original usa una API de bloqueo para la E/S, mientras que un ejemplo equivalente para la Web usa una API asíncrona. Cuando compilas para la Web, debes transformar de alguna manera entre esos dos modelos de ejecución, y WebAssembly aún no tiene la capacidad integrada para hacerlo.

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 adelante.

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

Uso en C/C++ con Emscripten

Si quisieras usar Asyncify para implementar un retraso asíncrono para 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 indica a Emscripten que suspenda el programa y proporciona 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 compilas este tipo de 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 podría ser asíncrona.

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

Esto le permite a Emscripten saber que cualquier llamada a esas funciones podría requerir guardar y restablecer el estado, de modo que el compilador inserte código de compatibilidad alrededor de esas llamadas.

Ahora, cuando ejecutes este código en el navegador, verás un registro de salida sin problemas, como esperas, con B después de una breve demora después de A.

A
B

También puedes mostrar valores de las funciones de Asyncify. Lo que debes hacer es mostrar el resultado de handleSleep() y pasarlo a la devolución de llamada de wakeUp(). Por ejemplo, si, en lugar de leer de un archivo, quieres recuperar un número de un recurso remoto, puedes usar un fragmento como el siguiente para emitir una solicitud, suspender el código C y reanudarlo una vez que se recupere el cuerpo de la respuesta, todo 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 dentro, lo que hace que el código se vea 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 aún 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? ¡Tú también puedes hacerlo!

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

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

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

Bien, todo funciona muy bien en Emscripten. ¿Qué sucede con otras cadenas de herramientas y lenguajes?

Uso desde 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 normal a través del bloque extern (o la sintaxis del lenguaje que elijas para las funciones extranjeras).

extern {
    fn get_answer() -> i32;
}

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

Y compila tu código en WebAssembly:

cargo build --target wasm32-unknown-unknown

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

Por suerte, la transformación de Asyncify es completamente independiente de la cadena de herramientas. Puede transformar archivos arbitrarios de WebAssembly, sin importar el compilador que lo produce. 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 de funciones asíncronas separadas por comas, en la que se debe suspender el estado del programa y, luego, reanudarlo.

Solo queda proporcionar un código de tiempo de ejecución compatible que realmente haga eso: suspender y reanudar el código de WebAssembly. Una vez más, en el caso de C/C++, Emscripten lo incluiría, pero ahora necesitas un código de unión de JavaScript personalizado que controle archivos WebAssembly arbitrarios. Creamos una biblioteca solo para eso.

Puedes encontrarla 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 en su propio espacio de nombres. La única diferencia es que, en una API de WebAssembly normal, solo puedes proporcionar funciones síncronas como importaciones, mientras que, en el wrapper de 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 el lado de 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, más adelante, una vez que se resuelva, restablecerá sin problemas la pila de llamadas y el estado, y continuará la ejecución como si nada hubiera pasado.

Como cualquier función del módulo puede realizar una llamada asíncrona, todas las exportaciones pueden ser potencialmente asíncronas también, por lo que también se unen. Es posible que hayas notado en el ejemplo anterior que debes await el resultado de instance.exports.main() para saber cuándo la ejecución realmente finalizó.

¿Cómo funciona todo esto?

Cuando Asyncify detecta una llamada a una de las funciones ASYNCIFY_IMPORTS, inicia una operación asíncrona, guarda todo el estado de la aplicación, incluida la pila de llamadas y cualquier elemento local temporal, y, más adelante, cuando finaliza esa operación, restablece toda la memoria y la pila de llamadas, y se reanuda desde el mismo lugar y con el mismo estado como si el programa nunca se hubiera detenido.

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

Cuando compilas el ejemplo de suspensión asíncrona que se mostró antes, ocurre lo siguiente:

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

Asyncify toma este código y lo transforma en algo similar al siguiente (pseudocó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 establece en NORMAL_EXECUTION. En consecuencia, la primera vez que se ejecute ese código transformado, solo se evaluará la parte que conduce a async_sleep(). Tan pronto como se programa la operación asíncrona, Asyncify guarda todos los elementos locales y desenrolla la pila volviendo desde cada función hasta el principio, de esta manera, lo que brinda control al bucle de eventos del navegador.

Luego, una vez que se resuelva async_sleep(), el código de compatibilidad de Asyncify cambiará mode a REWINDING y volverá a llamar a la función. Esta vez, se omite la rama de “ejecución normal”, ya que ya realizó la tarea la última vez y quiero evitar imprimir “A” dos veces. En su lugar, va directamente a la rama de “rebobinado”. Una vez que se alcanza, restablece todas las configuraciones locales almacenadas, cambia el modo a "normal" y continúa la ejecución como si el código nunca se hubiera detenido.

Costos de transformación

Lamentablemente, la transformación de Asyncify no es completamente gratuita, ya que debe insertar bastante código de compatibilidad para almacenar y restablecer todos esos objetos locales, navegar por la pila de llamadas en diferentes modos, etcétera. 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 aún puede sumar alrededor del 50% antes de la compresión.

Gráfico que muestra la sobrecarga de tamaño de código para varias comparativas, desde casi el 0% en condiciones de ajuste fino hasta más del 100% en los peores casos

Esto no es ideal, pero, en muchos casos, es aceptable 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 las optimizaciones para las compilaciones finales para evitar que aumente aún más. También puedes verificar las opciones de optimización específicas de Asyncify para reducir la sobrecarga limitando las transformaciones solo a funciones especificadas o solo a llamadas directas a funciones. También tiene un costo menor en el rendimiento del entorno de ejecución, pero se limita a las llamadas asíncronas en sí. Sin embargo, en comparación con el costo del trabajo real, suele ser insignificante.

Demos del mundo real

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

Como se mencionó al principio 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 de host real desde una aplicación web.

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

¿Qué pasaría si pudieras asignar uno a otro? Luego, podrías compilar cualquier aplicación en cualquier lenguaje fuente con cualquier cadena de herramientas que admita el destino WASI y ejecutarla en una zona de pruebas en la Web, a la vez que le permites operar en archivos de usuario reales. Con Asyncify, puedes hacer precisamente eso.

En esta demostración, compilé el paquete coreutils de Rust con algunos parches menores a WASI, que se pasaron a través de la transformación de Asyncify y se implementaron vinculaciones asíncronas de WASI a la API de File System Access en el lado de JavaScript. Una vez combinado con el componente de terminal Xterm.js, proporciona un shell realista que se ejecuta en la pestaña del navegador y opera en archivos de usuario reales, como una terminal real.

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

Los casos de uso de Asyncify tampoco se limitan solo a los temporizadores y los sistemas de archivos. Puedes ir más allá y usar APIs más específicas 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. Una vez asignados y compilados, obtuve pruebas y ejemplos estándar de libusb para ejecutar en los dispositivos elegidos directamente en la zona de pruebas de una página web.

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

Sin embargo, es probable que sea una historia para otra entrada de blog.

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