Python asyncio : coroutines, async/await et event loop
Comprenez vraiment asyncio — ce qu’est une coroutine, comment un seul thread gère de nombreuses attentes d’E/S à la fois, et le code qui le fait : async/await, asyncio.gather, create_task, async for/async with, et quand async l’emporte sur les threads.
Comprenez vraiment asyncio en Python : ce qu’est une coroutine, comment la boucle d’événements exécute de nombreuses attentes d’E/S en concurrence dans un seul thread, async/await, asyncio.gather et create_task, async for / async with, pourquoi asyncio.run() échoue dans un notebook (et l’await de niveau supérieur fonctionne à la place), et quand async l’emporte sur les threads.
Date de publication
18 juillet 2026
Modifié
18 juillet 2026
AstucePoints clés
asyncio exécute plusieurs attentes à la fois dans un seul thread. Pendant qu’un morceau de code est bloqué à attendre une E/S — une réponse réseau, une ligne de base de données, un fichier — la boucle d’événements en exécute un autre. C’est de la concurrence sans threads ni processus : pas de bataille pour le GIL, pas de cœurs supplémentaires, juste un seul thread qui ne reste jamais inactif tant qu’il y a d’autre travail à faire.
Une coroutine est une fonction que vous await. Définissez-la avec async def ; l’appeler ne fait rien — elle vous rend un objet coroutine, une recette en pause (la cousine asynchrone d’un générateur paresseux). Seul await l’exécute vraiment, et await est aussi l’endroit où la boucle d’événements est autorisée à passer à un autre travail.
asyncio.gather est la récompense. Dix attentes d’E/S de 0.2 s exécutées l’une après l’autre prennent ~2 s ; await asyncio.gather(*coros) les exécute en concurrence en ~0.2 s. asyncio.create_task planifie une coroutine pour qu’elle s’exécute en arrière-plan ; asyncio.as_completed renvoie les résultats à l’instant même où chacun se termine.
asyncio.run(main()) démarre la boucle — dans un script. C’est le seul point d’entrée qui crée la boucle d’événements et exécute votre coroutine de plus haut niveau. Dans un notebook ou un REPL, une boucle tourne déjà, donc asyncio.run() lève une RuntimeError — là, vous faites plutôt un await au niveau supérieur (exactement ce que font les cellules ci-dessous).
Optez pour async quand vous avez beaucoup d’attentes d’E/S concurrentes. Des centaines ou des milliers d’appels réseau, de pages scrapées ou de requêtes BD → async passe mieux à l’échelle qu’un thread par tâche. Le travail limité par le CPU a toujours besoin de processus ; une poignée d’appels bloquants est plus simple avec des threads. Le hic : async « colore » toute votre pile d’appels — dès qu’une fonction est async, ses appelants doivent l’être aussi.
Introduction
Vous voyez sans arrêt async def, await et asyncio — dans les frameworks web, les clients HTTP, les pilotes de base de données — et vous voulez vraiment les comprendre, pas seulement les coller. Voici toute l’idée en une phrase : asyncio permet à un seul thread de jongler avec de nombreuses tâches qui passent leur temps à attendre, en basculant vers une autre tâche dès que la tâche courante se bloque sur une E/S.
Imaginez un seul cuisinier dans une cuisine. Une approche fondée sur les threads embauche plus de cuisiniers (plus de threads, plus de cœurs). asyncio garde un cuisinier qui, dès qu’une casserole se met à bouillir, ne reste pas planté à la fixer — il commence à couper le plat suivant, remue un troisième, et revient à la casserole quand elle est prête. Le cuisinier ne fait jamais deux choses au même instant, mais rien n’attend inutilement. C’est la concurrence coopérative : le code rend volontairement la main — à chaque await — pour que la boucle d’événements (l’ordonnanceur d’asyncio) puisse exécuter autre chose pendant que cette tâche attend.
C’est un modèle mental différent de celui de la leçon sur le parallélisme et la concurrence en Python (threads et processus), et c’est la troisième réponse à la décision de cette leçon : travail limité par le CPU → processus, travail limité par les E/S → threads ou async. async l’emporte quand le nombre d’attentes d’E/S concurrentes est grand — des milliers de connexions réseau — parce qu’un seul thread jonglant avec des milliers d’attentes coûte bien moins de mémoire et de surcharge d’ordonnancement que des milliers de threads.
Nous allons le construire dans l’ordre où ça fait tilt. D’abord les coroutines (async def / await) — ce qu’est vraiment un awaitable. Puis la récompense, asyncio.gather, avec une véritable démo de chronométrage exécutable (dix attentes, en concurrence, en une fraction du temps sériel). Puis asyncio.run et la boucle d’événements — y compris la raison honnête pour laquelle asyncio.run() échoue dans un notebook. Puis async for / async with brièvement, une simple table de décision pour savoir quand async est le bon outil, et les vrais pièges.
Un mot sur la façon dont le code s’exécute. Les démos ci-dessous simulent des E/S avec asyncio.sleep(...) — une attente non bloquante qui tient lieu d’un appel réseau ou base de données, pour que rien ne touche le vrai réseau au moment de la génération et que les chronométrages soient honnêtes et reproductibles. Et elles utilisent directement l’await au niveau supérieur, parce que cette page est rendue dans un noyau de type notebook qui a déjà une boucle d’événements en cours. Dans un script .py autonome, vous envelopperiez plutôt le point d’entrée dans asyncio.run(main()) — la différence est une leçon en soi, traitée plus bas.
Coroutines : async def et await
La brique de base d’asyncio est la coroutine. Vous en écrivez une en mettant async devant def. Le point crucial et surprenant : appeler une fonction coroutine n’exécute pas son corps. Elle rend un objet coroutine — une recette en pause qui ne fait rien jusqu’à ce que vous fassiez un await dessus. (Si cela vous semble familier, c’est l’écho asynchrone d’un générateur, qui ne fait lui non plus aucun travail jusqu’à ce que vous l’itériez.)
import asyncioasyncdef greet(name):await asyncio.sleep(0.1) # a NON-blocking wait: yields to the loopreturnf"hello, {name}"coro = greet("Ada") # calling it runs NOTHING — no output yetprint("calling gave us a:", type(coro).__name__)result =await coro # await actually runs it to completionprint(result)
calling gave us a: coroutine
hello, Ada
Deux choses se sont produites. D’abord, greet("Ada") n’a rien affiché et a renvoyé un objet dont le type est coroutine — le corps ne s’est jamais exécuté ; nous avons seulement obtenu une recette suspendue. Ensuite, await coro l’a réellement exécutée : elle s’est déroulée jusqu’au await asyncio.sleep(0.1), a rendu la main à la boucle d’événements pendant ces 0.1 s (durant lesquelles la boucle pouvait exécuter un autre travail), a repris, et a renvoyé hello, Ada. C’est tout le contrat d’await : exécute cet awaitable, et pendant qu’il attend, laisse la boucle d’événements faire autre chose.asyncio.sleep est le sleep asynchrone et non bloquant — contrairement à time.sleep, qui figerait tout le thread et anéantirait l’intérêt (voir Problèmes fréquents).
En soi, attendre une seule coroutine n’a rien de spécial — elle s’est exécutée, nous avons attendu, nous avons obtenu un résultat, en séquence. La puissance apparaît dès l’instant où vous avez plusieurs coroutines et laissez leurs attentes se chevaucher.
Exécuter plusieurs à la fois : asyncio.gather
Voici la récompense. Supposons que vous ayez dix « fetches », chacun une attente d’E/S de 0.2 seconde. Attendus l’un après l’autre, les attentes s’empilent bout à bout — environ 2 secondes. Mais si vous les lancez ensemble et laissez la boucle d’événements entrelacer leurs attentes, ils se terminent en gros dans le temps d’une seule attente. asyncio.gather(*coros) fait exactement cela : il exécute toutes les coroutines que vous lui donnez en concurrence et renvoie leurs résultats sous forme de liste, dans l’ordre où vous les avez passées.
D’abord la base de référence lente et séquentielle — chaque await se termine avant que le suivant ne commence :
import asyncio, timeasyncdef fetch(name, delay):await asyncio.sleep(delay) # stand-in for a network / DB / disk waitreturnf"{name} done"# Sequential: ten 0.2s waits, one after another.start = time.perf_counter()sequential = []for i inrange(10): sequential.append(await fetch(f"task-{i}", 0.2))seq_time = time.perf_counter() - startprint(f"sequential: {seq_time:.2f} s")print(sequential[:3])
sequential: 2.02 s
['task-0 done', 'task-1 done', 'task-2 done']
Comme prévu, la boucle séquentielle a pris environ 2 s — dix attentes de 0.2 seconde posées bout à bout (10 × 0.2 = 2.0). Chaque await s’est entièrement terminé avant même que le fetch suivant ne démarre, donc rien ne s’est chevauché. Maintenant les mêmes dix fetches à travers asyncio.gather :
# Concurrent: launch all ten, let their waits overlap on one thread.start = time.perf_counter()concurrent =await asyncio.gather(*(fetch(f"task-{i}", 0.2) for i inrange(10)))gather_time = time.perf_counter() - startprint(f"gather: {gather_time:.2f} s")print(f"speedup: {seq_time / gather_time:.1f}x")print("same results, in order:", concurrent == sequential)
gather: 0.20 s
speedup: 10.0x
same results, in order: True
L’exécution concurrente est tombée à environ 0.2 s — les dix attentes se sont produites en même temps au lieu d’être en série, un gain de ~10× — et gather a renvoyé les résultats dans l’ordre d’entrée, donc concurrent == sequential vaut True. Aucun thread ni cœur supplémentaire n’était impliqué : un seul thread a démarré les dix attentes asyncio.sleep, la boucle d’événements a mis chacune en pause et est passée à la suivante, puis les a réveillées à l’expiration de leurs minuteurs. C’est toute l’astuce d’asyncio — et cela ne fonctionne que parce que les tâches attendent (sur asyncio.sleep ici, sur le réseau dans du vrai code). Si fetch broyait des nombres à la place, il n’y aurait aucun await où rendre la main, et gather les exécuterait une par une sans aucun gain.
Planifier avec create_task, et les résultats à mesure qu’ils arrivent
gather est le cas courant, mais deux compagnons méritent d’être connus. asyncio.create_task(coro) planifie une coroutine pour qu’elle commence à s’exécuter en arrière-plandès maintenant (en renvoyant un Task que vous pourrez await plus tard), plutôt que seulement au moment où vous faites un await — utile pour lancer un travail pendant que vous faites autre chose. Et asyncio.as_completed(tasks) renvoie chaque tâche à l’instant où elle se termine, quel que soit l’ordre dans lequel vous les avez démarrées — parfait pour réagir aux résultats à mesure qu’ils arrivent. Ici, trois « téléchargements » de durées différentes se terminent du plus court au plus long :
asyncdef download(name, seconds):await asyncio.sleep(seconds) # bigger files "take" longerreturn name, secondsjobs = [("large.json", 0.3), ("small.json", 0.1), ("medium.json", 0.2)]# create_task schedules each one to start running right away.tasks = [asyncio.create_task(download(name, secs)) for name, secs in jobs]# as_completed hands us each task as it finishes — shortest wait first.for finished in asyncio.as_completed(tasks): name, secs =await finished # await unwraps the task's return valueprint(f"done: {name} (waited {secs}s)")
Même si nous les avons planifiés du plus grand au plus petit, ils se sont terminés du plus court au plus long — small.json (0.1 s), puis medium.json (0.2 s), puis large.json (0.3 s) — parce qu’as_completed renvoie la tâche dont l’attente se termine ensuite. create_task a démarré les trois en concurrence à l’instant où nous avons construit la liste ; as_completed nous a ensuite laissés traiter chaque résultat dès qu’il était prêt (par exemple, pour mettre à jour une barre de progression), au lieu de bloquer sur un ordre fixe. Utilisez gather quand vous voulez tous les résultats ensemble et dans l’ordre ; optez pour create_task + as_completed quand vous voulez agir sur les résultats à mesure qu’ils arrivent.
asyncio.run et la boucle d’événements
Tout ce qui précède s’est exécuté sur une boucle d’événements — l’ordonnanceur d’asyncio, ce qui garde la trace des coroutines qui attendent et de celles qui sont prêtes à reprendre. La question naturelle : qui démarre la boucle ? Dans un vrai programme, la réponse est asyncio.run(main()) : il crée une boucle d’événements neuve, exécute votre coroutine de plus haut niveau (conventionnellement nommée main) jusqu’à ce qu’elle se termine, et nettoie la boucle. C’est le seul endroit où un script passe du code synchrone ordinaire au monde asynchrone. Dans un fichier autonome, vous écririez :
# app.py — save this to a file and run it: python app.pyimport asyncioasyncdef fetch(name, delay):await asyncio.sleep(delay)returnf"{name} done"asyncdef main():# inside a coroutine, await freely: results =await asyncio.gather(fetch("a", 0.2), fetch("b", 0.2), fetch("c", 0.2))print(results)asyncio.run(main()) # the ONE call that starts the event loop
Exécutez cela comme un script et il affiche ['a done', 'b done', 'c done'] en environ 0.2 s.
Maintenant la nuance honnête — et c’est l’une des erreurs asyncio les plus recherchées. Dans un notebook, IPython ou un REPL asynchrone, une boucle d’événements tourne déjà. Y appeler asyncio.run() lève :
RuntimeError: asyncio.run() cannot be called from a running event loop
parce qu’asyncio.run() tente de créer et d’exécuter une nouvelle boucle, et vous ne pouvez pas en démarrer une pendant qu’une autre tourne déjà. Ce n’est pas un bug à contourner — c’est le design. Quand une boucle tourne déjà, vous n’avez pas du tout besoin d’asyncio.run : vous faites un await au niveau supérieur directement, ce que font précisément les cellules exécutables ci-dessus (result = await coro, await asyncio.gather(...)). Donc la règle est :
Dans un script .py (pas encore de boucle) : définissez async def main(), et démarrez-le avec asyncio.run(main()).
Dans un notebook / REPL (une boucle tourne déjà) : abandonnez l’enveloppe asyncio.run et faites un await au niveau supérieur — la boucle de l’environnement l’exécute pour vous.
Les mêmes coroutines dans les deux cas ; seul le point d’entrée diffère. Savoir dans quelle situation vous êtes explique la RuntimeError la première fois qu’elle vous mord.
async for et async with
Deux autres éléments de syntaxe asynchrone complètent le modèle, tous deux calqués sur le Python ordinaire. Un bloc async with est un gestionnaire de contexte asynchrone — un with dont la mise en place et le nettoyage peuvent eux-mêmes faire un await (ouvrir une session réseau, acquérir une connexion depuis un pool). Une boucle async for itère un générateur asynchrone ou un flux asynchrone — la cousine asynchrone du générateur que vous avez rencontré plus tôt — en attendant l’élément suivant à chaque tour, pour que la boucle puisse rendre la main à la boucle d’événements entre les éléments.
L’exemple canonique du monde réel est le client HTTP aiohttp — l’analogue asynchrone de requests. Les deux marqueurs apparaissent en même temps (ceci est illustratif ; exécutez-le en local avec aiohttp installé — le rendu ne touche jamais le réseau) :
# run this locally: pip install aiohttpimport aiohttp, asyncioasyncdef fetch_json(url):asyncwith aiohttp.ClientSession() as session: # async context managerasyncwith session.get(url) as response: # awaits the connectionreturnawait response.json() # awaits the bodyasyncdef main(): urls = ["https://api.example.com/1", "https://api.example.com/2"]# gather runs the requests concurrently — the real payoff of async I/O:returnawait asyncio.gather(*(fetch_json(u) for u in urls))asyncio.run(main())
Les deux blocs async with s’assurent que la session et la réponse sont ouvertes et fermées correctement même si ces étapes impliquent d’attendre le réseau ; await response.json() lit le corps sans bloquer le thread ; et envelopper les appels dans asyncio.gather est ce qui transforme « récupérer ces URL » en « récupérer ces URL toutes à la fois ». Un async for ressemblerait exactement à un for normal — async for row in cursor: sur le résultat en flux d’un pilote de base de données, ou async for chunk in response.content: — en attendant chaque élément à mesure qu’il arrive. À retenir : async with et async for sont les versions conscientes d’await de with et for, et vous les utilisez partout où la mise en place, le nettoyage ou chaque étape d’itération est elle-même une attente d’E/S.
async ne peut pas aider — il n’y a aucun await où rendre la main ; il vous faut plusieurs cœurs
Un appel bloquant au sein de code async
Une bibliothèque synchrone héritée, une étape lourde en CPU
await loop.run_in_executor(...)
Pousse le travail bloquant vers un pool de threads/processus pour qu’il ne fige pas la boucle
Deux mises en garde honnêtes. Premièrement, async colore toute votre pile d’appels. Dès qu’une fonction est async, tout ce qui l’attend doit être async aussi, jusqu’à asyncio.run (ou l’await de niveau supérieur) — vous ne pouvez pas saupoudrer des await dans du code synchrone ordinaire. Cette propagation « tout ou rien » est le vrai coût de l’adoption d’asyncio, et la raison pour laquelle quelques appels bloquants sont souvent mieux servis par un pool de threads. Deuxièmement, async a besoin de bibliothèques compatibles async. Appeler une fonction bloquante ordinaire (requests classique, un pilote BD synchrone, time.sleep) au sein d’une coroutine bloque toute la boucle d’événements — toutes les autres tâches se figent — parce qu’il n’y a aucun await sur lequel la boucle peut basculer. Il vous faut la bibliothèque native async (aiohttp au lieu de requests, un pilote BD asynchrone) ou vous devez décharger l’appel bloquant avec run_in_executor.
Donc : beaucoup d’attentes d’E/S concurrentes → async ; quelques-unes → threads ; du calcul → processus. Cette seule phrase, c’est toute la décision.
La même idée en R
Vous travaillez dans les deux langages ? R n’a aucun analogue direct d’asyncio — pas de mots-clés async/await ni de modèle de boucle d’événements intégré au langage. Les outils les plus proches sont les paquets future/promises (évaluation asynchrone, très utilisée au sein de Shiny pour garder une application réactive) et later (planifier une fonction pour qu’elle s’exécute sur la boucle d’événements). Pour « exécuter de nombreuses tâches indépendantes », R se tourne plus souvent vers les outils de parallélisme de la note R de la leçon sur le parallélisme et la concurrence (future/furrr). Si vous avez besoin de la concurrence d’E/S coopérative sur un seul thread telle que l’asyncio de Python la fournit, c’est vraiment un domaine où le modèle de Python est plus abouti. Voir le pilier Programmation pour le versant R.
NoteCette leçon est reproductible
Chaque résultat ci-dessus a été produit par le code montré. Les blocs {python} exécutables ont tourné au moment de la génération en utilisant l’await au niveau supérieur (s’ils s’affichent, le code fonctionne), donc vous pouvez copier n’importe quel bloc et l’exécuter — avec une réserve sur le où : l’await au niveau supérieur fonctionne dans un notebook, IPython ou un REPL asynchrone (une boucle tourne déjà pour vous). Dans un simple script.py, enveloppez plutôt le point d’entrée dans asyncio.run(main()), exactement comme le montrent les blocs de script illustratifs. L’extrait aiohttp est illustratif — installez aiohttp et exécutez-le en local ; le rendu ne touche jamais le réseau. Il n’y a pas de bac à sable dans le navigateur sur cette page, donc le déroulé est copier-et-exécuter-en-local, pas éditer-et-re-générer.
🟢 Avec un agent IA
Un bout de code lent qui déclenche des appels réseau ou des requêtes BD l’un après l’autre ? Collez-le et demandez à Prova« réécris ceci pour exécuter les E/S en concurrence avec asyncio.gather ». Elle transforme vos fonctions bloquantes en coroutines, câble async def/await, et rassemble les appels pour que les attentes se chevauchent au lieu de s’empiler — et elle vous dira honnêtement si le travail est limité par le CPU et si async est le mauvais outil. Ensuite, copiez le code, exécutez-le sur votre propre machine, et chronométrez les deux versions — parce que le vrai test de « est-ce que c’est devenu plus rapide ? », c’est le chronomètre, pas une explication plausible. The runtime is the judge. Demandez à Prova →
Problèmes fréquents
RuntimeError: asyncio.run() cannot be called from a running event loop. Vous avez appelé asyncio.run(main()) dans un notebook, IPython ou un autre contexte asynchrone où une boucle tourne déjà — et asyncio.run insiste pour créer et exécuter une nouvelle. La correction dépend d’où vous êtes : dans un notebook/REPL, abandonnez l’enveloppe et faites un await au niveau supérieur (await main() ou await asyncio.gather(...)), parce que la boucle de l’environnement l’exécutera. Dans un script, asyncio.run(main()) est correct — c’est le seul endroit où il n’y a pas encore de boucle. (N’allez pas chercher nest_asyncio pour forcer une boucle imbriquée ; utilisez l’await de niveau supérieur.)
« coroutine was never awaited » — appeler une coroutine ne fait rien. Écrire fetch("a", 0.2) sans await construit un objet coroutine et le jette ; le corps ne s’exécute jamais, et Python avertit RuntimeWarning: coroutine 'fetch' was never awaited. Une coroutine ne s’exécute que quand vous faites un await dessus, la passez à asyncio.gather, ou la planifiez avec asyncio.create_task. Si votre fonction async « ne fait silencieusement rien », vous avez presque sûrement oublié l’await (ou oublié de rassembler/attendre les tâches que vous avez créées).
Vous avez utilisé time.sleep (ou un appel bloquant) et tout s’est figé.time.sleep(1) bloque le thread entier — et avec lui toute la boucle d’événements, donc chaque autre tâche se fige pendant cette seconde. Au sein de code async, attendez plutôt avec await asyncio.sleep(1) : il rend la main à la boucle pour que d’autres coroutines s’exécutent entre-temps. Le même piège s’applique à tout appel bloquant synchrone (requests.get classique, un pilote BD synchrone, une lourde boucle CPU) : soit utilisez l’équivalent natif async, soit déchargez-le avec await loop.run_in_executor(None, blocking_fn, *args) pour qu’il s’exécute sur un pool de threads/processus et ne fige pas la boucle.
async « infecte » toute votre pile d’appels. Vous avez rendu une fonction async et soudain ses appelants ne fonctionnent plus tant qu’ils ne sont pas async eux aussi. C’est voulu : vous ne pouvez faire un await qu’au sein d’une coroutine, donc async se propage jusqu’à asyncio.run (ou l’await de niveau supérieur). Si vous ne voulez pas convertir tout un chemin de code, c’est un signal qu’async est peut-être le mauvais outil ici — un pool de threads laisse quelques appels bloquants s’exécuter en concurrence sans rendre tout votre programme async.
Questions fréquentes
NoteQu’est-ce qu’une coroutine en Python ?
Une coroutine est une fonction définie avec async def. Contrairement à une fonction ordinaire, l’appeler n’exécute pas son corps — elle renvoie un objet coroutine, une recette en pause qui ne fait rien jusqu’à ce que vous fassiez un await dessus (un peu comme un générateur ne fait aucun travail jusqu’à ce que vous l’itériez). Au sein d’une coroutine, await exécute un autre awaitable et, pendant que cet awaitable attend une E/S, rend la main à la boucle d’événements pour que d’autres coroutines puissent s’exécuter. Une coroutine est donc l’unité de travail qu’asyncio ordonnance : vous écrivez du code linéaire avec async/await, et la boucle d’événements entrelace les attentes de nombreuses coroutines sur un seul thread.
NoteQuelle est la différence entre async et les threads en Python ?
Les deux gèrent du travail limité par les E/S (attente sur le réseau/disque), mais différemment. Les threads laissent l’OS basculer entre plusieurs threads de façon préemptive ; un thread en attente relâche le GIL pour que d’autres s’exécutent. Ils sont simples et n’obligent pas à réécrire votre code en async, mais chaque thread coûte de la vraie mémoire, donc des milliers d’entre eux deviennent coûteux. async exécute tout sur un seul thread et bascule de façon coopérative — seulement à chaque await — donc il jongle avec des milliers d’attentes concurrentes pour bien moins de surcharge, ce qui explique pourquoi il passe à l’échelle jusqu’à d’énormes nombres de connexions. Les compromis : async requiert des bibliothèques compatibles async et répand async dans toute votre pile d’appels ; les threads fonctionnent avec du code bloquant ordinaire mais passent moins loin à l’échelle. Aucun des deux n’accélère le travail limité par le CPU — cela nécessite des processus.
Noteasyncio.gather vs create_task — quelle est la différence ?
asyncio.create_task(coro) planifie une seule coroutine pour qu’elle commence à s’exécuter en arrière-plan immédiatement et renvoie un handle Task que vous pouvez await (ou vérifier) plus tard. asyncio.gather(*coros) est une commodité de plus haut niveau : il planifie plusieurs coroutines pour qu’elles s’exécutent en concurrence et attend qu’elles soient toutes terminées, en renvoyant leurs résultats sous forme de liste dans l’ordre où vous les avez passées. En coulisses, gather enveloppe ses coroutines en tâches pour vous. Utilisez gather quand vous voulez tous les résultats ensemble dans l’ordre d’entrée ; utilisez create_task quand vous voulez lancer un travail maintenant et l’attendre plus tard, ou combinez-le avec asyncio.as_completed pour traiter les résultats à mesure que chacun se termine.
NoteQuand devrais-je utiliser asyncio ?
Utilisez asyncio quand vous avez de nombreuses attentes concurrentes limitées par les E/S — des centaines ou des milliers de requêtes réseau, un web scraper, de nombreuses requêtes de base de données, un serveur websocket/chat — parce qu’un seul thread peut jongler avec toutes ces attentes pour bien moins de mémoire et de surcharge qu’un thread par tâche. N’utilisez pas async pour du travail limité par le CPU (calcul intensif) : il n’y a aucun await où rendre la main, alors utilisez plutôt des processus. Pour seulement une poignée d’appels bloquants, de simples threads (ThreadPoolExecutor) sont plus simples parce que vous n’avez pas à rendre toute votre pile d’appels async. Et rappelez-vous qu’async a besoin de bibliothèques natives async (comme aiohttp au lieu de requests) — un appel bloquant au sein d’une coroutine fige toute la boucle d’événements.
NotePourquoi asyncio.run() dit-il qu’il ne peut pas être appelé depuis une boucle d’événements en cours ?
Parce qu’asyncio.run() crée et exécute une nouvelle boucle d’événements, et vous ne pouvez avoir qu’une seule boucle en cours par thread — donc si une boucle tourne déjà, il lève RuntimeError: asyncio.run() cannot be called from a running event loop. Cela se produit dans un notebook, IPython ou un REPL asynchrone, qui démarrent une boucle pour vous. La solution n’est pas de forcer une boucle imbriquée — c’est de sauter l’enveloppe et de faire un await au niveau supérieur directement (await main()), en laissant la boucle existante exécuter votre coroutine. asyncio.run(main()) est destiné à un script autonome, où aucune boucle n’existe encore et où c’est le point d’entrée unique dans le code asynchrone.
Testez vos connaissances
ImportantÀ vous — rendez concurrentes trois attentes séquentielles
Vous avez trois « fetches » exécutés l’un après l’autre. Réécrivez-les pour qu’ils s’exécutent en concurrence afin que le temps total soit à peu près celui de l’attente la plus longue, et non la somme.
import asyncio, timeasyncdef fetch(name, delay):await asyncio.sleep(delay)returnf"{name} done"asyncdef main(): start = time.perf_counter() a =await fetch("a", 0.3) # these run one after another… b =await fetch("b", 0.3) c =await fetch("c", 0.3) # …so this takes ~0.9 s totalprint([a, b, c], f"in {time.perf_counter() - start:.2f} s")asyncio.run(main())
Tâche 1. Réécrivez main() pour que les trois fetches s’exécutent en concurrence et que l’ensemble se termine en environ 0.3 s au lieu de 0.9 s. Gardez les résultats dans l’ordre. Tâche 2. En une phrase, expliquez pourquoi cela accélère alors qu’il n’y a qu’un seul thread.
AstuceIndice
Les trois lignes await fetch(...) se terminent chacune entièrement avant que la suivante ne démarre — c’est la partie sérielle. Lancez-les ensemble avec asyncio.gather, qui exécute toutes les coroutines en concurrence et renvoie leurs résultats dans l’ordre : results = await asyncio.gather(fetch("a", 0.3), fetch("b", 0.3), fetch("c", 0.3)).
AstuceSolution
import asyncio, timeasyncdef fetch(name, delay):await asyncio.sleep(delay)returnf"{name} done"asyncdef main(): start = time.perf_counter() results =await asyncio.gather( # run all three concurrently fetch("a", 0.3), fetch("b", 0.3), fetch("c", 0.3), )print(results, f"in {time.perf_counter() - start:.2f} s")asyncio.run(main()) # in a notebook, drop asyncio.run and: await main() at top level
asyncio.gather lance les trois coroutines d’un coup et renvoie leurs résultats dans l’ordre (['a done', 'b done', 'c done']). Tâche 2 : les trois attentes asyncio.sleep se chevauchent — pendant qu’une coroutine est en pause à attendre, la boucle d’événements exécute les autres sur le même thread, donc le total est une seule attente de 0.3 s au lieu de trois empilées bout à bout. (Dans un notebook, remplacez asyncio.run(main()) par un await main() de niveau supérieur, parce qu’une boucle tourne déjà.)
Vérification rapide. Au sein d’un async def, vous remplacez await asyncio.sleep(1) par time.sleep(1). Vos autres coroutines qui s’exécutaient en concurrence se figent soudain pendant une seconde entière chacune. Pourquoi ?
AstuceAfficher la réponse
time.sleep(1) est un appel bloquant — il fige le thread entier pendant une seconde, et asyncio exécute tout sur un seul thread, donc toute la boucle d’événements est bloquée elle aussi : aucune autre coroutine ne peut s’exécuter tant qu’il n’est pas revenu. await asyncio.sleep(1) en revanche rend la main à la boucle, laissant d’autres coroutines s’exécuter pendant l’attente. La règle : au sein de code async, n’appelez jamais directement une fonction bloquante — utilisez son équivalent async (asyncio.sleep, aiohttp) ou déchargez-la avec loop.run_in_executor.
Conclusion
asyncio, c’est un seul thread qui n’attend jamais inutilement : pendant qu’une coroutine est bloquée sur une E/S, la boucle d’événements en exécute une autre, ce qui vous donne de la concurrence sans threads ni processus. Une coroutine (async def) est une recette en pause qui ne s’exécute que quand vous faites un await dessus — et await est aussi l’endroit où la boucle est libre de basculer de tâche. asyncio.gather est la récompense : de nombreuses attentes d’E/S qui s’empileraient en série se chevauchent à la place, donc dix attentes de 0.2 s se terminent en ~0.2 s, et non ~2 s ; create_task planifie du travail en arrière-plan et as_completed vous rend les résultats à mesure qu’ils arrivent. Démarrez la boucle avec asyncio.run(main()) dans un script — mais dans un notebook une boucle tourne déjà, donc vous faites un await au niveau supérieur à la place (la raison pour laquelle asyncio.run() lève là). Optez pour async quand vous avez beaucoup d’attentes d’E/S concurrentes ; utilisez des threads pour quelques-unes et des processus pour le travail limité par le CPU — et rappelez-vous qu’async colore toute votre pile d’appels et a besoin de bibliothèques compatibles async.
Leçons connexes
Pour aller plus loin :Itérateurs et générateurs Python — une coroutine est la cousine asynchrone d’un générateur paresseux (les deux sont des recettes en pause qui ne font rien jusqu’à ce qu’on les pilote), et les générateurs asynchrones (async def + yield, consommés avec async for) réunissent les deux idées.
Prouvez que vous savez le faire. Maîtrisez toute la série Python intermédiaire — suivez votre parcours, construisez des projets et obtenez un certificat.