Aller au contenu principal

L'asynchrone

Ce que ce chapitre apporte

  • Distinguer le multitâche préemptif du multitâche coopératif, et dire qui décide de l'interruption dans chacun.
  • Décrire une boucle d'événements et ce qu'elle fait d'une tâche qui attend.
  • Lire async et await, et désigner les points où un programme asynchrone peut rendre la main.
  • Expliquer pourquoi rien ne s'interrompt entre deux points d'attente, et dans quel cas un verrou reste nécessaire.
  • Reconnaître les deux erreurs qui reviennent toujours : le await oublié et l'appel bloquant.

Les trois chapitres précédents ont supposé une chose sans jamais la discuter : que le système peut interrompre un fil n'importe où, y compris entre la lecture d'un stock et son écriture. Tout le reste en découle, la condition de course, le verrou, l'ordre d'acquisition. Ce chapitre change cette supposition. Dans le modèle qui suit, un traitement n'est interrompu qu'aux endroits où son rédacteur a écrit qu'il acceptait de l'être. La conséquence est considérable : la plupart des verrous du chapitre sur le verrou n'ont plus lieu d'être, et cinquante équipements réseau s'interrogent avec un seul fil d'exécution.

Cinquante équipements à interroger

Un outil de supervision relève l'état de cinquante équipements d'un réseau industriel : commutateurs, automates, passerelles. Chaque interrogation demande d'envoyer une requête et d'attendre la réponse, soit deux cents millisecondes, dont le programme ne passe à peu près rien à calculer. Il attend.

En séquentiel, cinquante fois deux cents millisecondes font dix secondes, pour un travail effectif de quelques millisecondes. Le réflexe est alors de lancer cinquante fils d'exécution. Il fonctionne, et il coûte.

main.py
Sortie
>_ Prêt à exécuter…

Les chiffres sont des ordres de grandeur, et la réservation d'un fil n'est pas de la mémoire réellement consommée. Le rapport, lui, est le bon : environ deux mille sept cents. À cela s'ajoute le coût des changements de contexte, que le système opère cinquante fois pour des traitements qui, pour l'essentiel, ne font rien.

Le constat qui commande tout le chapitre : ces cinquante traitements ne se disputent pas le processeur, ils attendent le réseau. Un seul fil suffirait, à condition qu'il sache passer d'une attente à l'autre.

Rendre la main plutôt que se la faire prendre

Voici le programme du stock, tel que les chapitres précédents l'ont traité. Deux relevés à comptabiliser, trois instructions chacun, et le système qui interrompt où bon lui semble.

2 fils d'exécutionavancer l'un ou l'autre, dans l'ordre voulu
mémoire partagéereleves = 0

fil A

registre vide
  1. lire releves
  2. ajouter +1
  3. écrire releves

fil B

registre vide
  1. lire releves
  2. ajouter +1
  3. écrire releves

Avancer un fil, puis l'autre, dans l'ordre voulu. Un fil peut aussi être avancé plusieurs fois de suite : c'est ce que fait l'ordonnanceur quand il ne l'interrompt pas.

Couper le premier fil entre sa lecture et son écriture en avançant l'autre, puis demander le compte des entrelacements et relever combien laissent le compteur à 1.

Vingt entrelacements, dix-huit laissent le compteur à 1 au lieu de 2. C'est le modèle préemptif : l'interruption est décidée par le système, à tout moment, sans que le programme en sache rien.

Le modèle coopératif inverse la décision. Un seul fil existe, et il exécute les tâches l'une après l'autre ; une tâche garde le processeur jusqu'à ce qu'elle rende la main d'elle-même, ce qu'elle fait aux endroits marqués, et nulle part ailleurs. Dans la figure ci-dessous, le verrou nommé main représente cette possession : il n'est écrit dans aucun programme réel, il est tenu par la tâche qui s'exécute, et libérer main est le point où la tâche rend la main.

2 fils d'exécutionavancer l'un ou l'autre, dans l'ordre voulu
mémoire partagéereleves = 0main : libre

fil tache-A

registre vide
  1. verrouiller main
  2. lire releves
  3. ajouter +1
  4. écrire releves
  5. libérer main

fil tache-B

registre vide
  1. verrouiller main
  2. lire releves
  3. ajouter +1
  4. écrire releves
  5. libérer main

Avancer un fil, puis l'autre, dans l'ordre voulu. Un fil peut aussi être avancé plusieurs fois de suite : c'est ce que fait l'ordonnanceur quand il ne l'interrompt pas.

