web-dev-qa-db-fra.com

Pourquoi les coroutines sont de retour?

La plupart des travaux préparatoires pour les coroutines ont eu lieu dans les années 60/70, puis se sont arrêtés en faveur d'alternatives (par exemple, les fils)

Y a-t-il une substance au regain d'intérêt pour les coroutines qui s'est produit dans python et dans d'autres langues?

21
user1787812

Les Coroutines ne sont jamais parties, elles ont juste été éclipsées par d'autres choses en attendant. L'intérêt récemment accru pour la programmation asynchrone et donc les coroutines est en grande partie dû à trois facteurs: une acceptation accrue des techniques de programmation fonctionnelle, des ensembles d'outils avec un faible support pour le vrai parallélisme (JavaScript! Python!), Et surtout: les différents compromis entre les threads et les coroutines. Pour certains cas d'utilisation, les coroutines sont objectivement meilleures.

L'un des plus grands paradigmes de programmation des années 80, 90 et aujourd'hui est la POO. Si nous regardons l'histoire de OOP et plus précisément le développement du langage Simula, nous voyons que les classes ont évolué à partir de coroutines. Simula était destiné à la simulation de systèmes avec des événements discrets. Chaque élément de le système était un processus distinct qui s'exécutait en réponse aux événements pendant la durée d'une étape de simulation, puis cédait pour laisser d'autres processus faire leur travail. Pendant le développement de Simula 67, le concept de classe a été introduit. Maintenant, l'état persistant de la coroutine est stockées dans les membres de l'objet, et les événements sont déclenchés en appelant une méthode. Pour plus de détails, pensez à lire l'article Le développement des langages SIMULA par Nygaard & Dahl.

Donc, dans une tournure amusante, nous avons toujours utilisé des coroutines, nous les appelions simplement des objets et une programmation événementielle.

En ce qui concerne le parallélisme, il existe deux types de langages: ceux qui ont un modèle de mémoire approprié et ceux qui n'en ont pas. Un modèle de mémoire traite de choses comme "Si j'écris dans une variable et que je lis dans cette variable dans un autre thread, est-ce que je vois l'ancienne ou la nouvelle valeur ou peut-être une valeur non valide? Que signifient "avant" et "après"? Quelles opérations sont garanties atomiques? "

La création d'un bon modèle de mémoire est difficile, donc cet effort n'a tout simplement jamais été fait pour la plupart de ces langages open source dynamiques non spécifiés et définis par l'implémentation: Perl, JavaScript, Python, Ruby, PHP. Bien sûr, tous ces langages ont évolué bien au-delà des "scripts" pour lesquels ils ont été initialement conçus. Eh bien, certaines de ces langues ont une sorte de document de modèle de mémoire, mais celles-ci ne sont pas suffisantes. Au lieu de cela, nous avons des hacks:

  • Perl peut être compilé avec la prise en charge des threads, mais chaque thread contient un clone distinct de l'état complet de l'interpréteur, ce qui rend les threads extrêmement coûteux. Comme seul avantage, cette approche de partage de rien évite les courses de données et oblige les programmeurs à communiquer uniquement via des files d'attente/signaux/IPC. Perl n'a pas une histoire solide pour le traitement asynchrone.

  • JavaScript a toujours eu un support riche pour la programmation fonctionnelle, donc les programmeurs encodaient manuellement les continuations/rappels dans leurs programmes là où ils avaient besoin d'opérations asynchrones. Par exemple, avec des requêtes Ajax ou des retards d'animation. Comme le Web est intrinsèquement asynchrone, il y a beaucoup de code JavaScript asynchrone et la gestion de tous ces rappels est extrêmement douloureuse. Nous voyons donc de nombreux efforts pour mieux organiser ces rappels (promesses) ou pour les éliminer complètement.

  • Python a cette malheureuse fonctionnalité appelée Global Interpreter Lock. Fondamentalement, le modèle de mémoire Python est "Tous les effets apparaissent séquentiellement car il n'y a pas de parallélisme. Un seul thread s'exécutera Python code à la fois."). Python a des threads, ceux-ci sont simplement aussi puissants que les coroutines.[1] Python peut coder de nombreuses coroutines via des fonctions de générateur avec yield. S'il est utilisé correctement, cela seul peut éviter la plupart des enfers de rappel connus de JavaScript. Le système asynchrone/attente le plus récent de Python 3.5 rend les idiomes asynchrones plus pratiques en Python et intègre une boucle d'événement.

    [1]: Techniquement, ces restrictions ne s'appliquent qu'à CPython, l'implémentation de référence Python. D'autres implémentations comme Jython offrent de vrais threads qui peuvent s'exécuter en parallèle, mais doivent parcourir une grande longueur pour implémenter l'équivalent Essentiellement: chaque variable ou membre d'objet est une variable volatile de sorte que toutes les modifications sont atomiques et immédiatement visibles dans tous les threads. Bien sûr, l'utilisation de volatile est beaucoup plus cher que l’utilisation de variables normales.

  • Je n'en sais pas assez sur Ruby et PHP pour les rôtir correctement).

