web-dev-qa-db-fra.com

Comment fonctionne l'asyncio?

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:

  1. Les définitions de procédure de la forme async def foo(): ... sont en fait interprétées comme des méthodes d'une classe héritant de coroutine.
  2. Peut-être que 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.
  3. Si ce qui précède est vrai, l’exécution d’une coroutine revient essentiellement à appeler des méthodes d’objet coroutine par un gestionnaire global (boucle?).
  4. Le gestionnaire global est en quelque sorte (comment?) Au courant du moment où les opérations d’E/S sont effectuées par le code Python (uniquement?) Et peut choisir l’une des méthodes de coroutine en attente à exécuter une fois que la méthode en cours d’exécution a été rendue. control (appuyez sur l'instruction 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?

53
wvxvw

Comment fonctionne l'asyncio?

Avant de répondre à cette question, nous devons comprendre quelques termes de base. Ne les passez pas si vous en connaissez déjà un.

Générateurs

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é.

Communiquer avec un générateur

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é.

Renvoyer des valeurs de générateurs

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

Voici un nouveau mot clé: 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

Mettre tous ensemble

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 .

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:

  1. PENDING - future n'a pas de résultat ni d'exception.
  2. CANCELED - le futur a été annulé avec fut.cancel()
  3. FINISHED - le futur a été fini, soit par un jeu de résultats utilisant 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.

tâches

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.

Asyncio

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():

  1. select.select Attend.
  2. Une socket prête avec les données est renvoyée.
  3. Les données de la socket sont déplacées dans un tampon.
  4. future.set_result() est appelé.
  5. La tâche qui s'est ajoutée avec add_done_callback() est maintenant réveillée.
  6. La tâche appelle .send() sur la coroutine qui va jusqu'au coroutine le plus interne et la réveille.
  7. Les données sont lues à partir de la mémoire tampon et renvoyées à notre humble utilisateur.

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.

95
Bharel

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.

1. Coroutines versus sous-programmes dans une coquille de noix

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.

1.1. Sous-routines

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

  1. allouer de l'espace de pile pour bar et qux
  2. exécuter récursivement la première instruction et passer à l'instruction suivante
  3. une fois à return, envoie sa valeur à la pile appelante
  4. vider la pile (1.) et le pointeur d'instruction (2.)

4. 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

1.2. Coroutines en tant que sous-routines persistantes

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

  1. allouer de l'espace de pile pour bar et qux
  2. exécuter récursivement la première instruction et passer à l'instruction suivante
    1. une fois à un yield, envoyez sa valeur à la pile appelante mais stockez la pile et le pointeur d’instruction
    2. une fois l'appel dans yield, restaurez la pile et le pointeur d'instruction et transmettez les arguments à qux
  3. une fois à return, envoie sa valeur à la pile appelante
  4. vider la pile (1.) et le pointeur d'instruction (2.)

Notez 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.

1.3. Traverser la pile d'appels

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.

1.4. Coroutines tout en bas

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!

1.5. async et await de Python

L'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.

2. Anatomie d'une simple boucle d'événement

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.

2.1.1. Événements dans le temps

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.

2.1.2. Définir 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 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.

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 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.

2.2.2. Événement + sommeil

À ce stade, nous avons deux des mécanismes distincts à notre disposition:

  • AsyncSleep Evénements pouvant être générés depuis une coroutine
  • time.sleep qui peut attendre sans impact sur les coroutines

Notamment, 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.

2.3. Une boucle d'événements naïve

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:

  1. trier les coroutines en fonction de l'heure de réveil souhaitée
  2. choisir le premier qui veut se réveiller
  3. attendre jusqu'à ce moment
  4. lancer cette coroutine
  5. répéter à partir de 1.

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.

2.4. Attente coopérative

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.

3. Boucle d'événements I/O

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".

3.1. L'appel 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.

3.2. Evénement I/O de base

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.

3.3. Augmenter une boucle d'événement avec une entrée/sortie en lecture

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):
    ...

3.4. Mettre ensemble

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

3.5 Coopérative I/O

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

4. E/S non bloquantes

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 selectretourne 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.

4.1. Evénement d'E/S non bloquant

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.

4.2. Débloquer la boucle d'événement

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.

4.3. Le côté laid des entrées/sorties non bloquantes

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

Addenda

Exemple de code sur github

41
MisterMiyagi

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:

  1. Tout se passe dans le même fil par défaut.

  2. 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.

7
user4815162342

Tout se résume aux deux principaux défis auxquels asyncio s’attaque:

  • Comment effectuer plusieurs E/S dans un seul thread?
  • Comment implémenter le multitâche coopératif?

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.

3
Vincent