Sur la structure du calcul parallèle ou les arguments contre l'opérateur «Go»


Chaque langage qui prend en charge l'informatique parallèle (compétitive, asynchrone) a besoin d'un moyen d'exécuter du code en parallèle. Voici des exemples de différentes API:


go myfunc(); // Golang pthread_create(&thread_id, NULL, &myfunc); /* C with POSIX threads */ spawn(modulename, myfuncname, []) % Erlang threading.Thread(target=myfunc).start() # Python with threads asyncio.create_task(myfunc()) # Python with asyncio 

Il existe de nombreuses options pour la notation et la terminologie, mais une sémantique consiste à exécuter myfunc en parallèle avec le programme principal et à continuer le thread d'exécution parent (Eng. "Control Flow")


Une autre option est les rappels :


 QObject::connect(&emitter, SIGNAL(event()), // C++ with Qt &receiver, SLOT(myfunc())) g_signal_connect(emitter, "event", myfunc, NULL) /* C with GObject */ document.getElementById("myid").onclick = myfunc; // Javascript promise.then(myfunc, errorhandler) // Javascript with Promises deferred.addCallback(myfunc) # Python with Twisted future.add_done_callback(myfunc) # Python with asyncio 

Et encore une fois, la notation change, mais tous les exemples font en sorte que, à partir du moment actuel, si et quand un certain événement se produit, myfunc démarre. Une fois le rappel défini, le contrôle revient et la fonction d'appel continue. (Parfois, les rappels sont regroupés dans des fonctions de combinaison pratiques ou des protocoles de style torsadé , mais l'idée de base reste inchangée.)


Et ... c'est tout. Prenez n'importe quel langage à usage général populaire simultané et vous constaterez probablement qu'il tombe dans l'un de ces paradigmes (parfois les deux, asyncio).


Mais pas ma nouvelle bibliothèque Trio bizarre. Elle n'utilise pas ces approches. Au lieu de cela, si nous voulons exécuter myfunc et anotherfunc en parallèle, nous écrivons quelque chose comme ceci:


 async with trio.open_nursery() as nursery: nursery.start_soon(myfunc) nursery.start_soon(anotherfunc) 

pépinière - pépinière, pépinière

Pour la première fois face à la conception de "nursery", les gens sont perdus. Pourquoi y a-t-il un gestionnaire de contexte (avec bloc)? Qu'est-ce que cette pépinière et pourquoi est-elle nécessaire pour exécuter une tâche? Ensuite, les gens comprennent que la pépinière interfère avec les approches habituelles dans d'autres cadres et se fâche. Tout semble bizarre, spécifique et trop haut pour être une primitive de base. Toutes ces réactions sont compréhensibles! Mais supportez-le un peu.


Dans cet article, je veux vous convaincre que les nurseries ne sont pas une mode, mais plutôt une nouvelle primitive pour contrôler le flux d'exécution, aussi fondamentale que les boucles et les appels de fonction. De plus, les approches discutées ci-dessus (création de threads et enregistrement des rappels) doivent être rejetées et remplacées par des pépinières.


Semble trop gras? Mais cela s'est déjà produit: une fois goto largement utilisé pour contrôler le comportement d'un programme. Voilà une occasion de rire:



Plusieurs langues ont toujours le soi-disant goto , mais ses capacités sont beaucoup plus limitées que le goto origine. Et dans la plupart des langues, ce n'est pas du tout. Que lui est-il arrivé? Cette histoire est étonnamment pertinente, bien que peu connue de la plupart en raison de son antiquité. Rappelons-nous ce qu'était goto , puis voyons comment cela peut aider dans la programmation asynchrone.