Pour résumer: certains de ces langages ont des décisions de conception fondamentales qui rendent le multithreading indésirable ou impossible, conduisant à une concentration plus forte sur des alternatives comme les coroutines et sur les moyens de rendre la programmation asynchrone plus pratique.

Enfin, parlons des différences entre coroutines et threads:

Les threads sont essentiellement comme des processus, sauf que plusieurs threads à l'intérieur d'un processus partagent un espace mémoire. Cela signifie que les threads ne sont en aucun cas "légers" en termes de mémoire. Les threads sont planifiés de manière préventive par le système d'exploitation. Cela signifie que les commutateurs de tâches ont une surcharge élevée et peuvent se produire à des moments peu pratiques. Cette surcharge a deux composantes: le coût de la suspension de l'état du thread et le coût de la commutation entre le mode utilisateur (pour le thread) et le mode noyau (pour le planificateur).

Si un processus planifie ses propres threads directement et en coopération, le changement de contexte en mode noyau n'est pas nécessaire et les tâches de commutation sont comparativement coûteuses par rapport à un appel de fonction indirect, comme dans: assez bon marché. Ces fils légers peuvent être appelés fils verts, fibres ou coroutines selon divers détails. Les utilisateurs notables de fils/fibres verts ont été précoces Java implémentations, et plus récemment Goroutines à Golang. Un avantage conceptuel des coroutines est que leur exécution peut être comprise en termes de flux de contrôle passant explicitement d'avant en arrière entre les coroutines. Cependant, ces coroutines n'atteignent pas le véritable parallélisme sauf si elles sont planifiées sur plusieurs threads du système d'exploitation.

Où les coroutines bon marché sont-elles utiles? La plupart des logiciels n'ont pas besoin de threads gazillion, donc les threads chers normaux sont généralement OK. Cependant, la programmation asynchrone peut parfois simplifier votre code. Pour être utilisée librement, cette abstraction doit être suffisamment bon marché.

