12k
All articles

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.

OpenReplay Team
OpenReplay Team
async/await en JavaScript, Rust et Python

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 cet await s’exécute donc immédiatement.
  • Appeler une async fn Rust renvoie une Future et 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 def Python 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 avec asyncio.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 function s’exécute de manière synchrone jusqu’à son premier await. La Promise que vous récupérez est liée à un corps de fonction déjà en cours d’exécution.
  • Rust : une async fn renvoie impl Future. Le trait Future dé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 .await ou que vous ne transmettez pas la future à un mécanisme comme tokio::spawn.
  • Python : appeler une fonction async def vous 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 :

LangageAttendre l’ensembleDémarrer maintenant, attendre plus tard
JavaScriptPromise.allAppeler simplement la fonction
Rusttokio::join!tokio::spawn
Pythonasyncio.gather, asyncio.TaskGroupasyncio.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’await de premier niveau (top-level await) fonctionne dans les modules ES (fichiers .mjs, ou "type": "module" dans package.json).
  • Rust : la bibliothèque standard ne fournit aucun exécuteur. #[tokio::main] encapsule async fn main dans un runtime Tokio. Il utilise par défaut le runtime multithread (qui nécessite la feature rt-multi-thread), et flavor = "current_thread" bascule vers un runtime monothread.
  • Python : async def fait partie du langage et ne nécessite aucun import. Pour exécuter une coroutine, il vous faut toutefois une boucle, et asyncio.run() en crée une, exécute votre coroutine de premier niveau, puis ferme la boucle.

Récapitulatif comparatif

JavaScriptRust (Tokio)Python (asyncio)
Appel d’une fonction asyncS’exécute jusqu’au premier awaitRenvoie une Future inerteRenvoie une coroutine inerte
await oubliéLe travail s’exécute ; un rejet peut faire planter NodeAvertissement du compilateur, aucun travailRuntimeWarning, aucun travail
ConcurrencePromise.alljoin! / spawngather / TaskGroup / create_task
RuntimeIntégréChoix d’un exécuteurasyncio.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.

Open-source session replay

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

We use cookies to improve your experience. By using our site, you accept cookies.