Design: Propuesta: API JS Async / Await

Creado en 26 jul. 2021  ·  16Comentarios  ·  Fuente: WebAssembly/design

Esta propuesta se desarrolló en colaboración con @fmccabe , @thibaudmichaud , @lukewagner y @kripken , junto con los comentarios del Subgrupo Stacks (con una votación informal aprobando el avance a la Fase 0 hoy). Tenga en cuenta que, debido a limitaciones de tiempo, el plan es tener una presentación muy rápida (es decir, 5 minutos) y votar para avanzar a la Fase 1 el 3 de agosto. Para facilitar eso, recomendamos encarecidamente a las personas que planteen sus inquietudes aquí con anticipación para que podamos determinar si hay alguna inquietud importante que ameritaría retrasar la presentación + voto para una fecha posterior con más tiempo.

El propósito de esta propuesta es proporcionar una interoperabilidad relativamente eficiente y relativamente ergonómica entre las promesas de JavaScript y WebAssembly, pero trabajando bajo la restricción de que los únicos cambios son en la API de JS y no en el núcleo de wasm.
La expectativa es que la propuesta de Stack-Switching eventualmente extenderá WebAssembly central con la funcionalidad para implementar las operaciones que proporcionamos en esta propuesta directamente dentro de WebAssembly, junto con muchas otras valiosas operaciones de stack-switching, pero que este caso de uso particular para el cambio de pila tenía suficiente urgencia para merecer una ruta más rápida a través de solo la API de JS.
Para obtener más información, consulte las notas y diapositivas de la reunión del subgrupo de pila del 28 de junio de 2021 , que detalla los escenarios de uso y los factores que tomamos en consideración y resume la justificación de cómo llegamos al siguiente diseño.

ACTUALIZACIÓN: Después de los comentarios que el Subgrupo de Pilas había recibido de TC39, esta propuesta solo permite suspender las pilas de WebAssembly; no realiza cambios en el lenguaje JavaScript y, en particular, no habilita indirectamente la compatibilidad con asycn / await en JavaScript.

Esto depende (vagamente) de la propuesta js-types , que introduce WebAssembly.Function como una subclase de Function .

Interfaz

La propuesta es agregar la siguiente interfaz, constructor y métodos a la API de JS, con más detalles sobre su semántica a continuación.

interface Suspender {
   constructor();
   Function suspendOnReturnedPromise(Function func); // import wrapper
   // overloaded: WebAssembly.Function suspendOnReturnedPromise(WebAssembly.Function func);
   WebAssembly.Function returnPromiseOnSuspend(WebAssembly.Function func); // export wrapper
}

Ejemplo

El siguiente es un ejemplo de cómo esperamos que uno use esta API.
En nuestros escenarios de uso, nos pareció útil considerar que los módulos de WebAssembly tienen conceptualmente importaciones y exportaciones "sincrónicas" y "asincrónicas".
La API JS actual solo admite importaciones y exportaciones "síncronas".
Los métodos de la interfaz Suspender se utilizan para envolver las importaciones y exportaciones relevantes con el fin de hacer "asincrónicas", con el objeto Suspender en sí mismo conectando explícitamente estas importaciones y exportaciones para facilitar tanto la implementación como la componibilidad.

Montaje web ( demo.wasm ):

(module
    (import "js" "init_state" (func $init_state (result f64)))
    (import "js" "compute_delta" (func $compute_delta (result f64)))
    (global $state f64)
    (func $init (global.set $state (call $init_state)))
    (start $init)
    (func $get_state (export "get_state") (result f64) (global.get $state))
    (func $update_state (export "update_state") (result f64)
      (global.set (f64.add (global.get $state) (call $compute_delta)))
      (global.get $state)
    )
)

Texto ( data.txt ):

19827.987

JavaScript:

var suspender = new Suspender();
var init_state = () => 2.71;
var compute_delta = () => fetch('data.txt').then(res => res.text()).then(txt => parseFloat(txt));
var importObj = {js: {
    init_state: init_state,
    compute_delta: suspender.suspendOnReturnedPromise(compute_delta)
}};