Essayer de couper la première tâche entre sa lecture et son écriture, comme sur la figure précédente. Demander ensuite le compte des entrelacements et comparer les deux résultats possibles.

Deux entrelacements, un seul résultat, et aucun verrou écrit par le programmeur : le seul qui apparaisse est la main elle-même, que la tâche ne rend qu'à la fin de son traitement. La coupure entre la lecture et l'écriture est devenue impossible à provoquer, non pas improbable.

Définition

Une boucle d'événements est un fil unique qui tient une liste de tâches prêtes et les exécute l'une après l'autre. Une tâche rend la main à la boucle en signalant ce qu'elle attend ; la boucle la met de côté, donne la main à une autre tâche prête, et ne la reprendra que lorsque l'attente sera satisfaite.

Le multitâche est dit coopératif parce qu'il repose entièrement sur ce geste : une tâche qui ne rend jamais la main garde le fil pour elle seule, et rien ne peut l'en déloger.

async et await

Python marque les deux choses d'un mot chacune. Une fonction déclarée par async def est une coroutine : un traitement qui a le droit de rendre la main. L'opérateur await marque l'endroit où il la rend, et il indique ce qu'il attend.

Le programme ci-dessous s'exécute dans cette page. Une boucle d'événements y tourne déjà, donc await s'écrit directement au premier niveau, sans l'envelopper dans une fonction. Hors du navigateur, dans un fichier lancé par python releve.py, c'est asyncio.run(principal()) qui démarre la boucle.

main.py
Sortie
>_ Prêt à exécuter…

Trois points de lecture. asyncio.sleep tient ici la place d'une requête réseau, et c'est la seule ligne où la tâche rend la main. asyncio.gather lance les cinquante coroutines et attend qu'elles soient toutes terminées. await, au premier niveau, confie le tout à la boucle déjà en place : c'est le seul endroit du programme où quoi que ce soit se met en attente.

La durée mesurée le dit mieux qu'un raisonnement : la durée totale n'est pas cinquante fois deux cents millisecondes, mais deux cents millisecondes, le temps de la plus longue attente. Pendant que la première tâche attend sa réponse, les quarante-neuf autres envoient la leur.

3 fils d'exécutionavancer l'un ou l'autre, dans l'ordre voulu
mémoire partagéerepondus = 0main : libre

fil equipement-1

registre vide
  1. verrouiller main
  2. afficher « requête vers 10.0.0.1 »
  3. libérer main
  4. verrouiller main
  5. afficher « réponse de 10.0.0.1 »
  6. lire repondus
  7. ajouter +1
  8. écrire repondus
  9. libérer main

fil equipement-2

registre vide
  1. verrouiller main
  2. afficher « requête vers 10.0.0.2 »
  3. libérer main
  4. verrouiller main
  5. afficher « réponse de 10.0.0.2 »
  6. lire repondus
  7. ajouter +1
  8. écrire repondus
  9. libérer main

fil equipement-3

registre vide
  1. verrouiller main
  2. afficher « requête vers 10.0.0.3 »
  3. libérer main
  4. verrouiller main
  5. afficher « réponse de 10.0.0.3 »
  6. lire repondus
  7. ajouter +1
  8. écrire repondus
  9. libérer main

Avancer un fil, puis l'autre, dans l'ordre voulu. Un fil peut aussi être avancé plusieurs fois de suite : c'est ce que fait l'ordonnanceur quand il ne l'interrompt pas.

Envoyer les trois requêtes avant de laisser arriver la moindre réponse, puis servir les réponses dans l'ordre inverse. Demander le compte des entrelacements et vérifier que le compteur finit toujours à 3.

Le libérer main du milieu est le await : la tâche a envoyé sa requête et rend la main pendant l'attente. Quatre-vingt-dix entrelacements sont possibles, ce qui veut dire que l'ordre des réponses est tout aussi imprévisible qu'avec des fils, et le compteur finit pourtant à 3 dans les quatre-vingt-dix cas. L'incrément du compteur, lui, n'a jamais été coupé.

À retenir

Dans un programme asynchrone, rien ne s'interrompt entre deux points d'attente, puisque la main ne se rend qu'aux endroits marqués par await. Un fragment sans await s'exécute d'un seul tenant, quoi qu'il arrive. La section critique qui en contient un, elle, reste coupable : c'est l'objet de la section suivante.

C'est la raison profonde du succès de ce modèle pour les architectures réseau : les serveurs y traitent des milliers de connexions simultanées, presque toutes en attente, et la plupart des verrous que le modèle préemptif imposerait deviennent inutiles.

Ce que le modèle ne supprime pas

