Design: Proposta: Async / Await JS API

Criado em 26 jul. 2021  ·  16Comentários  ·  Fonte: WebAssembly/design

Esta proposta foi desenvolvida em colaboração com @fmccabe , @thibaudmichaud , @lukewagner e @kripken , junto com o feedback do Subgrupo de Pilhas (com uma votação informal aprovando seu avanço para a Fase 0 hoje). Observe que, devido a limitações de tempo, o plano é fazer uma apresentação muito rápida (ou seja, 5 minutos) e votar para avançar para a Fase 1 em 3 de agosto. Para facilitar isso, encorajamos fortemente as pessoas a levantarem preocupações aqui com antecedência, para que possamos determinar se há alguma preocupação importante que mereceria adiar a apresentação + voto de volta para uma data posterior com mais tempo.

O objetivo desta proposta é fornecer uma interoperabilidade relativamente eficiente e relativamente ergonômica entre as promessas do JavaScript e o WebAssembly, mas trabalhando sob a restrição de que as únicas alterações são na API JS e não no wasm principal.
A expectativa é que a proposta de troca de pilha acabe estendendo o WebAssembly central com a funcionalidade de implementar as operações que fornecemos nesta proposta diretamente no WebAssembly, junto com muitas outras operações de troca de pilha valiosas, mas que este caso de uso específico para troca de pilha teve urgência suficiente para merecer um caminho mais rápido por meio apenas da API JS.
Para obter mais informações, consulte as notas e slides da reunião do subgrupo Stack de 28 de junho de 2021 , que detalha os cenários de uso e os fatores que levamos em consideração e resume a justificativa de como chegamos ao design a seguir.

ATUALIZAÇÃO: após o feedback que o subgrupo de pilhas recebeu do TC39, esta proposta permite apenas que as pilhas do WebAssembly sejam suspensas - não faz alterações na linguagem JavaScript e, em particular, não habilita indiretamente o suporte para asycn / await em JavaScript.

Isso depende (vagamente) da proposta js-types , que apresenta WebAssembly.Function como uma subclasse de Function .

Interface

A proposta é adicionar a seguinte interface, construtor e métodos à API JS, com mais detalhes sobre sua semântica a seguir.

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

Exemplo

A seguir está um exemplo de como esperamos usar esta API.
Em nossos cenários de uso, achamos útil considerar os módulos WebAssembly para ter importações e exportações "síncronas" e "assíncronas".
A API JS atual suporta apenas importações e exportações "síncronas".
Os métodos da interface do Suspender são usados ​​para envolver as importações e exportações relevantes a fim de torná-las "assíncronas", com o próprio objeto Suspender conectando explicitamente essas importações e exportações para facilitar a implementação e a composição.

WebAssembly ( 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);
    ...
});

Neste exemplo, temos um módulo WebAssembly que é uma máquina de estado muito simplista - toda vez que você atualiza o estado, ele simplesmente chama uma importação para calcular um delta para adicionar ao estado.
No lado do JavaScript, entretanto, a função que queremos usar para calcular o delta precisa ser executada de forma assíncrona; ou seja, ele retorna uma promessa de um número em vez de um número em si.

Podemos preencher essa lacuna de sincronia usando a nova API JS.
No exemplo, uma importação do módulo WebAssembly é empacotada usando suspender.suspendOnReturnedPromise , e uma exportação é empacotada usando suspender.returnPromiseOnSuspend , ambos usando o mesmo suspender .
Esse suspender conecta os dois juntos.
Isso faz com que, se alguma vez a importação (desembrulhada) retornar uma promessa, a exportação (embalada) retornará uma promessa, com todos os cálculos sendo "suspensos" até que a promessa da importação seja resolvida.
O empacotamento da exportação está essencialmente adicionando um marcador async , e o empacotamento da importação está essencialmente adicionando um marcador await , mas ao contrário do JavaScript, não temos que encadear explicitamente async / await todas as funções intermediárias do WebAssembly!

Enquanto isso, a chamada feita para init_state durante a inicialização retorna necessariamente sem suspensão, e as chamadas para exportação get_state também sempre retornam sem suspensão, de modo que a proposta ainda suporta as importações e exportações "síncronas" existentes o ecossistema WebAssembly usa hoje.
Obviamente, há muitos detalhes sendo ignorados, como o fato de que se uma exportação síncrona chamar uma importação assíncrona, o programa irá interceptar se a importação tentar suspender.
O seguinte fornece uma especificação mais detalhada, bem como algumas estratégias de implementação.

Especificação