Table des matières


  • Qu'est-ce que goto?
  • Qu'est-ce que c'est?
  • Qu'est-il arrivé à goto?
    • goto détruit l'abstraction
    • Un nouveau monde courageux sans goto
    • Plus de goto
  • À propos des dangers des expressions de type «Go»
    • les expressions vont casser les abstractions.
    • les go-expressions interrompent le nettoyage automatique des ressources ouvertes.
    • les expressions go interrompent la gestion des erreurs.
    • Plus besoin d'aller
  • La pépinière en remplacement structurel de go
    • La pépinière conserve l'abstraction des fonctions.
    • Prise en charge dynamique des tâches d'ajout de pépinière.
    • Vous pouvez toujours quitter la pépinière.
    • Vous pouvez identifier de nouveaux types qui charlatanent comme une pépinière.
    • Non, cependant, les pépinières attendent toujours la fin de toutes les tâches à l'intérieur.
    • Travaux de nettoyage automatique des ressources.
    • La levée d'insectes fonctionne.
    • Un nouveau monde courageux sans aller
  • Pépinières en pratique
  • Conclusions
  • Commentaires
  • Remerciements
  • Notes de bas de page
  • À propos de l'auteur
  • Continuation

Qu'est-ce que goto ?


Les premiers ordinateurs ont été programmés en utilisant l' assembleur , ou même plus primitivement. Ce n'est pas très pratique. Ainsi, dans les années 1950, des gens tels que John Backus d'IBM et Grace Hopper de Remington Rand ont commencé à développer des langages tels que FORTRAN et FLOW-MATIC (mieux connu pour son descendant direct COBOL ).


FLOW-MATIC était très ambitieux à l'époque. Vous pouvez le considérer comme l'arrière-arrière-arrière-arrière-arrière-grand-père de Python - c'était le premier langage développé principalement pour les gens, et le second pour les ordinateurs. Il ressemblait à ceci:



Notez que contrairement aux langages modernes, il n'y a pas de conditionnel if blocs, des boucles ou des appels de fonction - en fait, il n'y a aucun bloc ou retrait du tout. Ceci est juste une liste séquentielle d'expressions. Pas parce que ce programme est trop court pour exiger des instructions de contrôle (autres que JUMP TO ) - une telle syntaxe n'a pas encore été inventée!



Au lieu de cela, FLOW-MATIC avait deux options pour contrôler le flux d'exécution. Habituellement, le flux était cohérent - commencez par le haut et descendez, une expression à la fois. Mais si vous exécutez l'expression spéciale JUMP TO , elle pourrait prendre le contrôle ailleurs. Par exemple, expression (13) passe à expression (2):



Tout comme avec les primitives du parallélisme depuis le début de l'article, il n'y avait pas d'accord sur ce qu'il fallait appeler cette opération de «saut à sens unique». Dans la liste, c'est JUMP TO , mais goto historiquement pris racine (comme "y aller"), que j'utilise ici.


Voici l'ensemble complet des sauts goto dans ce petit programme:



Cela vous semble déroutant non seulement! FLOW-MATIC a hérité de ce style de programmation basé sur le saut directement de l'assembleur. Il est puissant, bien proche de la façon dont le matériel informatique fonctionne réellement, mais il est très difficile de travailler directement avec lui. Cette boule de flèches est à l'origine de l'invention du terme "code spaghetti".


Mais pourquoi goto causé un tel problème? Pourquoi certaines déclarations de contrôle sont-elles bonnes et d'autres non? Comment choisir les bons? À l'époque, c'était complètement incompréhensible et si vous ne comprenez pas le problème, il est difficile à résoudre.


Qu'est-ce que c'est?


Écartons-nous de notre histoire. Tout le monde sait que goto était mauvais, mais qu'est-ce que cela a à voir avec l'asynchronie? Regardez la célèbre expression go de Golang, qui est utilisée pour engendrer le nouveau "goroutine" (flux léger):


 // Golang go myfunc(); 

Est-il possible de dessiner un diagramme de son flux d'exécution? Il est légèrement différent du diagramme ci-dessus, car ici le flux est divisé. Dessinons-le comme ceci:



Les couleurs ici sont destinées à montrer que les deux chemins sont choisis. Du point de vue du goroutine parent (ligne verte) - le flux de contrôle est exécuté séquentiellement: il commence par le haut puis descend immédiatement. Pendant ce temps, du point de vue de la fonction descendante (ligne lilas), le flux vient d'en haut puis saute dans le corps de myfunc . Contrairement à un appel de fonction normal, il y a un saut à sens unique - à partir de myfunc nous myfunc vers une pile complètement nouvelle et le runtime oublie immédiatement d'où nous venons.


apparemment, je veux dire la pile d'appels