La garantie porte sur les fragments sans await. Un await placé au milieu d'un traitement rouvre exactement la porte que le modèle avait fermée, et la course revient à l'identique. C'est le cas d'une tâche qui lit un compteur, interroge le réseau pour obtenir la valeur à ajouter, puis écrit.

2 fils d'exécutionavancer l'un ou l'autre, dans l'ordre voulu
mémoire partagéereleves = 0main : libre

fil tache-A

registre vide
  1. verrouiller main
  2. lire releves
  3. libérer main
  4. verrouiller main
  5. ajouter +1
  6. écrire releves
  7. libérer main

fil tache-B

registre vide
  1. verrouiller main
  2. lire releves
  3. libérer main
  4. verrouiller main
  5. ajouter +1
  6. écrire releves
  7. libérer main

Avancer un fil, puis l'autre, dans l'ordre voulu. Un fil peut aussi être avancé plusieurs fois de suite : c'est ce que fait l'ordonnanceur quand il ne l'interrompt pas.

Faire lire le compteur aux deux tâches avant de laisser l'une d'elles écrire, ce que le point d'attente autorise. Demander le compte des entrelacements et relever combien laissent le compteur à 1.

Six entrelacements, dont quatre laissent le compteur à 1. Le programme n'a qu'un seul fil d'exécution, et il perd quand même une mise à jour.

Le même défaut s'écrit en Python, et se lance ici. Cinquante tâches incrémentent le même compteur, avec un point d'attente entre la lecture et l'écriture.

main.py
Sortie
>_ Prêt à exécuter…

Un seul fil, cinquante mises à jour, et un compteur qui finit à 1. Déplacer l'attente hors de la mise à jour, ou entourer celle-ci d'un verrou, rétablit les cinquante relevés.

main.py
Sortie
>_ Prêt à exécuter…
Le piège

Un await au milieu d'une section critique est une interruption, exactement comme celle du modèle préemptif. La différence est qu'elle est visible : elle est écrite noir sur blanc dans le code, à un endroit que la relecture trouve. Un verrou reste nécessaire dans ce cas, et asyncio en fournit un, asyncio.Lock, qui s'emploie par async with.

La question à se poser devant toute donnée partagée devient donc très simple : y a-t-il un await entre le moment où elle est lue et celui où elle est réécrite ? Si non, aucun verrou. Si oui, un verrou, ou un découpage qui supprime l'attente.

Les deux erreurs qui reviennent toujours

La première est l'oubli du await. Une coroutine appelée sans await ne s'exécute pas : l'appel construit un objet et le rend, ce que le programme ci-dessous montre sans avoir besoin d'une boucle d'événements.

main.py
Sortie
>_ Prêt à exécuter…

Le résultat n'est pas un couple, c'est une coroutine qui n'a jamais tourné. Aucune erreur n'est levée sur le moment : l'erreur se manifeste plus loin, quand le programme essaie de lire le premier élément de ce qu'il croit être un couple, ou pire, quand il n'essaie rien du tout et qu'un relevé manque sans que personne ne s'en aperçoive. C'est l'erreur la plus fréquente du modèle, et elle se reconnaît à un message caractéristique, coroutine ... was never awaited.

La seconde erreur est l'appel bloquant. Une tâche qui exécute un traitement long sans jamais rendre la main fige la boucle entière, et donc toutes les autres tâches.

3 fils d'exécutionavancer l'un ou l'autre, dans l'ordre voulu
mémoire partagéerepondus = 0main : libre

fil tri-du-fichier

registre vide
  1. verrouiller main
  2. afficher « lecture du journal complet »
  3. afficher « tri des 200000 lignes »
  4. afficher « écriture du résultat »
  5. libérer main

fil equipement-1

registre vide
  1. verrouiller main
  2. afficher « réponse de 10.0.0.1 »
  3. lire repondus
  4. ajouter +1
  5. écrire repondus
  6. libérer main

fil equipement-2

registre vide
  1. verrouiller main
  2. afficher « réponse de 10.0.0.2 »
  3. lire repondus
  4. ajouter +1
  5. écrire repondus
  6. libérer main

Avancer un fil, puis l'autre, dans l'ordre voulu. Un fil peut aussi être avancé plusieurs fois de suite : c'est ce que fait l'ordonnanceur quand il ne l'interrompt pas.

Lancer la tâche de tri au premier pas, puis essayer de faire avancer les deux équipements pendant ses trois étapes. Compter les clics refusés avant que la première réponse ne soit traitée.