A Suspender está em um dos seguintes estados:

  • Inativo - não está sendo usado no momento
  • Ativo [ caller ] - o controle está dentro de Suspender , com caller sendo a função que chamou Suspender e está esperando um externref a ser devolvido
  • Suspenso - atualmente aguardando alguma promessa de resolução

O método suspender.returnPromiseOnSuspend(func) afirma que func é um WebAssembly.Function com um tipo de função da forma [ti*] -> [to] e retorna WebAssembly.Function com um tipo de função [ti*] -> [externref] que faz o seguinte quando chamado com argumentos args :

  1. Traps se o estado de suspender não for Inativo
  2. Altera o estado de suspender para Ativo [ caller ] (onde caller é o chamador atual)
  3. Permite que result seja o resultado de chamar func(args) (ou qualquer armadilha ou exceção lançada)
  4. Afirma que o estado de suspender está Ativo [ caller' ] por algum caller' (deve ser garantido, embora o chamador possa ter mudado)
  5. Altera o estado de suspender para Inativo
  6. Retorna (ou relança) result para caller'

O método suspender.suspendOnReturnedPromise(func)

  • se func é WebAssembly.Function , então afirma que seu tipo de função é da forma [t*] -> [externref] e retorna WebAssembly.Function com tipo de função [t*] -> [externref] ;
  • caso contrário, afirma que func é Function e retorna Function .

Em ambos os casos, a função retornada por suspender.suspendOnReturnedPromise(func) faz o seguinte quando chamada com os argumentos args :

  1. Permite que result seja o resultado de chamar func(args) (ou qualquer armadilha ou exceção lançada)
  2. Se result não for uma promessa devolvida, então retorna (ou relança) result
  3. Traps se o estado de suspender não for Ativo [ caller ] para algum caller
  4. Permite que frames sejam os quadros de pilha desde caller
  5. Traps se houver quadros de funções não suspensas em frames
  6. Altera o estado de suspender para suspenso
  7. Retorna o resultado de result.then(onFulfilled, onRejected) com as funções onFulfilled e onRejected que fazem o seguinte:

    1. Afirma que o estado de suspender está suspenso (deve ser garantido)

    2. Altera o estado de suspender para Ativo [ caller' ], onde caller' é o chamador de onFulfilled / onRejected



      • No caso de onFulfilled , converte o valor fornecido em externref e retorna para frames


      • No caso de onRejected , lança o valor fornecido até frames como uma exceção de acordo com a API JS da proposta de Tratamento de Exceções



Uma função pode ser suspensa se fosse

  • definido por um módulo WebAssembly,
  • devolvido por suspendOnReturnedPromise ,
  • devolvido por returnPromiseOnSuspend ,
  • ou gerado pela criação de uma função de host para uma função que pode ser suspensa

É importante ressaltar que as funções escritas em JavaScript não podem TC39 , e as funções do host (exceto algumas listadas acima) não podem

Implementação

A seguir está uma estratégia de implementação para esta proposta.
Ele pressupõe suporte de mecanismo para comutação de pilha, que, obviamente, é onde residem os principais desafios de implementação.

Existem dois tipos de pilhas: uma pilha de host (e JavaScript) e uma pilha WebAssembly. Cada pilha WebAssembly tem um campo suspenso chamado suspender . Cada thread tem uma pilha de host.

Cada Suspender tem dois campos de referência de pilha: um chamado caller e outro chamado suspended .

  • No estado Inativo , ambos os campos são nulos.
  • No estado Ativo , o campo caller referência à pilha (suspensa) do chamador e o campo suspended é nulo
  • No estado Suspenso , o campo suspended referência à pilha WebAssembly (suspensa) atualmente associada ao suspensor, e o campo caller é nulo.

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

  1. Verificar se suspender.caller e suspended.suspended são nulos (captura de outra forma)
  2. Deixando stack ser uma pilha WebAssembly recém-alocada associada a suspender
  3. Mudando para stack e armazenando a pilha anterior em suspender.caller
  4. Deixando result ser o resultado de func(args) (ou qualquer armadilha ou exceção lançada)
  5. Mudando para suspender.caller e definindo-o como nulo
  6. Liberando stack
  7. Devolver (ou relançar) result

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

  1. Chamando func(args) , pegando qualquer armadilha ou exceção lançada
  2. Se result não for uma promessa devolvida, retornar (ou relançar) result
  3. Verificar se suspender.caller não é nulo (captura de outra forma)
  4. Seja stack a pilha atual
  5. Embora stack não seja uma pilha WebAssembly associada a suspender :

    • Verificar se stack é uma pilha WebAssembly (trapping de outra forma)

    • Atualizando stack para ser stack.suspender.caller

  6. Mudando para suspender.caller , definindo-o como nulo e armazenando a pilha anterior em suspender.suspended
  7. Retornando o resultado de result.then(onFulfilled, onRejected) com as funções onFulfilled e onRejected que são implementadas por

    1. Mudando para suspender.suspended , definindo-o como nulo e armazenando a pilha anterior em suspender.caller



      • No caso de onFulfilled , converter o valor fornecido em externref e devolvê-lo


      • No caso de onRejected , relançar o valor fornecido



A implementação da função gerada pela criação de uma função de host para uma função que pode ser suspensa é alterada para primeiro alternar para a pilha de host do thread atual (se ainda não estiver nela) e, por último, voltar para a pilha anterior.

Todos 16 comentários

É possível expor uma API que recebe uma função / gerador assíncrono (sincronização ou assíncrona) e, em seguida, transformá-lo em uma função que pode ser suspensa?

Você pode esclarecer, talvez com algum pseudocódigo ou um caso de uso, o que você quer dizer? Quero ter certeza de que vou dar uma resposta precisa.

A intenção é que Suspender faça parte do JS ou seja uma API separada? É exclusivamente para wasm ( WebAssembly.Suspender )? Parece-me que essa proposta deve ser discutida no TC39.

Não se destina especificamente a afetar programas JS. Mais precisamente, tentar suspender uma função JS resultará em uma armadilha. Nós tivemos alguns problemas para garantir isso.
No entanto, posso falar com Shu-yu para saber sua opinião.

Desculpe, @chicoxyzzy , vejo que esqueci de incluir alguns contextos / atualizações do subgrupo de pilhas. As propostas de troca de pilha mais velhos foram escritos com a expectativa de que você deve ser capaz de capturar quadros JavaScript / hospedeiro em pilhas suspensos. No entanto, recebemos feedback de pessoas no TC39 de que havia uma preocupação de que isso afetaria drasticamente o ecossistema JS e recebemos feedback de implementadores de host de que havia a preocupação de que nem todos os frames do host seriam capazes de tolerar a suspensão. Portanto, o subgrupo de pilhas tem garantido que os designs capturem apenas quadros do WebAssembly (relacionados) em pilhas suspensas, e esta proposta atende a essa propriedade. Eu atualizei o OP para incluir esta nota importante.

É ótimo ver o progresso aqui. Existem exemplos de como isso seria usado na integração ESM para Wasm?

A má notícia é que, como tudo isso está na API JS, você não pode simplesmente importar um módulo wasm ESM e obter esse suporte de troca de pilha para promessas. A boa notícia é que você ainda pode usar módulos ESM com esta API, apenas com alguns módulos JS ESM como cola.

Em particular, você configurou três módulos ESM: foo-exports.js , foo-wasm.wasm e foo-imports.js . O módulo foo-imports.js cria o suspensor, usa-o para envolver todas as importações "assíncronas" de produção de promessa necessárias para foo-wasm.wasm e exporta o suspensor e essas importações. foo-wasm.wasm então importa todas as importações "assíncronas" de foo-imports.js e todas as importações "síncronas" diretamente de seus respectivos módulos (ou, é claro, você também pode procurá-las por meio de foo-imports.js , que pode exportá-los sem embalagem). Por último, foo-exports.js importa o suspensor de foo-imports.js , importa as exportações de foo-wasm.wasm , envolve as exportações "assíncronas" usando o suspensor e, em seguida, exporta o (desembrulhado) "síncrono" exportações e as exportações "assíncronas" agrupadas. Os clientes então importam de foo-exports.js e nunca tocam diretamente (ou precisam do conhecimento) foo-wasm.wasm ou foo-imports.js .

É um obstáculo infeliz, mas foi o melhor que pudemos alcançar devido à restrição de não modificar o wasm do núcleo. Nosso objetivo é garantir, no entanto, que este projeto seja compatível com a proposta de extensão do núcleo wasm de tal forma que, quando a proposta for enviada, você poderá trocar esses três módulos por um módulo estendido-wasm e ninguém pode semanticamente dizer a diferença (renomeação de arquivo de módulo).

Isso foi compreensível e você acha que atenderia às suas necessidades (embora de forma estranha)?

Eu entendo a necessidade de empacotamento, pelo menos enquanto as importações do tipo Wasm WebAssembly.Module ainda não são possíveis (e espero que sejam no devido tempo).

Porém, mais especificamente, eu estava me perguntando se havia espaço para decorar esses padrões na integração do ESM para que ambos os lados da cola do suspensório pudessem ser mais gerenciados. Por exemplo, se houver alguns metadados que vinculam as funções exportadas e importadas no formato binário, a integração do ESM pode interrogar isso e combinar as funções de suspensão de empacotamento de importação / exportação duplas internamente como parte da camada de integração com base em certas regras previsíveis.

Ah. No momento, nenhum plano desse tipo está em vigor. O feedback que recebi foi de que também havia o desejo de não alterar a integração do ESM. Em suma, a esperança é que, eventualmente, tudo isso seja possível no core wasm, e por isso queremos que esta proposta deixe o menor espaço possível.

O feedback que recebi foi de que havia um desejo de não alterar a integração do ESM também

Você pode explicar de onde vem esse feedback? Há muito escopo para estender a integração do ESM com semântica de integração de nível superior, um espaço que não sinto que tenha sido totalmente explorado, por isso o mencionei. Não ouvi falar de resistência em melhorar essa área no passado. Vendo isso como uma área para sugaring pode ser um benefício para os desenvolvedores JS ao permitir importações / exportações diretas da Promise.

É importante notar que esta proposta impede a capacidade de um único módulo JS em um ciclo ser o importador e o importador para um módulo Wasm que ainda pode funcionar no momento para importações de função graças à função de ciclo JS içada na integração ESM , mas não suportaria esse levantamento de ciclo com um wrapper de expressão Suspender em torno da função importada.

Tive essa impressão de @lukewagner. Concordo que há escopo para estender a integração ESM, mas meu entendimento é que isso requer alterações / extensões para o arquivo wasm - o que estávamos tentando evitar (como parte do objetivo de pequena pegada) - então não queríamos tais mudanças / extensões a fazerem parte desta proposta. Obviamente, se tais mudanças / extensões fossem adicionadas à proposta do ESM, elas idealmente complementariam esta proposta de forma que não fosse necessário os módulos de wrapper JS para obter a funcionalidade que esta proposta oferece.

Eu li errado o comentário de @Jack-Works, ajustei meu comentário acima.

Obrigado @RossTate pelos esclarecimentos, sim, estou sugerindo explorar a possibilidade de combinar esses contextos de suspensão de importação e exportação por meio de metadados no próprio binário para informar as integrações de host, mas não esperando isso no MVP de forma alguma. Também estou aproveitando a oportunidade para apontar que a integração do ESM é um espaço que pode se beneficiar do açúcar de forma mais geral, separadamente da API JS de base.

Para ser claro, o desafio que apontei foi que quaisquer opções que adicionamos a WebAssembly.instantiate() (ou novas versões de WebAssembly.instantiate() com novos parâmetros) também teriam que aparecer de alguma forma quando o wasm foi carregado via ESM -integração, não que ESM-integração fosse imutável.

Ah, legal, então temos mais flexibilidade em relação ao ESM do que eu imaginava, se necessário. Obrigado por corrigir meu mal-entendido.

Parece que estamos falando sobre algum tipo de seção personalizada para especificar como certas funções Wasm exportadas devem aparecer para JS como APIs baseadas em Promise, e talvez inversamente como as importações de Wasm podem ser convertidas de APIs baseadas em JS Promise em algum tipo de comutação de pilha. Estou entendendo corretamente?

Eu gosto desta ideia. Suspeito que iremos querer uma seção customizada análoga para integração Wasm GC / JS-ESM (ou parte da mesma). Não tenho certeza de até que ponto essa seção personalizada pode ser entre idiomas, mas em ambos os casos, é provavelmente um pouco menos universal do que os tipos de interface e também tende a ser usada dentro de um componente, não apenas entre eles.

Alguém quer escrever algum tipo de essência ou README descrevendo um design básico para esta seção personalizada?

Parece que essa é uma opção possível. Como você mencionou, opções semelhantes foram discutidas na proposta do GC, como em WebAssembly / gc # 203. A integração JS está provisoriamente agendada para ser discutida no subgrupo GC amanhã, então pode ser bom manter a possível conexão com esta proposta em mente durante a discussão (ou pode não estar relacionada, dependendo de como a discussão vai).

Esta página foi útil?
0 / 5 - 0 avaliações

Questões relacionadas

mfateev picture mfateev  ·  5Comentários

JimmyVV picture JimmyVV  ·  4Comentários

beriberikix picture beriberikix  ·  7Comentários

Artur-A picture Artur-A  ·  3Comentários

bobOnGitHub picture bobOnGitHub  ·  6Comentários