fetch('demo.wasm').then(response =>
    response.arrayBuffer()
).then(buffer =>
    WebAssembly.instantiate(buffer, importObj)
).then(({module, instance}) => {
    var get_state = instance.exports.get_state;
    var update_state = suspender.returnPromiseOnSuspend(instance.exports.update_state);
    ...
});

En este ejemplo, tenemos un módulo WebAssembly que es una máquina de estado muy simplista: cada vez que actualiza el estado, simplemente llama a una importación para calcular un delta y agregarlo al estado.
Sin embargo, en el lado de JavaScript, la función que queremos usar para calcular el delta resulta que debe ejecutarse de forma asíncrona; es decir, devuelve una Promesa de un Número en lugar de un Número en sí.

Podemos cerrar esta brecha de sincronía utilizando la nueva API de JS.
En el ejemplo, una importación del módulo WebAssembly se ajusta con suspender.suspendOnReturnedPromise , y una exportación se ajusta con suspender.returnPromiseOnSuspend , ambos con el mismo suspender .
Ese suspender conecta a los dos juntos.
Hace que, si alguna vez la importación (sin empaquetar) devuelve una Promesa, la exportación (empaquetada) devuelve una Promesa, con todos los cálculos intermedios "suspendidos" hasta que se resuelva la Promesa de la importación.
El empaquetado de la exportación consiste esencialmente en agregar un marcador async , y el empaquetado de la importación esencialmente agrega un marcador await , pero a diferencia de JavaScript, no tenemos que enhebrar explícitamente async / await través de todas las funciones intermedias de WebAssembly!

Mientras tanto, la llamada realizada a init_state durante la inicialización necesariamente regresa sin suspender, y las llamadas a la exportación get_state también regresan siempre sin suspender, por lo que la propuesta aún admite las importaciones y exportaciones "síncronas" existentes. que utiliza el ecosistema WebAssembly en la actualidad.
Por supuesto, hay muchos detalles que se están repasando, como el hecho de que si una exportación síncrona llama a una importación asincrónica, el programa interceptará si la importación intenta suspender.
A continuación, se proporciona una especificación más detallada, así como una estrategia de implementación.

Especificación

Un Suspender encuentra en uno de los siguientes estados:

  • Inactivo : no se está utilizando en este momento
  • Activo [ caller ] - el control está dentro de Suspender , siendo caller la función que llamó al Suspender y está esperando un externref para ser devuelto
  • Suspendido : actualmente esperando que se resuelva alguna promesa

