async/await en JavaScript, Rust et Python
Comparez async/await en JavaScript, Rust et Python : voyez quand les fonctions démarrent, ce qui se passe sans await et comment exécuter des tâches en parallèle.
Une fonction async JavaScript commence à s’exécuter dès que vous l’appelez. Une async fn Rust ou une fonction async def Python ne fait strictement rien tant que quelque chose ne l’attend pas (await) ou ne la planifie pas.
Cette différence dans le moment où démarre le travail asynchrone est à l’origine d’un bug bien connu. Le portage en Rust ou en Python d’un code TypeScript fonctionnel n’exécute jamais la fonction, ou bien exécute les appels les uns après les autres alors que vous vous attendiez à ce qu’ils se chevauchent. Cet article exécute le même petit extrait de code dans Node.js, en Rust avec Tokio et en Python 3.11+, et montre l’ordre de sortie pour chacun. Il suppose que vous maîtrisez déjà l’asynchrone en JavaScript. Si vous souhaitez d’abord vous familiariser avec ces notions, consultez les guides d’OpenReplay sur les bonnes pratiques de la programmation asynchrone en JavaScript et sur les astuces, techniques et pièges de l’asynchrone en JavaScript.
Points clés à retenir
- Appeler une fonction async en JavaScript exécute son corps de manière synchrone jusqu’au premier
await: le code situé avant cetawaits’exécute donc immédiatement. - Appeler une
async fnRust renvoie uneFutureet n’exécute aucune partie du corps tant que la future n’est pas attendue ou confiée à un runtime tel que Tokio. - Appeler une fonction
async defPython renvoie un objet coroutine qui n’exécute rien tant qu’il n’est pas attendu ou encapsulé dans une tâche. - Si vous créez deux appels async puis attendez chacun à tour de rôle, le travail se chevauche en JavaScript, mais s’exécute séquentiellement en Rust et en Python.
- Les runtimes JavaScript intègrent nativement une boucle d’événements. Rust nécessite un exécuteur tel que
#[tokio::main], et Python démarre sa boucle avecasyncio.run().
Un extrait, trois langages, trois sorties
Chaque version ci-dessous définit la même fonction : un affichage, l’attente d’une pause de 10 ms, puis un nouvel affichage. Chacune appelle ensuite la fonction sans l’attendre. Seul JavaScript affiche a: start.
Les exemples ciblent une version LTS de Node.js, Tokio 1.x et Python 3.11 ou ultérieur.
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) avec 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
Pourquoi async/await en Rust ne s’exécute-t-il pas avant d’être attendu ?
La différence entre async/await en JavaScript, en Rust et en Python tient à ce que renvoie l’appel d’une fonction async. JavaScript renvoie une Promise associée à un travail déjà démarré. Rust et Python renvoient un objet qui représente un travail qui n’a pas encore commencé.
- JavaScript : une
async functions’exécute de manière synchrone jusqu’à son premierawait. La Promise que vous récupérez est liée à un corps de fonction déjà en cours d’exécution. - Rust : une
async fnrenvoieimpl Future. Le traitFuturedécrit une valeur qui ne progresse que lorsqu’elle est interrogée (polled). Aucune partie du corps ne s’exécute tant que vous n’écrivez pas.awaitou que vous ne transmettez pas la future à un mécanisme commetokio::spawn. - Python : appeler une fonction
async defvous donne un objet coroutine. La documentation d’asyncio indique clairement qu’appeler une coroutine ne planifie pas son exécution.
L’article de recherche de Gray, Krishnamurthi et Crichton décrit ce choix comme une dimension de la conception de l’asynchrone appelée « Eagerness » (empressement), avec trois options : Lazy (paresseux), Eager (empressé) et Semi-Eager (semi-empressé). En pratique, JavaScript démarre le travail au point d’appel, tandis que les deux autres attendent.
Que se passe-t-il si vous oubliez await ?
Les trois langages vous permettent d’oublier await, mais l’échec se manifeste différemment. JavaScript effectue tout de même le travail : le risque est donc une erreur que rien n’intercepte. Rust et Python ignorent complètement le travail et vous le signalent.
JavaScript. Le corps s’exécute, mais s’il est rejeté et que rien ne gère ce rejet, Node le traite comme une erreur. Le mode par défaut de --unhandled-rejections est throw : le rejet est levé sous forme d’exception non interceptée et le processus se termine, sauf si vous avez enregistré un gestionnaire :
async function save() { throw new Error("disk full"); }
save(); // no await, no .catch()
Rust. Le compilateur émet un avertissement lors de la compilation via le lint unused_must_use :
warning: unused implementer of `Future` that must be used
= note: futures do nothing unless you `.await` or poll them
Python. Lorsque la coroutine non attendue est récupérée par le ramasse-miettes, CPython affiche un avertissement sur stderr. Le guide d’asyncio aborde ce point dans la section détecter les coroutines jamais attendues :
RuntimeWarning: coroutine 'work' was never awaited
La correction est la même dans chaque langage : await work("a") en JavaScript et en Python, et work("a").await en Rust. Avec cette modification, les trois affichent a: start, a: end, main: after call.
Comment exécuter du travail asynchrone de manière concurrente ?
En JavaScript, il suffit d’appeler les fonctions pour obtenir de la concurrence. En Rust et en Python, vous devez la demander explicitement à l’aide d’un combinateur ou d’une tâche.
Le motif « créer puis attendre » illustre clairement la différence :
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
Ni la future Rust ni la coroutine Python ne démarrent avant d’être attendues : chaque appel ne s’exécute donc qu’une fois le précédent terminé. Les solutions :
| Langage | Attendre l’ensemble | Démarrer maintenant, attendre plus tard |
|---|---|---|
| JavaScript | Promise.all | Appeler simplement la fonction |
| Rust | tokio::join! | tokio::spawn |
| Python | asyncio.gather, asyncio.TaskGroup | asyncio.create_task |
tokio::join! exécute plusieurs futures de manière concurrente au sein de la tâche courante. tokio::spawn, en revanche, confie la future au runtime sous forme de tâche distincte et renvoie un JoinHandle que vous pourrez attendre plus tard. Une future lancée avec spawn doit être Send + 'static, d’où le paramètre &'static str de work.
asyncio.TaskGroup a été introduit dans Python 3.11. Il attend toutes les tâches à la sortie de son bloc async with, et si l’une d’elles lève une exception, il annule les autres :
async with asyncio.TaskGroup() as tg:
tg.create_task(work("a"))
tg.create_task(work("b"))
Même le lancement d’une tâche n’est pas aussi empressé qu’un appel JavaScript. Le corps d’une tâche lancée ne s’exécute pas tant que l’appelant ne rend pas la main :
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
Les deux affichent d’abord main: after ..., puis a: start et a: end. JavaScript, lui, affiche a: start avant la ligne suivante de l’appelant. Une réserve : l’ordre indiqué pour Rust est valable avec current_thread. Sur un runtime multithread, un autre worker peut prendre en charge la tâche lancée immédiatement, si bien que les lignes peuvent s’entrelacer différemment.
D’où vient la boucle d’événements ?
Les runtimes JavaScript intègrent nativement une boucle d’événements, alors que Rust et Python vous obligent à en démarrer une : Rust via un exécuteur tel que #[tokio::main], et Python via asyncio.run().
- JavaScript : Node.js et les navigateurs exécutent déjà une boucle d’événements avant que votre code ne démarre. L’article d’OpenReplay sur la boucle d’événements et la pile d’appels JavaScript explique son fonctionnement. Dans Node, l’
awaitde premier niveau (top-level await) fonctionne dans les modules ES (fichiers.mjs, ou"type": "module"danspackage.json). - Rust : la bibliothèque standard ne fournit aucun exécuteur.
#[tokio::main]encapsuleasync fn maindans un runtime Tokio. Il utilise par défaut le runtime multithread (qui nécessite la featurert-multi-thread), etflavor = "current_thread"bascule vers un runtime monothread. - Python :
async deffait partie du langage et ne nécessite aucun import. Pour exécuter une coroutine, il vous faut toutefois une boucle, etasyncio.run()en crée une, exécute votre coroutine de premier niveau, puis ferme la boucle.
Récapitulatif comparatif
| JavaScript | Rust (Tokio) | Python (asyncio) | |
|---|---|---|---|
| Appel d’une fonction async | S’exécute jusqu’au premier await | Renvoie une Future inerte | Renvoie une coroutine inerte |
await oublié | Le travail s’exécute ; un rejet peut faire planter Node | Avertissement du compilateur, aucun travail | RuntimeWarning, aucun travail |
| Concurrence | Promise.all | join! / spawn | gather / TaskGroup / create_task |
| Runtime | Intégré | Choix d’un exécuteur | asyncio.run() |
Conclusion
La syntaxe async/await semble identique en JavaScript, en Rust et en Python, mais les trois langages divergent quant au moment où le travail commence. JavaScript le démarre dès l’appel. Rust et Python ne le démarrent que lorsque vous attendez le résultat ou que vous le planifiez explicitement. Lorsque vous portez du code asynchrone, vérifiez chaque point d’appel où le résultat n’est pas immédiatement attendu. En Rust, remplacez ces appels par tokio::join! ou tokio::spawn. En Python, utilisez asyncio.gather, TaskGroup ou create_task.
FAQ
Les tâches asyncio de Python peuvent-elles démarrer de manière empressée comme les fonctions async JavaScript ?
Oui, sur option. Python 3.12 a introduit asyncio.eager_task_factory, que vous installez avec loop.set_task_factory(asyncio.eager_task_factory). Dès lors, une nouvelle tâche commence à exécuter sa coroutine immédiatement au sein de l'appel à create_task, jusqu'au premier point où elle se bloque. Si la coroutine se termine sans se bloquer, elle ne passe jamais par la boucle d'événements. La planification paresseuse reste le comportement par défaut, et le passage aux tâches empressées peut modifier l'ordre d'exécution.
Qu'advient-il d'une tâche tokio::spawn si vous abandonnez son JoinHandle ?
La tâche continue de s'exécuter. Abandonner (drop) un JoinHandle détache la tâche : elle se poursuit en arrière-plan, mais sa valeur de retour est perdue et plus rien ne peut l'attendre. Ce comportement diffère de celui d'une future non attendue renvoyée par l'appel d'une async fn, qui ne s'exécute jamais. Pour arrêter une tâche lancée, appelez abort() sur le JoinHandle. Attendre une tâche interrompue renvoie une JoinError qui signale l'annulation.
Quelle est la différence entre asyncio.gather et asyncio.TaskGroup lorsqu'une tâche échoue ?
asyncio.gather n'annule pas les autres tâches lorsque l'une d'elles lève une exception. Avec la valeur par défaut return_exceptions=False, la première exception parvient immédiatement au code qui attend, tandis que les tâches restantes continuent de s'exécuter. Avec return_exceptions=True, les exceptions sont renvoyées dans la liste des résultats. asyncio.TaskGroup, introduit dans Python 3.11, annule les tâches restantes lorsque l'une d'elles échoue, de sorte que le travail en échec ne continue pas à s'exécuter sans que personne ne s'en aperçoive.
Pourquoi une tâche asyncio lancée en mode « fire-and-forget » peut-elle disparaître avant d'avoir terminé ?
La boucle d'événements d'asyncio ne conserve que des références faibles vers les tâches : le ramasse-miettes peut donc supprimer en cours d'exécution une tâche créée avec asyncio.create_task si votre code ne la stocke jamais. La documentation Python recommande de conserver une référence, par exemple en ajoutant chaque tâche à un ensemble (set) et en appelant task.add_done_callback(background_tasks.discard). Un asyncio.TaskGroup évite ce problème, car il conserve une référence forte vers chaque tâche et les attend.
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