Mais cela ne s'applique pas seulement à Golang. Ce diagramme est vrai pour toutes les primitives (contrôles) répertoriées au début de l'article:


  • Les bibliothèques de threads retournent généralement une sorte d'objet de contrôle qui leur permettra de rejoindre le thread plus tard - mais il s'agit d'une opération indépendante dont le langage lui-même ne sait rien. La primitive de création d'un nouveau thread a le schéma ci-dessus.
  • L'enregistrement de rappel équivaut sémantiquement à la création d'un thread d'arrière-plan (bien qu'il soit évident que l'implémentation est différente), ce qui:
    a) est bloqué jusqu'à ce qu'un événement se produise, puis
    b) lance une fonction de rappel
    Ainsi, en termes d'opérateurs de contrôle de haut niveau, l'enregistrement de rappel est une expression identique à go .
  • Avec Futures et Promises la même chose - lorsque vous exécutez la fonction et qu'elle renvoie Promise , cela signifie qu'elle a prévu de fonctionner en arrière-plan et renvoie un objet de contrôle pour obtenir le résultat plus tard (si vous le souhaitez). Du point de vue de la sémantique de gestion, cela revient à créer un flux. Après cela, vous passez le rappel à Promis, puis comme dans le paragraphe précédent.

Ce même modèle se manifeste sous de nombreuses formes - la similitude clé est que dans tous ces cas, le flux de contrôle est divisé - un saut est effectué vers le nouveau thread, mais le parent revient à celui qui l'a appelé. Sachant quoi regarder, vous le verrez partout! C'est un jeu intéressant (au moins pour certains types de personnes)!


Pourtant, cela m'énerve qu'il n'y ait pas de nom standard pour cette catégorie d'instructions de contrôle. J'utilise l'expression «go» pour les appeler, tout comme «goto» est devenu un terme générique pour toutes les expressions de goto . Pourquoi go ? L'une des raisons est que Golang nous donne un exemple très clair d'une telle syntaxe. Et l'autre est:



Remarquez la similitude? C'est vrai - go est l'une des formes de goto .


Les programmes asynchrones sont connus pour la difficulté d'écrire et d'analyser. Ainsi que des programmes basés sur goto . Les problèmes causés par goto pour la plupart résolus dans les langues modernes. Si nous apprenons à corriger goto , cela aidera-t-il à créer des API asynchrones plus pratiques? Voyons!


Qu'est-il arrivé à goto ?


Alors, quel est le problème avec goto qui cause tant de problèmes? À la fin des années 60, Edsger Wieb Dijkstra a écrit quelques ouvrages maintenant connus qui ont aidé à comprendre cela beaucoup plus clairement: les arguments contre l'opérateur goto et les notes sur la programmation structurelle .


goto détruit l'abstraction


Dans ces travaux, Dijkstra s'est inquiété de la façon dont nous écrivons des programmes non triviaux et garantissons leur exactitude. Il y a beaucoup de points intéressants. Par exemple, vous avez probablement entendu cette phrase:


Les programmes de test peuvent montrer la présence d'erreurs, mais jamais leur absence.

Oui, cela provient des notes de programmation structurelle . Mais sa principale préoccupation était l' abstraction . Il voulait écrire des programmes trop gros pour les tenir dans leur tête. Pour ce faire, vous devez traiter les parties du programme comme des boîtes noires - par exemple, vous voyez ce programme en Python:


 print("Hello World!") 

et vous n'avez pas besoin de connaître tous les détails du fonctionnement de l' print (formatage des lignes, mise en mémoire tampon, différences entre plates-formes, etc.). Tout ce que vous devez savoir, c'est que l' print imprime en quelque sorte le texte que vous avez transmis et vous pouvez vous concentrer sur ce que vous voulez faire dans ce morceau de code. Dijkstra voulait que les langues prennent en charge ce type d'abstraction.


À ce stade, la syntaxe des blocs a été inventée et des langages comme ALGOL ont accumulé environ 5 types différents d'instructions de contrôle: ils avaient toujours un fil d'exécution séquentiel et goto :



Et aussi des conditions acquises, des cycles et des appels de fonction:



Vous pouvez implémenter ces constructions de haut niveau à l'aide de goto , et c'est ainsi que les gens les considéraient auparavant: comme un raccourci pratique. Mais Dijkstra a souligné la grande différence entre goto et le reste des opérateurs de contrôle. Pour tout sauf goto , le fil d'exécution


  • vient d'en haut => [quelque chose se passe] => le flux vient d'en bas

Nous pouvons appeler cela la «règle de la boîte noire» - si la structure de contrôle (opérateur de contrôle) a cette forme, alors dans une situation où vous n'êtes pas intéressé par les détails à l'intérieur, vous pouvez ignorer la partie «quelque chose se passe» et traiter le bloc comme avec une série régulière équipe. Encore mieux, cela est vrai pour tout code composé de ces blocs. Quand je regarde:


 print("Hello World!") 

Je n'ai pas besoin de lire les sources d' print et toutes ses dépendances pour comprendre où ira le fil d'exécution. Peut-être qu'à l'intérieur de print il y a une boucle, et qu'il y a une condition dans laquelle il y a un appel à une autre fonction ... ce n'est pas important - je sais que le thread ira à print , la fonction fera son travail, et finalement le thread reviendra au code que je J'ai lu.


Mais si vous avez un langage avec goto - un langage où les fonctions et tout le reste est construit sur la base de goto , et goto peut sauter n'importe où, n'importe quand - alors ces structures ne sont pas du tout des boîtes noires! Si vous avez une fonction avec une boucle, à l'intérieur de laquelle il y a une condition, et à l'intérieur, il y a goto ... alors ce goto peut passer l'exécution n'importe où. Peut-être que le contrôle reviendra soudainement complètement d'une autre fonction que vous n'avez même pas appelée! Tu ne sais pas!


Et cela rompt l'abstraction - n'importe quelle fonction peut avoir un potentiel goto intérieur, et la seule façon de savoir si c'est le cas est de garder à l'esprit tout le code source de votre système. Une fois que le langage est goto , vous ne pouvez pas prédire le flux d'exécution. C'est pourquoi goto mène au code spaghetti.


Et dès que Dijkstra a compris le problème, il a pu le résoudre. Voici son hypothèse révolutionnaire - nous ne devons pas considérer les conditions / boucles / appels de fonction comme des abréviations pour goto , mais comme des primitives fondamentales avec nos droits - et nous devons supprimer complètement goto de nos langages.


À partir de 2018, cela semble assez évident. Mais comment les programmeurs réagissent-ils lorsque vous essayez de récupérer leurs jouets dangereux? En 1969, la proposition de Dijkstra semblait incroyablement douteuse. Donald Knuth a défendu goto . Les gens qui sont devenus des experts en codage avec goto étaient à juste titre indignés de devoir réapprendre à exprimer leurs idées en termes nouveaux et plus restrictifs. Et bien sûr, il a fallu créer des langages complètement nouveaux.


En conséquence, les langues modernes sont un peu moins strictes que la formulation originale de Dijkstra.



À gauche: goto traditionnel. À droite: goto domestiqué, comme en C, C #, Golang, etc. Ne pas franchir les limites d'une fonction signifie qu'il peut toujours faire pipi sur vos chaussures, mais il est peu probable qu'il vous déchire.

Ils vous permettent de sauter les niveaux d'imbrication des instructions de contrôle structurel à l'aide des instructions break , continue ou return . Mais à un niveau de base, ils sont tous construits autour de l'idée de Dijkstra et peuvent perturber le flux séquentiel d'exécution de manière strictement limitée. En particulier, les fonctions - un outil fondamental pour envelopper un thread d'exécution dans une boîte noire - sont indestructibles. Vous ne pouvez pas exécuter la commande break d'une fonction à une autre et return ne peut pas vous renvoyer plus loin que la fonction actuelle. Aucune manipulation du thread d'exécution à l'intérieur de la fonction n'affectera les autres fonctions.


Et les langages qui préservaient l'opérateur goto (C, C #, Golang, ...) le limitaient fortement. Au minimum, ils ne vous permettent pas de sauter du corps d'une fonction à une autre. Si vous n'utilisez pas Assembler [2], le goto classique et illimité appartient au passé. Dijkstra a gagné.


Un nouveau monde courageux sans goto


