8.1. Concurrence coopérative

Le modèle d’ordonnancement d’asyncio est coopératif, et non préemptif. Cette distinction est le modèle mental le plus important sur lequel s’appuie le reste de cette section, il vaut donc la peine de bien la cerner avant que le moindre code n’apparaisse.

8.1.1. Préemptif contre coopératif

Un ordonnanceur préemptif — le genre qu’utilise un système d’exploitation de bureau pour faire tourner plusieurs programmes à la fois — peut suspendre à tout moment le morceau de code en cours d’exécution et basculer vers un autre. Le code en cours d’exécution n’a rien de particulier à faire ; c’est l’ordonnanceur qui l’interrompt. Cela rend l’ordonnancement préemptif très flexible (aucun morceau de code ne peut affamer les autres en étant lent), mais cela signifie aussi que toute variable partagée doit être protégée avec soin, car le basculement peut survenir n’importe où — même au beau milieu de l’écriture d’une valeur, ou en plein milieu de la lecture d’une liste.

Un ordonnanceur coopératif ne peut basculer entre des morceaux de code qu’aux points où le morceau en cours d’exécution rend explicitement le contrôle. Avec asyncio, ces points sont chaque await et chaque appel à une coroutine qui cède le contrôle en interne (le plus souvent asyncio.sleep()). Entre deux awaits, la coroutine en cours d’exécution dispose du CPU pour elle seule.

Deux conséquences en découlent :

  • Une coroutine qui n’attend jamais (await) n’est jamais suspendue. Si une coroutine reste dans une boucle serrée sans aucun await à l’intérieur, elle monopolise l’ordonnanceur et rien d’autre ne progresse. La solution consiste à placer un await asyncio.sleep_ms(0) (ou un autre appel d’attente) à un point judicieux de la boucle.

  • L’état partagé est sûr entre les awaits. Deux coroutines ne peuvent pas s’entrelacer au beau milieu d’une opération qui ne contient aucun await. Le type de corruption qui survient lorsque la préemption tombe au milieu d’une mise à jour en plusieurs étapes — un morceau de code lisant une valeur pendant qu’un autre est en train de la modifier — ne peut tout simplement pas se produire ici. Une coordination entre coroutines reste nécessaire lorsque plusieurs d’entre elles doivent partager une ressource à travers des awaits, mais le problème d’entrelacement en plein milieu d’une ligne ne s’applique pas.

8.1.2. Les trois couches

Tout script asyncio est bâti à partir des trois mêmes couches. Les deux pages suivantes les détaillent ; voici les étiquettes à garder à l’esprit en les lisant.

  • Les coroutines — des fonctions déclarées avec async def, chacune étant une unité de travail autonome qui attend (await) là où il convient. La Vue d’ensemble de Python a introduit les mots-clés async/await ; dans asyncio, c’est par leur intermédiaire qu’une coroutine cède le contrôle à l’ordonnanceur.

  • Les tâches — un emballage que asyncio.create_task() place autour d’une coroutine pour l’ordonnancer en parallèle de la coroutine courante. L’application crée typiquement une poignée de tâches pour les travaux de longue durée (la boucle de capture, le client réseau, le lecteur UART, …).

  • La boucle d’événements — le moteur sous-jacent qui garde la trace des coroutines en attente et de celles prêtes à s’exécuter, basculant entre les tâches à chaque await. L’application n’écrit pas la boucle ; elle remet une coroutine de premier niveau à asyncio.run() et la boucle pilote tout à partir de là.

Lorsque l’application est décrite ainsi — comme un petit ensemble de coroutines composées par une boucle d’événements — la concurrence devient une propriété de la forme du programme, et non quelque chose que l’application doit gérer étape par étape.