El método suspender.returnPromiseOnSuspend(func) afirma que func es un WebAssembly.Function con un tipo de función de la forma [ti*] -> [to] y luego devuelve un WebAssembly.Function con el tipo de función [ti*] -> [externref] que hace lo siguiente cuando se llama con argumentos args :

  1. Trampa si el estado de suspender no está Inactivo
  2. Cambia el estado de suspender a Activo [ caller ] (donde caller es la persona que llama actualmente)
  3. Permite que result sea ​​el resultado de llamar a func(args) (o cualquier trampa o excepción lanzada)
  4. Afirma que el estado de suspender es Activo [ caller' ] por unos caller' (debería estar garantizado, aunque la persona que llama podría haber cambiado)
  5. Cambia el estado de suspender a Inactivo
  6. Devuelve (o vuelve a lanzar) result a caller'

El método suspender.suspendOnReturnedPromise(func)

  • si func es WebAssembly.Function , entonces afirma que su tipo de función es de la forma [t*] -> [externref] y devuelve WebAssembly.Function con el tipo de función [t*] -> [externref] ;
  • de lo contrario, afirma que func es un Function y devuelve un Function .

En cualquier caso, la función devuelta por suspender.suspendOnReturnedPromise(func) hace lo siguiente cuando se llama con argumentos args :

  1. Permite que result sea ​​el resultado de llamar a func(args) (o cualquier trampa o excepción lanzada)
  2. Si result no es una Promesa devuelta, entonces devuelve (o vuelve a lanzar) result
  3. Trampas si el estado de suspender no está activo [ caller ] para algunos caller
  4. Permite que frames sean los marcos de pila desde caller
  5. Captura si hay marcos de funciones que no se pueden suspender en frames
  6. Cambia el estado de suspender a Suspendido
  7. Devuelve el resultado de result.then(onFulfilled, onRejected) con las funciones onFulfilled y onRejected que hacen lo siguiente:

    1. Afirma que el estado de suspender está suspendido (debe estar garantizado)

    2. Cambia el estado de suspender a Activo [ caller' ], donde caller' es el llamador de onFulfilled / onRejected



      • En el caso de onFulfilled , convierte el valor dado en externref y lo devuelve a frames


      • En el caso de onRejected , arroja el valor dado hasta frames como una excepción de acuerdo con la API de JS de la propuesta de manejo de excepciones



Una función se puede suspender si se

  • definido por un módulo WebAssembly,
  • devuelto por suspendOnReturnedPromise ,
  • devuelto por returnPromiseOnSuspend ,
  • o generado mediante la creación de una función de host para una función suspendible

Es importante destacar que las funciones escritas en JavaScript no se TC39 , y las funciones del host (excepto las pocas enumeradas anteriormente) no se

Implementación

La siguiente es una estrategia de implementación para esta propuesta.
Asume el soporte del motor para el cambio de pila, que por supuesto es donde se encuentran los principales desafíos de implementación.

Hay dos tipos de pilas: una pila de host (y JavaScript) y una pila de WebAssembly. Cada pila de WebAssembly tiene un campo de suspensión llamado suspender . Cada hilo tiene una pila de hosts.

Cada Suspender tiene dos campos de referencia de pila: uno llamado caller y otro llamado suspended .

  • En el estado Inactivo , ambos campos son nulos.
  • En el estado Activo , el campo caller referencia a la pila (suspendida) de la persona que llama, y ​​el campo suspended es nulo
  • En el estado Suspendido , el campo suspended referencia a la pila de WebAssembly (suspendida) actualmente asociada con la suspensión, y el campo caller es nulo.

suspender.returnPromiseOnSuspend(func)(args) es implementado por

  1. Verificando que suspender.caller y suspended.suspended son nulos (trapping de lo contrario)
  2. Dejando que stack sea ​​una pila de ensamblaje web recién asignada asociada con suspender
  3. Cambiar a stack y almacenar la pila anterior en suspender.caller
  4. Dejando que result sea ​​el resultado de func(args) (o cualquier trampa o excepción lanzada)
  5. Cambiar a suspender.caller y establecerlo en nulo
  6. Liberando stack
  7. Devolver (o volver a tirar) result

suspender.suspendOnReturnedPromise(func)(args) es implementado por

  1. Llamando a func(args) , atrapando cualquier trampa o excepción lanzada
  2. Si result no es una Promesa devuelta, devolver (o volver a lanzar) result
  3. Verificando que suspender.caller no sea nulo (trapping de lo contrario)
  4. Sea stack la pila actual
  5. Si bien stack no es una pila de WebAssembly asociada con suspender :

    • Comprobando que stack es una pila de WebAssembly (de lo contrario, se capturará)

    • Actualizando stack para que sea stack.suspender.caller

  6. Cambiar a suspender.caller , establecerlo en nulo y almacenar la pila anterior en suspender.suspended
  7. Devolviendo el resultado de result.then(onFulfilled, onRejected) con las funciones onFulfilled y onRejected implementadas por

    1. Cambiar a suspender.suspended , establecerlo en nulo y almacenar la pila anterior en suspender.caller



      • En el caso de onFulfilled , convertir el valor dado a externref y devolverlo


      • En el caso de onRejected , volviendo a arrojar el valor dado



La implementación de la función generada al crear una función de host para una función suspendible se cambia para cambiar primero a la pila de host del hilo actual (si no está ya en ella) y, por último, volver a la pila anterior.

Todos 16 comentarios

¿Es posible exponer una API que recibe una función / generador asíncrono (sincronizado o asíncrono) y luego convertirlo en una función suspendible?

¿Puede aclarar, tal vez con algún pseudocódigo o un caso de uso, lo que quiere decir? Quiero asegurarme de darte una respuesta precisa.

¿La intención es que Suspender sea ​​parte de JS o sea una API separada? ¿Es exclusivamente para wasm ( WebAssembly.Suspender )? Me parece que esta propuesta debería debatirse en el TC39.

Específicamente NO está destinado a afectar los programas JS. Más precisamente, intentar suspender una función JS resultará en una trampa. Nos hemos tomado la molestia de asegurar esto.
Sin embargo, puedo planteárselo a Shu-yu para obtener su opinión.

Lo siento, @chicoxyzzy , veo que olvidé incluir algunos contextos / actualizaciones del subgrupo de pilas. Las propuestas de conmutación de pila más antiguas se escribieron con la expectativa de que debería poder capturar marcos de JavaScript / host en pilas suspendidas. Sin embargo, recibimos comentarios de las personas en TC39 de que existía la preocupación de que esto afectara demasiado drásticamente el ecosistema JS, y recibimos comentarios de los implementadores de host de que existía la preocupación de que no todos los marcos de host pudieran tolerar la suspensión. Por lo tanto, desde entonces, el subgrupo de pilas se ha asegurado de que los diseños solo capturen marcos de WebAssembly (relacionados) en pilas suspendidas, y esta propuesta satisface esa propiedad. Actualicé el OP para incluir esta nota importante.

Es genial ver el progreso aquí. ¿Hay ejemplos de cómo se usaría esto en la integración de ESM para Wasm?

La mala noticia es que, debido a que todo esto está en la API de JS, no puede simplemente importar un módulo wasm de ESM y obtener este soporte de cambio de pila para promesas. La buena noticia es que aún puede usar módulos ESM con esta API, solo con algunos módulos JS ESM como pegamento.

En particular, configura tres módulos ESM: foo-exports.js , foo-wasm.wasm y foo-imports.js . El módulo foo-imports.js crea el suspensor, lo usa para envolver todas las importaciones "asincrónicas" que producen promesas necesarias para foo-wasm.wasm , y exporta el suspensor y esas importaciones. foo-wasm.wasm luego importa todas las importaciones "asincrónicas" de foo-imports.js y todas las importaciones "síncronas" directamente desde sus respectivos módulos (o, por supuesto, también puede usarlas como proxy a través de foo-imports.js , que podría exportarlos sin envolver). Por último, foo-exports.js importa el suspensor de foo-imports.js , importa las exportaciones de foo-wasm.wasm , envuelve las exportaciones "asincrónicas" usando el suspensor y luego exporta el (desenvuelto) "síncrono" exportaciones y las exportaciones "asincrónicas" envueltas. Luego, los clientes importan desde foo-exports.js y nunca tocan directamente (o necesitan conocer) foo-wasm.wasm o foo-imports.js .

Es un obstáculo desafortunado, pero fue lo mejor que pudimos lograr dada la restricción de no modificar el núcleo central. Sin embargo, nuestro objetivo es garantizar que este diseño sea compatible con la propuesta que extiende el núcleo wasm de tal manera que, cuando se envíe esa propuesta, pueda intercambiar estos tres módulos por un módulo extendido-wasm y nadie puede hacerlo semánticamente. decir la diferencia (cambio de nombre de archivo de módulo).

¿Fue comprensible y cree que podría satisfacer sus necesidades (aunque de forma incómoda)?

Entiendo la necesidad de envolver, al menos mientras las importaciones de Wasm de tipo WebAssembly.Module aún no son posibles (y espero que lo sean a su debido tiempo).

Sin embargo, más específicamente, me preguntaba si había margen para decorar estos patrones en la integración de ESM para que ambos lados del pegamento de suspensión pudieran manejarse mejor. Por ejemplo, si había algunos metadatos que vinculaban las funciones exportadas e importadas en formato binario, la integración de ESM podría interrogar eso y hacer coincidir las funciones de suspensión de envoltura de importación / exportación dual internamente como parte de la capa de integración en función de ciertas reglas predecibles.

¡Ah! En la actualidad, no existe tal plan. La retroalimentación que había recibido era que tampoco había el deseo de cambiar la integración de ESM. En resumen, la esperanza es que eventualmente todo esto sea posible en core wasm, por lo que queremos que esta propuesta deje una huella lo más pequeña posible.

La retroalimentación que había recibido era que tampoco había el deseo de cambiar la integración de ESM

¿Puede colaborar sobre de dónde provienen estos comentarios? Hay mucho margen para extender la integración de ESM con semánticas de integración de nivel superior, un espacio que no creo que se haya explorado por completo, por lo que lo menciono. No he oído hablar de resistencia a mejorar esta área en el pasado. Ver esto como un área para endulzar puede ser un beneficio para los desarrolladores de JS al permitir importaciones / exportaciones directas de Promise.

Vale la pena señalar que esta propuesta dificulta la capacidad de que un solo módulo JS en un ciclo sea tanto el importador como el importador a un módulo Wasm que aún puede funcionar en el momento para las importaciones de funciones gracias al levantamiento de funciones del ciclo JS en la integración ESM. , pero no admitiría este ciclo de elevación con un contenedor de expresión Suspender alrededor de la función importada.

Recibí esta impresión de @lukewagner. Estoy de acuerdo en que hay margen para ampliar la integración de ESM, pero tengo entendido que esto requiere cambios / extensiones en el archivo wasm, que estábamos tratando de evitar (como parte del objetivo de tamaño reducido), por lo que no queríamos tales cambios / extensiones para formar parte de esta propuesta. Por supuesto, si dichos cambios / extensiones se agregaran a la propuesta de ESM, lo ideal sería que complementaran esta propuesta de modo que uno no necesitaría los módulos de envoltura JS para obtener la funcionalidad que ofrece esta propuesta.

Leí mal el comentario de @ Jack-Works, he ajustado mi comentario anterior.

Gracias @RossTate por las aclaraciones, sí, sugiero explorar la posibilidad de hacer coincidir estos contextos de suspensión de importación y exportación a través de metadatos en el propio binario para informar las integraciones de host, pero sin esperar eso en el MVP de ninguna manera. También estoy aprovechando la oportunidad para señalar que la integración de ESM es un espacio que podría beneficiarse del azúcar de manera más general, por separado de la API JS base.

Para ser claros, el desafío que señalé fue que cualquier opción que agreguemos a WebAssembly.instantiate() (o nuevas versiones de WebAssembly.instantiate() con nuevos parámetros) también tendrían que aparecer de alguna manera cuando wasm se cargó a través de ESM. -integración, no es que la integración de ESM fuera inmutable.

Ah, genial, entonces tenemos más flexibilidad con respecto a ESM de lo que pensé, en caso de que surja la necesidad. Gracias por corregir mi malentendido.

Parece que estamos hablando de algún tipo de sección personalizada para especificar cómo ciertas funciones Wasm exportadas deberían aparecer en JS como API basadas en Promise, y tal vez a la inversa, cómo las importaciones de Wasm se pueden convertir de API basadas en JS Promise a algún tipo. de conmutación de pila. ¿Estoy entendiendo correctamente?

Me gusta esta idea. Sospecho que nos encontraremos queriendo una sección personalizada análoga para la integración de Wasm GC / JS-ESM (o parte de la misma). No estoy seguro de hasta qué punto esta sección personalizada podría ser de varios idiomas, pero en ambos casos, probablemente sea un poco menos universal que los tipos de interfaz, y también tiende a usarse dentro de un componente, no solo entre ellos.

¿Alguien quiere escribir algún tipo de esencia o README que describa un diseño básico para esta sección personalizada?

Parece que es una opción posible. Como mencionas, se han discutido opciones similares en la propuesta de GC, como en WebAssembly / gc # 203. La integración de JS está programada tentativamente para ser discutida en el subgrupo de GC mañana, por lo que sería bueno tener en cuenta la posible conexión con esta propuesta durante esa discusión (o podría resultar que no está relacionada, dependiendo de cómo se desarrolle la discusión).

¿Fue útil esta página
0 / 5 - 0 calificaciones

Temas relacionados

Artur-A picture Artur-A  ·  3Comentarios

artem-v-shamsutdinov picture artem-v-shamsutdinov  ·  6Comentarios

arunetm picture arunetm  ·  7Comentarios

badumt55 picture badumt55  ·  8Comentarios

chicoxyzzy picture chicoxyzzy  ·  5Comentarios