Quelque chose d'intéressant s'est produit avec la disparition de goto - les créateurs de langage ont pu commencer à ajouter de nouvelles fonctionnalités basées sur un flux d'exécution structuré.


Par exemple, Python a une syntaxe sympa pour effacer automatiquement les ressources - un gestionnaire de contexte . Vous pouvez écrire:


 # Python with open("my-file") as file_handle: some code 

et cela garantit que le fichier sera ouvert au moment de l'exécution some code mais après cela - immédiatement fermé. La plupart des langages modernes ont des équivalents ( RAII , using , try-with-resource, defer , ...). Et ils supposent tous que le flux de contrôle est en ordre. Et que se passe-t-il si nous sautons dans le bloc with utilisant goto ? Le fichier est-il ouvert ou non? Et si nous sautions de là au lieu de partir comme d'habitude?


une fois le code à l'intérieur du bloc terminé, with démarre la __exit__() qui ferme les ressources ouvertes, telles que les fichiers et les connexions.

Le fichier se fermera-t-il? Dans goto , les gestionnaires de contexte ne fonctionnent tout simplement pas de manière claire.


Le même problème avec la gestion des erreurs - en cas de problème, que doit faire le code? Souvent - envoyez une description de l'erreur dans la pile (d'appels) au code appelant et laissez-le décider quoi faire. Les langages modernes ont des constructions spécifiquement pour cela, telles que les exceptions ou d'autres formes de génération automatique d'erreurs . Mais cette aide n'est disponible que si la langue a une pile d'appels et un concept "d'appel" robuste. Rappelez-vous les spaghettis dans l'exemple de flux dans l'exemple FLOW-MATIC et imaginez l'exception levée au milieu. D'où peut-il même venir?


Plus de goto


Ainsi, le goto traditionnel - qui ignore les limites des fonctions - est mauvais non seulement parce qu'il est difficile à utiliser correctement. Si seulement cela, goto aurait pu rester - de nombreuses constructions de mauvais langage sont restées.


Mais même la fonction très goto du langage rend tout plus compliqué. Les bibliothèques tierces ne peuvent pas être considérées comme une boîte noire - sans connaître la source, vous ne pouvez pas déterminer quelles fonctions sont normales et lesquelles contrôlent de manière imprévisible le flux d'exécution. Il s'agit d'un obstacle majeur à la prédiction du comportement du code local. Des fonctionnalités puissantes telles que les gestionnaires de contexte et les fenêtres contextuelles d'erreur automatiques sont également perdues. Il est préférable de supprimer complètement goto , au profit des opérateurs de contrôle qui prennent en charge la règle de la boîte noire.


À propos des dangers d'expressions comme "Go"


Nous avons donc regardé l'histoire de goto . Mais est-ce applicable à l'opérateur go ? Eh bien ... dans l'ensemble! L'analogie est d'une précision choquante.


les expressions vont casser les abstractions.


Rappelez-vous comment nous avons dit que si le langage autorise goto , alors n'importe quelle fonction peut cacher goto en soi? Dans la plupart des frameworks asynchrones, les expressions go conduisent au même problème - n'importe quelle fonction peut (ou non) exécuter la tâche en arrière-plan. Il semble que la fonction ait renvoyé le contrôle, mais fonctionne-t-elle toujours en arrière-plan? Et il n'y a aucun moyen de le savoir sans lire la source de la fonction et tout ce qu'elle appelle. Et quand cela se terminera-t-il? C'est difficile à dire. Si vous avez go et ses analogues, alors les fonctions ne sont plus des boîtes noires qui respectent le flux d'exécution. Dans mon premier article sur les API asynchrones , j'ai appelé cela une «violation de causalité» et j'ai constaté que c'était la cause première de nombreux problèmes réels et courants dans les programmes utilisant asyncio et Twisted, tels que les problèmes de contrôle de flux, les problèmes d'arrêt corrects, etc.


Il s'agit du contrôle du flux de données entrant et sortant du programme. Par exemple, le programme reçoit des données à une vitesse de 3 Mo / s et part à une vitesse de 1 Mo / s, et en conséquence le programme consomme de plus en plus de mémoire, voir un autre article de l'auteur

les go-expressions interrompent le nettoyage automatique des ressources ouvertes.


