🇬🇧 English version avalaible here
Note de lecture :
Python est un langage à la con dont la structure est basée sur des caractères blancs (indentation), ce qui supporte très mal les copier-coller. Une fois les caractères blancs perdus, il devient très difficile de corriger le code, d'autant plus que le code fonctionne encore, mais mal.
Lors du passage de cet article en HTML, je me suis rendu compte que certaines indentations ont sauté dans les exemples de code. Si certains snippets ne fonctionnent pas chez vous, faites-le moi savoir et je corrigerai.
Pour comprendre pourquoi asyncio existe et comment il fonctionne, il faut partir du commencement : le processeur de votre machine. Très grossièrement, un processeur ne sait faire qu'une seule chose à la fois (pour simplifier, j'ignore le cas des multiprocesseurs). Il exécute une instruction, puis la suivante, puis celle d'après, dans un ordre strictement séquentiel.
Cette réalité physique peut paraître évidente, mais elle a des implications profondes sur la façon dont vos programmes s'exécutent. Quand vous écrivez :
print("Instruction 1")
print("Instruction 2")
print("Instruction 3")
Le processeur exécute littéralement ces trois appels l'un après l'autre. Il n'y a pas de parallélisme, pas d'exécution simultanée. C'est une séquence linéaire d'opérations.
On peut faire l'analogie avec un chef cuisinier travaillant seul dans sa cuisine. Il peut être très rapide et très efficace, mais il ne peut physiquement faire qu'une seule action à la fois : couper les légumes, ou allumer le feu, ou remuer la sauce. Jamais deux actions simultanément.
Cette contrainte fondamentale nous amène à la notion de "thread d'exécution" ou "fil d'exécution". Au niveau du processeur, il n'existe qu'un seul thread : celui qui exécute l'instruction courante. Tout le reste — le multitâche, les applications qui semblent tourner en parallèle, les interfaces qui restent réactives pendant qu'un téléchargement se déroule — tout cela est une illusion soigneusement orchestrée par le système d'exploitation.
Cette illusion repose sur la commutation de contexte (context switching). Le système d'exploitation interrompt régulièrement le processus en cours d'exécution, sauvegarde son état (registres, pile, compteur de programme), puis passe la main à un autre processus. Cette opération se répète des milliers de fois par seconde, donnant l'impression que plusieurs programmes s'exécutent simultanément.
L'OS maintient ce qu'on appelle le "contexte d'exécution" de chaque processus : l'ensemble des informations nécessaires pour reprendre l'exécution exactement où elle s'était arrêtée. C'est comme si notre chef cuisinier avait un assistant qui, toutes les secondes, prenait une photo de l'état de la cuisine, rangeait tout, installait les ingrédients d'un autre plat, puis reprenait la cuisine du premier plat exactement dans l'état photographié.
C'est ici que l'on comprend pourquoi il ne faut pas ignorer le système d'exploitation quand on veut comprendre asyncio. Python ne tourne pas dans le vide : il s'exécute au-dessus d'un OS qui gère déjà le multitâche, les processus, les threads, et toute la complexité de faire tourner plusieurs programmes sur un seul processeur. Asyncio vient s'ajouter par-dessus cette couche, avec ses propres mécanismes de gestion de tâches.
Cette superposition de systèmes de gestion de tâches — l'OS d'un côté, asyncio de l'autre — peut créer des interactions subtiles et parfois surprenantes. Comprendre que votre code asyncio s'exécute au final sur un processeur séquentiel, géré par un OS qui fait du multitâche préemptif, est essentiel pour éviter les pièges et comprendre les limites de la programmation asynchrone.
Une opération bloquante est une instruction qui suspend l'exécution du programme en attendant qu'un événement externe se produise. Les exemples les plus courants sont :
Les entrées/sorties disque : on attend passivement que le périphérique de stockage fournisse les données, ce qui est des milliers de fois plus lent qu'un accès RAM, même avec du SSD
Les opérations réseau : on attend que le serveur distant traite notre requête et renvoie sa réponse
Les temporisations : on attend simplement que le temps passe (time.sleep()
)
Prenons un exemple concret avec Python qui fait entre autres une requête sur httpbin :
httpbin.org est un service HTTP gratuit spécialement conçu pour tester les requêtes HTTP. Il propose diverses endpoints utiles pour le développement :
/delay/n
pour simuler une latence, /json
pour recevoir du JSON, /status/code
pour tester les codes d'erreur, etc. C'est l'outil parfait pour les exemples car il permet de reproduire fidèlement les comportements réseau réels sans dépendre de services tiers imprévisibles.
import time
import requests
def traiter_donnees():
print("Début du traitement")
# Opération bloquante : le processeur attend 2 secondes
time.sleep(2)
print("Fin de l'attente")
# Opération bloquante : requête HTTP
response = requests.get("https://httpbin.org/delay/1")
print(f"Réponse reçue : {response.status_code}")
# Opération bloquante : lecture fichier
with open("/etc/passwd", "r") as f:
contenu = f.read()
print(f"Fichier lu : {len(contenu)} caractères")
# Chronométrons l'exécution
start = time.time()
traiter_donnees()
end = time.time()
print(f"Temps total : {end - start:.2f} secondes")
À l'exécution, ce programme prend environ 3 secondes (2s + 1s + temps de lecture disque). Pendant tout ce temps, le processeur reste largement inactif.
Pour comprendre l'ampleur du problème de gaspillage de ressources, il faut saisir les ordres de grandeur des temps d'exécution :
Une lecture disque correspond donc à environ 300 000 instructions CPU. Pendant qu'une requête HTTP s'exécute, le processeur pourrait théoriquement traiter 150 millions d'instructions.
Au-delà de l'aspect purement technique, il y a un enjeu économique : que vous louiez un serveur cloud à l'heure ou que vous ayez acheté votre matériel, le coût reste identique que votre CPU soit utilisé à 1% ou 100%. Laisser un processeur attendre passivement alors qu'il pourrait traiter d'autres tâches (comme des requêtes web en parallèle) représente un gaspillage financier direct.
Le problème fondamental est que notre modèle d'exécution traditionnel (séquentiel) ne correspond pas à la réalité des applications modernes. Nous avons :
C'est exactement comme avoir un chef cuisinier ultra-performant qui, après avoir mis un plat au four, reste planté devant à attendre qu'il cuise au lieu de préparer d'autres commandes.
Le problème des tâches bloquantes peut être résolu de plusieurs façons fondamentalement différentes. On peut créer plusieurs threads ou processus pour exécuter les tâches en parallèle, ou encore adopter une approche où les tâches cèdent le contrôle, volontairement ou non. Chaque solution a ses propres implications en termes de complexité, performance et robustesse.
Contrairement à ce que l'on pourrait penser, il n'existe pas une seule façon de gérer le multitâche. Le système d'exploitation et les applications peuvent adopter des stratégies radicalement différentes. Mais, toutes doivent cohabiter.
Le multitâche préemptif est le mode de fonctionnement que la plupart des développeurs connaissent sans même s'en rendre compte. Dans ce modèle, c'est l'OS qui décide quand interrompre une tâche pour en exécuter une autre. Le processeur dispose d'un timer matériel qui génère régulièrement des interruptions (typiquement toutes les millisecondes), et à chaque interruption, l'OS reprend la main, notamment pour donner l'exécution à un autre thread.
import threading
import time
def tache_longue(nom):
for i in range(5):
print(f"Tâche {nom}: étape {i}")
time.sleep(1) # Simulation d'un travail
# Création de deux threads
thread1 = threading.Thread(target=tache_longue, args=("A",))
thread2 = threading.Thread(target=tache_longue, args=("B",))
thread1.start()
thread2.start()
Ici, même si chaque tâche fait un time.sleep(1)
, les deux threads s'exécutent en parallèle parce que l'OS peut les interrompre et les reprendre à sa guise. C'est le multitâche préemptif : les tâches n'ont pas leur mot à dire sur le moment où elles sont interrompues.
Cet exemple fonctionne et donne l'illusion du parallélisme, mais il faut comprendre qu'en réalité, le GIL (Global Interpreter Lock) de Python empêche l'exécution parallèle de code Python pur dans plusieurs threads. L'alternance visible ici provient du fait que
time.sleep()
délègue l'attente à la couche sous-jacente en C, libérant temporairement le GIL et permettant à l'autre thread de s'exécuter. Il faut voir cet exemple comme une illustration du concept de multitâche préemptif dans un monde logique, plutôt que comme une démonstration fidèle du parallélisme Python. Nous n'approfondissons pas ce point car ce n'est pas le sujet de cet article, mais cette nuance explique en partie pourquoi asyncio représente une approche alternative intéressante.
On peut s'en convaincre en remplaçant l'attente à base de time.sleep() par du code CPU-intensif. Ce code devrait théoriquement utiliser 100% de chaque cœur CPU disponible et s'exécuter réellement en parallèle sur une machine multi-cœurs. Mais le GIL, par un jeu de verrous, garantit qu'un seul thread peut exécuter du code Python à la fois, limitant artificiellement l'exécution à un seul cœur :
import threading
import time
def tache_longue(nom):
start = time.time()
for i in range(5):
for j in range(30000000):
pass
step = time.time()
print(f"Tâche {nom}: étape {i}, duration: {step - start}")
start = step
# Création de deux threads
thread1 = threading.Thread(target=tache_longue, args=("A",))
thread2 = threading.Thread(target=tache_longue, args=("B",))
thread3 = threading.Thread(target=tache_longue, args=("C",))
thread1.start()
# thread2.start()
# thread3.start()
Le résultat met bien en avant des differences de temps d'executions proportionnelles au nombre de thread concurrents. Il suffira de décommenter les lignes threadX.start()
pour le vérifier. Voila les résultat sur ma machine :
1 thread :
Tâche A: étape 0, duration: 2.0162436962127686
Tâche A: étape 1, duration: 2.0153567790985107
Tâche A: étape 2, duration: 2.015307903289795
Tâche A: étape 3, duration: 2.0152580738067627
Tâche A: étape 4, duration: 2.0151071548461914
2 thread :
Tâche A: étape 0, duration: 4.170198678970337
Tâche B: étape 0, duration: 4.172067642211914
Tâche A: étape 1, duration: 4.193373680114746
Tâche B: étape 1, duration: 4.213354825973511
Tâche A: étape 2, duration: 4.180951118469238
Tâche B: étape 2, duration: 4.169952869415283
Tâche A: étape 3, duration: 4.282079219818115
Tâche B: étape 3, duration: 4.27399754524231
Tâche B: étape 4, duration: 4.311825513839722
Tâche A: étape 4, duration: 4.361750841140747
3 thread :
Tâche B: étape 0, duration: 4.5046913623809814
Tâche A: étape 0, duration: 7.235995769500732
Tâche C: étape 0, duration: 7.3113625049591064
Tâche B: étape 1, duration: 4.408862352371216
Tâche A: étape 1, duration: 6.340449810028076
Tâche C: étape 1, duration: 6.8771936893463135
Tâche B: étape 2, duration: 6.176913261413574
Tâche C: étape 2, duration: 4.872439384460449
Tâche B: étape 3, duration: 4.644377708435059
Tâche A: étape 2, duration: 7.488765239715576
Tâche C: étape 3, duration: 6.49780011177063
Tâche A: étape 3, duration: 5.1964192390441895
Tâche B: étape 4, duration: 6.820744752883911
Tâche A: étape 4, duration: 4.06696629524231
Tâche C: étape 4, duration: 4.835111856460571
Avec 1 thread, chaque étape prend ~2 secondes. Avec 2 threads, chaque étape prend ~4 secondes (doublement du temps). Avec 3 threads, les temps deviennent encore plus erratiques et longs. Cette dégradation linéaire montre que les threads Python ne s'exécutent jamais réellement en parallèle pour du code CPU-intensif.
Sous Linux, on peut observer cette différence en regardant les statistiques de changement de contexte. Chaque processus dispose de compteurs pour les commutations volontaires et involontaires :
# Regarder les context switches d'un processus
$ cat /proc/<PID>/status | grep ctxt
voluntary_ctxt_switches: 1523
nonvoluntary_ctxt_switches: 892
Les nonvoluntary_ctxt_switches
représentent les interruptions forcées par l'OS - c'est le multitâche préemptif en action.
À l'opposé, la programmation coopérative repose sur un principe simple : chaque tâche cède volontairement le contrôle quand elle n'a plus rien à faire. Il n'y a pas d'interruption forcée. Si une tâche décide de ne jamais céder la main, elle peut monopoliser le processeur indéfiniment.
Cette approche peut sembler fragile, mais elle présente des avantages considérables. Puisque les tâches ne peuvent être interrompues qu'aux points où elles acceptent de céder le contrôle, les conditions de course (race conditions) les plus pernicieuses ne peuvent pas exister. Pas besoin de mécanismes de synchronisation complexes tels que les mutex.
Une race-condition (condition de course) survient quand plusieurs threads accèdent simultanément à une ressource partagée, et le résultat final dépend de l'ordre d'exécution imprévisible des threads. Par exemple, si deux threads incrémentent un compteur en même temps, le résultat peut être incorrect car les opérations "lire-modifier-écrire" peuvent s'entremêler.
En asyncio, les race conditions par interruption arbitraire sont évitées : les opérations simples comme compteur += 1
sont atomiques car aucune suspension ne peut survenir au milieu. Cependant, dès qu'un await sépare une lecture d'une écriture, une race condition redevient possible.
Un mutex (mutual exclusion) est un verrou qui permet à un seul thread d'accéder à une ressource critique à la fois. Ils introduisent leurs propres problèmes : les deadlocks (interblocages) quand deux threads s'attendent mutuellement, et la contention quand plusieurs threads se disputent le même verrou, dégradant les performances.
Les voluntary_ctxt_switches
dans les statistiques Linux correspondent à ce modèle : la tâche demande explicitement à être suspendue, généralement parce qu'elle attend une ressource.
Il faut noter qu'au sein d'un noyau Linux, les deux modes de commutation cohabitent et ne sont pas mutuellement exclusifs. Un même processus peut subir des commutations préemptives (quand son quantum de temps expire) et des commutations coopératives (quand il fait appel à un syscall bloquant). Cette coexistence explique pourquoi on retrouve les deux compteurs dans les statistiques système.
Ces deux modes de multitâche au niveau système ont leurs équivalents dans les modèles de programmation. Le multitâche préemptif correspond à la programmation avec threads, où l'OS peut interrompre n'importe quel thread à tout moment. Le mode coopératif correspond à la programmation asynchrone, où les tâches cèdent explicitement le contrôle aux points d'attente.
Cette correspondance n'est pas anodine. Un programme asynchrone bien conçu s'harmonise naturellement avec les mécanismes du noyau. Au lieu de subir des interruptions forcées, il cède volontairement le contrôle quand il attend des ressources (I/O, réseau, etc.). L'OS enregistrera alors beaucoup plus de voluntary_ctxt_switches
et beaucoup moins de nonvoluntary_ctxt_switches
.
Cette coopération entre le programme et l'OS optimise considérablement les performances. Les commutations volontaires sont moins coûteuses car elles surviennent à des moments prévisibles, permettant au noyau de mieux optimiser la gestion des ressources. Le programme évite le gaspillage des interruptions timer inutiles et réduit la contention sur le scheduler du noyau.
Pour comprendre intuitivement la différence, imaginons un serveur dans un restaurant qui doit gérer plusieurs tables.
Dans le modèle préemptif, le serveur aurait un chef autoritaire qui lui crie toutes les 30 secondes : "Change de table !" Peu importe qu'il soit en train de prendre une commande ou d'expliquer le menu, il doit immédiatement abandonner sa table actuelle et passer à la suivante. Ce système fonctionne, mais il est chaotique et inefficace.
Dans le modèle coopératif, le serveur gère lui-même son planning. Il prend la commande à la table 1, va transmettre en cuisine, et pendant que les cuisiniers préparent (opération "bloquante"), il va naturellement voir la table 2. Quand il revient de la table 2, il peut vérifier si les plats de la table 1 sont prêts. S'ils ne le sont pas, il peut servir la table 3. Le serveur ne reste jamais inactif à attendre une seule table.
Cette analogie révèle l'essence de la programmation asynchrone : optimiser les temps d'attente en faisant autre chose. Le serveur (notre event loop) coordonne plusieurs tâches, mais il n'y a qu'un seul serveur (un seul thread d'exécution).
Pourquoi choisir la coopération plutôt que la préemption et les threads ? Les avantages sont substantiels :
Pas de conditions de course : Puisqu'une tâche ne peut être interrompue qu'aux points où elle accepte de céder le contrôle, il n'y a pas de corruption de données partagées.
Pas de verrous : Plus de prévisibilité et dispense des problèmes classiques de verrous comme les deadlocks (interblocages) et la contention. Le code échappe aux complexités des mutex et semaphores.
Performance : Les changements de contexte sont moins coûteux car ils sont planifiés et correspondent à des moments où le programme attend naturellement une ressource.
Scalabilité : On peut gérer des milliers de tâches concurrentes avec un seul thread, là où les threads système sont limités par la mémoire et les ressources du noyau.
Mais cette approche a aussi ses pièges. Une seule tâche mal conçue qui ne cède jamais le contrôle peut bloquer tout le système. C'est pourquoi comprendre les mécanismes sous-jacents est crucial pour écrire du code asynchrone robuste.
La prochaine étape consiste à comprendre comment l'OS nous aide à implémenter cette coopération efficacement, notamment grâce aux mécanismes de polling non-bloquant.
Pour comprendre comment asyncio peut fonctionner, il faut d'abord comprendre comment le système d'exploitation gère les opérations d'entrée/sortie. C'est ici que se trouve le mécanisme fondamental qui permet à Python de ne pas rester bloqué en attendant qu'un fichier se charge ou qu'une requête réseau aboutisse.
Pour résoudre ce problème, les systèmes d'exploitation Unix proposent une alternative : les appels système non-bloquants. Au lieu d'attendre indéfiniment, ces appels retournent immédiatement un résultat, même si l'opération n'est pas terminée.
import socket
import errno
# Création d'un socket TCP pour communiquer sur le réseau
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Activation du mode non-bloquant : garantit qu'aucun
# appel ne sera bloquant
sock.setblocking(False)
try:
sock.connect(('httpbin.org', 80))
except Exception as e:
print(e) # [Errno 36] Operation now in progress
Ce code illustre parfaitement le comportement non-bloquant : sock.connect()
retourne immédiatement avec une erreur "Operation now in progress", ce qui signifie que la connexion est en cours d'établissement en arrière-plan. Le programme n'attend pas que la connexion soit établie.
C'est le principe du "je reviens voir plus tard" - notre analogie du serveur de restaurant qui ne reste pas planté devant une table en attendant que le client décide quoi commander. Ici, le programme lance la connexion et peut faire autre chose pendant qu'elle s'établit.
Le problème avec les appels non-bloquants, c'est qu'il faut constamment vérifier si les opérations sont prêtes. C'est là qu'intervient le polling : un mécanisme pour surveiller efficacement plusieurs opérations d'I/O simultanément.
Le plus ancien et plus répandu de ces mécanismes sur les systèmes POSIX (Unix, Linux, BSD, macOS) est select()
. Il permet de surveiller plusieurs file descriptors (sockets, fichiers, pipes) et de savoir lesquels sont prêts pour la lecture ou l'écriture. select()
est très rapide avec quelques centaines de file descriptors, mais ses performances se dégradent au-delà.
import select
import socket
# Création d'un socket et lancement de la connexion
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setblocking(False)
# Tentative de connexion
try:
sock.connect(('httpbin.org', 80)) #
is_connected = True # Connexion immédiate (rare)
except BlockingIOError:
is_connected = False # Connexion en cours
except Exception as e:
print(f"Erreur de connexion : {e}")
sock.close()
exit(1)
# Si la connexion n'est pas immédiate, utiliser select()
# pour attendre
while not is_connected:
# Surveiller le socket en écriture : il devient prêt
# quand la connexion aboutit
_, ready_to_write, error_sockets = select.select([],
[sock], [sock], 5.0)
if error_sockets:
print("Erreur de connexion")
break
elif ready_to_write:
# Vérifier si la connexion a réussi
try:
error = sock.getsockopt(socket.SOL_SOCKET,
socket.SO_ERROR)
if error:
print(f"Connexion échouée : {error}")
break
else:
print("Connexion établie !")
is_connected = True
except Exception as e:
print(f"Erreur lors de la vérification : {e}")
break
else:
print("Connexion toujours en cours...")
sock.connect(('httpbin.org', 80))
effectue silencieusement une résolution DNS qui est bloquante. Pour simplifier cet article, nous passons cette complexité sous silence, mais dans une vraie application asynchrone, la résolution DNS doit aussi être non-bloquante.
L'appel select()
bloque jusqu'à ce qu'au moins un des file descriptors surveillés devienne prêt, ou jusqu'à ce que le timeout expire. C'est différent d'un blocage sur une opération unique : ici, on bloque en attendant que n'importe laquelle des opérations surveillées devienne prête.
Le contraste est saisissant : remplacer un simple sock.connect()
par sa version non bloquante multiplie la complexité du code par dix. Cette explosion de complexité pour un gain d'efficacité illustre parfaitement le dilemme d'asyncio : performance contre simplicité.
Pour les besoins de cet article, nous nous concentrons sur select()
qui illustre parfaitement le principe du polling. D'autres mécanismes plus modernes existent (epoll
sur Linux, kqueue
sur BSD), mais le principe reste identique.
Pour reprendre notre serveur de restaurant : imagine qu'il ait un système de "bips" - chaque table a un petit appareil qui sonne quand elle a besoin de quelque chose. Au lieu de faire le tour de toutes les tables en permanence, le serveur peut s'asseoir et attendre que l'un des bips sonne. Dès qu'un bip retentit, il sait exactement quelle table demande son attention.
select()
fonctionne exactement comme ce système de bips : il permet au programme d'attendre efficacement que l'une des opérations surveillées devienne prête, sans avoir à les vérifier une par une en boucle.
Ces mécanismes de polling de bas niveau sont la fondation technique sur laquelle repose la programmation asynchrone moderne. Ils permettent à un seul thread de surveiller efficacement des centaines d'opérations d'I/O simultanément, en ne s'occupant que de celles qui sont réellement prêtes à progresser.
yield
Avant de plonger dans les subtilités d'asyncio, il faut comprendre un mécanisme fondamental de Python qui constitue sa base technique : les générateurs. Sans cette compréhension, le fonctionnement d'asyncio sera très obscur. Avec elle, tout devient limpide.
Un générateur en Python est une fonction qui peut suspendre son exécution et la reprendre plus tard, exactement là où elle s'était arrêtée. Cette capacité de suspension/reprise est au cœur du fonctionnement d'asyncio.
Prenons un exemple simple :
def compter():
print("Début du générateur")
yield 1
print("Après le premier yield")
yield 2
print("Après le second yield")
yield 3
print("Fin du générateur")
# Créer le générateur (ne l'exécute pas encore)
gen = compter()
print(f"Type du générateur : {type(gen)}")
Type du générateur : <class 'generator'>
Déjà, on observe quelque chose d'inhabituel : appeler compter()
ne déclenche pas l'exécution de la fonction. Python détecte la présence du mot-clé yield
et transforme automatiquement la fonction en générateur. C'est cette détection qui fait toute la différence.
Oui, c'est bien la simple présence du mot-clé yield
qui change la nature fondamentale de la fonction. Peu importe que ce yield
soit dans une branche jamais exécutée ou après un return
- Python transforme automatiquement la fonction en générateur dès l'analyse syntaxique. Cette transformation implicite basée sur la détection de mots-clés peut sembler déroutante pour les développeurs venus d'autres langages, et c'est bien normal, même souhaitable.
Maintenant, utilisons le générateur :
print("Premier next() :")
value1 = next(gen)
print(f"Valeur reçue : {value1}")
print("\nDeuxième next() :")
value2 = next(gen)
print(f"Valeur reçue : {value2}")
print("\nTroisième next() :")
value3 = next(gen)
print(f"Valeur reçue : {value3}")
print("\nQuatrième next() (déclenche StopIteration) :")
try:
next(gen)
except StopIteration as e:
print(f"StopIteration levée : {e}")
Premier next() :
Début du générateur
Valeur reçue : 1
Deuxième next() :
Après le premier yield
Valeur reçue : 2
Troisième next() :
Après le second yield
Valeur reçue : 3
Quatrième next() (déclenche StopIteration) :
Fin du générateur
StopIteration levée :
L'exécution s'arrête à chaque yield
et reprend exactement au même endroit lors du next()
suivant. Toutes les variables locales, l'état de la pile d'exécution, tout est conservé entre les appels.
Voici un détail technique fondamental que peu de développeurs Python connaissent, mais qui est important pour comprendre asyncio. Observons ce qui se passe quand un générateur utilise return
:
def generateur_avec_return():
yield 1
yield 2
return "valeur de retour"
yield 3 # Ce yield ne sera jamais atteint
gen = generateur_avec_return()
print("Premier yield :", next(gen))
print("Deuxième yield :", next(gen))
try:
next(gen) # Ici, le return sera exécuté
except StopIteration as e:
print(f"StopIteration avec valeur : {e.value}")
Premier yield : 1
Deuxième yield : 2
StopIteration avec valeur : valeur de retour
Donc, quand un générateur exécute return value
, Python lève une exception StopIteration
avec cette valeur stockée dans l'attribut value
de l'exception. C'est ainsi que Python transmet la "valeur de retour" d'un générateur.
Ce mécanisme d'exception est le fondement même sur lequel asyncio s'appuie pour faire communiquer les coroutines avec l'event loop.
Les générateurs conservent leur état complet entre les suspensions. Démonstration :
def compteur_avec_etat():
x = 0
while x < 3:
x += 1
yield f"Compteur à {x}"
print(f"Reprise, x vaut maintenant {x}")
gen = compteur_avec_etat()
print(next(gen))
print("--- Pause dans l'exécution ---")
print(next(gen))
print("--- Autre pause ---")
print(next(gen))
Compteur à 1
--- Pause dans l'exécution ---
Reprise, x vaut maintenant 1
Compteur à 2
--- Autre pause ---
Reprise, x vaut maintenant 2
Compteur à 3
La variable x
garde sa valeur entre chaque suspension. Python conserve l'état complet du générateur : variables locales, position dans le code, pile d'exécution.
La différence fondamentale entre un générateur et une fonction normale est que la fonction normale s'exécute du début à la fin et retourne une valeur. Le générateur devient un objet que l'on peut "réveiller" et qui peut reprendre son exécution.
Cette capacité de suspension et de reprise est exactement ce dont nous avons besoin pour l'asynchrone. Imaginez qu'au lieu de yield
, nous ayons await
pour une opération I/O :
# Concept (pas encore du vrai asyncio)
def tache_conceptuelle():
print("Début de la tâche")
# yield "j'attends une opération I/O"
print("Reprise après I/O")
# yield "j'attends une autre opération"
print("Fin de la tâche")
Le générateur peut se suspendre pendant qu'une opération I/O est en cours, laisser l'event loop gérer d'autres tâches, puis reprendre son exécution quand l'opération est terminée.
C'est exactement le principe d'asyncio : les coroutines sont basées sur les générateurs, et await
fonctionne comme un yield
sophistiqué qui communique avec l'event loop.
Maintenant que nous comprenons les générateurs et leur capacité fondamentale de suspension/reprise, nous pouvons aborder leur évolution vers les coroutines. Cette transition n'est pas qu'une simple amélioration syntaxique : elle représente un changement de paradigme dans la façon dont Python gère l'asynchrone.
Les générateurs que nous avons vus jusqu'ici ont une limitation importante : ils ne peuvent que produire des valeurs vers l'extérieur. Pour construire des systèmes asynchrones complexes, nous avons besoin de pouvoir composer plusieurs générateurs entre eux, de les faire communiquer, et de déléguer l'exécution de l'un à l'autre.
Considérons cette tentative naïve de composition :
def operation_simple():
print("Début opération simple")
yield "résultat simple"
print("Fin opération simple")
def operation_composee():
print("Début opération composée")
# Tentative naive de délégation
gen = operation_simple()
resultat = next(gen) # Récupère le résultat
print(f"Résultat reçu : {resultat}")
yield "résultat composé"
print("Fin opération composée")
gen = operation_composee()
print(next(gen))
Début opération composée
Début opération simple
Résultat reçu : résultat simple
résultat composé
Cette approche fonctionne pour des cas simples, mais elle a un défaut majeur : operation_composee()
doit connaître intimement le fonctionnement d'operation_simple()
. Si operation_simple()
produisait plusieurs valeurs, ou si elle avait une logique de suspension plus complexe, le code de composition deviendrait rapidement ingérable.
yield from
: la délégation transparentePython 3.3 a introduit yield from
pour résoudre exactement ce problème. Cette construction permet à un générateur de déléguer complètement l'exécution à un autre générateur :
def operation_io_simulee():
print(" ↳ Début I/O")
yield "en cours..."
yield "progression 50%"
yield "progression 90%"
print(" ↳ Fin I/O")
return "données chargées"
def tache_complexe():
print("Début tâche complexe")
# Délégation complète avec yield from
resultat = yield from operation_io_simulee()
print(f"Traitement des données : {resultat}")
yield "traitement terminé"
return "tâche accomplie"
# Exécution
gen = tache_complexe()
try:
while True:
valeur = next(gen)
print(f"Reçu : {valeur}")
except StopIteration as e:
print(f"Valeur finale : {e.value}")
Début tâche complexe
↳ Début I/O
Reçu : en cours...
Reçu : progression 50%
Reçu : progression 90%
↳ Fin I/O
Traitement des données : données chargées
Reçu : traitement terminé
Valeur finale : tâche accomplie
Observons ce qui se passe ici. yield from operation_io_simulee()
délègue complètement l'exécution à operation_io_simulee()
. Toutes les valeurs produites par le générateur délégué remontent directement à l'appelant, et quand le générateur délégué se termine (avec return
), sa valeur de retour est assignée à resultat
.
Cette délégation est transparente : l'appelant de tache_complexe()
ne sait pas qu'il y a une délégation en cours. Il reçoit directement les valeurs d'operation_io_simulee()
.
Jusqu'ici, nos générateurs étaient unidirectionnels : ils produisaient des valeurs vers l'extérieur. Les coroutines Python introduisent la bidirectionnalité : elles peuvent aussi recevoir des valeurs.
def coroutine_bidirectionnelle():
print("Coroutine démarrée")
# Recevoir une valeur de l'extérieur
valeur_recue = yield "prêt à recevoir"
print(f"Valeur reçue : {valeur_recue}")
# Faire quelque chose avec cette valeur
resultat = valeur_recue * 2
# Renvoyer le résultat
nouvelle_valeur = yield f"résultat : {resultat}"
print(f"Nouvelle valeur : {nouvelle_valeur}")
return "terminé"
# Utilisation d'une coroutine bidirectionnelle
coro = coroutine_bidirectionnelle()
# Premier next() pour démarrer la coroutine
message = next(coro)
print(f"Message initial : {message}")
# Envoyer une valeur avec send()
try:
response = coro.send(42)
print(f"Réponse : {response}")
# Envoyer une autre valeur
coro.send("final")
except StopIteration as e:
print(f"Coroutine terminée : {e.value}")
Message initial : prêt à recevoir
Valeur reçue : 42
Réponse : résultat : 84
Nouvelle valeur : final
Coroutine terminée : terminé
La méthode send()
permet d'envoyer une valeur à la coroutine. Cette valeur devient le résultat de l'expression yield
courante. C'est cette bidirectionnalité qui ouvre la voie à la programmation asynchrone.
L'asynchrone en Python a évolué en deux étapes majeures. Python 3.4 a introduit asyncio
en utilisant les générateurs existants avec @asyncio.coroutine
et yield from
. Python 3.5 a ensuite introduit async def
et await
pour améliorer la lisibilité et la sûreté de type :
import asyncio
# Ancienne syntaxe (Python 3.4)
@asyncio.coroutine
def ancienne_syntaxe():
resultat = yield from asyncio.sleep(1)
return "terminé"
# Syntaxe moderne (Python 3.5+)
async def syntaxe_moderne():
await asyncio.sleep(1)
return "terminé"
Cette transition n'est pas qu'un changement cosmétique. Les coroutines natives (async def
) sont un type d'objet distinct des générateurs, avec leurs propres vérifications de type et leur propre protocole.
Bien que les coroutines natives soient basées sur les générateurs, Python les traite comme des types d'objets distincts. Les deux utilisent le même mécanisme fondamental de suspension/reprise, mais avec des syntaxes différentes pour des usages différents.
Les générateurs sont conçus pour l'itération et la production de séquences de valeurs. Les coroutines natives sont spécialisées pour la programmation asynchrone, avec des vérifications de type plus strictes et une syntaxe dédiée (async
/await
) qui rend le code plus lisible et moins sujet aux erreurs.
Cette évolution des générateurs vers les coroutines natives n'efface pas les mécanismes fondamentaux que nous avons décrits. Au contraire, elle les encapsule dans une syntaxe plus sûre et plus expressive.
Les coroutines natives utilisent toujours le même principe de suspension/reprise que les générateurs. Elles communiquent toujours via des mécanismes similaires à yield
et send()
. Et l'infrastructure sous-jacente utilise toujours les mêmes mécanismes de polling système que nous avons vus précédemment.
Maintenant que nous comprenons les générateurs et les mécanismes de polling système, nous pouvons révéler ce qui se passe vraiment quand vous écrivez async def
et await
. Cette syntaxe moderne n'est qu'une façade élégante sur les mécanismes que nous venons de détailler.
Les coroutines natives implémentent le même protocole que les générateurs, révélant leur nature commune :
import asyncio
import types
async def ma_coroutine():
await asyncio.sleep(0.1)
return "terminé"
# Créer la coroutine
coro = ma_coroutine()
# Vérifier qu'elle implémente le protocole générateur
print(f"Méthode send : {hasattr(coro, 'send')}")
print(f"Méthode throw : {hasattr(coro, 'throw')}")
print(f"Méthode close : {hasattr(coro, 'close')}")
# Mais Python la distingue des générateurs classiques
print(f"Est un générateur : {isinstance(coro, types.GeneratorType)}")
print(f"Est une coroutine : {isinstance(coro, types.CoroutineType)}")
coro.close()
La coroutine possède les mêmes méthodes (send
, throw
, close
) que les générateurs, car elle utilise les mêmes mécanismes internes de suspension et reprise.
await
: délégation sophistiquéeQuand vous écrivez await expression
, Python effectue une série d'opérations qui correspondent exactement à yield from
avec des vérifications supplémentaires :
import asyncio
# Démonstration du protocole __await__
async def operation_async():
await asyncio.sleep(0.1)
return "données"
async def coroutine_detaillee():
print("Avant await")
# Ce qui se passe lors d'un await :
operation = operation_async()
# 1. Python vérifie que l'objet est "awaitable"
awaiter = operation.__await__()
print(f"Type de l'awaiter : {type(awaiter)}")
# 2. Délègue à cet awaiter (comme yield from)
try:
awaiter.send(None) # Premier send pour démarrer
except StopIteration as e:
result = e.value
print(f"Résultat récupéré via StopIteration : {result}")
operation.close()
asyncio.run(coroutine_detaillee())
Avant await
Type de l'awaiter : <class 'coroutine'>
Résultat récupéré via StopIteration : données
Le mécanisme __await__()
retourne un générateur (ou un objet générateur-compatible) que Python utilise exactement comme avec yield from
. La valeur de retour transite par le même mécanisme StopIteration
que nous avons vu avec les générateurs.
L'event loop est une boucle infinie qui coordonne trois composants fondamentaux pour orchestrer l'exécution asynchrone : les file descriptors prêts en lecture, les file descriptors prêts en écriture, et les timeouts pour les opérations temporisées.
Au cœur de l'event loop se trouve un appel à select()
(le mécanisme de polling que nous avons vu précédemment). L'event loop maintient des listes de file descriptors à surveiller pour différents types d'opérations.
Quand vous faites await reader.read()
, la coroutine se suspend et communique avec l'event loop via le mécanisme yield
sous-jacent. Cette communication transporte l'information nécessaire : "j'attends que le file descriptor X soit prêt en lecture". L'event loop ajoute alors ce file descriptor à sa liste de surveillance.
De même, await writer.write()
signale "j'attends que le file descriptor Y soit prêt en écriture". L'appel select()
surveille tous ces file descriptors simultanément et retourne immédiatement ceux qui sont prêts pour l'opération demandée.
L'event loop doit aussi gérer les opérations temporisées comme asyncio.sleep()
. Il maintient une queue de tâches ordonnées par échéance temporelle. Pour calculer le timeout à passer à select()
, l'event loop regarde simplement la prochaine tâche programmée : si elle doit s'exécuter dans 1,3 seconde, alors select()
recevra un timeout de 1,3 seconde maximum. Si aucune tâche n'est programmée, select()
peut attendre indéfiniment.
L'event loop combine ces éléments dans une boucle simple mais puissante. À chaque itération, il calcule le timeout pour la prochaine tâche temporisée, puis utilise select()
avec ce timeout pour surveiller les I/O. Quand select()
retourne, soit des file descriptors sont prêts, soit le timeout est écoulé. Dans le premier cas, l'event loop réveille les coroutines dont les I/O sont disponibles. Dans le second cas, il réveille les coroutines dont l'échéance temporelle est atteinte. Enfin, il exécute toutes les tâches prêtes en leur envoyant None
via la méthode send()
pour les reprendre exactement où elles s'étaient arrêtées.
Cette boucle résout élégamment le problème fondamental de l'asynchrone : ne jamais attendre passivement. Soit des I/O sont prêtes (retour immédiat de select()
), soit une échéance temporelle arrive (timeout de select()
), soit les deux. Dans tous les cas, l'event loop attendra seulement si elle n'a rien à faire.
Chaque await
sur une opération I/O se traduit finalement par une suspension de la coroutine via le mécanisme yield
, l'enregistrement du file descriptor dans le select()
système, le passage à d'autres tâches pendant l'attente, puis le réveil de la coroutine quand le select()
signale que l'opération est prête, et la reprise de l'exécution exactement où elle s'était arrêtée.
Cette architecture explique pourquoi asyncio est plus efficace que du code multithread : un seul thread peut gérer des milliers de connexions simultanées, car il ne fait que coordonner les moments où chaque opération devient réellement prête à progresser.
await
Pour concrétiser ces mécanismes, suivons l'exécution complète de ce code simple :
async def simple():
result = await asyncio.sleep(1.0)
return "terminé"
asyncio.run(simple())
Timeline d'exécution :
Démarrage de l'event loop : asyncio.run()
crée un event loop et initialise ses structures (self._ready =
collections.deque()
pour les tâches prêtes, self._scheduled =
[]
pour les tâches temporisées)
Ajout de la coroutine principale : simple()
est transformée en Task et ajoutée à self._ready
Exécution de self._ready
: l'event loop trouve simple()
dans self._ready
et l'exécute via coroutine.send(None)
Rencontre de l'await : la coroutine atteint await
asyncio.sleep(1.0)
et se suspend
Communication avec l'event loop : la suspension transmet l'information "réveille-moi dans 1.0 seconde" via le mécanisme yield
sous-jacent
Planification temporelle : l'event loop calcule l'échéance (timestamp actuel + 1.0s) et ajoute la tâche à self._scheduled
Calcul du timeout : self._ready
vide, prochaine échéance dans 1.0s → timeout = 1.0s pour select()
Attente dans select() : select([], [], [], 1.0)
- aucun file descriptor à surveiller, timeout à 1 seconde
Réveil après timeout : select()
retourne après 1 seconde (aucun I/O, timeout écoulé)
Vérification des échéances : l'event loop trouve que la tâche simple()
doit être réveillée maintenant
Retour en self._ready
: la tâche simple()
est déplacée de self._scheduled
vers self._ready
Reprise d'exécution : coroutine.send(None)
reprend simple()
exactement après l'await
Fin de coroutine : return "terminé"
lève StopIteration("terminé")
Récupération du résultat : l'event loop capture l'exception et récupère "terminé"
dans exception.value
Vérification finale : self._ready
vide, self._scheduled
vide, aucun file descriptor → arrêt de l'event loop
Cette timeline révèle comment un simple await asyncio.sleep(1.0)
mobilise tout l'arsenal technique que nous avons décrit : mécanismes de générateurs, communication par exceptions, calcul de timeouts, et appel système select()
.
Cette élégance syntaxique cache une complexité technique considérable. Quand vous écrivez :
async def simple():
result = await some_async_operation()
return result
Python transforme cela en une machine complexe qui transforme votre fonction en générateur sophistiqué, gère le protocole __await__
pour la délégation, communique avec l'event loop via des mécanismes de suspension/reprise, utilise les syscalls de polling pour optimiser l'attente, gère les exceptions à travers plusieurs couches d'abstraction, et coordonne potentiellement des milliers de tâches concurrentes.
Cette transformation n'est pas anodine. Elle explique pourquoi certains comportements d'asyncio peuvent sembler surprenants si l'on ignore les mécanismes sous-jacents. Elle explique aussi pourquoi mélanger code synchrone et asynchrone crée des problèmes : les deux mondes utilisent des modèles d'exécution fondamentalement différents.
La syntaxe async
/await
donne l'illusion de simplicité en masquant un système d'une complexité technique redoutable. Mais cette accessibilité a un prix : elle cache la réalité des mécanismes sous-jacents.
Comprendre que await
n'est qu'un yield from
sophistiqué, que les coroutines utilisent les mêmes mécanismes que les générateurs, et que l'event loop repose sur les syscalls de polling système, permet de dépasser l'utilisation superficielle d'asyncio.
Cette compréhension devient nécessaire quand il faut déboguer des problèmes de performance, gérer des exceptions complexes, ou concevoir des architectures asynchrones robustes. La syntaxe moderne cache bien la complexité, mais ne l'élimine pas.
Nous voici arrivés au terme de notre exploration d'asyncio, et il est temps de prendre du recul sur ce que nous venons de découvrir. Cette plongée technique révèle un paradoxe saisissant : un langage créé pour la simplicité qui cache aujourd'hui une complexité technique redoutable.
Python n'a pas été conçu comme un langage de production haute performance. Guido van Rossum l'a créé avec une philosophie claire : "Computer Programming for Everybody". L'objectif était de rendre la programmation accessible aux chercheurs non-informaticiens, aux enfants, aux scientifiques qui avaient besoin d'automatiser leurs calculs sans devenir des experts en informatique.
Cette vision transparaît encore aujourd'hui dans le Zen de Python : "There should be one obvious way to do it", "Simple is better than complex", "Readability counts". Python devait être le langage du prototypage rapide, de l'expérimentation, de l'apprentissage. Un outil pédagogique avant d'être un outil de production.
Cette philosophie explique pourquoi Python a longtemps été perçu comme "lent mais simple". Le GIL (Global Interpreter Lock) interdisait le vrai parallélisme du code python, mais garantissait la simplicité du modèle d'exécution. Pas de conditions de course, pas de corruption mémoire, pas de complexité de synchronisation. Un seul thread d'exécution, un modèle mental simple.
Mais Python a connu un succès qui a dépassé les intentions originales de ses créateurs. Pour des raisons économiques bien connues, les prototypes finissent invariablement en production. Un proof-of-concept développé rapidement en Python pour valider une idée devient progressivement un système critique qu'il faut maintenir, faire évoluer, et surtout faire passer à l'échelle. Cette dérive du prototypage vers la production n'est pas spécifique à Python, mais elle touche ce langage de plein fouet du fait de sa facilité d'apprentissage et de sa productivité initiale.
Le résultat est que Python se retrouve aujourd'hui en production dans des contextes pour lesquels il n'a jamais été conçu : serveurs web haute charge, systèmes temps réel, applications critiques nécessitant des performances soutenues. Cette adoption massive par défaut a créé des besoins que Python n'était pas initialement équipé pour satisfaire.
Les développeurs ont réclamé des performances, du parallélisme, de la capacité à gérer des milliers de connexions simultanées. Ils voulaient garder la simplicité de Python tout en rivalisant avec les performances de langages comme Go, Rust ou Node.js. Cette pression a poussé l'écosystème Python vers des solutions de plus en plus sophistiquées.
Asyncio représente cette tension parfaitement. Il permet à Python de gérer des milliers de connexions simultanées sans s'effondrer complètement, ce qui est déjà un progrès par rapport aux threads, mais au prix d'une complexité technique considérable. Derrière la syntaxe élégante async
/await
se cache un système d'une complexité redoutable.
Notre exploration technique révèle l'ampleur de cette complexité cachée. Un simple await asyncio.sleep(1.0)
mobilise :
yield
__await__()
et la délégation transparenteStopIteration
pour transporter les valeurs de retourselect
, epoll
, kqueue
) pour optimiser l'attente I/OCette accumulation de couches d'abstraction transforme Python en iceberg : une surface lisse et simple, mais une profondeur technique dangereuse pour qui ne la comprend pas. Et pour qui la comprend, c'est tout aussi dangereux car cette surface lisse rend la visibilité restreinte sur les mécanismes réellement à l'œuvre. Cette évolution historique est documentée dans PEP 3156 (Python 3.4, avec
@asyncio.coroutine
et yield from
) puis PEP 492 (Python 3.5+, avec
async def
et await
).
Prenons cet exemple qui illustre parfaitement les pièges multiples d'asyncio :
async def download_data():
try:
data = await http_client.get("https://api.example.com/data")
return data.json()
except:
return None # Danger !
Ce code semble innocent, mais le except:
nu peut masquer des exceptions critiques comme asyncio.CancelledError
, KeyboardInterrupt
, ou SystemExit
. Ces exceptions doivent remonter pour permettre l'arrêt propre du programme. Mais cette subtilité n'est compréhensible que si l'on comprend les mécanismes sous-jacents d'asyncio.
Le piège est que la syntaxe async
/await
donne l'illusion que l'on écrit du code synchrone classique. En réalité, on manipule un système complexe de coroutines, d'event loops, et de communication inter-tâches. Cette illusion pousse les développeurs à appliquer des patterns synchrones à du code asynchrone, créant des bugs subtils.
Mais il y a pire : l'écosystème Python repose massivement sur des bibliothèques tierces, dont certaines sont mal documentées ou de provenance douteuse. Ces bibliothèques peuvent faire des appels bloquants au sein d'un système asynchrone sans que le développeur le sache. Python ne fournit aucun mécanisme de détection ni de protection contre ces sabotages involontaires.
Le scénario type est révélateur : vous intégrez une bibliothèque apparemment innocente il y a 6 mois. En développement, avec un ou deux utilisateurs simultanés, tout fonctionne correctement. Les tests passent, les appels bloquants ne durent pas assez longtemps pour être visibles. Le service passe en production. Quelques mois plus tard, le trafic augmente et vous vous retrouvez avec 5000 utilisateurs concurrents. Progressivement, le service fonctionne très mal : timeouts, lenteurs inexpliquées, blocages mystérieux.
Après des heures de debug, vous découvrez qu'une bibliothèque fait une requête DNS bloquante en douce, ou utilise un appel système synchrone non documenté. Cette unique bibliothèque mal conçue suffit à paralyser tout votre système asynchrone, car elle bloque l'event loop principal. Et le pire, c'est que vous ne le savez pas - le code semble parfaitement asynchrone en surface.
Asyncio illustre un paradoxe fascinant de l'évolution des langages de programmation. Pour rester pertinent face à de nouveaux besoins, Python a dû s'enrichir de fonctionnalités sophistiquées. Mais chaque ajout complexifie le langage et s'éloigne de la philosophie originale de simplicité.
Cette évolution n'est pas unique à Python. JavaScript a connu la même trajectoire avec l'ajout de async
/await
par-dessus les Promises et les callbacks. Mais JavaScript a réussi son implémentation en forçant ce mode : une fois async
/await
adopté, l'écosystème entier s'est aligné sur ce modèle. Python, lui, doit cohabiter avec un écosystème mixte où code synchrone et asynchrone s'entremêlent dangereusement. Java a également ajouté les streams, les lambdas, et les Virtual Threads. Chaque langage de haut niveau fait face au même dilemme : rester simple et devenir obsolète, ou évoluer et perdre sa simplicité originale.
Cette complexification change fondamentalement la nature du langage. Python n'est plus le langage simple des années 1990. C'est devenu un écosystème riche et complexe qui demande une expertise approfondie pour être maîtrisé.
Asyncio représente à la fois le meilleur et le pire de l'évolution de Python. Le meilleur parce qu'il permet à Python de rester pertinent dans un monde où les performances asynchrones sont reconnues et adoptées. Le pire parce qu'il trahit la simplicité originale du langage.
Cette tension n'est pas près de se résoudre. Python continuera d'évoluer pour répondre aux besoins changeants des développeurs. Chaque nouvelle fonctionnalité ajoutera sa propre complexité. Le défi pour la communauté Python sera de maintenir un équilibre entre puissance et simplicité.
Pour les développeurs, la leçon est claire : maîtriser Python moderne demande d'aller au-delà de la syntaxe de surface. Il faut comprendre les mécanismes sous-jacents, accepter la complexité cachée, et développer l'expertise nécessaire pour naviguer dans cet écosystème sophistiqué.
Asyncio n'est plus l'exception dans Python moderne : c'est un exemple représentatif de la direction que prend le langage. Un langage qui garde une syntaxe accessible en surface, mais qui cache une profondeur technique considérable. Un iceberg dont la beauté de la partie émergée ne doit pas faire oublier les dangers de la partie immergée.
La leçon est brutale mais claire : utilisez Python pour ce pourquoi il a été conçu. Prototypage rapide, apprentissage pour les enfants, calculs scientifiques ponctuels, automatisation de scripts. Mais gardez-le loin des environnements de production critiques.
Python moderne a trahi sa mission originale de simplicité. Il est devenu un piège pour les développeurs : facile à apprendre, difficile à maîtriser, dangereux à déployer. Les performances sont lamentables, la complexité cachée est traître, et l'écosystème de production est fragile.
Pour la production, préférez des langages conçus pour : Go pour la simplicité ET de relatives performances, C, C++ ou Rust pour les performances et la maîtrise, ou même Java qui assume sa complexité plutôt que de la cacher.