23 points par darjeeling 2025-11-16 | 12 commentaires | Partager sur WhatsApp

Causes du ralentissement du code async et solutions (résumé technique)

Cette vidéo traite des causes les plus courantes qui rendent le code Python asyncio plus lent que le code synchrone, ainsi que des méthodes techniques pour y remédier.

1. Concepts clés d'Asyncio

  • Boucle d'événements (Event Loop) : c'est le cœur de toute application asynchrone. Elle démarre avec asyncio.run() et gère ainsi que planifie l'exécution des tâches sur un seul thread.
  • Coroutines : ce sont des fonctions asynchrones déclarées avec async def. Lorsqu'elles rencontrent le mot-clé await, elles peuvent suspendre leur exécution et rendre le contrôle à la boucle d'événements.
  • Tâches (Tasks) : elles encapsulent les coroutines et les planifient pour qu'elles s'exécutent concurremment dans la boucle d'événements. Elles sont créées via asyncio.create_task().
  • Futures : objets de bas niveau représentant le résultat final d'une opération asynchrone.

2. Exemple de conversion d'un code synchrone en code asynchrone

On remplace le time.sleep() synchrone existant par await asyncio.sleep() en version asynchrone, on déclare la fonction avec async def, puis on exécute la coroutine principale avec asyncio.run().


Erreurs courantes qui provoquent une baisse de performance et solutions

Erreur 1 : exécution séquentielle (Sequential Execution)

Si des tâches indépendantes sont await l'une après l'autre au lieu d'être exécutées en parallèle, le temps total d'exécution devient la somme des temps de toutes les tâches.

  • Mauvais exemple (séquentiel) :

    # Chaque await attend la fin de l'opération précédente  
    await get_user_notifications()  
    await get_recent_activity()  
    await get_unread_messages()  
    
  • Solution (parallèle) : utiliser asyncio.gather ou asyncio.TaskGroup pour exécuter simultanément des tâches indépendantes. Le temps total d'exécution est alors ramené à celui de la tâche la plus longue.

    # Les trois opérations démarrent en même temps  
    await asyncio.gather(  
        get_user_notifications(),  
        get_recent_activity(),  
        get_unread_messages()  
    )  
    

Comparaison des outils d'exécution parallèle

  • asyncio.gather :
    • exécute plusieurs coroutines simultanément.
    • Inconvénient : la gestion des erreurs est limitée. Si une exception se produit dans une tâche, les autres tâches en cours d'exécution sont annulées.
  • asyncio.create_task :
    • permet un contrôle et une gestion des erreurs tâche par tâche.
    • utile pour l'exécution en arrière-plan, mais contraint à await chaque tâche individuellement.
  • asyncio.TaskGroup (Python 3.11+) :
    • alternative moderne pour la « concurrence structurée ».
    • gère un groupe de tâches avec la syntaxe async with, et garantit que toutes les tâches sont terminées ou que les exceptions sont traitées à la sortie du contexte.
    async with asyncio.TaskGroup() as tg:  
        tg.create_task(some_coro_1())  
        tg.create_task(some_coro_2())  
    # À la fin du bloc 'async with', toutes les tâches ont été await  
    

Erreur 2 : utilisation de bibliothèques synchrones

Utiliser des bibliothèques synchrones (bloquantes) comme requests ou pathlib dans du code asyncio bloque l'ensemble de la boucle d'événements. Même à l'intérieur d'un asyncio.gather, elles fonctionnent en pratique de manière séquentielle.

  • Solution : utiliser des bibliothèques dédiées compatibles asynchrone (non bloquantes), comme aiohttp (à la place de requests) ou aiofiles (à la place des fichiers/pathlib).

Erreur 3 : blocage de la boucle d'événements par des tâches CPU-bound

Comme asyncio s'exécute sur un seul thread, les calculs lourds (travaux CPU-bound) stoppent la boucle d'événements et retardent les autres opérations d'E/S.

  • Solution : déporter les tâches CPU-bound vers un pool de threads distinct (par défaut) ou un pool de processus avec loop.run_in_executor().
    loop = asyncio.get_running_loop()  
    # Exécute une fonction intensive pour le CPU dans un thread séparé  
    await loop.run_in_executor(  
        None,  # Utilise le pool de threads par défaut  
        cpu_bound_function,  
        arg1  
    )  
    