Les deux équipements attendent la fin des trois étapes du tri, alors que leurs réponses, elles, sont déjà arrivées. Un calcul long, une lecture de fichier avec la fonction ordinaire open, un appel à une bibliothèque qui n'a pas été écrite pour l'asynchrone : tous ont le même effet, et aucun ne provoque d'erreur. Le symptôme est un outil de supervision qui devient subitement lent, sans raison visible.

Le remède tient en une phrase : un traitement qui calcule longtemps ou qui appelle une bibliothèque bloquante se confie à un fil séparé, par asyncio.to_thread, et la boucle reste libre pour ce qu'elle sait faire.

import asyncio

async def principal():
    # Le tri s'exécute dans un fil séparé : la boucle continue de servir les réponses.
    lignes = await asyncio.to_thread(trier_le_journal, "journal.txt")
    print(len(lignes), "lignes triées")

asyncio.run(principal())

Ce programme-ci ne s'exécute pas dans cette page, mais pour une autre raison : asyncio.to_thread demande un fil d'exécution séparé, et l'interpréteur du navigateur ne sait pas en démarrer un. La fonction de tri, elle, resterait à écrire.

Exercices type

Reprendre la tâche qui lit un compteur, attend le réseau, puis écrit. Comment supprimer la course sans employer de verrou ?

En déplaçant le point d'attente hors de la section critique. La tâche attend d'abord la réponse du réseau, puis lit et écrit le compteur d'un seul tenant, sans await entre les deux.

2 fils d'exécutionavancer l'un ou l'autre, dans l'ordre voulu
mémoire partagéereleves = 0main : libre

fil tache-A

registre vide
  1. verrouiller main
  2. afficher « réponse de 10.0.0.1 reçue »
  3. libérer main
  4. verrouiller main
  5. lire releves
  6. ajouter +1
  7. écrire releves
  8. libérer main

fil tache-B

registre vide
  1. verrouiller main
  2. afficher « réponse de 10.0.0.2 reçue »
  3. libérer main
  4. verrouiller main
  5. lire releves
  6. ajouter +1
  7. écrire releves
  8. libérer main

Avancer un fil, puis l'autre, dans l'ordre voulu. Un fil peut aussi être avancé plusieurs fois de suite : c'est ce que fait l'ordonnanceur quand il ne l'interrompt pas.

Rejouer l'entrelacement qui perdait une mise à jour, en laissant les deux tâches attendre avant que l'une n'écrive. Demander le compte des entrelacements et vérifier qu'aucun ne laisse le compteur à 1.

Six entrelacements, et le compteur finit à 2 dans les six cas. L'attente est toujours là, l'entrelacement aussi, mais il ne tombe plus au milieu de la mise à jour.

C'est la manœuvre à préférer chaque fois qu'elle est possible : un await sorti de la section critique vaut mieux qu'un verrou ajouté autour, parce qu'il ne crée ni attente supplémentaire, ni ordre d'acquisition à respecter.

Un relevé sur cinquante concerne un équipement hors tension, qui ne répondra jamais. Que devient le programme asynchrone, et que devient la version à cinquante fils ?

Dans les deux cas, le programme attend indéfiniment cette réponse, et asyncio.gather ne rend la main que lorsque toutes les coroutines sont terminées : un seul équipement muet suffit à empêcher le relevé complet de se terminer.

La différence est dans le coût de l'attente et dans le remède. Côté asynchrone, la tâche muette ne consomme rien et le délai d'expiration s'écrit sur la coroutine elle-même, sans toucher au reste.

resultats = await asyncio.gather(
    *(asyncio.wait_for(relever(a), timeout=2) for a in adresses),
    return_exceptions=True,
)

wait_for interrompt la coroutine au bout de deux secondes, et return_exceptions fait rendre l'échec comme un résultat parmi les autres plutôt que d'abandonner tout le relevé. Le programme rend alors quarante-neuf états et un dépassement de délai, ce qui est exactement l'information attendue d'un outil de supervision.

Côté fils d'exécution, un fil bloqué sur une lecture réseau ne s'interrompt pas proprement : il faut avoir posé un délai sur la connexion elle-même au moment de sa création, faute de quoi le fil reste en attente jusqu'à ce que le système abandonne, parfois plusieurs minutes.

Le relevé est écrit dans un fichier de journal par chacune des cinquante tâches. Faut-il un verrou, comme au chapitre sur le verrou ?

Si l'écriture de la ligne se fait d'un seul tenant, sans await en son milieu, aucun verrou n'est nécessaire : la boucle ne peut pas interrompre la tâche entre la composition de la ligne et son écriture, donc deux lignes ne peuvent pas s'entremêler.

Deux réserves, et elles sont importantes.

