Em resumo:
- A prova já está implementada em C ++ , JS e PHP , adequada para Java .
- Mais rápido que a corotina e o Promise, mais recursos.
- Não requer uma pilha de software separada.
- Amiga todas as ferramentas de segurança e depuração.
- Ele funciona em qualquer arquitetura e não requer sinalizadores especiais do compilador.
Olhe para trás
No início do computador, havia um único fluxo de controle com bloqueio na entrada e saída. Em seguida, foram adicionadas interrupções de ferro. Agora você pode efetivamente usar dispositivos lentos e imprevisíveis.
Com o crescimento das capacidades de ferro e sua baixa disponibilidade, tornou-se necessário executar várias tarefas simultaneamente, o que forneceu suporte de hardware. Portanto, houve processos isolados com interrupções abstraídas do ferro na forma de sinais.
O próximo estágio evolutivo foi o multithreading, implementado com base nos mesmos processos, mas com acesso compartilhado à memória e outros recursos. Essa abordagem tem suas limitações e uma sobrecarga significativa para alternar para um sistema operacional seguro.
Para comunicação entre processos e até máquinas diferentes, a abstração Promise / Future foi proposta há mais de 40 anos.
As interfaces com o usuário e o ridículo problema do cliente de 10K levaram ao auge das abordagens Event Loop, Reactor e Proactor, mais orientadas a eventos do que uma lógica comercial clara e consistente.
Finalmente, chegamos à corotina moderna (corotina), que em essência é uma emulação de fluxos sobre as abstrações descritas acima, com as correspondentes limitações técnicas e transferência determinística de controle.
Para transmitir eventos, resultados e exceções, todos retornaram ao mesmo conceito de Promessa / Futuro. Alguns escritórios decidiram nomear um pouco diferente - "Tarefa".
No final, eles ocultaram tudo em um belo pacote async/await
, que requer suporte do compilador ou tradutor, dependendo da tecnologia.
Problemas com situações atuais de lógica de negócios assíncronas
Considere apenas corotinas e Promessa, decoradas com async/await
, como a existência de problemas em abordagens mais antigas confirma o próprio processo de evolução.
Esses dois termos não são idênticos. Por exemplo, no ECMAScript não há corotinas, mas há um alívio sintático para o uso do Promise
, que por sua vez só organiza o trabalho com o inferno de retorno de chamada. De fato, mecanismos de script como o V8 vão mais longe e fazem otimizações especiais para funções e chamadas puramente async/await
.
Especialistas co_async/co_await
que não se enquadrava no C ++ 17 aqui no recurso , mas a pressão das corotinas gigantes de software pode aparecer no padrão exatamente em sua forma. Enquanto isso, a solução tradicionalmente reconhecida é Boost.Context , Boost.Fiber e Boost.Coroutine2 .
Em Java, ainda não há async/await
no nível da linguagem, mas existem soluções como o EA Async , que, como o Boost.Context, precisam ser customizadas para cada versão da JVM e byte de código.
O Go tem suas próprias rotinas, mas se você examinar com atenção os artigos e os relatórios de erros de projetos abertos, acontece que aqui tudo não é tão tranquilo. Talvez perder a interface da corotina como entidade gerenciada não seja uma boa ideia.
Opinião do autor: coroutines bare-metal são perigosas
Pessoalmente, o autor tem pouco contra corotinas em linguagens dinâmicas, mas é extremamente cauteloso com qualquer flerte com a pilha no nível do código da máquina.
Alguns pontos:
- Pilha necessária:
- a pilha na pilha tem várias desvantagens: problemas de determinação oportuna do estouro, danos por vizinhos e outros problemas de confiabilidade / segurança,
- uma pilha segura requer pelo menos uma página de memória física, uma página condicional e sobrecarga adicional para cada chamada para funções
async
: 4 + KB (mínimo) + limites de sistema aumentados, - em última análise, pode ser que uma parte significativa da memória alocada para as pilhas não seja usada durante o tempo de inatividade da corotina.
- É necessário implementar uma lógica complexa de salvar, restaurar e excluir o estado das corotinas:
- para todos os casos de arquitetura de processador (modelos pares) e interface binária (ABI): exemplo ,
- os recursos de arquitetura nova ou opcional apresentam problemas potencialmente latentes (por exemplo, co-processadores Intel TSX, ARM ou MIPS),
- outros problemas em potencial devido à documentação fechada dos sistemas proprietários (a documentação do Boost se refere a isso).
- Problemas potenciais com ferramentas de análise dinâmica e com segurança em geral:
- por exemplo, a integração com o Valgrind é necessária devido às mesmas pilhas de salto,
- é difícil falar em antivírus, mas provavelmente eles não gostam muito do exemplo de problemas com a JVM no passado,
- Tenho certeza de que novos tipos de ataques aparecerão e serão reveladas vulnerabilidades associadas à implementação de corotinas.
Opinião do autor: geradores e yield
mal fundamental
Esse tema aparentemente de terceiros está diretamente relacionado ao conceito de corotinas e à propriedade "continue".
Em resumo, um iterador completo deve existir para qualquer coleção. Por que criar um problema de gerador de iterador recortado não está claro. Por exemplo, um caso com range()
em Python é mais um show exclusivo do que uma desculpa para complicações técnicas.
Se o caso for um gerador infinito, a lógica de sua implementação é elementar. Por que criar dificuldades técnicas adicionais para impulsionar um ciclo contínuo sem fim.
A única justificativa sensata que mais tarde surgiu, que os defensores das corotinas dão é todo tipo de analisador de fluxo com controle invertido. De fato, esse é um caso especializado restrito para resolver problemas únicos no nível da biblioteca, não a lógica de negócios dos aplicativos. Ao mesmo tempo, existe uma solução elegante, simples e mais descritiva através de máquinas de estados finitos. A área desses problemas técnicos é muito menor que a área da lógica comercial comum.
De fato, o problema a ser resolvido é obtido com um dedo e requer esforços relativamente sérios para a implementação inicial e o suporte a longo prazo. Tanto é assim que alguns projetos podem introduzir uma proibição no uso de corotinas no nível de código de máquina, seguindo o exemplo de uma proibição de goto
ou o uso de alocação dinâmica de memória em indústrias individuais.
Opinião dos autores: O modelo async/await
do ECMAScript Promise é mais confiável, mas requer adaptação
Diferentemente das corotinas contínuas, nesse modelo as partes do código são secretamente divididas em blocos ininterruptos, projetados como funções anônimas. No C ++, isso não é totalmente adequado devido às peculiaridades do gerenciamento de memória, um exemplo:
struct SomeObject { using Value = std::vector<int>; Promise funcPromise() { return Promise.resolved(value_); } void funcCallback(std::function<void()> &&cb, const Value& val) { somehow_call_later(cb); } Value value_; }; Promise example() { SomeObject some_obj; return some_obj.funcPromise() .catch([](const std::exception &e){
Primeiramente, some_obj
será destruído ao sair de example()
e antes de chamar funções lambda.
Em segundo lugar, funções lambda com captura de variáveis ou referências são objetos e secretamente adicionam copiar / mover, o que pode afetar negativamente o desempenho com um grande número de capturas e a necessidade de alocar memória no heap durante o apagamento de tipo na std::function
usual.
Em terceiro lugar, a própria interface Promise
foi concebida com base no conceito de "promessa" do resultado, e não na execução consistente da lógica de negócios.
Uma solução esquemática NÃO ótima pode ser algo como isto:
Promise example() { struct LocalContext { SomeObject some_obj; }; auto ctx = std::make_shared<LocalContext>(); return some_obj.funcPromise() .catch([](const std::exception &e){
Nota: std::move
vez de std::shared_ptr
não std::shared_ptr
adequado devido à incapacidade de transferir para várias lambdas de uma só vez e ao crescimento de seu tamanho.
Com a adição de async/await
os horrores assíncronos entram em um estado digerível:
async void example() { SomeObject some_obj; try { SomeObject::Value val = await some_obj.func(); } catch (const std::exception& e) (
Opinião do autor: planejador de corotina é um fracasso
Alguns críticos consideram a falta de um agendador e o uso "desonesto" dos recursos do processador. Talvez um problema mais sério seja a localidade dos dados e o uso eficiente do cache do processador.
No primeiro problema: a priorização no nível das corotinas individuais parece uma grande sobrecarga. Em vez disso, eles podem ser operados em comum para uma tarefa unificada específica. É isso que os fluxos de tráfego fazem.
Isso é possível criando instâncias separadas do Event Loop com seus próprios threads "de ferro" e planejando no nível do SO. A segunda opção é sincronizar corotinas com uma primitiva relativamente primitiva (Mutex, Throttle) em termos de competição e / ou desempenho.
A programação assíncrona não torna os recursos do processador flexíveis e requer restrições absolutamente normais no número de tarefas processadas simultaneamente e limites no tempo total de execução.
A proteção contra bloqueios prolongados em uma rotina exige as mesmas medidas dos retornos de chamada - para evitar o bloqueio de chamadas do sistema e longos ciclos de processamento de dados.
O segundo problema requer pesquisa, mas pelo menos a rotina se empilha e os detalhes da implementação do Futuro / Promessa já violam a localidade dos dados. Existe a oportunidade de tentar continuar a execução da mesma rotina, se o futuro já interessar. É necessário um certo mecanismo para calcular o tempo de execução ou o número de tais continuações, a fim de impedir que uma corotina capture o tempo inteiro do processador. Isso pode não resultar em resultados duplos, dependendo do tamanho do cache do processador e do número de threads.
Há também um terceiro ponto - muitas implementações de agendadores de corotina permitem executá-los em diferentes núcleos de processador, o que, pelo contrário, adiciona problemas devido à sincronização obrigatória ao acessar recursos compartilhados. No caso de um único fluxo de Loop de Eventos, essa sincronização é necessária apenas no nível lógico, pois É garantido que cada bloco de retorno de chamada síncrono funcione sem uma corrida com outras pessoas.
Opinião do autor: tudo é bom com moderação
A presença de threads em sistemas operacionais modernos não nega o uso de processos individuais. Além disso, o processamento de um grande número de clientes no Event Loop não nega o uso de threads "iron" isolados para outras necessidades.
De qualquer forma, as corotinas e várias variantes dos Loops de Eventos complicam o processo de depuração sem o suporte necessário nas ferramentas, e com as variáveis locais na pilha de corotinas, tudo se torna ainda mais difícil - praticamente não há como chegar a elas.
FutoIn AsyncSteps - uma alternativa às corotinas
Tomamos como base o já estabelecido padrão de loop de eventos e a organização de esquemas de retorno de chamada de acordo com o tipo de promessa ECMAScript (JavaScript).
Em termos de planejamento de execução, estamos interessados nas seguintes atividades do Event Loop:
Handle immediate(callack)
exija uma pilha de chamadas limpa.- Retorno de chamada
Handle deferred(delay, callback)
. - Cancele o retorno de chamada
handle.cancel()
.
Portanto, obtemos uma interface chamada AsyncTool
, que pode ser implementada de várias maneiras, inclusive em cima de desenvolvimentos comprovados existentes. Ele não tem relação direta com a escrita da lógica de negócios, portanto não entraremos em mais detalhes.
Árvore de etapas:
No conceito AsyncSteps, uma árvore abstrata de etapas síncronas é alinhada e executada, aprofundando a sequência de criação. As etapas de cada nível mais profundo são definidas dinamicamente à medida que essa passagem é concluída.
Toda interação ocorre através de uma única interface AsyncSteps
, que, por convenção, é passada como o primeiro parâmetro para cada etapa. Por convenção, o nome do parâmetro é asi
ou descontinuado as
. Essa abordagem permite interromper quase completamente a conexão entre uma implementação específica e escrever a lógica de negócios em plugins e bibliotecas.
Nas implementações canônicas, cada etapa recebe sua própria instância de um objeto que implementa o AsyncSteps
, que permite o rastreamento oportuno de erros lógicos no uso da interface.
Exemplo abstrato:
asi.add( // Level 0 step 1 func( asi ){ print( "Level 0 func" ) asi.add( // Level 1 step 1 func( asi ){ print( "Level 1 func" ) asi.error( "MyError" ) }, onerror( asi, error ){ // Level 1 step 1 catch print( "Level 1 onerror: " + error ) asi.error( "NewError" ) } ) }, onerror( asi, error ){ // Level 0 step 1 catch print( "Level 0 onerror: " + error ) if ( error strequal "NewError" ) { asi.success( "Prm", 123, [1, 2, 3], true) } } ) asi.add( // Level 0 step 2 func( asi, str_param, int_param, array_param ){ print( "Level 0 func2: " + param ) } )
Resultado da execução:
Level 0 func 1 Level 1 func 1 Level 1 onerror 1: MyError Level 0 onerror 1: NewError Level 0 func 2: Prm
Em sincronia, ficaria assim:
str_res, int_res, array_res, bool_res // undefined try { // Level 0 step 1 print( "Level 0 func 1" ) try { // Level 1 step 1 print( "Level 1 func 1" ) throw "MyError" } catch( error ){ // Level 1 step 1 catch print( "Level 1 onerror 1: " + error ) throw "NewError" } } catch( error ){ // Level 0 step 1 catch print( "Level 0 onerror 1: " + error ) if ( error strequal "NewError" ) { str_res = "Prm" int_res = 123 array_res = [1, 2, 3] bool_res = true } else { re-throw } } { // Level 0 step 2 print( "Level 0 func 2: " + str_res ) }
A imitação máxima do código síncrono tradicional é imediatamente visível, o que deve ajudar na legibilidade.
Do ponto de vista da lógica de negócios, um grande número de requisitos cresce com o tempo, mas podemos dividi-lo em partes facilmente compreendidas. Descrito abaixo, o resultado da execução na prática por quatro anos.
APIs de tempo de execução principal:
add(func[, onerror])
- imitação de try-catch
.success([args...])
- uma indicação explícita da conclusão bem-sucedida:
- implícito por padrão
- pode passar os resultados para a próxima etapa.
error(code[, reason)
- interrupção da execução com um erro:
code
- possui um tipo de string para melhor integrar-se aos protocolos de rede na arquitetura de microsserviço,reason
- uma explicação arbitrária para uma pessoa.
state()
- um análogo do Thread Local Storage. Chaves associativas predefinidas:
error_info
- explicação do último erro de uma pessoa,last_exception
- ponteiro para o objeto da última exceção,async_stack
- uma pilha de chamadas assíncronas, async_stack
quanto a tecnologia permitir,- o resto é definido pelo usuário.
O exemplo anterior já está com código C ++ real e alguns recursos adicionais:
#include <futoin/iasyncsteps.hpp> using namespace futoin; void some_api(IAsyncSteps& asi) { asi.add( [](IAsyncSteps& asi) { std::cout << "Level 0 func 1" << std::endl; asi.add( [](IAsyncSteps& asi) { std::cout << "Level 1 func 1" << std::endl; asi.error("MyError"); }, [](IAsyncSteps& asi, ErrorCode code) { std::cout << "Level 1 onerror 1: " << code << std::endl; asi.error("NewError", "Human-readable description"); } ); }, [](IAsyncSteps& asi, ErrorCode code) { std::cout << "Level 0 onerror 1: " << code << std::endl; if (code == "NewError") { // Human-readable error info assert(asi.state().error_info == "Human-readable description"); // Last exception thrown is also available in state std::exception_ptr e = asi.state().last_exception; // NOTE: smart conversion of "const char*" asi.success("Prm", 123, std::vector<int>({1, 2, 3}, true)); } } ); asi.add( [](IAsyncSteps& asi, const futoin::string& str_res, int int_res, std::vector<int>&& arr_res) { std::cout << "Level 0 func 2: " << str_res << std::endl; } ); }
API para criar loops:
loop( func, [, label] )
- avance com um corpo infinitamente repetitivo.forEach( map|list, func [, label] )
- iteração passo a passo do objeto de coleção.repeat( count, func [, label] )
- número de vezes especificado na iteração por etapas.break( [label] )
é um análogo da interrupção tradicional do loop.continue( [label] )
é um análogo da continuação tradicional do loop com uma nova iteração.
A especificação oferece nomes alternativos breakLoop
, continueLoop
e outros em caso de conflito com palavras reservadas.
Exemplo de C ++:
asi.loop([](IAsyncSteps& asi) {
API para integração com eventos externos:
setTimeout( timeout_ms )
- gera um erro de tempo Timeout
após um tempo limite, se a etapa e sua subárvore não tiverem concluído a execução.setCancel( handler )
- define o manipulador de cancelamento, que é chamado quando o encadeamento é cancelado completamente e quando a pilha de etapas assíncronas é expandida durante o processamento de erros.waitExternal()
- uma espera simples por um evento externo.
- Nota: É seguro usar somente em tecnologias com um coletor de lixo.
Uma chamada para qualquer uma dessas funções torna necessária uma chamada explícita para success()
.
Exemplo de C ++:
asi.add([](IAsyncSteps& asi) { auto handle = schedule_external_callback([&](bool err) { if (err) { try { asi.error("ExternalError"); } catch (...) {
Exemplo ECMAScript:
asi.add( (asi) => { asi.waitExternal();
API de integração futura / promissora:
await(promise_future[, on_error])
- aguardando Futuro / Promessa como uma etapa.promise()
- transforma todo o fluxo de execução em Futuro / Promessa, usado em vez de execute()
.
Exemplo de C ++:
[](IAsyncSteps& asi) { // Proper way to create new AsyncSteps instances // without hard dependency on implementation. auto new_steps = asi.newInstance(); new_steps->add([](IAsyncSteps& asi) {}); // Can be called outside of AsyncSteps event loop // new_steps.promise().wait(); // or // new_steps.promise<int>().get(); // Proper way to wait for standard std::future asi.await(new_steps->promise()); // Ensure instance lifetime asi.state()["some_obj"] = std::move(new_steps); };
API de controle de fluxo da lógica de negócios:
AsyncSteps(AsyncTool&)
é um construtor que vincula um encadeamento de execução a um loop de evento específico.execute()
- inicia o thread de execução.cancel()
- cancela o encadeamento de execução.
Uma implementação de interface específica já é necessária aqui.
Exemplo de C ++:
#include <futoin/ri/asyncsteps.hpp> #include <futoin/ri/asynctool.hpp> void example() { futoin::ri::AsyncTool at; futoin::ri::AsyncSteps asi{at}; asi.loop([&](futoin::IAsyncSteps &asi){ // Some infinite loop logic }); asi.execute(); std::this_thread::sleep_for(std::chrono::seconds{10}); asi.cancel(); // called in d-tor by fact }
outras APIs:
newInstance()
- permite criar um novo encadeamento de execução sem dependência direta da implementação.sync(object, func, onerror)
- o mesmo, mas com a sincronização relativa a um objeto que implementa a interface correspondente.parallel([on_error])
- add()
especial add()
, cujas subetapas são fluxos AsyncSteps separados:
- todos os threads têm
state()
comum state()
, - o encadeamento pai continua a execução após a conclusão de todos os filhos
- um erro não detectado em qualquer filho cancela imediatamente todos os outros threads filhos.
Exemplos de C ++:
#include <futoin/ri/mutex.hpp> using namespace futoin; ri::Mutex mtx_a; void sync_example(IAsyncSteps& asi) { asi.sync(mtx_a, [](IAsyncSteps& asi) { // synchronized section asi.add([](IAsyncSteps& asi) { // inner step in the section // This synchronization is NOOP for already // acquired Mutex. asi.sync(mtx_a, [](IAsyncSteps& asi) { }); }); }); } void parallel_example(IAsyncSteps& asi) { using OrderVector = std::vector<int>; asi.state("order", OrderVector{}); auto& p = asi.parallel([](IAsyncSteps& asi, ErrorCode) { // Overall error handler asi.success(); }); p.add([](IAsyncSteps& asi) { // regular flow asi.state<OrderVector>("order").push_back(1); asi.add([](IAsyncSteps& asi) { asi.state<OrderVector>("order").push_back(4); }); }); p.add([](IAsyncSteps& asi) { asi.state<OrderVector>("order").push_back(2); asi.add([](IAsyncSteps& asi) { asi.state<OrderVector>("order").push_back(5); asi.error("SomeError"); }); }); p.add([](IAsyncSteps& asi) { asi.state<OrderVector>("order").push_back(3); asi.add([](IAsyncSteps& asi) { asi.state<OrderVector>("order").push_back(6); }); }); asi.add([](IAsyncSteps& asi) { asi.state<OrderVector>("order"); // 1, 2, 3, 4, 5 }); };
Primitivas padrão para sincronização
Mutex
- restringe a execução simultânea de N
threads com uma fila em Q
, por padrão N=1, Q=unlimited
.Throttle
- limita o número de entradas N
no período P
com uma fila em Q
, por padrão N=1, P=1s, Q=0
.Limiter
é uma combinação de Mutex
e Throttle
, que normalmente é usada na entrada do processamento de solicitações externas e ao chamar sistemas externos com a finalidade de operação estável sob carga.
No caso de DefenseRejected
limites DefenseRejected
fila, é DefenseRejected
um erro DefenseRejected
, cujo significado é claro na descrição do Limiter
.
Principais Benefícios
O conceito de AsyncSteps não era um fim em si, mas nasceu da necessidade de execução assíncrona mais controlada de programas em termos de prazo, cancelamento e conectividade geral de retornos de chamada individuais. Nenhuma das soluções universais da época e agora oferece a mesma funcionalidade. Portanto:
FTN12 — .
setCancel()
— . , . RAII atexit()
.
cancel()
— , . SIGTERM
pthread_cancel()
, .
setTimeout()
— . , "Timeout".
— FutoIn AsyncSteps .
— ABI , . Embedded MMU.
Intel Xeon E3-1245v2/DDR1333 Debian Stretch .
:
- Boost.Fiber
protected_fixedsize_stack
. - Boost.Fiber
pooled_fixedsize_stack
. - FutoIn AsyncSteps .
- FutoIn AsyncSteps (
FUTOIN_USE_MEMPOOL=false
).
- FutoIn NitroSteps<> — .
Boost.Fiber :
- 1 . .
- 30 . 1 . .
- 30 .
mmap()/mprotect()
boost::fiber::protected_fixedsize_stack
. - .
- 30 . 10 . .
"" , .. , . . .
GCC 6.3.0. lang tcmalloc , .
GitHub GitLab .
1.
| Tempo | |
---|
Boost.Fiber protected | 4.8s | 208333.333Hz |
Boost.Fiber pooled | 0.23s | 4347826.086Hz |
FutoIn AsyncSteps | 0.21s | 4761904.761Hz |
FutoIn AsyncSteps no mempool | 0.31s | 3225806.451Hz |
FutoIn NitroSteps | 0.255s | 3921568.627Hz |
— .
Boost.Fiber - , pooled_fixedsize_stack
, AsyncSteps.
2.
| Tempo | |
---|
Boost.Fiber protected | 6.31s | 158478.605Hz |
Boost.Fiber pooled | 1.558s | 641848.523Hz |
FutoIn AsyncSteps | 1.13s | 884955.752Hz |
FutoIn AsyncSteps no mempool | 1.353s | 739098.300Hz |
FutoIn NitroSteps | 1.43s | 699300.699Hz |
— .
, . , — .
3.
| Tempo | |
---|
Boost.Fiber protected | 5.096s | 1962323.390Hz |
Boost.Fiber pooled | 5.077s | 1969667.126Hz |
FutoIn AsyncSteps | 5.361s | 1865323.633Hz |
FutoIn AsyncSteps no mempool | 8.288s | 1206563.706Hz |
FutoIn NitroSteps | 3.68s | 2717391.304Hz |
— .
, Boost.Fiber AsyncSteps, NitroSteps.
| |
---|
Boost.Fiber protected | 124M |
Boost.Fiber pooled | 505M |
FutoIn AsyncSteps | 124M |
FutoIn AsyncSteps no mempool | 84M |
FutoIn NitroSteps | 115M |
— .
, Boost.Fiber .
: Node.js
- Promise
: + 10 . . 10 . JIT NODE_ENV=production
, @futoin/optihelp
.
GitHub GitLab . Node.js v8.12.0 v10.11.0, FutoIn CID .
Tech | Simple | Loop |
---|
Node.js v10 | | |
FutoIn AsyncSteps | 1342899.520Hz | 587.777Hz |
async/await | 524983.234Hz | 630.863Hz |
Node.js v8 | | |
FutoIn AsyncSteps | 682420.735Hz | 588.336Hz |
async/await | 365050.395Hz | 400.575Hz |
— .
async/await
? , V8 Node.js v10 .
, Promise async/await
Node.js Event Loop. ( ), FutoIn AsyncSteps .
AsyncSteps Node.js Event Loop async/await
- Node.js v10.
, ++ — . , Node.js 10 .
Conclusões
C++, FutoIn AsyncSteps Boost.Fiber , Boost.Fiber mmap()/mprotect
.
, - , . .
FutoIn AsyncSteps JavaScript async/await
Node.js v10.
, -, . .
- "" . — API.
Conclusão
, FutoIn AsyncSteps , "" async/await
. , . Promise
ECMAScript, AsyncSteps "" .
. AsyncSteps NitroSteps .
, - .
Java/JVM — . .
, GitHub / GitLab .