async/await em JavaScript vs Rust vs Python
Compare async/await em JavaScript, Rust e Python: veja quando as funções começam, o que ocorre sem await e como executar tarefas em paralelo.
Uma função async em JavaScript começa a ser executada no momento em que você a chama. Já uma async fn em Rust ou uma async def em Python não fazem absolutamente nada até que algo as aguarde (await) ou as agende.
Essa diferença no momento em que o trabalho assíncrono começa causa um bug bem conhecido. A versão em Rust ou Python de um código TypeScript que funciona ou nunca executa a função, ou executa as chamadas uma após a outra quando você esperava que elas se sobrepusessem. Este artigo executa o mesmo trecho de código em Node.js, em Rust com Tokio e em Python 3.11+, e mostra a ordem da saída em cada caso. Partimos do princípio de que você já conhece async em JavaScript. Se quiser revisar esse conteúdo antes, confira os guias da OpenReplay sobre boas práticas de programação assíncrona em JavaScript e dicas, truques e armadilhas de async em JavaScript.
Principais conclusões
- Chamar uma função async em JavaScript executa seu corpo de forma síncrona até o primeiro
await, então o código antes desseawaitroda imediatamente. - Chamar uma
async fnem Rust retorna umaFuturee não executa nada do corpo até que a future seja aguardada ou entregue a um runtime como o Tokio. - Chamar uma função
async defem Python retorna um objeto coroutine que não executa nada até ser aguardado ou encapsulado em uma task. - Se você cria duas chamadas async e depois aguarda cada uma delas em sequência, o trabalho se sobrepõe em JavaScript, mas é executado um após o outro em Rust e Python.
- Os runtimes de JavaScript já vêm com um event loop embutido. Rust precisa de um executor como
#[tokio::main], e Python inicia seu loop comasyncio.run().
Um trecho de código, três linguagens, três saídas
Cada versão abaixo define a mesma função: imprime uma mensagem, aguarda um sleep de 10 ms e imprime de novo. Em seguida, cada uma chama a função sem aguardá-la. Apenas o JavaScript imprime a: start.
Os exemplos têm como alvo uma versão LTS do Node.js, o Tokio 1.x e o Python 3.11 ou superior.
JavaScript (Node.js)
async function work(label) {
console.log(`${label}: start`);
await new Promise((r) => setTimeout(r, 10));
console.log(`${label}: end`);
}
work("a"); // no await
console.log("main: after call");
a: start
main: after call
a: end
Rust (Tokio) com tokio = { version = "1", features = ["macros", "rt", "time"] }
use std::time::Duration;
async fn work(label: &'static str) {
println!("{}: start", label);
tokio::time::sleep(Duration::from_millis(10)).await;
println!("{}: end", label);
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
work("a"); // future created, never awaited
println!("main: after call");
}
main: after call
Python 3.11+
import asyncio
async def work(label):
print(f"{label}: start")
await asyncio.sleep(0.01)
print(f"{label}: end")
async def main():
work("a") # coroutine created, never awaited
print("main: after call")
asyncio.run(main())
main: after call
Por que o async/await do Rust não executa até ser aguardado?
A diferença entre o async/await de JavaScript, Rust e Python está no que a chamada de uma função async retorna. O JavaScript retorna uma Promise referente a um trabalho que já começou. Rust e Python retornam um objeto que representa um trabalho que ainda não começou.
- JavaScript: uma
async functionexecuta de forma síncrona até o seu primeiroawait. A Promise que você recebe pertence a um corpo que já está em execução. - Rust: uma
async fnretornaimpl Future. A traitFuturedescreve um valor que só avança quando é consultado via polling. Nada no corpo é executado até que você escreva.awaitou passe a future para algo comotokio::spawn. - Python: chamar uma função
async defretorna um objeto coroutine. A documentação do asyncio deixa claro que chamar uma coroutine não a agenda para execução.
O artigo científico de Gray, Krishnamurthi e Crichton descreve essa escolha como uma dimensão de “Eagerness” (avidez) no design de async, com três opções: Lazy, Eager e Semi-Eager. Na prática, o JavaScript inicia o trabalho no ponto da chamada, enquanto as outras duas linguagens esperam.
O que acontece quando você esquece o await?
As três linguagens permitem que você esqueça o await, mas falham de maneiras diferentes. O JavaScript ainda executa o trabalho, então o risco é um erro que ninguém captura. Rust e Python pulam o trabalho por completo e avisam você sobre isso.
JavaScript. O corpo é executado, mas, se ele rejeitar e nada tratar a rejeição, o Node a trata como erro. O modo padrão de --unhandled-rejections é throw, que lança a rejeição como uma exceção não capturada e encerra o processo, a menos que você tenha um handler registrado:
async function save() { throw new Error("disk full"); }
save(); // no await, no .catch()
Rust. O compilador emite um aviso em tempo de build por meio do lint unused_must_use:
warning: unused implementer of `Future` that must be used
= note: futures do nothing unless you `.await` or poll them
Python. Quando a coroutine não aguardada é coletada pelo garbage collector, o CPython imprime um aviso no stderr. O guia do asyncio aborda isso na seção sobre detecção de coroutines nunca aguardadas:
RuntimeWarning: coroutine 'work' was never awaited
A correção é a mesma em todas as linguagens: await work("a") em JavaScript e Python, e work("a").await em Rust. Com essa mudança, as três imprimem a: start, a: end, main: after call.
Como executar trabalho assíncrono de forma concorrente?
Em JavaScript, basta chamar as funções para obter concorrência. Em Rust e Python, você precisa pedir isso explicitamente com um combinator ou uma task.
O padrão “criar e depois aguardar” mostra a diferença com clareza:
const pa = work("a"), pb = work("b");
await pa; await pb; // JS: a: start, b: start, a: end, b: end
let fa = work("a"); let fb = work("b");
fa.await; fb.await; // Rust: a: start, a: end, b: start, b: end
ca = work("a"); cb = work("b")
await ca; await cb # Python: a: start, a: end, b: start, b: end
Nem a future do Rust nem a coroutine do Python começam antes de serem aguardadas, então cada chamada só é executada depois que a anterior termina. As soluções:
| Linguagem | Aguardar todas | Iniciar agora, aguardar depois |
|---|---|---|
| JavaScript | Promise.all | Basta chamar a função |
| Rust | tokio::join! | tokio::spawn |
| Python | asyncio.gather, asyncio.TaskGroup | asyncio.create_task |
tokio::join! executa várias futures de forma concorrente dentro da task atual. Já tokio::spawn entrega a future ao runtime como uma task separada e retorna um JoinHandle que você pode aguardar depois. Uma future passada para spawn precisa ser Send + 'static, e é por isso que work recebe &'static str.
asyncio.TaskGroup foi adicionado no Python 3.11. Ele aguarda todas as tasks quando o bloco async with termina e, se uma task lançar uma exceção, cancela as demais:
async with asyncio.TaskGroup() as tg:
tg.create_task(work("a"))
tg.create_task(work("b"))
Mesmo criar uma task não é tão “eager” quanto uma chamada em JavaScript. O corpo de uma task criada só é executado quando o chamador cede o controle:
let h = tokio::spawn(work("a"));
println!("main: after spawn");
h.await.unwrap();
t = asyncio.create_task(work("a"))
print("main: after create_task")
await t
Ambos imprimem main: after ... primeiro, depois a: start e a: end. O JavaScript imprime a: start antes da próxima linha do chamador. Uma ressalva: a ordem no Rust vale para o current_thread. Em um runtime multi-thread, outro worker pode assumir a task criada imediatamente, então as linhas podem se intercalar de outra forma.
De onde vem o event loop?
Os runtimes de JavaScript já vêm com um event loop embutido, enquanto Rust e Python exigem que você inicie um: Rust por meio de um executor como #[tokio::main], e Python por meio de asyncio.run().
- JavaScript: o Node.js e os navegadores já executam um event loop antes de o seu código começar. O artigo da OpenReplay sobre event loop e call stack em JavaScript explica como ele funciona. No Node, o
awaitde nível superior (top-level) funciona em ES modules (arquivos.mjsou"type": "module"nopackage.json). - Rust: a biblioteca padrão não tem executor.
#[tokio::main]encapsulaasync fn mainem um runtime do Tokio. Por padrão, ele usa o runtime multi-thread (que exige a featurert-multi-thread), eflavor = "current_thread"muda para um runtime single-thread. - Python:
async deffaz parte da linguagem e não precisa de nenhum import. Para executar uma coroutine, porém, você precisa de um loop, easyncio.run()cria um, executa a sua coroutine de nível superior e depois fecha o loop.
Resumo comparativo
| JavaScript | Rust (Tokio) | Python (asyncio) | |
|---|---|---|---|
| Chamar uma função async | Executa até o primeiro await | Retorna uma Future inerte | Retorna uma coroutine inerte |
await esquecido | O trabalho é executado; a rejeição pode derrubar o Node | Aviso do compilador, nenhum trabalho | RuntimeWarning, nenhum trabalho |
| Concorrência | Promise.all | join! / spawn | gather / TaskGroup / create_task |
| Runtime | Embutido | Escolha um executor | asyncio.run() |
Conclusão
A sintaxe async/await parece a mesma em JavaScript, Rust e Python, mas as três linguagens divergem quanto ao momento em que o trabalho começa. O JavaScript o inicia na chamada. Rust e Python o iniciam somente quando você aguarda o resultado ou o agenda explicitamente. Ao portar código assíncrono, verifique cada ponto de chamada em que o resultado não é aguardado imediatamente. Em Rust, substitua essas chamadas por tokio::join! ou tokio::spawn. Em Python, use asyncio.gather, TaskGroup ou create_task.
Perguntas frequentes
As tasks do asyncio em Python podem iniciar de forma eager, como as funções async do JavaScript?
Sim, de forma opcional. O Python 3.12 adicionou o asyncio.eager_task_factory, que você instala com loop.set_task_factory(asyncio.eager_task_factory). A partir daí, uma nova task começa a executar sua coroutine imediatamente dentro da chamada a create_task, até o primeiro ponto em que ela bloqueia. Se a coroutine terminar sem bloquear, ela nunca chega a entrar no event loop. O agendamento lazy continua sendo o padrão, e mudar para tasks eager pode alterar a ordem de execução.
O que acontece com uma task criada com tokio::spawn se você descartar o seu JoinHandle?
A task continua em execução. Descartar um JoinHandle desanexa a task, então ela continua rodando em segundo plano, mas seu valor de retorno é perdido e nada pode fazer join nela. Isso é diferente de uma future não aguardada retornada por uma chamada de async fn, que nunca chega a ser executada. Para interromper uma task criada com spawn, chame abort() no JoinHandle. Aguardar uma task abortada retorna um JoinError que informa o cancelamento.
Qual é a diferença entre asyncio.gather e asyncio.TaskGroup quando uma task falha?
O asyncio.gather não cancela as outras tasks quando uma delas lança uma exceção. Com o padrão return_exceptions=False, a primeira exceção chega imediatamente ao código que está aguardando, enquanto as tasks restantes continuam em execução. Com return_exceptions=True, as exceções são retornadas na lista de resultados. O asyncio.TaskGroup, adicionado no Python 3.11, cancela as tasks restantes quando uma falha, de modo que o trabalho que falhou não continue rodando sem ser percebido.
Por que uma task do asyncio no estilo fire-and-forget pode desaparecer antes de terminar?
O event loop do asyncio mantém apenas referências fracas para as tasks, então o garbage collector pode remover uma task criada com asyncio.create_task no meio da execução se o seu código nunca a armazenar. A documentação do Python recomenda manter uma referência, por exemplo, adicionando cada task a um set e chamando task.add_done_callback(background_tasks.discard). Um asyncio.TaskGroup evita o problema porque mantém uma referência forte para cada task e as aguarda.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k