Parler async/await
et ce asyncio
n'est pas la même chose. La première est une construction fondamentale de bas niveau (coroutines) tandis que la dernière est une bibliothèque utilisant ces constructions. À l'inverse, il n'y a pas de réponse ultime unique.
Ce qui suit est une description générale de la façon async/await
et le asyncio
travail des bibliothèques -comme. Autrement dit, il peut y avoir d'autres astuces en plus (il y en a ...) mais elles sont sans importance à moins que vous ne les construisiez vous-même. La différence devrait être négligeable sauf si vous en savez déjà assez pour ne pas avoir à poser une telle question.
1. Coroutines versus sous-programmes dans une coquille de noix
Juste comme sous-programmes (fonctions, procédures, ...), les coroutines (générateurs, ...) sont une abstraction de pile d'appels et de pointeur d'instruction: il y a une pile de morceaux de code en cours d'exécution, et chacun est à une instruction spécifique.
La distinction entre def
contre async def
est simplement pour plus de clarté. La différence réelle est return
par rapport àyield
. À partir de là, await
ou yield from
faites la différence entre les appels individuels et des piles entières.
1.1. Sous-programmes
Un sous-programme représente un nouveau niveau de pile pour contenir les variables locales, et un seul parcours de ses instructions pour atteindre une fin. Considérez un sous-programme comme celui-ci:
def subfoo(bar):
qux = 3
return qux * bar
Lorsque vous l'exécutez, cela signifie
- allouer de l'espace de pile pour
bar
etqux
- exécuter récursivement la première instruction et passer à l'instruction suivante
- une fois à la
return
, poussez sa valeur vers la pile appelante
- effacez la pile (1.) et le pointeur d'instructions (2.)
Notamment, 4. signifie qu'un sous-programme démarre toujours au même état. Tout ce qui est exclusif à la fonction elle-même est perdu à la fin. Une fonction ne peut pas être reprise, même s'il y a des instructions après return
.
root -\
: \- subfoo --\
:/--<---return --/
|
V
1.2. Les coroutines comme sous-programmes persistants
Une coroutine est comme un sous-programme, mais peut sortir sans détruire son état. Considérez une coroutine comme celle-ci:
def cofoo(bar):
qux = yield bar # yield marks a break point
return qux
Lorsque vous l'exécutez, cela signifie
- allouer de l'espace de pile pour
bar
etqux
- exécuter récursivement la première instruction et passer à l'instruction suivante
- une fois à a
yield
, poussez sa valeur vers la pile appelante mais stockez la pile et le pointeur d'instruction
- une fois l'appel
yield
, restaurez le pointeur de pile et d'instruction et poussez les arguments versqux
- une fois à a
return
, poussez sa valeur vers la pile appelante
- effacez la pile (1.) et le pointeur d'instructions (2.)
Notez l'ajout de 2.1 et 2.2 - une coroutine peut être suspendue et reprise à des points prédéfinis. Ceci est similaire à la façon dont un sous-programme est suspendu lors de l'appel d'un autre sous-programme. La différence est que la coroutine active n'est pas strictement liée à sa pile appelante. Au lieu de cela, une coroutine suspendue fait partie d'une pile séparée et isolée.
root -\
: \- cofoo --\
:/--<+--yield --/
| :
V :
Cela signifie que les coroutines suspendues peuvent être librement stockées ou déplacées entre les piles. Toute pile d'appels ayant accès à une coroutine peut décider de la reprendre.
1.3. Traverser la pile d'appels
Jusqu'à présent, notre coroutine descend uniquement dans la pile d'appels avec yield
. Un sous-programme peut descendre et remonter la pile d'appels avec return
et ()
. Pour être complet, les coroutines ont également besoin d'un mécanisme pour remonter la pile d'appels. Considérez une coroutine comme celle-ci:
def wrap():
yield 'before'
yield from cofoo()
yield 'after'
Lorsque vous l'exécutez, cela signifie qu'il alloue toujours le pointeur de pile et d'instruction comme un sous-programme. Quand il se suspend, c'est comme stocker un sous-programme.
Cependant, yield from
fait les deux . Il suspend la pile et le pointeur d'instructions wrap
et s'exécute cofoo
. Notez que wrap
reste suspendu jusqu'à ce qu'il se cofoo
termine complètement. Chaque fois que cofoo
suspend ou quelque chose est envoyé, cofoo
est directement connecté à la pile appelante.
1.4. Coroutines tout en bas
Comme établi, yield from
permet de connecter deux oscilloscopes sur un autre intermédiaire. Lorsqu'il est appliqué de manière récursive, cela signifie que le haut de la pile peut être connecté au bas de la pile.
root -\
: \-> coro_a -yield-from-> coro_b --\
:/ <-+------------------------yield ---/
| :
:\ --+-- coro_a.send----------yield ---\
: coro_b <-/
Notez cela root
et coro_b
ne vous connaissez pas. Cela rend les coroutines beaucoup plus propres que les callbacks: les coroutines sont toujours construites sur une relation 1: 1 comme les sous-programmes. Les coroutines suspendent et reprennent l'intégralité de leur pile d'exécution existante jusqu'à un point d'appel normal.
Notamment, root
pourrait avoir un nombre arbitraire de coroutines à reprendre. Pourtant, il ne peut jamais en reprendre plus d'un à la fois. Les coroutines de même racine sont concurrentes mais pas parallèles!
1.5. Python async
etawait
L'explication a jusqu'à présent utilisé explicitement le vocabulaire yield
et les yield from
générateurs - la fonctionnalité sous-jacente est la même. La nouvelle syntaxe Python3.5 async
et await
existe principalement pour plus de clarté.
def foo(): # subroutine?
return None
def foo(): # coroutine?
yield from foofoo() # generator? coroutine?
async def foo(): # coroutine!
await foofoo() # coroutine!
return None
Les instructions async for
et async with
sont nécessaires car vous briseriez la yield from/await
chaîne avec les instructions for
et nues with
.
2. Anatomie d'une simple boucle d'événements
En soi, une coroutine n'a aucun concept de céder le contrôle à une autre coroutine. Il ne peut céder le contrôle qu'à l'appelant au bas d'une pile de coroutine. Cet appelant peut alors passer à une autre coroutine et l'exécuter.
Ce nœud racine de plusieurs coroutines est généralement une boucle d'événement : en cas de suspension, une coroutine génère un événement sur lequel elle veut reprendre. À son tour, la boucle d'événements est capable d'attendre efficacement que ces événements se produisent. Cela lui permet de décider quelle coroutine exécuter ensuite, ou comment attendre avant de reprendre.
Une telle conception implique qu'il existe un ensemble d'événements prédéfinis que la boucle comprend. Plusieurs coroutines await
, jusqu'à ce que finalement un événement soit await
édité. Cet événement peut communiquer directement avec la boucle d'événements yield
en contrôlant.
loop -\
: \-> coroutine --await--> event --\
:/ <-+----------------------- yield --/
| :
| : # loop waits for event to happen
| :
:\ --+-- send(reply) -------- yield --\
: coroutine <--yield-- event <-/
La clé est que la suspension de coroutine permet à la boucle d'événements et aux événements de communiquer directement. La pile de coroutine intermédiaire ne nécessite pas aucune connaissance de la boucle qui l'exécute, ni du fonctionnement des événements.
2.1.1. Événements dans le temps
L'événement le plus simple à gérer atteint un point dans le temps. Il s'agit également d'un bloc fondamental de code threadé: un thread sleep
s à plusieurs reprises jusqu'à ce qu'une condition soit vraie. Cependant, un habituésleep
exécution bloque par elle-même - nous voulons que les autres coroutines ne soient pas bloquées. Au lieu de cela, nous voulons dire à la boucle d'événements quand elle doit reprendre la pile de coroutine actuelle.
2.1.2. Définition d'un événement
Un événement est simplement une valeur que nous pouvons identifier - que ce soit via une énumération, un type ou une autre identité. Nous pouvons définir cela avec une classe simple qui stocke notre temps cible. En plus de stocker les informations d'événement, nous pouvons autoriser await
une classe directement.
class AsyncSleep:
"""Event to sleep until a point in time"""
def __init__(self, until: float):
self.until = until
# used whenever someone ``await``s an instance of this Event
def __await__(self):
# yield this Event to the loop
yield self
def __repr__(self):
return '%s(until=%.1f)' % (self.__class__.__name__, self.until)
Cette classe ne stocke que l'événement - elle ne dit pas comment le gérer réellement.
La seule particularité est __await__
- c'est ce que recherche le await
mot - clé. En pratique, il s'agit d'un itérateur mais non disponible pour les machines d'itération régulières.
2.2.1. En attente d'un événement
Maintenant que nous avons un événement, comment réagissent les coroutines? Nous devrions pouvoir exprimer l'équivalent de sleep
par await
notre événement. Pour mieux voir ce qui se passe, nous attendons deux fois la moitié du temps:
import time
async def asleep(duration: float):
"""await that ``duration`` seconds pass"""
await AsyncSleep(time.time() + duration / 2)
await AsyncSleep(time.time() + duration / 2)
Nous pouvons directement instancier et exécuter cette coroutine. Similaire à un générateur, l'utilisation coroutine.send
exécute la coroutine jusqu'à ce qu'elle soit yield
un résultat.
coroutine = asleep(100)
while True:
print(coroutine.send(None))
time.sleep(0.1)
Cela nous donne deux AsyncSleep
événements et ensuite un StopIteration
lorsque la coroutine est terminée. Notez que le seul retard provient de time.sleep
la boucle! Chacun AsyncSleep
ne stocke qu'un décalage par rapport à l'heure actuelle.
2.2.2. Événement + sommeil
À ce stade, nous avons deux mécanismes distincts à notre disposition:
AsyncSleep
Événements pouvant être générés depuis l'intérieur d'une coroutine
time.sleep
qui peut attendre sans impacter les coroutines
Notamment, ces deux sont orthogonaux: ni l'un n'affecte ni ne déclenche l'autre. En conséquence, nous pouvons proposer notre propre stratégie pour sleep
faire face au retard d'un AsyncSleep
.
2.3. Une boucle événementielle naïve
Si nous avons plusieurs coroutines, chacune peut nous dire quand elle veut être réveillée. On peut alors attendre que le premier d'entre eux veuille être repris, puis celui d'après, et ainsi de suite. Notamment, à chaque point, nous ne nous soucions que de celui qui est le suivant .
Cela permet une planification simple:
- trier les coroutines en fonction de l'heure de réveil souhaitée
- choisissez le premier qui veut se réveiller
- attendez jusqu'à ce moment
- exécuter cette coroutine
- répéter à partir de 1.
Une implémentation triviale ne nécessite aucun concept avancé. A list
permet de trier les coroutines par date. L'attente est un habitué time.sleep
. L'exécution de coroutines fonctionne comme avant avec coroutine.send
.
def run(*coroutines):
"""Cooperatively run all ``coroutines`` until completion"""
# store wake-up-time and coroutines
waiting = [(0, coroutine) for coroutine in coroutines]
while waiting:
# 2. pick the first coroutine that wants to wake up
until, coroutine = waiting.pop(0)
# 3. wait until this point in time
time.sleep(max(0.0, until - time.time()))
# 4. run this coroutine
try:
command = coroutine.send(None)
except StopIteration:
continue
# 1. sort coroutines by their desired suspension
if isinstance(command, AsyncSleep):
waiting.append((command.until, coroutine))
waiting.sort(key=lambda item: item[0])
Bien entendu, cela peut être amélioré. Nous pouvons utiliser un tas pour la file d'attente ou une table de répartition pour les événements. Nous pourrions également récupérer les valeurs de retour duStopIteration
et les affecter à la coroutine. Cependant, le principe fondamental reste le même.
2.4. Attente coopérative
L' AsyncSleep
événement et la run
boucle d'événements sont une implémentation entièrement fonctionnelle des événements chronométrés.
async def sleepy(identifier: str = "coroutine", count=5):
for i in range(count):
print(identifier, 'step', i + 1, 'at %.2f' % time.time())
await asleep(0.1)
run(*(sleepy("coroutine %d" % j) for j in range(5)))
Cela commute en coopération entre chacune des cinq coroutines, en les suspendant chacune pendant 0,1 seconde. Même si la boucle d'événements est synchrone, elle exécute toujours le travail en 0,5 seconde au lieu de 2,5 secondes. Chaque coroutine détient un état et agit indépendamment.
3. Boucle d'événements d'E / S
Une boucle d'événements qui prend en charge sleep
convient à l' interrogation . Cependant, l'attente d'E / S sur un descripteur de fichier peut se faire plus efficacement: le système d'exploitation implémente les E / S et sait donc quels descripteurs sont prêts. Idéalement, une boucle d'événement devrait prendre en charge un événement explicite «prêt pour les E / S».
3.1. L' select
appel
Python a déjà une interface pour interroger le système d'exploitation pour les poignées d'E / S de lecture. Lorsqu'il est appelé avec des poignées pour lire ou écrire, il renvoie les poignées prêtes à lire ou à écrire:
readable, writeable, _ = select.select(rlist, wlist, xlist, timeout)
Par exemple, nous pouvons open
un fichier à écrire et attendre qu'il soit prêt:
write_target = open('/tmp/foo')
readable, writeable, _ = select.select([], [write_target], [])
Une fois les retours sélectionnés, writeable
contient notre fichier ouvert.
3.2. Événement d'E / S de base
Comme pour la AsyncSleep
demande, nous devons définir un événement pour les E / S. Avec la select
logique sous-jacente , l'événement doit faire référence à un objet lisible - disons un open
fichier. De plus, nous stockons la quantité de données à lire.
class AsyncRead:
def __init__(self, file, amount=1):
self.file = file
self.amount = amount
self._buffer = ''
def __await__(self):
while len(self._buffer) < self.amount:
yield self
# we only get here if ``read`` should not block
self._buffer += self.file.read(1)
return self._buffer
def __repr__(self):
return '%s(file=%s, amount=%d, progress=%d)' % (
self.__class__.__name__, self.file, self.amount, len(self._buffer)
)
Comme pour la AsyncSleep
plupart, nous stockons simplement les données nécessaires à l'appel système sous-jacent. Cette fois, il __await__
est capable d'être repris plusieurs fois - jusqu'à ce que notre désir amount
ait été lu. De plus, nous obtenons return
le résultat d'E / S au lieu de simplement reprendre.
3.3. Augmenter une boucle d'événements avec des E / S de lecture
La base de notre boucle d'événements est toujours celle run
définie précédemment. Tout d'abord, nous devons suivre les demandes de lecture. Ce n'est plus un planning trié, nous mappons uniquement les requêtes de lecture aux coroutines.
# new
waiting_read = {} # type: Dict[file, coroutine]
Comme select.select
prend un paramètre de délai d'attente, nous pouvons l'utiliser à la place de time.sleep
.
# old
time.sleep(max(0.0, until - time.time()))
# new
readable, _, _ = select.select(list(reads), [], [])
Cela nous donne tous les fichiers lisibles - s'il y en a, nous exécutons la coroutine correspondante. S'il n'y en a pas, nous avons attendu assez longtemps pour que notre coroutine actuelle s'exécute.
# new - reschedule waiting coroutine, run readable coroutine
if readable:
waiting.append((until, coroutine))
waiting.sort()
coroutine = waiting_read[readable[0]]
Enfin, nous devons réellement écouter les demandes de lecture.
# new
if isinstance(command, AsyncSleep):
...
elif isinstance(command, AsyncRead):
...
3.4. Mettre ensemble
Ce qui précède était un peu une simplification. Nous devons faire quelques changements pour ne pas affamer les coroutines endormies si nous pouvons toujours lire. Nous devons gérer n'avoir rien à lire ou rien à attendre. Cependant, le résultat final s'inscrit toujours dans 30 LOC.
def run(*coroutines):
"""Cooperatively run all ``coroutines`` until completion"""
waiting_read = {} # type: Dict[file, coroutine]
waiting = [(0, coroutine) for coroutine in coroutines]
while waiting or waiting_read:
# 2. wait until the next coroutine may run or read ...
try:
until, coroutine = waiting.pop(0)
except IndexError:
until, coroutine = float('inf'), None
readable, _, _ = select.select(list(waiting_read), [], [])
else:
readable, _, _ = select.select(list(waiting_read), [], [], max(0.0, until - time.time()))
# ... and select the appropriate one
if readable and time.time() < until:
if until and coroutine:
waiting.append((until, coroutine))
waiting.sort()
coroutine = waiting_read.pop(readable[0])
# 3. run this coroutine
try:
command = coroutine.send(None)
except StopIteration:
continue
# 1. sort coroutines by their desired suspension ...
if isinstance(command, AsyncSleep):
waiting.append((command.until, coroutine))
waiting.sort(key=lambda item: item[0])
# ... or register reads
elif isinstance(command, AsyncRead):
waiting_read[command.file] = coroutine
3.5. E / S coopératives
Les implémentations AsyncSleep
, AsyncRead
et run
sont désormais entièrement fonctionnelles pour dormir et / ou lire. Comme pour sleepy
, nous pouvons définir un assistant pour tester la lecture:
async def ready(path, amount=1024*32):
print('read', path, 'at', '%d' % time.time())
with open(path, 'rb') as file:
result = return await AsyncRead(file, amount)
print('done', path, 'at', '%d' % time.time())
print('got', len(result), 'B')
run(sleepy('background', 5), ready('/dev/urandom'))
En exécutant cela, nous pouvons voir que nos E / S sont entrelacées avec la tâche en attente:
id background round 1
read /dev/urandom at 1530721148
id background round 2
id background round 3
id background round 4
id background round 5
done /dev/urandom at 1530721148
got 1024 B
4. E / S non bloquantes
Alors que les E / S sur les fichiers font passer le concept, cela ne convient pas vraiment à une bibliothèque comme asyncio
: l' select
appel revient toujours pour les fichiers , et les deux open
et read
peuvent bloquer indéfiniment . Cela bloque toutes les coroutines d'une boucle d'événements - ce qui est mauvais. Les bibliothèques comme l' aiofiles
utilisation de threads et la synchronisation pour simuler des E / S et des événements non bloquants sur fichier.
Cependant, les sockets permettent des E / S non bloquantes - et leur latence inhérente la rend beaucoup plus critique. Lorsqu'il est utilisé dans une boucle d'événements, l'attente de données et la nouvelle tentative peuvent être encapsulées sans rien bloquer.
4.1. Événement d'E / S non bloquant
Semblable à notre AsyncRead
, nous pouvons définir un événement suspend-and-read pour les sockets. Au lieu de prendre un fichier, nous prenons une socket - qui doit être non bloquante. En outre, nos __await__
utilisations socket.recv
au lieu de file.read
.
class AsyncRecv:
def __init__(self, connection, amount=1, read_buffer=1024):
assert not connection.getblocking(), 'connection must be non-blocking for async recv'
self.connection = connection
self.amount = amount
self.read_buffer = read_buffer
self._buffer = b''
def __await__(self):
while len(self._buffer) < self.amount:
try:
self._buffer += self.connection.recv(self.read_buffer)
except BlockingIOError:
yield self
return self._buffer
def __repr__(self):
return '%s(file=%s, amount=%d, progress=%d)' % (
self.__class__.__name__, self.connection, self.amount, len(self._buffer)
)
Contrairement à AsyncRead
, __await__
effectue des E / S vraiment non bloquantes. Lorsque les données sont disponibles, elles sont toujours lues. Lorsqu'aucune donnée n'est disponible, il est toujours suspendu. Cela signifie que la boucle d'événements n'est bloquée que pendant que nous effectuons un travail utile.
4.2. Débloquer la boucle d'événements
En ce qui concerne la boucle d'événements, rien ne change beaucoup. L'événement à écouter est toujours le même que pour les fichiers - un descripteur de fichier marqué prêt par select
.
# old
elif isinstance(command, AsyncRead):
waiting_read[command.file] = coroutine
# new
elif isinstance(command, AsyncRead):
waiting_read[command.file] = coroutine
elif isinstance(command, AsyncRecv):
waiting_read[command.connection] = coroutine
À ce stade, il devrait être évident que AsyncRead
et AsyncRecv
sont le même genre d'événement. Nous pourrions facilement les refactoriser en un seul événement avec un composant d'E / S échangeable. En effet, la boucle d'événements, les coroutines et les événements séparent proprement un planificateur, un code intermédiaire arbitraire et les E / S réelles.
4.3. Le côté laid des E / S non bloquantes
En principe, ce que vous devez faire à ce stade est de reproduire la logique de read
as a recv
for AsyncRecv
. Cependant, c'est beaucoup plus laid maintenant - vous devez gérer les retours anticipés lorsque les fonctions se bloquent à l'intérieur du noyau, mais vous cèdent le contrôle. Par exemple, l'ouverture d'une connexion par rapport à l'ouverture d'un fichier est beaucoup plus longue:
# file
file = open(path, 'rb')
# non-blocking socket
connection = socket.socket()
connection.setblocking(False)
# open without blocking - retry on failure
try:
connection.connect((url, port))
except BlockingIOError:
pass
Pour faire court, il ne reste que quelques dizaines de lignes de gestion des exceptions. Les événements et la boucle d'événements fonctionnent déjà à ce stade.
id background round 1
read localhost:25000 at 1530783569
read /dev/urandom at 1530783569
done localhost:25000 at 1530783569 got 32768 B
id background round 2
id background round 3
id background round 4
done /dev/urandom at 1530783569 got 4096 B
id background round 5
Addenda
Exemple de code sur github
BaseEventLoop
est implémenté: github.com/python/cpython/blob/…