La première : l'écriture doit être réellement dépourvue de point d'attente. Une écriture asynchrone, qui s'écrirait await sortie.write(ligne), rend la main, et deux tâches peuvent alors se croiser au milieu de leurs lignes respectives. Le verrou redevient nécessaire, sous la forme async with verrou.

La seconde : un fichier ouvert avec open et écrit sans attente bloque la boucle le temps de l'écriture. Pour quelques dizaines de lignes courtes, c'est négligeable ; pour un journal volumineux, l'écriture se confie à un fil séparé, et le raisonnement du chapitre sur le verrou redevient valable puisqu'il y a de nouveau plusieurs fils.

La règle de décision reste la même dans les trois cas : chercher s'il existe un point d'attente entre le premier accès à la donnée partagée et le dernier.

Vérification

Vérification rapideon peut se reprendre

1.Qui décide de l'interruption dans un multitâche coopératif ?

2.Que fait une boucle d'événements d'une tâche qui attend une réponse réseau ?

3.Dans un programme asynchrone, où un traitement peut-il être interrompu ?

4.Pourquoi cinquante tâches coûtent-elles bien moins que cinquante fils ?

5.Une tâche lit un compteur, exécute un await, puis écrit le compteur. Le résultat est-il sûr ?

6.Une coroutine est appelée sans await. Que vaut le résultat de l'appel ?

7.Une tâche trie deux cent mille lignes sans aucun await. Que se passe-t-il pour les autres tâches ?

8.Quand un verrou reste-t-il nécessaire dans un programme asynchrone ?

La méthode

  1. Reconnaître le type de charge avant de choisir un modèle : un traitement qui attend le réseau, le disque ou un équipement relève de l'asynchrone ; un traitement qui calcule vraiment relève des fils ou des processus.
  2. Marquer les attentes, et elles seules, par await : chaque await est un endroit où le programme accepte d'être interrompu, et cela se lit.
  3. Lancer les tâches ensemble par asyncio.gather plutôt que de les attendre une par une, sans quoi la durée totale redevient la somme des attentes.
  4. Chercher un await dans chaque section critique : s'il n'y en a pas, aucun verrou ; s'il y en a un, le déplacer hors de la section, et à défaut poser un asyncio.Lock employé par async with.
  5. Traquer les appels bloquants dans les coroutines, calculs longs, lectures de fichiers, bibliothèques non asynchrones, et les confier à asyncio.to_thread.
  6. Poser un délai d'expiration sur toute attente réseau, par asyncio.wait_for, et décider ce que le programme rend quand un équipement ne répond pas.
  7. Relire à la recherche des await manquants devant chaque appel de coroutine, et traiter le message coroutine ... was never awaited comme une erreur, jamais comme un avertissement.

Synthèse

  • Le multitâche préemptif laisse le système interrompre un fil n'importe où ; le multitâche coopératif confie cette décision à la tâche, qui rend la main aux points marqués.
  • Une boucle d'événements est un fil unique qui exécute les tâches prêtes, met de côté celles qui attendent, et les reprend quand leur attente est satisfaite.
  • async def déclare une coroutine, await marque le point où elle rend la main et ce qu'elle attend, asyncio.gather lance un ensemble de coroutines, asyncio.run démarre la boucle.
  • Rien ne s'interrompt au milieu d'un fragment sans await : la plupart des verrous du modèle préemptif disparaissent, et c'est ce qui explique le succès de ce modèle pour les architectures réseau.
  • Un await dans une section critique rouvre la course à l'identique : quatre entrelacements sur six perdent alors une mise à jour, avec un seul fil d'exécution.
  • Les deux erreurs qui reviennent sont le await oublié, qui rend un objet coroutine au lieu d'un résultat sans rien signaler, et l'appel bloquant, qui fige la boucle entière et toutes les autres tâches.
  • Cinquante attentes réseau coûtent cinquante tâches plutôt que cinquante fils, pour un rapport de mémoire de l'ordre de deux mille sept cents et une durée totale égale à la plus longue attente.

Ce chapitre referme le parcours. Il a commencé sur trois pièces, des registres, de la mémoire et un compteur ordinal, et il se termine sur une machine qui paraît en mener plusieurs de front sans jamais cesser de n'exécuter qu'une instruction à la fois. Deux prolongements naturels : les structures de données du parcours Python, pour ranger proprement les cinquante relevés qu'un programme concurrent rapporte, et le chapitre sur le transport et la résolution de noms, qui dit ce qui se passe réellement pendant les deux cents millisecondes d'une attente réseau, et pourquoi elle dure ce qu'elle dure.