Erreur 4 : blocage causé par des tâches non critiques

Si l'on await des opérations non essentielles comme la journalisation, qui n'ont pas de lien direct avec la réponse utilisateur, le temps de réponse est allongé inutilement.

  • Solution : isoler ces opérations dans une tâche en arrière-plan avec asyncio.create_task() sans les await.
    user_profile = await get_user_profile()  
    # Exécute la journalisation en arrière-plan sans await  
    asyncio.create_task(send_logs_to_external_service())  
    return user_profile  
    

Erreur 5 : création d'un trop grand nombre de tâches

Transformer en tâches une très grande quantité de petites opérations peut introduire un surcoût de changement de contexte et dégrader les performances.

  • Solution 1 : regrouper les petites opérations (batching) en quelques tâches plus grosses.
  • Solution 2 : limiter le nombre maximal de tâches exécutées simultanément avec asyncio.Semaphore.
    # Autorise au maximum 10 opérations simultanées  
    semaphore = asyncio.Semaphore(10)  
    
    async with semaphore:  
        await fetch_data()  
    

Autres erreurs

  • Coroutines « Never Awaited » : une coroutine appelée sans await ne s'exécute même pas et échoue silencieusement. Cela peut être détecté avec un linter comme flake8-async.
  • Gestion inadéquate des ressources : utiliser des fichiers, connexions DB, etc. sans try...finally peut provoquer des fuites de ressources. On peut résoudre cela avec des gestionnaires de contexte asynchrones via async with.

Débogage et choix du modèle de concurrence

Mode debug d'Asyncio

Activer le mode debug, désactivé par défaut (asyncio.run(debug=True)), aide à détecter les problèmes suivants.

  • coroutines non await (RuntimeWarning).
  • API asynchrones appelées depuis le mauvais thread.
  • callbacks dont le temps d'exécution dépasse 100 ms.
  • opérations lentes du sélecteur d'E/S (selector).

Autres outils de débogage

  • Scalene : profileur CPU et mémoire.
  • aio-monitor : supervision et CLI pour les applications asyncio.
  • pdb : débogueur Python par défaut.
  • py-stack : affiche la stack trace d'un processus Python en cours d'exécution pour repérer les points de blocage.

Guide de choix du modèle de concurrence

  • Asyncio (single-thread) : optimal pour un grand nombre de tâches I/O-bound à forte latence (par ex. requêtes réseau, E/S fichier).
  • Threads (multi-thread) : utilisés pour des tâches I/O-bound nécessitant un accès à des données partagées. À cause du GIL (Global Interpreter Lock), il ne s'agit pas d'un parallélisme réel, mais un autre thread peut s'exécuter pendant l'attente d'E/S.
  • Processes (multi-process) : utilisés pour les tâches CPU-bound (par ex. traitement d'image, calcul intensif). Ils permettent un véritable parallélisme en exploitant plusieurs cœurs CPU, mais avec un surcoût élevé en mémoire et en communication.

https://youtu.be/wGDOwNW6lVk

12 commentaires

 
savvykang 2025-11-18

Python est assurément un excellent langage, mais son interface asynchrone semble être une fonctionnalité mal conçue.

 
ceruns 2025-11-17

Il manque eager_start=True au point 4. Comme create_task crée une weakref, ce code pourrait produire une tâche qui ne s’exécutera jamais...

 
tested 2025-11-17

> https://rosettalens.com/s/ko/python-to-node

Cette personne aussi disait être passée à Node.js à cause de l'async de Python.

 
kandk 2025-11-17

Conclusion : l’interface asynchrone de Python n’est toujours pas intuitive.

 
bungker 2025-11-17

En réalité, si le projet est suffisamment important pour justifier l’optimisation de l’asynchrone en Python, il est bien préférable de l’écrire dans un autre langage, tant pour les performances que pour la stabilité.

 
euphcat 2025-11-17

Si on ne passe pas à un langage compilé, est-ce qu’il y a vraiment une grosse différence de performances ? Dans le cas du multithreading, il y aura bien sûr un écart important à cause de l’existence du GIL, mais si, de toute façon, on est sur une architecture asynchrone où tourne une boucle d’événements, je me demande quelles différences peuvent apparaître selon le langage.

 
vwjdalsgkv 2025-11-17

L’existence ou non d’une compilation JIT a un impact plus important qu’on ne le pense. V8 est très bien optimisé.

 
euphcat 2025-11-16

Je n’ai pas vérifié la vidéo source, mais le code de solution pour l’erreur n°4 est incorrect.

L’instance de tâche renvoyée par create_task() doit être assignée à au moins une variable, et cette variable doit rester vivante jusqu’à la fin de la tâche. Sinon, il existe un risque que l’instance de tâche soit ramassée par le garbage collector alors même que la coroutine est en cours d’exécution.

Si, comme ci-dessus, la fonction qui crée la tâche se termine aussitôt, il faut utiliser une méthode comme renvoyer l’instance de tâche, l’assigner à une variable globale ou l’assigner à une variable d’instance.

P.-S.)
Même si vous n’avez pas vraiment besoin de la valeur de retour et que vous êtes sûr que la coroutine se terminera rapidement, il vaut mieux écrire le code de façon à faire un await sur l’instance de tâche à un moment ou à un autre. Sinon, il faut au minimum mettre en place une gestion d’exceptions très stricte dans chaque coroutine destinée à fonctionner comme tâche, avec une structure permettant d’afficher tous les messages de log sans rien laisser passer. Sans cela, même si la tâche provoque un gros problème, il peut arriver que l’exception ne soit pas traitée et que cela échoue silencieusement.