Jetons à nouveau un exemple with déclaration:


 # Python with open("my-file") as file_handle: some code 

Plus tôt, nous avons dit que nous étions «garantis» que le fichier serait ouvert pendant que some code fonctionnaient, et fermé après. Mais que faire si some code démarre une tâche en arrière-plan? : , , with , with , , , . , ; , , some code .


, , - , , .


, Python threading — , , — , with

, , , ( ). , . , .


go- .


, , (exceptions), . " ". , . , , . , , … , . , - . ( , , " - " — ; .) Rust — , , - — . (thread) , Rust .


, , join , errbacks Twisted Promise.catch Javascript . , , . , Traceback . Promise.catch .


, .


go


, goto , go- — , , , . , goto , , go .


, , ! :


  • go -, , " ",
  • , go -.

, Trio .


go


: , , , . , , :



, , , " " .


? " " ,


) , , ( ),
) , .


. , - . , .. [3]


: , , , "" , . Trio , async with :



, as nursery nursery . nursery.start_soon() , () : myfunc anotherfunc . . , , () , , .



, , — , , . , .


, .

:



, . En voici quelques uns:


.


go- — , , , . — , , . , , .


.


, . :


 run_concurrently([myfunc, anotherfunc]) 

async.gather Python, Promise.all Javascript, ..

, , , . , accept , .
accept Trio:


 async with trio.open_nursery() as nursery: while True: incoming_connection = await server_socket.accept() nursery.start_soon(connection_handler, incoming_connection) 

, , run_concurrently . , run_concurrently — , , run_concurrently , .


.


. , , ? : . , async with open_nursery() nursery.start_soon() , — [4], , , . , , .


, , " ", :


  • , , , , , .
  • , .
  • , .

, .


, , go-, .


, .


, - . , , . :


 async with my_supervisor_library.open_supervisor() as nursery_alike: nursery_alike.start_soon(...) 

, , . .


Trio , asyncio : start_soon() , Future ( , Future ). , ( , Trio Future !), .


, , .


, , — — .


Trio, . , , " " ( ), Cancelled . , , — - , " ", , .. , , . , , , .


.


" ", with . , with , .


.


, , . .


Trio, , … - . , . , — " " — , myfunc anotherfunc , . , , .


, : (re-raise) , . ,


" " , , , , , .

, , . ?


— ( ) , . , , , , .


, , - ( task cancellation ). C# Golang, — .


go


goto , with ; go - . Par exemple:


  • , , , . ( : - )
  • — Python , ctrl-C ( ). , .


, . ?


… : ! , , . , , , break continue .


, . — , 1970 , goto .


. (Knuth, 1974, .275):


, goto , , " " goto . goto ! , , goto , . , , . , — , — "goto" .

: . , , . , , . , , .


, Happy Eyeballs ( RFC 8305 ), TCP . , — , , . Twisted — 600 Python . 15 . , , , . , , . , . ? . .


Conclusions


go , , , Futures , Promises ,… — goto , . goto , -- goto , . , , ; , . , goto , .


, , ( CTRL+C ) , .


, , , , — , goto . FLOW-MATIC , , - . , , Trio , , .


Commentaires


Trio .


:


Trio : https://trio.discourse.group/

Remerciements


Graydon Hoare, Quentin Pradet, and Hynek Schlawack . , , .


berez .

: FLOW-MATIC (PDF), .


Wolves in Action, Martin Pannier, CC-BY-SA 2.0 , .
, Daniel Borker, CC0 public domain dedication .



[2] WebAssembly , goto : ,


[3] , , , , :
The "parallel composition" operator in Cooperating/Communicating Sequential Processes and Occam, the fork/join model, Erlang supervisors, Martin Sústrik's libdill , crossbeam::scope / rayon::scope Rust. golang.org/x/sync/errgroup github.com/oklog/run Golang.
, - .


[4] start_soon() , , start_soon , , , . , .


À propos de l'auteur


Nathaniel J. Smith , Ph.D., UC Berkeley numpy , Python . Nathaniel .



:



, , , Haskell , , .


( , 0xd34df00d , ) , ( Happy Eyeballs ), .


, Trio ? Haskell Golang ?


:

Source: https://habr.com/ru/post/fr479186/


All Articles