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. Cuándo cuando compilas código en WebAssembly, necesitas conectar un tipo de APIs a otro, y este puente es Método asíncrono. En esta publicación, aprenderás cuándo y cómo usar Asyncify, y de qué manera funciona de manera interna.

E/S en idiomas del sistema

Comenzaré con un ejemplo sencillo en C. Supongamos que quieres leer el nombre del usuario desde un archivo y saludar con el mensaje "Hello, (username)!" mensaje:

#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 al mundo externo. Toda esta interacción con el mundo exterior ocurre a través de algunos funciones comúnmente llamadas 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. fread para leer datos de allí. 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 maquinaria necesaria para leer o escribir datos. Sin embargo, según el entorno, puede haber bastante suceden muchas cosas adentro:

  • Si el archivo de entrada se encuentra en una unidad local, la aplicación debe realizar una serie de a la memoria y al disco para ubicar el archivo, verificar los permisos, abrirlo para leerlo y, luego, read 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 red también se involucrarán, lo que aumentará la complejidad, la latencia y la cantidad reintentos 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 operaciones de E/S pueden ser lentas y no se puede predecir cuánto tiempo llevará una llamada en particular un vistazo rápido al código. Mientras se esté ejecutando la operación, toda la aplicación aparecerá inmóvil. y no responde al usuario.

Esto tampoco se limita a C o C++. La mayoría de los lenguajes del sistema presentan todas las E/S de forma síncronas. Por ejemplo, si traduces el ejemplo a Rust, la API podría parecer más simple, pero aplican los mismos principios. Solo haces una llamada y esperas de manera síncrona a que devuelva el resultado mientras realiza todas las operaciones costosas y, al final, 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, ¿qué podría "lecturar archivo" ¿A qué se debe traducir esta operación? Sería necesita leer datos desde algún almacenamiento.

Modelo asíncrono de la Web

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

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

Esta es una de las propiedades principales de ejecutar código en la Web: incluye cualquier E/S, tiene que 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 la IU. Debe competir con otras tareas importantes, como el diseño, la renderización y el manejo de eventos para el tiempo de CPU. No querrías que una parte de JavaScript o WebAssembly para poder iniciar una "lectura de archivos" y bloquear todo lo demás: la pestaña completa, o, en el pasado, todo el navegador durante un intervalo de milisegundos a algunos 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 termine. Estas devoluciones de llamada se ejecutan como parte del bucle de eventos del navegador. No seré Vamos a hablar de los detalles, pero si te interesa aprender cómo funciona el bucle de eventos en segundo plano, finalizar la compra Tareas, microtareas, colas y programas 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, de la fila uno por uno. Cuando se activa algún evento, el navegador pone en cola las en el controlador correspondiente y, en la siguiente iteración de bucle, se quita de la cola y se ejecuta. Este mecanismo permite simular la simultaneidad y ejecutar muchas operaciones paralelas mientras se usa solo en un solo subproceso.

Lo importante que debes recordar sobre este mecanismo es que, si bien tu código JavaScript personalizado (o WebAssembly) se ejecuta, se bloquea el bucle de eventos y, mientras está, no hay forma de reaccionar cualquier controlador externo, evento, E/S, etc. La única forma de obtener los resultados de E/S es registrar un terminar de ejecutar el código y devolverle el control al navegador para que pueda procesar las tareas pendientes. Una vez finalizada la E/S, tu controlador se convertirá en una de esas tareas y se ejecutarán.

Por ejemplo, si quisieras volver a escribir las muestras anteriores en un JavaScript moderno y decidieras leer un de una URL remota, usarás 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 niveles más profundos, cada await es esencialmente sintaxis para devoluciones de llamada:

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 el HTTP encabezados: invoca esta devolución de llamada de forma asíncrona. La devolución de llamada comienza a leer el cuerpo como texto usando response.text() y se suscribe al resultado con otra devolución de llamada. Por último, una vez que fetch tenga y recuperó todo el contenido, invoca la última devolución de llamada, que imprime "Hello, (username)!". al de Cloud.

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

Por último, incluso las APIs simples, como "sleep", que hace que una aplicación espere 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 sencilla y bloquear el hilo actual. hasta que el tiempo venza:

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 "dormir" pero es muy ineficiente, bloqueará toda la IU y no permitirá que se controle ningún otro evento. mientras tanto. Por lo general, no debes hacer eso en el código de producción.