Dans un projet que je développe et administre pour gagner ma vie, j’avais conçu un pattern où des dizaines de modules créaient chacun une tâche du genre while self.ok(): cmd = await self.cmd_queue.get(); await self.process(cmd); et la faisaient tourner en continu. Avant d’établir un vrai pattern de gestion des exceptions, chaque incident me faisait vivre cette expérience rare où, à chaque problème qui éclatait, mon mental explosait en même temps haha

 
kunggom 2025-11-16

Même du point de vue de quelqu’un qui travaille dans une entreprise utilisant C#, qu’on peut considérer comme le précurseur (?) du modèle Async/Await, on voit assez souvent du mauvais code comme dans l’erreur n°1, où des await sont simplement enchaînés les uns à la suite des autres dans l’ordre.

Quand je vois ce genre de code, j’ai souvent l’impression qu’il y a un point commun : on sait seulement qu’il faut mettre le mot-clé await devant un appel de méthode async, sans vraiment réfléchir davantage à l’ordre d’exécution asynchrone, et c’est ainsi qu’on aboutit à ce type de code.
Quand plusieurs await apparaissent, certains résultats sont utilisés juste en dessous, donc on récupère avant cela la valeur issue du await de l’objet Task<T>, tandis que d’autres ne seront utilisés que bien plus loin, donc on se contente de récupérer le Task<T> pour ne faire le await que plus tard ; écrire le code de cette manière en tenant compte du flux asynchrone demande forcément plus de réflexion.

Pour ma part, dans les méthodes déclarées asynchrones, j’essaie au moins d’écrire le code en tenant compte de ce flux de traitement. Mais parfois, quand je regarde du code existant d’un ancien employé parti de l’entreprise et dont je dois assurer la maintenance, j’ai aussi l’impression de voir une logique du genre : « Moi, je veux simplement écrire du code synchrone, mais comme la méthode que je dois utiliser au milieu n’existe qu’en version asynchrone, alors je l’écris juste comme ça. »

 
skageektp 2025-11-17

Si le point 1 est toujours indépendant, c’est effectivement une bonne façon de faire,
mais si, après modification du code, il cesse de l’être, il y a aussi l’inconvénient de devoir vérifier et corriger partout où cette fonction est utilisée.
Si ce n’est pas une opération qui prend énormément de temps, faire les await en série peut aussi être préférable du point de vue de la maintenance du code.

 
euphcat 2025-11-17

Je pense qu’il faut aborder cela avec l’idée que, « comme le surcoût du multithreading est contraignant, on choisit comme solution de repli de découper un seul thread pour gérer le parallélisme ». C’est pourquoi il me semble normal que, par défaut, cela demande parfois encore plus d’attention que le multithreading.

 
kunggom 2025-11-17

C’est vrai aussi.
J’ai l’impression qu’un vrai code asynchrone est, par essence, un type de code qui demande beaucoup d’attention.