Et puis il y a le web. Comme mentionné ci-dessus, le web est intrinsèquement asynchrone. Les demandes de réseau prennent simplement beaucoup de temps. De nombreux serveurs Web maintiennent un pool de threads plein de threads de travail. Cependant, la plupart du temps, ces threads seront inactifs car ils attendent une ressource, que ce soit en attendant un événement d'E/S lors du chargement d'un fichier à partir du disque, en attendant que le client ait accusé réception d'une partie de la réponse ou en attendant qu'une base de données la requête se termine. NodeJS a démontré de façon phénoménale qu'une conception de serveur basée sur les événements et asynchrone qui en résulte fonctionne extrêmement bien. Évidemment, JavaScript est loin d'être le seul langage utilisé pour les applications Web, il existe donc également une grande incitation pour les autres langages (visible en Python et C #) pour faciliter la programmation Web asynchrone.

29
amon

Les coroutines étaient utiles parce que les systèmes d'exploitation n'effectuaient pas la planification préemptive . Une fois qu'ils ont commencé à fournir une planification préventive, il était plus nécessaire de renoncer périodiquement au contrôle de votre programme.

Au fur et à mesure que les processeurs multicœurs deviennent plus répandus, les coroutines sont utilisées pour atteindre parallélisme des tâches et/ou maintenir une utilisation élevée du système (lorsqu'un thread d'exécution doit attendre une ressource, un autre peut commencer à s'exécuter à sa place) ).

NodeJS est un cas spécial, où des coroutines sont utilisées pour obtenir un accès parallèle à IO. Autrement dit, plusieurs threads sont utilisés pour traiter les demandes IO, mais un seul thread est utilisé pour exécuter le code javascript. Le but de l'exécution d'un code utilisateur dans un thread de signle est d'éviter la nécessité de utiliser les mutex, ce qui entre dans la catégorie des efforts visant à maintenir une utilisation élevée du système, comme mentionné ci-dessus.

7
dlasalle

Les premiers systèmes utilisaient les coroutines pour fournir la simultanéité, principalement parce qu'ils étaient le moyen le plus simple de le faire. Les threads nécessitent une bonne quantité de support du système d'exploitation (vous pouvez les implémenter au niveau de l'utilisateur, mais vous aurez besoin d'un moyen pour que le système interrompe périodiquement votre processus) et sont plus difficiles à implémenter même lorsque vous avez le support .

Les threads ont commencé à prendre le relais plus tard car, dans les années 70 ou 80, tous les systèmes d'exploitation sérieux les prenaient en charge (et, dans les années 90, même Windows!), Et ils sont plus généraux. Et ils sont plus faciles à utiliser. Tout à coup, tout le monde pensait que les fils étaient la prochaine grande chose.

À la fin des années 90, des fissures commençaient à apparaître, et au début des années 2000, il est devenu évident qu'il y avait de graves problèmes avec les fils:

  1. ils consomment beaucoup de ressources
  2. les changements de contexte prennent beaucoup de temps, relativement parlant, et sont souvent inutiles
  3. ils détruisent la localité de référence
  4. écrire un code correct qui coordonne plusieurs ressources pouvant nécessiter un accès exclusif est d'une difficulté inattendue

Au fil du temps, le nombre de tâches que les programmes doivent généralement effectuer à tout moment a augmenté rapidement, augmentant les problèmes causés par (1) et (2) ci-dessus. La disparité entre la vitesse du processeur et les temps d'accès à la mémoire a augmenté, exacerbant le problème (3). Et la complexité des programmes en termes de nombre et de types de ressources dont ils ont besoin a augmenté, augmentant la pertinence du problème (4).

Mais en perdant un peu de généralité et en obligeant le programmeur à réfléchir à la façon dont leurs processus peuvent fonctionner ensemble, les coroutines peuvent résoudre tous ces problèmes.

  1. Les coroutines nécessitent un peu plus de ressources qu'une poignée de pages pour la pile, beaucoup moins que la plupart des implémentations de threads.
  2. Les coroutines ne changent de contexte qu'aux points définis par le programmeur, ce qui, espérons-le, ne signifie que lorsque cela est nécessaire. De plus, ils n'ont généralement pas besoin de conserver autant d'informations de contexte (par exemple, les valeurs de registre) que les threads, ce qui signifie que chaque commutateur est généralement plus rapide et en nécessite moins.
  3. Les modèles de coroutines courants, y compris les opérations de type producteur/consommateur, transfèrent les données entre les routines d'une manière qui augmente activement la localité. En outre, les changements de contexte ne se produisent généralement qu'entre des unités de travail ne se trouvant pas en leur sein, c'est-à-dire à un moment où la localité est généralement minimisée de toute façon.
  4. Le verrouillage des ressources est moins susceptible d'être nécessaire lorsque les routines savent qu'elles ne peuvent pas être interrompues arbitrairement au milieu d'une opération, ce qui permet aux implémentations plus simples de fonctionner correctement.
6
Jules

Préface

Je veux commencer par énoncer une raison pour laquelle les coroutines n'obtiennent pas une résurgence, un parallélisme. En général, les coroutines modernes ne sont pas un moyen de réaliser un parallélisme basé sur les tâches, car les implémentations modernes n'utilisent pas la fonctionnalité de multitraitement. La chose la plus proche que vous obtenez est des choses comme fibres .

Utilisation moderne (pourquoi ils sont de retour)

Les coroutines modernes sont venues comme un moyen d'atteindre évaluation paresseuse , quelque chose de très utile dans les langages fonctionnels comme haskell, où au lieu d'itérer sur un ensemble entier pour effectuer une opération, vous seriez en mesure d'effectuer une opération évaluation seulement autant que nécessaire (utile pour des ensembles infinis d'articles ou autrement de grands ensembles avec résiliation anticipée et sous-ensembles).

Avec l'utilisation du mot-clé Yield pour créer générateurs (qui en eux-mêmes satisfont une partie des besoins d'évaluation paresseux) dans des langages comme Python et C #, coroutines, dans les modernes l'implémentation était non seulement possible, mais possible sans syntaxe spéciale dans le langage lui-même (bien que python a finalement ajouté quelques bits pour aider). Les co-routines aident à l'évaluation paresseuse avec l'idée de future s où si vous n'avez pas besoin de la valeur d'une variable à ce moment-là, vous pouvez retarder son acquisition jusqu'à ce que vous l'ayez explicitement demander cette valeur (vous permettant d'utiliser la valeur et l'évaluer paresseusement à un moment différent de l'instanciation).

