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 conectar un tipo de APIs con otro, y ese conector 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 de 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 internamente y escribe salidas de vuelta al mundo externo. Toda esta interacción con el mundo exterior se produce a través de algunas funciones que se conocen comúnmente como 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 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.
A primera vista, esas funciones parecen bastante simples y no tienes que pensar demasiado en el mecanismo involucrado para leer o escribir datos. Sin embargo, según el entorno, puede haber muchas cosas sucediendo en el interior:
- 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 lectura y, luego, leerlo bloque por bloque hasta que se recupere la cantidad de bytes solicitada. 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 también estará involucrada, lo que aumentará la complejidad, la latencia y la cantidad de reintentos potenciales para cada operación.
- Por último, incluso
printfno garantiza que se impriman elementos en la consola y es posible que se redireccione a un archivo o a una ubicación de red, en cuyo caso tendría que seguir los mismos pasos anteriores.
En resumen, las operaciones de E/S pueden ser lentas y no puedes predecir cuánto tardará una llamada en particular con solo echar un vistazo al código. Mientras se ejecuta esa operación, toda tu aplicación parecerá bloqueada y no responderá al usuario.
Esto tampoco se limita a C o C++. La mayoría de los idiomas del sistema presentan todas las E/S en forma de APIs síncronas. Por ejemplo, si traduces el ejemplo a Rust, la API podría parecer más simple, pero se aplican los mismos principios. Solo tienes que hacer una llamada y esperar de forma síncrona a que devuelva el resultado, mientras realiza todas las operaciones costosas y, finalmente, devuelve 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 esas muestras en WebAssembly y traducirlas a la Web? O, para proporcionar un ejemplo específico, ¿a qué podría traducirse 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 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 durante 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 lleve mucho tiempo, incluida cualquier E/S, debe ser asíncrona.
El motivo es que, históricamente, la Web ha sido de un solo subproceso, y cualquier código del usuario que toque la IU debe ejecutarse en el mismo subproceso que la IU. Debe competir con 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 pudiera iniciar una operación de "lectura de archivos" y bloquear todo lo demás (toda la pestaña o, en el pasado, todo el navegador) durante un período de milisegundos a unos 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 aquí, pero si te interesa saber cómo funciona el bucle de evento en segundo plano, consulta Tareas, microtareas, colas y programaciones, que 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, tomándolos 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 saca de la cola y se ejecuta. Este mecanismo permite simular la simultaneidad y ejecutar muchas operaciones paralelas con un solo subproceso.
Lo importante que debes recordar sobre este mecanismo es que, mientras se ejecuta tu código personalizado de JavaScript (o WebAssembly), el bucle de eventos se bloquea y, mientras lo está, 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 tu código y devolver el control al navegador para que pueda seguir 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 quisieras reescribir las muestras anteriores en JavaScript moderno y decidieras leer un nombre desde 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 parezca síncrono, cada await es, en esencia, sintaxis edulcorada para las devoluciones de llamada:
function main() {
return fetch("name.txt")
.then(response => response.text())
.then(name => console.log("Hello, %s!", name));
}
En este ejemplo desazucarado, 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 esta devolución de llamada de forma asíncrona. 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 recuperó 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 programa la E/S y dejar toda la IU disponible y con capacidad de respuesta para otras tareas, como la renderización, el desplazamiento, etcétera, mientras la E/S se ejecuta en segundo plano.
Como ejemplo final, incluso las APIs simples, como "sleep", que hacen 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");
Claro que podrías 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 es muy ineficiente, bloqueará toda la IU y no permitirá que se controlen 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 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 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.
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. En el interior, usa una función Asyncify.handleSleep() que le indica a Emscripten que suspenda el programa y proporciona un controlador wakeUp() 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 podría usar en cualquier otro contexto que acepte devoluciones de llamada. Por último, puedes llamar a async_sleep() en cualquier lugar que desees, al igual que a sleep() normal o cualquier otra API síncrona.
Cuando compiles ese código, deberás 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ían ser asíncronas.
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, por lo que el compilador insertará código de asistencia alrededor de esas llamadas.
Ahora, cuando ejecutes este código en el navegador, verás un registro de salida sin interrupciones como el que esperas, con B después de una breve demora después de A.
A
B
También puedes devolver valores de las funciones de Asyncify. Lo que debes hacer es devolver el resultado de handleSleep() y pasar el resultado a la devolución de llamada wakeUp(). Por ejemplo, si, en lugar de leer desde un archivo, quieres recuperar un número de 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 se realiza 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 Promises, como fetch(), incluso puedes combinar Asyncify con la función async-await de JavaScript en lugar de usar la API basada en devoluciones de llamadas. Para ello, 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 su 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 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? ¡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 admite Asyncify, por lo que puedes llamar a await() en Promise externos y funcionará igual que await en el código JavaScript de 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>();
Cuando usas este método, ni siquiera necesitas pasar ASYNCIFY_IMPORTS como una marca de compilación, ya que se incluye de forma predeterminada.
Bien, todo esto funciona muy bien en Emscripten. ¿Qué sucede con otras cadenas de herramientas y otros lenguajes?
Uso en 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 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 externas).
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 y 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.
Por suerte, la transformación Asyncify en sí es completamente independiente de la cadena de herramientas. Puede transformar archivos WebAssembly arbitrarios, sin importar con qué compilador se hayan producido. 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 las que se debe suspender y reanudar el estado del programa más adelante.
Lo único que queda es proporcionar código de tiempo de ejecución de asistencia que realmente haga eso: suspender y reanudar el código de WebAssembly. Nuevamente, en el caso de C / C++, Emscripten lo incluiría, pero ahora necesitas código de vinculació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 instanciación de WebAssembly estándar, pero bajo su propio espacio de nombres. La única diferencia es que, con una API de WebAssembly normal, solo puedes proporcionar funciones síncronas como importaciones, mientras que, con 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 de este tipo, como get_answer() en el ejemplo anterior, desde el lado de WebAssembly, la biblioteca detectará el Promise devuelto, suspenderá y guardará el estado de la aplicación de WebAssembly, se suscribirá a la finalización de la promesa y, luego, una vez que se resuelva, restablecerá sin problemas la pila de llamadas y el estado, y continuará la ejecución como si nada hubiera sucedido.
Dado que cualquier función del módulo puede realizar una llamada asíncrona, todas las exportaciones también se vuelven potencialmente asíncronas, por lo que también se encapsulan. Es posible que hayas notado en el ejemplo anterior que necesitas await el resultado de instance.exports.main() para saber cuándo finalizó 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 todo el estado de la aplicación, incluida la pila de llamadas y cualquier variable local temporal, y, más adelante, cuando finaliza esa operación, restablece toda la memoria y la pila de llamadas, y 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 asíncrona-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 tiempo de compilación.
Cuando se compila el ejemplo de suspensión asíncrona que se mostró anteriormente, 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):
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. Del mismo modo, la primera vez que se ejecute ese código transformado, solo se evaluará la parte que conduce a async_sleep(). En cuanto se programa la operación asíncrona, Asyncify guarda todas las variables locales y deshace la pila devolviendo el control desde cada función hasta la parte superior, lo que permite que el bucle de eventos del navegador recupere el control.
Luego, una vez que async_sleep() se resuelva, 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 hizo el trabajo la última vez y quiero evitar imprimir "A" dos veces, y, en cambio, se dirige directamente a la rama de "retroceso". Una vez que se alcanza, se restablecen todas las variables locales almacenadas, se vuelve al modo "normal" y se 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 una gran cantidad de código de asistencia para almacenar y restablecer todas esas variables 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 podría acumularse hasta aproximadamente el 50% antes de la compresión.