En cambio, se usa una versión más idiomática de "sleep" en JavaScript implicaría llamar a setTimeout(). suscribirte 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 del código original usa una API de bloqueo para la E/S, mientras que un ejemplo equivalente para la Web usa un asíncrona. Cuando compilas en la Web, necesitas de alguna manera transformar entre esas dos modelos de ejecución, y WebAssembly aún no tiene una capacidad integrada para hacerlo.

Reduce las diferencias con Asyncify

Aquí es donde entra en juego Asyncify. El método asíncrono es un función de tiempo de compilación compatible con Emscripten, que permite pausar todo el programa y y, luego, lo reanuda de forma asíncrona.

Un gráfico de llamadas
describir un JavaScript -> WebAssembly -> API web -> Invocación de tarea asíncrona, en la que Asyncify se conecta
el resultado de la tarea asíncrona de vuelta en WebAssembly

Uso en C / C++ con Emscripten

Si quisieras usar Asyncify para implementar una suspensión asíncrona en el último ejemplo, podrías hacer lo siguiente: 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 un 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() que debe ser una vez finalizada la operación asíncrona. En el ejemplo anterior, el controlador se pasa a setTimeout(), pero podría usarse en cualquier otro contexto que acepte devoluciones de llamada. Por último, puedes Llama a async_sleep() en cualquier lugar que desees, como el 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. Hazlo antes del pasando -s ASYNCIFY y -s ASYNCIFY_IMPORTS=[func1, func2] con un Una lista de funciones similares a un array y que pueden ser asíncronas.

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

Esto permite a Emscripten saber que cualquier llamada a esas funciones puede requerir guardar y restablecer el para que el compilador inserte código compatible alrededor de esas llamadas.

Ahora, cuando ejecutes este código en el navegador, verás un registro de resultados sin interrupciones como lo esperas. donde B viene después de una breve demora después de A.

A
B

Puedes devolver valores de Asyncify también. Qué que debes hacer es mostrar el resultado de handleSleep() y pasarlo a wakeUp() devolución de llamada. Por ejemplo, si, en lugar de leer de un archivo, quieres recuperar un número de un control remoto recurso, puedes usar un fragmento como el que se muestra a continuación para emitir una solicitud, suspender el código C y una vez que se recupera el cuerpo de la respuesta, todo sin interrupciones 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, en el caso de las APIs basadas en promesas, como fetch(), incluso puedes combinar Asyncify con código de JavaScript. async-await 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 wakeUp(), puedes pasar una función async de JavaScript y usar await y return 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 ejemplo, cuando 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 permite controlar las conversiones entre valores de JavaScript y C++. También es compatible con Asyncify, por lo que Puedes llamar a await() en objetos Promise externos y actuará como await en async-await Código JavaScript:

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 uses este método, ni siquiera necesitarás pasar ASYNCIFY_IMPORTS como una marca de compilación, ya que es ya se incluyen de forma predeterminada.

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

Uso de otros idiomas

Digamos que tienes una llamada síncrona similar en algún lugar de tu código de Rust a la que quieres asignar 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 el la sintaxis del lenguaje para 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. Para C / C++, Emscripten lo haría por nosotros, pero no se usa aquí, por lo que el proceso es un poco más manual.

Afortunadamente, la transformación Asyncify en sí misma es completamente agnóstica a la cadena de herramientas. Puede transformar valores arbitrarios WebAssembly, independientemente del compilador que lo produjo. La transformación se proporciona por separado como parte del optimizador wasm-opt de Binaryen cadena de herramientas 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 un valor de Lista de funciones asíncronas, en las que el estado del programa debe suspenderse y reanudarse más tarde.

Lo único que falta es proporcionar un código de entorno de ejecución compatible que lo haga: suspender y reanudar. de WebAssembly. Una vez más, en el caso C / C++, Emscripten lo incluiría, pero ahora debes código de adhesión de JavaScript personalizado que controlaría archivos de WebAssembly arbitrarios. Creamos una biblioteca solo para eso.

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

Simula una instancia de WebAssembly estándar API, pero en su propio espacio de nombres. El único con la diferencia de que, con una API de WebAssembly normal, solo puedes proporcionar funciones síncronas como 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 WebAssembly, suscríbete a la finalización de la promesa y, una vez que se resuelva, restablece sin problemas la pila de llamadas y el estado, y continúa la ejecución como si no hubiera sucedido nada.