Au-delà de l'évaluation paresseuse, cependant, en particulier dans la sphère Web, ces routines co aident à résoudre enfer de rappel . Les coroutines deviennent utiles dans l'accès à la base de données, les transactions en ligne, l'interface utilisateur, etc., où le temps de traitement sur la machine cliente elle-même ne va pas entraîner un accès plus rapide à ce dont vous avez besoin. Le threading peut remplir la même chose, mais nécessite beaucoup plus de temps dans ce domaine, et contrairement aux coroutines, sont en fait utiles pour le parallélisme des tâches .

En bref, à mesure que le développement Web se développe et que les paradigmes fonctionnels fusionnent davantage avec les langages impératifs, les coroutines sont devenues une solution aux problèmes asynchrones et à l'évaluation paresseuse. Les coroutines arrivent dans des espaces problématiques où le filetage multiprocessus et le filetage en général sont soit inutiles, incommodes ou impossibles.

Exemple moderne

Les coroutines dans des langages comme Javascript, Lua, C # et Python dérivent toutes leurs implémentations par des fonctions individuelles abandonnant le contrôle du fil principal vers d'autres fonctions (rien à voir avec les appels du système d'exploitation).

Dans cet python , nous avons une drôle python avec quelque chose appelé await à l'intérieur). est fondamentalement un rendement, qui renvoie l'exécution au loop qui permet ensuite à une fonction différente de s'exécuter (dans ce cas, une fonction factorial différente). Notez que lorsqu'il dit "Exécution parallèle des tâches "c'est un terme impropre, il n'exécute pas en parallèle, son entrelacement l'exécution de la fonction à travers l'utilisation de la attendre mot-clé (qui gardent à l'esprit est juste un type spécial de rendement)

Ils permettent des rendements de contrôle uniques et non parallèles pour les processus simultanés qui ne sont pas des tâches parallèles , en ce sens que ces tâches ne fonctionnent pas jamais en même temps. Les coroutines ne sont pas des fils dans les implémentations de langage moderne. Toutes ces implémentations de langages de routines de co sont dérivées de ces appels de rendement de fonction (que vous, le programmeur, devez réellement mettre manuellement dans vos routines de co).

EDIT: C++ Boost coroutine2 fonctionne de la même manière, et leur explication devrait donner une meilleure vision de ce dont je parle avec yeilds, voir ici . Comme vous pouvez le voir, il n'y a pas de "cas spécial" avec les implémentations, des choses comme boost fibres sont l'exception à la règle, et même alors nécessitent une synchronisation explicite.

EDIT2: puisque quelqu'un pensait que je parlais d'un système basé sur les tâches c #, je ne l'étais pas. Je parlais de système d'Unity et naïf implémentations c #

5
whn