Cette question est motivée par mon autre question: Comment attendre dans cdef?
Il y a des tonnes d'articles et de billets de blog sur le Web à propos de asyncio
, mais ils sont tous très superficiels. Je n'ai trouvé aucune information sur la manière dont asyncio
est réellement implémenté et sur ce qui rend les E/S asynchrones. J'essayais de lire le code source, mais ce sont des milliers de lignes qui ne sont pas du code C de la plus haute qualité, dont beaucoup traitent d'objets auxiliaires, mais le plus crucial est qu'il est difficile de se connecter entre la syntaxe Python et quel code C il traduirait.
La propre documentation d'Asycnio est encore moins utile. Il ne contient aucune information sur son fonctionnement, mais seulement quelques lignes directrices sur son utilisation, qui sont également parfois trompeuses/très mal écrites.
Je connais bien la mise en oeuvre de coroutines par Go et espérais un peu que Python faisait la même chose. Si tel était le cas, le code que j'ai trouvé dans le post ci-dessus aurait fonctionné. Comme ce n'est pas le cas, j'essaie maintenant de comprendre pourquoi. Ma meilleure estimation jusqu'à présent est la suivante, corrigez-moi s'il vous plaît où je me trompe:
async def foo(): ...
sont en fait interprétées comme des méthodes d'une classe héritant de coroutine
.async def
Est en fait divisé en plusieurs méthodes par des instructions await
, dans lesquelles l’objet sur lequel ces méthodes sont appelées est en mesure de suivre les progrès qu’il a réalisés jusqu’à présent dans l’exécution.await
).En d'autres termes, voici ma tentative de "désinsertion" d'une certaine syntaxe asyncio
en quelque chose de plus compréhensible:
async def coro(name):
print('before', name)
await asyncio.sleep()
print('after', name)
asyncio.gather(coro('first'), coro('second'))
# translated from async def coro(name)
class Coro(coroutine):
def before(self, name):
print('before', name)
def after(self, name):
print('after', name)
def __init__(self, name):
self.name = name
self.parts = self.before, self.after
self.pos = 0
def __call__():
self.parts[self.pos](self.name)
self.pos += 1
def done(self):
return self.pos == len(self.parts)
# translated from asyncio.gather()
class AsyncIOManager:
def gather(*coros):
while not every(c.done() for c in coros):
coro = random.choice(coros)
coro()
Si ma supposition s'avère correcte, j'ai un problème. Comment les entrées/sorties se produisent-elles dans ce scénario? Dans un fil séparé? Est-ce que l'interprète tout entier est suspendu et que les E/S se produisent en dehors de l'interprète? Que veut-on dire exactement par I/O? Si ma procédure python appelée C open()
et envoie à son tour une interruption au noyau en y cédant le contrôle, comment l’interprète Python en est-il informé? capable de continuer à exécuter un autre code, alors que le code du noyau effectue les E/S réelles et jusqu’à ce qu’il réveille la procédure Python qui avait envoyé l’interruption à l’origine? Comment Python interprète, en principe, peut-il être au courant?
Avant de répondre à cette question, nous devons comprendre quelques termes de base. Ne les passez pas si vous en connaissez déjà un.
Les générateurs sont des objets qui nous permettent de suspendre l'exécution d'une fonction python. Les générateurs choisis par l'utilisateur sont implémentés à l'aide du mot clé yield
. En créant une fonction normale contenant le mot clé yield
, nous transformons cette fonction en générateur:
>>> def test():
... yield 1
... yield 2
...
>>> gen = test()
>>> next(gen)
1
>>> next(gen)
2
>>> next(gen)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
Comme vous pouvez le constater, l'appel de next()
sur le générateur force l'interpréteur à charger la trame du test et à renvoyer la valeur yield
ed. Appelez à nouveau next()
, chargez à nouveau le cadre dans la pile d'interpréteur, puis continuez sur une autre valeur yield
.
À la troisième fois, next()
est appelée, notre générateur est terminé et StopIteration
est lancé.
Une caractéristique moins connue des générateurs est le fait que vous pouvez communiquer avec eux en utilisant deux méthodes: send()
et throw()
.
>>> def test():
... val = yield 1
... print(val)
... yield 2
... yield 3
...
>>> gen = test()
>>> next(gen)
1
>>> gen.send("abc")
abc
2
>>> gen.throw(Exception())
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 4, in test
Exception
Lors de l'appel de gen.send()
, la valeur est transmise en tant que valeur renvoyée par le mot clé yield
.
gen.throw()
permet par contre de lancer des exceptions à l'intérieur des générateurs, l'exception étant déclenchée au même endroit yield
a été appelé.
Le renvoi d'une valeur d'un générateur entraîne son insertion dans l'exception StopIteration
. Nous pourrons plus tard récupérer la valeur de l'exception et l'utiliser à nos besoins.
>>> def test():
... yield 1
... return "abc"
...
>>> gen = test()
>>> next(gen)
1
>>> try:
... next(gen)
... except StopIteration as exc:
... print(exc.value)
...
abc
yield from
Python 3.4 est venu avec l'ajout d'un nouveau mot clé: yield from
. Ce que ce mot clé nous permet de faire est de passer n'importe quel next()
, send()
et throw()
dans un générateur imbriqué le plus à l'intérieur. Si le générateur interne renvoie une valeur, il s'agit également de la valeur de retour de yield from
:
>>> def inner():
... print((yield 2))
... return 3
...
>>> def outer():
... yield 1
... val = yield from inner()
... print(val)
... yield 4
...
>>> gen = outer()
>>> next(gen)
1
>>> next(gen)
2
>>> gen.send("abc")
abc
3
4
Lors de l’introduction du nouveau mot-clé yield from
Dans Python 3.4, nous pouvions maintenant créer des générateurs à l’intérieur de générateurs qui, à l’instar d’un tunnel, transmettaient les données de la base la plus interne. ce qui a donné un nouveau sens aux générateurs - coroutines.
Coroutines sont des fonctions qui peuvent être arrêtées et reprises en cours d'exécution. En Python, ils sont définis avec le mot-clé async def
. Tout comme les générateurs, ils utilisent aussi leur propre forme de yield from
Qui est await
. Avant que async
et await
aient été introduits dans Python 3.5, nous avons créé des coroutines de la même manière que les générateurs ont été créés (avec yield from
Au lieu de await
).
async def inner():
return 1
async def outer():
await inner()
Comme tous les itérateurs ou générateurs qui implémentent la méthode __iter__()
, les coroutines implémentent __await__()
, ce qui leur permet de continuer chaque fois que await coro
Est appelé.
Il y a un joli diagramme de séquence à l'intérieur du documents Python que vous devriez vérifier.
En asyncio, outre les fonctions de coroutine, nous avons 2 objets importants: tâches et futures .
Les contrats à terme sont des objets pour lesquels la méthode __await__()
est implémentée. Leur travail consiste à conserver un certain état et un certain résultat. L'état peut être l'un des suivants:
fut.cancel()
fut.set_result()
, soit par un ensemble d'exceptions utilisant fut.set_exception()
Comme vous l'avez deviné, le résultat peut être soit un objet Python, qui sera renvoyé, soit une exception susceptible d'être déclenchée.
Une autre caractéristique importante == des objets future
est qu’ils contiennent une méthode appelée add_done_callback()
=. Cette méthode permet d’appeler des fonctions dès que la tâche est terminée - qu’elle déclenche une exception ou soit terminée.
Les objets de tâches sont des futurs spéciaux, qui enveloppent les coroutines et communiquent avec les coroutines les plus internes et les plus externes. Chaque fois qu'une coroutine await
devient un futur, celui-ci est renvoyé à la tâche (comme dans yield from
), Et la tâche le reçoit.
Ensuite, la tâche se lie au futur. Il le fait en appelant add_done_callback()
sur le futur. A partir de maintenant, si l'avenir se réalisait, soit en annulant, soit en transmettant une exception, soit en transmettant un objet Python à la suite, le rappel de la tâche sera appelé et il remontera jusqu'à l'existence.
La dernière question ardente à laquelle nous devons répondre est la suivante: comment le IO est-il implémenté?
Au fond de l'asyncio, nous avons une boucle d'événement. Une boucle d'événements de tâches. Le travail de la boucle d'événements consiste à appeler des tâches chaque fois qu'elles sont prêtes et à coordonner tous ces efforts sur une seule machine en fonctionnement.
La partie IO) de la boucle d'événements est construite sur une fonction cruciale appelée select
. Select est une fonction de blocage, implémentée par le système d’exploitation sous-jacent, qui permet d’attendre les données entrantes ou sortantes sur les sockets.
Lorsque vous essayez de recevoir ou d'envoyer des données via un socket via asyncio, ce qui se passe réellement ci-dessous, c'est que le socket est d'abord vérifié s'il contient des données pouvant être lues ou envoyées immédiatement. Si c'est .send()
tampon est plein ou si le tampon .recv()
est vide, le socket est enregistré dans la fonction select
(en l'ajoutant simplement à l'une des listes, rlist
pour recv
et wlist
pour send
) et la fonction appropriée await
est un objet future
nouvellement créé, lié à cette prise.
Lorsque toutes les tâches disponibles attendent des futurs, la boucle d'événement appelle select
et attend. Lorsque l'un des sockets contient des données entrantes ou que sa mémoire tampon send
est épuisée, asyncio recherche le futur objet lié à ce socket et le définit à done.
Maintenant toute la magie se produit. L’avenir est prêt, la tâche qui s’est ajoutée auparavant avec add_done_callback()
revient à la vie et appelle .send()
sur la coroutine qui reprend la coroutine la plus interne (à cause de la Chaîne await
) et vous lisez les données récemment reçues d’un tampon proche dans lequel elles ont été déversées.
Méthode encore une fois, en cas de recv()
:
select.select
Attend.future.set_result()
est appelé.add_done_callback()
est maintenant réveillée..send()
sur la coroutine qui va jusqu'au coroutine le plus interne et la réveille.En résumé, asyncio utilise les fonctionnalités du générateur, qui permettent de suspendre et de reprendre des fonctions. Il utilise les fonctionnalités yield from
Qui permettent le transfert de données du générateur le plus interne au plus externe. Il utilise tous ces éléments afin d’arrêter l’exécution de la fonction pendant l’attente de IO) (en utilisant la fonction OS select
).
Et le meilleur de tous? Pendant qu'une fonction est en pause, une autre peut être exécutée et entrelacée avec le tissu délicat, qui est asyncio.
Parler de async/await
et de 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 du fonctionnement des bibliothèques de type async/await
et asyncio
. C'est-à-dire qu'il peut y avoir d'autres astuces en plus (il y en a ...) mais elles sont sans importance si vous ne les construisez pas vous-même. La différence devrait être négligeable à moins que vous en sachiez déjà suffisamment pour ne pas avoir à poser une telle question.
Tout comme les sous-routines (fonctions, procédures, ...), les coroutines (generators, ...) sont une abstraction de la pile d’appel et du pointeur d’instruction: il existe une pile de codes en cours d’exécution, chacun se trouvant à une instruction spécifique.
La distinction entre def
et async def
est simplement pour plus de clarté. La différence réelle est de return
par rapport à yield
. À partir de là, await
ou yield from
prend la différence d'appels individuels en piles entières.
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érons un sous-programme comme celui-ci:
def subfoo(bar):
qux = 3
return qux * bar
Lorsque vous l'exécutez, cela signifie
bar
et qux
return
, envoie sa valeur à la pile appelante4. signifie notamment qu'un sous-programme commence 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 existe des instructions après return
.
root -\
: \- subfoo --\
:/--<---return --/
|
V
Une coroutine est comme un sous-programme, mais peut sortir sans détruire son état. Considérons une coroutine comme ceci:
def cofoo(bar):
qux = yield bar # yield marks a break point
return qux
Lorsque vous l'exécutez, cela signifie
bar
et qux
yield
, envoyez sa valeur à la pile appelante mais stockez la pile et le pointeur d’instructionyield
, restaurez la pile et le pointeur d'instruction et transmettez les arguments à qux
return
, envoie sa valeur à la pile appelanteNotez les ajouts 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 d'appels. 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 routines 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.
Jusqu'ici, notre coroutine ne met que dans la pile d'appels avec yield
. Un sous-programme peut descendre et plus la pile d'appels avec return
et ()
. Pour être complet, les routines ont également besoin d’un mécanisme permettant de remonter la pile d’appels. Considérons une coroutine comme ceci:
def wrap():
yield 'before'
yield from cofoo()
yield 'after'
Lorsque vous l'exécutez, cela signifie qu'il alloue toujours la pile et le pointeur d'instruction comme un sous-programme. Quand il est suspendu, cela revient toujours à stocker un sous-programme.
Cependant, yield from
fait les deux. Il suspend la pile et le pointeur d'instruction de wrap
et exécute cofoo
. Notez que wrap
reste suspendu jusqu'à ce que cofoo
se termine complètement. Chaque fois que cofoo
est suspendu ou que quelque chose est envoyé, cofoo
est directement connecté à la pile d'appel.
Comme établi, yield from
permet de connecter deux étendues sur une autre intermédiaire. 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 que root
et coro_b
ne se connaissent pas. Cela rend les coroutines beaucoup plus propres que les callbacks: des coroutines toujours construites sur une relation 1: 1 comme des sous-routines. Les routines suspendent et reprennent toute 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 reprendre plus d’un en même temps. Les coroutines de la même racine sont concurrentes mais pas parallèles!
async
et await
de PythonL'explication a jusqu'ici explicitement utilisé le vocabulaire des générateurs yield
et yield from
- les fonctionnalités sous-jacentes sont les mêmes. La nouvelle syntaxe Python3.5 async
et await
existe principalement pour des raisons 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 pour rompre la chaîne yield from/await
avec les instructions nues for
et with
.
En soi, une coroutine n'a aucun concept de céder le contrôle à un autre coroutine. Il ne peut céder le contrôle à l'appelant qu'au bas d'une pile de messages. Cet appelant peut ensuite basculer vers une autre coroutine et l'exécuter.
Ce nœud racine de plusieurs coroutines est généralement une boucle d’événement : en suspension, une coroutine génère un événement sur lequel il 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 ligne de commande 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
les unes aux autres, jusqu'à ce qu'un événement soit enfin édité await
. Cet événement peut communiquer directement avec la boucle d'événement à l'aide du contrôle yield
.
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 de aucune connaissance de la boucle qui l’exécute, ni du fonctionnement des événements.
L'événement le plus simple à gérer est d'atteindre un point dans le temps. Il s'agit également d'un bloc fondamental de code threadé: un thread répété sleep
s jusqu'à ce qu'une condition soit vraie. Cependant, un sleep
normal bloque l'exécution par lui-même - nous voulons que les autres routines ne soient pas bloquées. Au lieu de cela, nous voulons indiquer à la boucle d’événements quand elle doit reprendre la pile de coroutine actuelle.
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 heure cible. En plus de stocker les informations sur l'événement, nous pouvons autoriser directement await
une classe.
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 uniquement stores l'événement - il ne dit pas comment le gérer réellement.
La seule particularité est __await__
- c'est ce que recherche le mot-clé await
. En pratique, il s’agit d’un itérateur, mais il n’est pas disponible pour les machines d’itération régulières.
Maintenant que nous avons un événement, comment réagissent les coroutines? Nous devrions pouvoir exprimer l'équivalent de sleep
en await
dans 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. Semblable à un générateur, utiliser coroutine.send
lance la coroutine jusqu'à ce que yield
soit un résultat.
coroutine = asleep(100)
while True:
print(coroutine.send(None))
time.sleep(0.1)
Cela nous donne deux événements AsyncSleep
, puis un StopIteration
lorsque la coroutine est terminée. Notez que le seul délai est de time.sleep
dans la boucle! Chaque AsyncSleep
ne stocke qu’un décalage par rapport à l’heure actuelle.
À ce stade, nous avons deux des mécanismes distincts à notre disposition:
AsyncSleep
Evénements pouvant être générés depuis une coroutinetime.sleep
qui peut attendre sans impact sur les coroutinesNotamment, ces deux sont orthogonaux: ni l'un ni l'autre n'affecte ou ne déclenche l'autre. En conséquence, nous pouvons proposer notre propre stratégie consistant à sleep
pour faire face au retard d’un AsyncSleep
.
Si nous avons plusieurs coroutines, chacun peut nous dire quand il veut être réveillé. Nous pouvons alors attendre que le premier d’entre eux veuille être repris, puis le suivant, et ainsi de suite. Notamment, à chaque point, nous ne nous soucions que de celui qui est next.
Cela permet une planification simple:
Une implémentation triviale ne nécessite aucun concept avancé. Un list
permet de trier les coroutines par date. Attendre est un time.sleep
normal. 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 sûr, cela peut encore être amélioré. Nous pouvons utiliser un segment 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 à partir du StopIteration
et les affecter à la coroutine. Cependant, le principe fondamental reste le même.
L'événement AsyncSleep
et la boucle d'événement run
constituent une implémentation complète 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)))
Ceci commute de manière coopérative entre chacune des cinq stations secondaires, en les suspendant pendant 0,1 seconde. Même si la boucle d'événements est synchrone, elle exécute le travail en 0,5 seconde au lieu de 2,5 secondes. Chaque coroutine est titulaire d'un statut et agit indépendamment.
Une boucle d'événement prenant en charge sleep
convient à polling. Cependant, l'attente des E/S sur un descripteur de fichier peut être réalisée de manière plus efficace: 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".
select
Python dispose déjà d’une interface permettant d’interroger le système d’exploitation sur les descripteurs d’entrées/sorties en lecture. Lorsqu'il est appelé avec des handles pour lire ou écrire, il renvoie les handles ready pour 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 que select est retourné, writeable
contient notre fichier ouvert.
Semblable à la demande AsyncSleep
, nous devons définir un événement pour les E/S. Avec la logique sous-jacente select
, l'événement doit faire référence à un objet lisible - disons un fichier open
. De plus, nous stockons combien 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 avec AsyncSleep
, nous ne stockons généralement que les données requises pour l'appel système sous-jacent. Cette fois, __await__
peut être repris plusieurs fois - jusqu'à ce que notre amount
souhaité ait été lu. De plus, nous return
le résultat d’E/S au lieu de simplement reprendre.
La base de notre boucle d’événements reste le run
défini précédemment. Premièrement, nous devons suivre les demandes de lecture. Ce n'est plus un programme trié, nous mappons uniquement les demandes de lecture aux coroutines.
# new
waiting_read = {} # type: Dict[file, coroutine]
Puisque 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 lançons la coroutine correspondante. S'il n'y en a pas, nous avons attendu assez longtemps pour que notre coroutine actuelle fonctionne.
# 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):
...
Ce qui précède était un peu une simplification. Nous devons changer pour ne pas affamer les coroutines endormies si nous pouvons toujours lire. Nous devons nous occuper de n'avoir rien à lire ni rien à attendre. Cependant, le résultat final correspond toujours à 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
Les implémentations AsyncSleep
, AsyncRead
et run
sont maintenant 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 ceci, nous pouvons voir que notre I/O est entrelacée 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
Alors que les E/S sur les fichiers font passer le concept, ce n’est pas vraiment adapté à une bibliothèque telle que asyncio
: l’appel select
retourne toujours pour les fichiers , et les deux open
et read
peuvent bloquer indéfiniment . Cela bloque toutes les coroutines d'une boucle d'événement - ce qui est mauvais. Des bibliothèques comme aiofiles
utilisent des threads et la synchronisation pour simuler des E/S et des événements non bloquants dans un fichier.
Cependant, les sockets permettent des E/S non bloquantes - et leur latence inhérente les rend beaucoup plus critiques. Lorsqu'elles sont utilisées dans une boucle d'événement, l'attente de données et les nouvelles tentatives peuvent être encapsulées sans rien bloquer.
Semblable à notre AsyncRead
, nous pouvons définir un événement suspendre et lire pour les sockets. Au lieu de prendre un fichier, nous prenons un socket - qui doit être non bloquant. De plus, notre __await__
utilise 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__
exécute des E/S véritablement non bloquantes. Lorsque les données sont disponibles, il toujours lit. Lorsqu'aucune donnée n'est disponible, il toujours est suspendu. Cela signifie que la boucle d'événements n'est bloquée que lorsque nous effectuons un travail utile.
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é comme étant 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 du même type d’événement. Nous pourrions facilement les transformer en événement un avec un composant I/O échangeable. En effet, la boucle d’événements, les coroutines et les événements clairement séparés un planificateur, un code intermédiaire arbitraire et la périphérie réelle.
En principe, vous devez maintenant répliquer la logique de read
en tant que recv
pour AsyncRecv
. Cependant, c’est beaucoup plus moche à présent: vous devez gérer les premiers retours lorsque les fonctions se bloquent dans le noyau, mais vous céder le contrôle. Par exemple, ouvrir une connexion ou ouvrir un fichier est beaucoup plus long:
# 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
En résumé, il ne reste que quelques dizaines de lignes de traitement 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
Votre desugaring coro
est conceptuellement correct, mais légèrement incomplet.
await
ne suspend pas inconditionnellement, mais uniquement s'il rencontre un appel bloquant. Comment sait-il qu'un appel est bloqué? Ceci est décidé par le code attendu. Par exemple, une implémentation attendue de socket lue pourrait être déconseillée:
def read(sock, n):
# sock must be in non-blocking mode
try:
return sock.recv(n)
except EWOULDBLOCK:
event_loop.add_reader(sock.fileno, current_task())
return SUSPEND
En asyncio réel, le code équivalent modifie l'état d'un Future
au lieu de renvoyer des valeurs magiques, mais le concept est le même. Lorsqu'il est adapté à un objet de type générateur, le code ci-dessus peut être await
ed.
Du côté de l'appelant, lorsque votre coroutine contient:
data = await read(sock, 1024)
Il se sépare en quelque chose de proche de:
data = read(sock, 1024)
if data is SUSPEND:
return SUSPEND
self.pos += 1
self.parts[self.pos](...)
Les personnes familiarisées avec les générateurs ont tendance à décrire ce qui précède en termes de yield from
, Qui effectue automatiquement la suspension.
La chaîne de suspension continue jusqu'à la boucle d'événement, qui constate que la coroutine est suspendue, la supprime de l'ensemble exécutable et continue à exécuter des coroutines exécutables, le cas échéant. Si aucune coroutine n'est exécutable, la boucle attend dans select()
_ jusqu'à ce qu'un descripteur de fichier auquel une coroutine est intéressée soit prêt pour l'IO. (La boucle d'événement maintient un mappage de descripteur de fichier à coroutine.)
Dans l'exemple ci-dessus, une fois que select()
indique à la boucle d'événement que sock
est lisible, il sera rajouté coro
à l'ensemble exécutable afin qu'il continue à partir du point de suspension.
En d'autres termes:
Tout se passe dans le même fil par défaut.
La boucle d’événements est responsable de la planification des coroutines et de leur réveil lorsque tout ce qu’ils attendaient (en général un appel IO qui bloquerait normalement ou un délai d’expiration) est prêt.
Pour des informations sur les boucles d'événement conduisant à la coroutine, je recommande this talk de Dave Beazley, où il montre comment coder une boucle d'événement à partir de zéro devant un public en direct.
Tout se résume aux deux principaux défis auxquels asyncio s’attaque:
La réponse au premier point existe depuis longtemps et s'appelle un boucle de sélection . En python, il est implémenté dans le module de sélection .
La deuxième question est liée au concept de coroutine , c'est-à-dire des fonctions qui peuvent arrêter leur exécution et être restaurées ultérieurement. En python, les coroutines sont implémentées en utilisant générateurs et l'instruction rendement de . C'est ce qui se cache derrière la syntaxe asynchrone/wait .
Plus de ressources dans ce réponse .
EDIT: Répondez à votre commentaire sur les goroutines:
L'équivalent le plus proche d'une goroutine en asyncio n'est en réalité pas une coroutine mais une tâche (voir la différence entre documentation ). En python, une coroutine (ou un générateur) ne connaît rien aux concepts de boucle d'événement ou d'E/S. C’est simplement une fonction qui peut arrêter son exécution en utilisant yield
tout en conservant son état actuel, afin qu’elle puisse être restaurée ultérieurement. Le yield from
La syntaxe permet de les chaîner de manière transparente.
Maintenant, dans une tâche asyncienne, la coroutine tout en bas de la chaîne finit toujours par donner un futur . Ce futur bouillonne alors dans la boucle de l'événement et s'intègre dans la machinerie interne. Lorsque le futur est défini par un autre rappel interne, la boucle d'événement peut restaurer la tâche en renvoyant le futur dans la chaîne de coroutine.
EDIT: Répondant à certaines des questions de votre message:
Comment les entrées/sorties se produisent-elles dans ce scénario? Dans un fil séparé? Est-ce que l'interprète tout entier est suspendu et que les E/S se produisent en dehors de l'interprète?
Non, rien ne se passe dans un fil. Les E/S sont toujours gérées par la boucle d'événements, principalement par des descripteurs de fichiers. Cependant, l'enregistrement de ces descripteurs de fichier est généralement masqué par des coroutines de haut niveau, ce qui vous facilite la tâche.
Que veut-on dire exactement par I/O? Si ma procédure python appelée procédure C open ()) et qu'elle envoie à son tour une interruption au noyau en y cédant le contrôle, comment Python en est-il informé?) et est capable de continuer à exécuter un autre code, tandis que le code du noyau effectue les E/S réelles et jusqu’à ce qu’il réveille la procédure Python qui a envoyé l’interruption à l’origine? Comment peut-on Python interprète en principe, être au courant de ce qui se passe?
Une E/S est un appel bloquant. En asyncio, toutes les opérations d'E/S doivent passer par la boucle d'événement car, comme vous l'avez dit, la boucle d'événement n'a aucun moyen de savoir qu'un appel bloquant est exécuté dans un code synchrone. Cela signifie que vous n'êtes pas supposé utiliser un open
synchrone dans le contexte d'une coroutine. Utilisez plutôt une bibliothèque dédiée telle aiofiles , qui fournit une version asynchrone de open
.