Como cualquier función del módulo puede realizar una llamada asíncrona, todas las exportaciones asíncronas, por lo que también se unen. En el ejemplo anterior, tal vez hayas notado que necesitas await el resultado de instance.exports.main() para saber cuándo la ejecución es realmente finalizado.

¿Cómo funciona todo esto de forma interna?

Cuando Asyncify detecta una llamada a una de las funciones ASYNCIFY_IMPORTS, inicia una llamada asíncrona. guarda el estado completo de la aplicación, incluida la pila de llamadas, y cualquier locales y, más adelante, cuando la operación finalice, 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.

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

Cuando se compila el ejemplo de sueño asíncrono que se mostró anteriormente, haz lo siguiente:

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

Asyncify toma este código y lo transforma de manera similar al siguiente (pseudocódigo, real la transformación está más involucrada 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 transformó el código , solo se evaluará la parte que conduce a async_sleep(). En cuanto el una operación asíncrona está programada, Asyncify guarda todos los elementos locales y desenrolla la pila volviendo desde cada función hasta la parte superior, de esta manera, se devuelve el control al navegador bucle de evento.

Luego, una vez que se resuelva async_sleep(), el código de asistencia de Asyncify cambiará mode a REWINDING. vuelve a llamar a la función. Esta vez, la “ejecución normal” se omite, como ya lo hizo, al trabajo la última vez y no quiero imprimir la “A” dos veces y, en cambio, se trata directamente “rebobinar” . Una vez que se alcanza, restablece todos los elementos locales almacenados y vuelve a cambiar 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 Asyncify no es completamente gratuita, ya que debe inyectar bastante código compatible para almacenar y restablecer todos esos elementos locales, navegando por la pila de llamadas en diferentes modos, etcétera. Intenta modificar solo las funciones marcadas como asíncronas en el comando. línea, así como cualquiera de sus posibles llamadas, pero la sobrecarga del tamaño del código aún puede ser de alrededor del 50% antes de la compresión.

Gráfico que muestra código
una sobrecarga de tamaño para diversas comparativas, desde cerca del 0% en condiciones más precisas hasta más del 100% en los peores casos.
casos

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

Asegúrate de habilitar siempre optimizaciones para las compilaciones finales a fin de evitar que las cargas sean aún mayores. Puedes También puede verificar la optimización específica de Asyncify opciones para reducir la sobrecarga limitar las transformaciones solo a funciones específicas o solo a llamadas directas a funciones También hay un un costo menor para el rendimiento del entorno de ejecución, pero está limitado a las llamadas asíncronas en sí. Sin embargo, en comparación del costo real del trabajo, suele ser insignificante.

Demostraciones del mundo real

Ahora que viste los ejemplos sencillos, 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 File System Access asíncrona. Proporciona acceso a un un sistema de archivos del host real desde una aplicación web.

Por otro lado, hay un estándar de facto llamado WASI. para las E/S de WebAssembly en la consola y en el servidor. Fue diseñado como un objetivo de compilación para lenguajes de sistema de archivos y expone todo tipo de sistema de archivos y otras operaciones en un entorno síncrono.

¿Y si pudieras asignar una a otra? Luego, puedes compilar cualquier aplicación en cualquier lenguaje de origen. cualquier cadena de herramientas que admita el objetivo WASI y ejecutarla en una zona de pruebas en la Web, sin dejar de lo que le permite operar con archivos de usuarios reales. Con Asyncify, puedes hacer precisamente eso.

En esta demostración, compilé el contenedor coreutils de Rust con un algunos parches menores a WASI, que se pasan a través de la transformación Asyncify y se implementan de forma asíncrona vinculaciones de WASI a la API de File System Access en JavaScript. Una vez que se combinen Xterm.js, proporciona una shell realista que se ejecuta en la del navegador y funciona con archivos de usuarios reales, como si fuera 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 más APIs específicas en la Web.

Por ejemplo, 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 otorga acceso asíncrono a esos dispositivos en la Web. Una vez asignados y compilados, obtuve pruebas libusb estándar y ejemplos para ejecutarlos con los directamente en la zona de pruebas de una página web.

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

Sin embargo, probablemente sea una historia para otra entrada de blog.

Esos ejemplos demuestran la potencia de Asyncify para reducir la brecha y trasladar a la web, lo que te permite obtener acceso multiplataforma, zonas de pruebas y mejores sin perder funcionalidad.