Esto no es ideal, pero en muchos casos es aceptable cuando la alternativa es no tener la funcionalidad en absoluto o tener que volver a escribir de forma significativa el código original.
Asegúrate de habilitar siempre las optimizaciones para las compilaciones finales y evitar que aumente aún más. También puedes consultar las opciones de optimización específicas de Asyncify para reducir la sobrecarga limitando las transformaciones solo a las funciones especificadas o solo a las llamadas directas a funciones. También hay un costo menor en el rendimiento del tiempo de ejecución, pero se limita a las llamadas asíncronas. Sin embargo, en comparación con el costo del trabajo real, suele ser insignificante.
Demostraciones en situaciones reales
Ahora que viste los ejemplos simples, pasaré 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 File System Access asíncrona. Proporciona acceso a un sistema de archivos 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 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 idioma de origen con cualquier cadena de herramientas que admita el destino de WASI y ejecutarla en una zona de pruebas en la Web, y, al mismo tiempo, permitir que opere en archivos de usuarios reales. Con Asyncify, puedes hacer precisamente eso.
En esta demostración, compilé el crate coreutils de Rust con algunos parches menores para WASI, que se pasaron a través de la transformación de Asyncify y se implementaron enlaces asíncronos de WASI a la API de File System Access en el lado de JavaScript. Una vez que se combina con el componente de terminal Xterm.js, esto proporciona un shell realista que se ejecuta en la pestaña del navegador y opera en archivos de usuarios reales, al igual que una terminal real.
Puedes verlo en vivo en https://wasi.rreverser.com/.
Los casos de uso de Asyncify no se limitan solo a los temporizadores y los sistemas de archivos. Puedes ir más allá y usar más APIs 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. Una vez que se asignó y compiló, pude ejecutar pruebas y ejemplos estándar de libusb en los dispositivos elegidos directamente en la zona de pruebas de una página web.

Sin embargo, probablemente sea una historia para otra entrada de blog.
Estos 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, aislamiento y mejor seguridad, todo sin perder funcionalidad.