Concurrence structurée en Python avec les nurseries

a marqué ce sujet comme résolu.

Une discussion intéressante a lieu dans les commentaires de l’article Plongée au cœur de l’asynchrone en Python. Il y a quelques désaccords, mais nous sommes tous d’accord sur le fait qu’elle est hors-sujet :D Alors j’ai préféré ouvrir un sujet ici.

Voilà le message de @nohar auquel je souhaite répondre.

J’aurais aimé voir la gestion des exceptions dans gather(), c’est un point qui est trop souvent oublié et qui fait que asyncio.create_task et future.add_done_callback sont à bannir dans asyncio (plus de détails dans https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/).

Quentin

La partie que j’ai mise en gras m’a fait tiquer, et je m’apprêtais à écrire une réponse un peu lapidaire… avant de me souvenir du dernier projet sur lequel j’ai travaillé en Python, avec du asyncio bien vénère, jusqu’à l’année dernière. :D

En effet, le billet cité en lien (quoi que l’auteur ne se prend quand même pas pour de la m… en se comparant à Dijkstra) aborde un problème sur lequel j’ai eu l’occasion de passer une grosse nuit blanche à réfléchir pour lui trouver une solution simple et structurante, car il s’agit d’une douloureuse lacune d'asyncio : celui-ci nous donne asyncio.ensure_future sans nous donner de mécanisme solide pour s’assurer que toutes les coroutines seront terminées ou annulées à la sortie du programme. Mon boulot en tant que lead aura été de créer une abstraction qui réponde à ce problème sans interdire quoi que ce soit aux développeurs de mon équipe, et je m’aperçois que cette abstraction revient ni plus ni moins à la même chose que ce que Trio appelle une nursery (sauf que dans le contexte de mon framework ça s’appelait un Scheduler, mais c’est pas la question…).

Pour en revenir à la formulation qui m’a fait tiquer : non, il ne faut rien bannir, et ce n’est pas non plus ce que fait Trio. Ce que le billet appelle un "go statement" répond à un besoin bien réel : si je développe dans un contexte concurrent, alors je veux absolument pouvoir être capable lancer une tâche et l’oublier dans le contexte d’où je l’ai lancée (fire and forget) tout en ayant la certitude que quelqu’un, plus haut, s’assurera que cette tâche sera terminée ou annulée en temps voulu. C’est à ce besoin que répond (partiellement) asyncio avec create_task. C’est une façon parfaitement intuitive de penser son code, et il n’y a aucune raison de vouloir l’interdire, sinon cela revient à ce que j’appelle "chier dans les bottes des développeurs", c’est-à-dire leur imposer de faire des contorsions et se mettre à penser d’une manière qui ne leur est pas naturelle. Si asyncio y répond mal, c’est simplement parce qu’il ne fournit pas, par défaut, de poignée sur un contexte (/scope/nursery/scheduler/waitgroup…) qui permette soit d’attendre que toutes ces tâches soient terminées, soit que les erreurs soient propagées : même gather n’y répond pas (il ne permet pas de faire du fire and forget, c’est-à-dire de rajouter dynamiquement une nouvelle tâche au groupe en cours de route). En créant une telle poignée explicite (et franchement, ça ne casse pas non plus trois pattes à un canard…) à laquelle il suffit de passer le résultat de l’appel à create_task, on règle le problème, et on respecte du même coup le zen de Python (explicit is better than implicit).

Pour le coup, le billet cité en lien prend Go en disant "regardez ce qu’il ne faut pas faire" et je trouve que ce faisant, il est à côté de la plaque, car Go fournit dans sa bibliothèque standard des outils qui permettent de faire ça depuis longtemps avec le type WaitGroup, et le package (provisionnel) x/sync/errgroup, dont l’implémentation d’une version qui prend en compte l’annulation lorsqu’une erreur se produit dans une tâche concurrente est complètement triviale. Outre le fait que le problème soit mal posé (toute sa comparaison entre go et goto est un gigantesque bullshit qu’il contredit lui-même plus loin en présentant une solution qui ne change absolument rien à son prétendu problème), je trouve qu’il est très malhonnête de la part de l’auteur de ce billet de ne pas parler de ces solutions et de se poser en inventeur génial d’un concept novateur… que j’ai moi-même dû pondre de mon côté en une nuit il y a deux ans (et je ne suis certainement pas le seul à l’avoir fait !), sans pour autant écrire un billet où je remets en question la moitié des langages de programmation de la Terre.

Enfin, j’aimerais conclure en disant que ce sujet dépasse d’assez loin le cadre de cet article, car il s’agit, au fond, de donner une réflexion sur les bonnes pratiques à adopter lorsque l’on conçoit un programme concurrent, alors qu’ici le principe de l’article est plutôt de décortiquer, techniquement, les mécanismes internes qui rendent la programmation asynchrone possible en Python.

nohar

(Disclaimer : je contribue à Trio, suis un mainteneur d’urllib3 et j’ai pas mal travaillé sur un port d’urllib3 à asyncio, Twisted et Trio. Je ne suis pas neutre, mais pense pouvoir apporter une perspective intéressante.)

Je vais commencer par évacuer quelques points mineurs dans un bloc masqué.

quoi que l’auteur ne se prend quand même pas pour de la m… en se comparant à Dijkstra

Non seulement l’auteur ne se compare pas à Dijkstra, mais même s’il le faisait, est-ce qu’on pourrait éviter les attaques personnelles ?

C’est une façon parfaitement intuitive de penser son code, et il n’y a aucune raison de vouloir l’interdire, sinon cela revient à ce que j’appelle "chier dans les bottes des développeurs", c’est-à-dire leur imposer de faire des contorsions et se mettre à penser d’une manière qui ne leur est pas naturelle.

Je ne suis pas convaincu par l’argument. C’est aussi ce que les gens disaient quand on a voulu remplacer goto, ou ce qu’ils disent aujourd’hui quand on conseille d’éviter les langages non memory safe au motif que eux sont capables d’écrire du C++ correct.

Pour le coup, le billet cité en lien prend Go en disant "regardez ce qu’il ne faut pas faire"

Go n’est qu’un exemple, le tout début de l’article explique que c’est omniprésent, et la suite explique que Go est simplement un exemple intéressant en raison du parallèle entre les instructions go et go to.

Go fournit dans sa bibliothèque standard des outils qui permettent de faire ça depuis longtemps avec le type WaitGroup, et le package (provisionnel) x/sync/errgroup

WaitGroup manque de fonctionnalités par rapport aux nurseries, mais s/sync/errgroup est en effet de la concurrence structurée, et est d’ailleurs cité en note de bas de page.

je trouve qu’il est très malhonnête de la part de l’auteur de ce billet de ne pas parler de ces solutions et de se poser en inventeur génial d’un concept novateur

L’auteur dit : "But please be aware that I’m not claiming to have like, invented the idea of concurrency or something, this draws inspiration from many sources, I’m standing on the shoulders of giants, etc."

Il y a eu d’autres inventeurs (https://en.wikipedia.org/wiki/Structured_concurrency), mais ce qu’il dit c’est que la formulation complète n’a pas été présentée auparavant. C’est probablement vrai, mais ce n’est pas vraiment important. C’est juste gênant d’utiliser ça comme argument pour discréditer les idées.

@nohar Je vais essayer de résumer tes deux arguments majeurs pour pouvoir y répondre. Dis-moi si je me trompe ! Selon moi, tu dis :

  1. Oui les nurseries sont une bonne chose, mais ça reste assez trivial, et on pourrait d’ailleurs facilement les rajouter dans asyncio.
  2. asyncio.create_task() ou go, c’est très utile pour faire du fire and forget tout en ayant la certitude que quelqu’un, plus haut, s’assurera que cette tâche sera terminée ou annulée en temps voulu.

Alors pour le point 1. oui, nous sommes d’accord. Les développeurs d’asyncio voulaient d’ailleurs les inclure dans asyncio, mais ça ne s’est pas fait par manque de temps. Ceci dit, les nurseries sont bien plus qu’un WaitGroup, elles sont un x/sync/errgroup !

Cela a au moins deux impacts majeurs :

  • Si une tâche lance une exception, alors toutes les autres tâches de la nursery sont annulées proprement (dans Trio c’est fait en injectant une exception).
  • Aucune exception n’est lâchée dans la nature.

Ceci m’amène à ton second point : à mon sens le fire and forget ne devrait peut-être pas être banni complètement mais évité dans 99% des cas parce que c’est le meilleur moyen pour écrire du code qui n’est pas fiable. Si tu appelles asyncio.create_task() mais ne fais rien du résultat, et que la fonction lance une exception, l’exception sera… affichée dans stderr et tu ne pourras rien y faire. Ce qui est un gros problème en production.

Et oui, tu peux écrire avec les primitives proposées par asyncio du code équivalent à ce qui se serait passé avec des nurseries, mais pourquoi ne pas utiliser les nurseries directement dans ce cas ?

Pour résumer, les nurseries n’ont pas de quoi casser trois pattes à un canard, tout comme for et if face à goto, mais j’adhère à la thèse selon laquelle toute programmation concurrente devrait être structurée, parce que c’est le seul moyen qui me permette d’écrire du code concurrent qui fonctionne.

J’ai dû mal m’exprimer. Je ne suis pas contre la concurrence structurée puisque j’utilise ce paradigme depuis un long moment (sans forcément l’appeler comme ça). Par contre, si je démonte ce billet, c’est parce qu’il s’y prend de la pire façon possible pour en expliquer la nécessité : en faisant des comparaisons fallacieuses. Toute cette comparaison entre le passage à une concurrence structurée et l’abolition de goto n’a aucun sens et ne sert qu’à citer Dijkstra hors contexte, ce qui vaut à peine mieux qu’un argument d’autorité.

Autrement dit : la concurrence structurée n’a pas besoin de marketing. C’est les problématiques de concurrence qui ont besoin d’être explicitées, et la concurrence structurée n’est qu’un pattern possible pour faire les choses correctement dans certains cas. Si je prends par exemple un livre comme Concurrency in Go: Tools and Techniques for Developers, à aucun moment on n’y parle de concurrence structurée, et pourtant l’auteur expose des tonnes de guidelines simples pour produire du code concurrent safe et efficace. Ce faisant, je peux te citer pas mal de use-cases dans lesquels il est inutile de dégainer une nursery car un WaitGroup ou un bête channel suffit. Par expérience, je peux aussi citer d’autres cas dans lesquels on peut légitimement avoir besoin de quelque chose de plus intelligent qu’une nursery, notamment lorsque les tâches concurrentes sont interdépendantes (les unes ont besoin du résultat des autres pour se réveiller) et mutualisées (si on a besoin d’un résultat qui est en train d’être calculé par un autre, on veut se contenter d’attendre ce résultat plutôt que de lancer une nouvelle tâche parallèle), et où propager les erreurs serait contre-productif (quand on fait du best effort et la perte d’une partie du résultat est préférable à relancer la totalité du traitement).

Et c’est pour ça que je ne suis pas d’accord quand tu dis que c’est « le seul moyen qui permette d’écrire du code concurrent qui fonctionne ». C’en est un, certes, mais ni le seul, ni le plus adapté à toutes les situations, ou dit autrement, ce n’est certainement pas une silver bullet. C’est pour cette raison que j’ai une réaction épidermique chaque fois que quelqu’un parle de bannir ou d'imposer quoi que ce soit en développement.

Fournir des outils et en expliquer l’utilité, oui. Forcer les gens à tout faire avec, non.

Par contre, on est bien d’accord que les primitives proposées par asyncio en standard sont insuffisantes.

PS:

Je m’aperçois que j’ai pris un bouquin de Go pour argumenter sur Python. Il faut savoir que les coroutines dans Python sont plus riches que les goroutines : fonctionnellement elles sont équivalentes à des fonctions qui prennent un contexte en premier argument et qui gèrent presque automatiquement l’annulation du contexte (alors qu’en Go, il appartient à l’utilisateur d’écouter explicitement le contexte chaque fois qu’on se met en attente de quelque chose pour savoir s’il a été annulé). Ainsi, tout ce que l’on peut trouver en Go (qui est globalement un langage plus dénudé) est valable également en Python.

Par contre, je pars également du principe qu’en Python, on récupère systématiquement le Future qui est produit par create_task (ou ensure_future) pour ne pas le laisser se trimballer dans la nature, car le fond du problème est là. Soit celui-ci est explicitement récupéré dans le code, soit on wrappe l’appel dans un objet comme une nursery. Mais asyncio nous y force déjà, quelque part, car si une loop est arrêtée alors qu’on a lancé une coroutine sans attendre ni récupérer le résultat ni l’erreur d’une tâche parallèle, celle-ci crache une exception assez violente à la fermeture du programme, et c’est que devrait commencer tout raisonnement pour aboutir à la programmation structurée, plutôt que par une comparaison foireuse avec goto.

+0 -0

Merci pour ta réponse !

Et c’est pour ça que je ne suis pas d’accord quand tu dis que c’est « le seul moyen qui permette d’écrire du code concurrent qui fonctionne ».

Tu as oublié un mot très important ici en me citant : c’est le seul moyen qui me permette d’écrire du code qui fonctionne. Je ne prétends pas que ce soit le cas pour tout le monde, même si je pense que ça inclut de nombreuses personnes.

Si je prends par exemple un livre comme Concurrency in Go: Tools and Techniques for Developers, à aucun moment on n’y parle de concurrence structurée, et pourtant l’auteur expose des tonnes de guidelines simples pour produire du code concurrent safe et efficace.

Je ne prétends pas le contraire, et c’est même le cœur de mon argument : il y a de nombreux motifs qui permettent d’écrire du code sûr, tout comme il y a de nombreux motifs pour écrire du code avec uniquement des goto. Mais c’est dommage de devoir gérer à la main des choses qui pourraient être exprimées avec des concepts plus haut niveau.

J’ai commencé la programmation avec goto sur ma TI-89, et j’ai commencé à écrire du code concurrent avec des threads Java puis avec asyncio, et je trouve que la comparaison avec goto est parfaitement adaptée ici. J’avais "inventé" les fonctions sur ma calculatrice aussi, et j’ai adoré en avoir de vraies. Idem pour la concurrence.

Ce faisant, je peux te citer pas mal de use-cases dans lesquels il est inutile de dégainer une nursery car un WaitGroup ou un bête channel suffit. Par expérience, je peux aussi citer d’autres cas dans lesquels on peut légitimement avoir besoin de quelque chose de plus intelligent qu’une nursery

Je connais moins les autres systèmes de concurrence structurée mais Trio inclut des channels et différents outils de synchronisation, te laisse lancer des tâches globales, te laisse interdire les annulations, etc. Tu ne perds pas en expressivité ou en généralité avec la concurrence structurée.

Par contre, je pars également du principe qu’en Python, on récupère systématiquement le Future qui est produit par create_task (ou ensure_future) pour ne pas le laisser se trimballer dans la nature, car le fond du problème est là.

Oui, il est là. Et je préfère un système où c’est ce qui arrive par défaut, plutôt qu’un système où il faut y penser à chaque fois.

Mais asyncio nous y force déjà, cas si une loop est arrêtée alors qu’on a lancé une coroutine sans attendre ni récupérer le résultat ni l’erreur d’une tâche parallèle, celle-ci crache une exception assez violente à la fermeture du programme, et c’est là que devrait commencer tout raisonnement pour aboutir à la programmation structurée, plutôt que par une comparaison foireuse avec goto.

Ce n’est pas vrai : comme tu le sais, si la fonction asynchrone lancée par asyncio.create_task a terminé avec succès, la loop ne te dira rien en se fermant, que tu aies récupéré le résultat ou non. Et il s’avère que les erreurs ont plutôt tendance à arriver en production que sur ta machine. Donc je ne comprends pas du tout cet argument. :/


Au final, je comprends bien que ce billet t’ait irrité parce qu’il te semble faire des comparaison fallacieuses, taper sur Go et "chier dans les bottes des développeurs". Mais à mon sens ce n’est pas une raison pour ne pas profiter de x/sync/errgroup ou de Trio, au contraire à mon avis je suis sûr que tu peux t’éclater avec ces outils :D Et continuer à utiliser asyncio.gather/WaitGroup quand ce sera suffisant.

+0 -0

En fait, je pense qu’il faut que j’explique pourquoi la comparaison avec goto est foireuse. Avant d’en proposer une bien plus adaptée.

Le problème fondamental de goto (avant qu’il n’ait été banni des langages de programmation, puis réintroduit mais dans une version nerfée) n’était pas, en soi, un problème de sécurité. Son problème majeur était qu’il n’était pas structurant. À partir de là, on a identifié des cas canoniques d’utilisation de goto pour arriver à formuler while, call/return, break, continue et consorts. Le premier apport de ces éléments syntaxiques est qu’ils ont rendu explicite l’intention des développeurs, et donc le code plus lisible, et donc, par ricochet, plus facile à lire, et enfin par extension, plus safe.

Très bien, mais est-ce que le problème des go statements est vraiment leur lisibilité où le fait qu’on ne comprenne pas explicitement l’intention des développeurs ? Absolument pas. Si j’écris :

c := make(chan struct{})
go func() {
    defer close(c)
    // ...
}()
// ...
<-c

N’importe qui sachant lire du Go comprend mon intention. Le code concurrent est parfaitement délimité par la fonction anonyme que j’appelle. On sait que le canal sera fermé quand la fonction aura fini de travailler et on sait même à quel moment on va attendre que cette goroutine se termine. On n’est pas du tout dans un cas comparable à des goto, parce que si je souhaite écrire du code concurrent, ma pensée est dores et déjà structurée :

  1. j’initialise une structure qui permet de gérer l’arrêt,
  2. je lance ma tâche concurrente,
  3. je m’assure de la terminaison de cette tâche.

C’est parfaitement comparable à :

  1. j’initialise ma nursery
  2. je lance ma tâche concurrente
  3. j’utilise un context manager pour délimiter quand ma tâche sera finie.

Ou encore:

  1. j’initialise un thread,
  2. je le lance,
  3. m’assure qu’il est join() quelque part.

On n’est donc absolument pas sur un problème de structuration de la pensée ni d'intelligibilité comparable aux plats de spaghettis qu’engendraient les langages à base de goto.

Par contre, la classe de problèmes qui vient avec cette gestion manuelle de la concurrence est tout à fait analogue à la classe de problèmes qui vient avec la gestion manuelle de la mémoire.

  • On n’a par défaut aucune garantie qu’une tâche concurrente gérée manuellement sera finie avant la fin du programme.
  • On n’a par défaut aucune garantie qu’un bloc de mémoire alloué manuellement sera libéré avant la fin du programme.

Cette comparaison a beaucoup plus de sens, car l’histoire des langages de programmation nous en dit long sur la complexité de ce problème, et surtout, c’est important d’établir une comparaison correcte parce que cela permet de bien mieux situer la façon dont il convient de l’aborder.

Contrairement aux sauts explicites comme goto, les problématiques de gestion de mémoire n’ont jusqu’à présent trouvé aucune solution idéale :

  • Les développeurs C, rompus à l’exercice, s’en sortent généralement bien en utilisant des idiomatismes et des réflexes mentaux… même si leur langage ne les prémunit pas contre les SEGFAULT et les double-frees, et les développeurs C++ vont un peu plus loin en ajoutant des mécanismes de RAII sans obliger personne à les utiliser,
  • Les développeurs de la plupart des autres langages (Python, Java, Go, …) disposent d’un ramasse-miettes pour faire ça à leur place et n’ont presque plus de problèmes. Jusqu’au jour où ils créent une structure avec des références circulaires, comme un arbre. Le problème est donc massivement réduit, mais également déplacé : quand ça merde, ce n’est pas à proprement parler de la faute du GC, mais c’est très compliqué de comprendre ce qui ne va pas parce que dans ces langages, on ne pense jamais à comment la mémoire sera libérée.
  • Les développeurs Rust ont pour eux un langage qui, tant que l’on n’écrit pas de code unsafe résout la totalité de ces problèmes à la compilation, au prix d’une complexité explicite et inévitable (la gestion des durées de vie et les règles d’emprunt), et donc d’une charge cognitive accrue.

Est-ce qu’aujourd’hui, on peut dire que l’une ou l’autre des ces approches est objectivement la meilleure ? Non. Quoi que l’on choisisse, c’est un compromis qui dépend du contexte (contraintes du projet, techniques, humaines, etc.) et de l’alignement D&D de chacun.

Pour les problématiques de concurrence, c’est pareil.

  • Certains vont adopter une solution structurante (la nursery est un peu un ramasse-miettes pour les coroutines…) qui leur fera oublier ces problématiques 99% du temps, jusqu’au jour où ils tomberont sur un problème déplacé encore pire : par exemple, un deadlock parce que des tâches mutualisées et concurrentes s’attendent les unes les autres. Ça ne sera pas la faute de la concurrence structurée, mais celle-ci n’aidera pas à placer les développeurs dans un contexte où ils font attention à la façon dont leurs tâches concurrentes s’agencent entre elles.
  • D’autres vont préférer garder le contrôle et s’y prendre différemment pour X raison: parce qu’ils ont des outils pour détecter statiquement cette classe de problème, parce que leur modèle de concurrence est clairement établi et ne justifie pas un overhead, parce qu’il se formule de façon beaucoup plus simple et élégante qu’en passant par un errgroup, etc.

Par analogie avec les problématiques de gestion de la mémoire : quelle approche est la meilleure ? J’en sais rien, c’est une question de contexte et d’alignement D&D. :)

En ce qui me concerne, je préfère encore garder le contrôle sur ce que je fais et choisir selon ce qui me semble le plus avantageux sur le moment.

Il faut bien voir que dans tous les cas, adopter une concurrence structurée ne va pas nous affranchir d’un autre problème que je trouve au moins aussi grave : chaque fois qu’on écrit await, on doit systématiquement penser à l’entourer d’un try/except asyncio.CancelledError. Pour moi, récupérer le résultat d’un create_task et devoir se poser la question de ce que j’en fais, ou bien devoir écrire ce try/except et me poser la question de ce que je fais de ma CancelledError sont deux choses du même ordre, et ça me semble primordial de se poser ces question à chaque fois, quitte à choisir la concurrence structurée pour répondre à la première.

Si je me refuse à dire à quiconque : « ne fais JAMAIS ça », c’est parce qu’en toute rigueur, ça devrait être « ne fais JAMAIS ça sauf quand tu sais que ça vaut le coup », et donc « sache identifier les cas où ça vaut mieux de faire ça ».

PS : En bonus, je peux te proposer une autre comparaison qui vaut mieux que celle avec goto : la gestion des erreurs.

De la même façon que pour la gestion de la mémoire, on n’a aucune solution idéale : certains utilisent des exceptions, au prix de découvrir uniquement en production qu’ils ont oublié la clause except qui va bien, d’autres comme Go vont le faire explicitement à travers les valeurs de retour, ce qui a le mérite qu’on ne peut pas ignorer implicitement une erreur : soit on l’ignore explicitement avec _, soit on la traite, soit on oublie et dans ce cas le compilateur nous envoie ballader. D’autres encore, comme Rust, vont passer par leur système de types et une monade Maybe (le type Result), qui a le mérite d’être extrêmement expressive, mais, comme tout en Rust, rend le code beaucoup plus difficile à écrire, et peut-être même moins facile à réellement comprendre (du moins de mon point de vue).

Partant de là, quelle est la meilleure solution ? Encore une fois, c’est une question d’alignement D&D

+0 -0

Merci pour tes efforts, la discussion continue d’être intéressante ! Et désolé pour le temps de réponse.

Très bien, mais est-ce que le problème des go statements est vraiment leur lisibilité où le fait qu’on ne comprenne pas explicitement l’intention des développeurs ? Absolument pas. Si j’écris :

c := make(chan struct{})
go func() {
    defer close(c)
    // ...
}()
// ...
<-c

N’importe qui sachant lire du Go comprend mon intention. Le code concurrent est parfaitement délimité par la fonction anonyme que j’appelle. On sait que le canal sera fermé quand la fonction aura fini de travailler et on sait même à quel moment on va attendre que cette goroutine se termine.

L’analogie n’est peut-être pas parfaite, mais je peux faire précisément le même argument avec goto ! Si j’écris :

goto main1;

func:
  // ...
  fclose(fp)
  goto main2;

main1:
  FILE *fp = fopen("texte.txt", "r");
  // ...
  goto func;
main2:
  //...

N’importe qui sachant lire du C comprend mon intention. La fonction est parfaitement délimitée par le label func. On sait que le fichier sera fermé quand la fonction aura fini de travailler et on sait même où on reprend quand func est terminé.

Tu pourrais me dire que ton exemple était simplifié, mais c’est précisément mon argument : dès qu’on programme à large échelle, alors tout comme goto, go devient bien plus compliqué à utiliser correctement. Et on ne sait pas quel genre d’horreur peut arriver dans les trois petits points.

La différence majeure dans ton exemple est <-c parce qu’il est écrit dans main et pas dans func, mais tu aurais ça gratuitement avec de la concurrence structurée. Et oui, c’est la limite principale de l’analogie.

On n’est pas du tout dans un cas comparable à des goto, parce que si je souhaite écrire du code concurrent, ma pensée est dores et déjà structurée :

  1. j’initialise une structure qui permet de gérer l’arrêt,
  2. je lance ma tâche concurrente,
  3. je m’assure de la terminaison de cette tâche.

Je voudrais profiter de cet exemple pour donner du crédit à Go : la conception générale du langage fait que c’est plus compliqué de se tirer une balle dans le pied. Mais le problème est seulement mitigé, pas évité.

C’est parfaitement comparable à :

  1. j’initialise ma nursery
  2. je lance ma tâche concurrente
  3. j’utilise un context manager pour délimiter quand ma tâche sera finie.

La différence c’est qu’avec les nurseries tu ne peux pas séparer 1. et 3. Si ta pensée est déjà structurée, alors toutes choses égales par ailleurs, pourquoi ne pas écrire dans un langage qui correspond à ta pensée ?

Ou encore:

  1. j’initialise un thread,
  2. je le lance,
  3. m’assure qu’il est join() quelque part.

Pareil, le join() peut être n’importe où, voire complètement oublié ou seulement raté dans des cas rares.

On n’est donc absolument pas sur un problème de structuration de la pensée ni d'intelligibilité comparable aux plats de spaghettis qu’engendraient les langages à base de goto.

Comme tu l’as deviné, je ne suis pas d’accord. Pour moi, la principale différence c’est que des goto tu en as absolument partout, alors que les go seront bien moins fréquents dans du code. Donc le problème se voit moins vite. Quelque part, c’est dommage, parce qu’il se voit trop tard.

Par contre, la classe de problèmes qui vient avec cette gestion manuelle de la concurrence est tout à fait analogue à la classe de problèmes qui vient avec la gestion manuelle de la mémoire.

C’est une analogie intéressante, merci !

Contrairement aux sauts explicites comme goto, les problématiques de gestion de mémoire n’ont jusqu’à présent trouvé aucune solution idéale

Étant donné toutes les vulnérabilités qu’elle a provoqué, je peux dire objectivement que la méthode C est la pire. Le C++ est mieux, mais malheureusement RAII n’est pas obligatoire. Pour Rust, mon expérience est que dans la plupart des cas quand le compilateur te crie dessus c’est qu’effectivement la conception était mauvaise. Malheureusement, il semble qu’arriver au stade "je travaille avec le compilateur, et non plus contre" prenne du temps, et je n’ai pas écrit assez de Rust pour y être arrivé.

Pour les problématiques de concurrence, c’est pareil.

  • Certains vont adopter une solution structurante (la nursery est un peu un ramasse-miettes pour les coroutines…) qui leur fera oublier ces problématiques 99% du temps

Les nurseries ne sont pas du tout un ramasse-miettes, au contraire la gestion des tâches est explicite, donc tu ne peux pas tomber sur un problème déplacé. Pour moi c’est du RAII obligatoire. Et dans mon expérience on est loin de la complexité de Rust, parce que comme tu le dis toi-même plus haut il est naturel de réfléchir de manière structurée quand on écrit du code concurrent.

C’est amusant que tu parles d’overhead. Un des arguments de Knuth pour goto au début était que certains programmes pouvaient s’exécuter avec moins d’instruction avec goto et donc être plus rapides. (Fun fact : il obtient une amélioration de 12%, dit que c’est significatif, et dans le paragraphe d’après sort sa fameuse phrase sur l’optimisation prématurée.)

Je ne pense pas que les nurseries apportent de la lenteur non plus. Il y a probablement des cas où ça demande à structurer le code différemment, et cette structure peut être plus lente que ce qu’on aurait obtenu avec une approche plus directe.

Par analogie avec les problématiques de gestion de la mémoire : quelle approche est la meilleure ? J’en sais rien, c’est une question de contexte et d’alignement D&D. :)

Je continue à penser que les nurseries sont mieux. 🤷

Il faut bien voir que dans tous les cas, adopter une concurrence structurée ne va pas nous affranchir d’un autre problème que je trouve au moins aussi grave : chaque fois qu’on écrit await, on doit systématiquement penser à l’entourer d’un try/except asyncio.CancelledError. Pour moi, récupérer le résultat d’un create_task et devoir se poser la question de ce que j’en fais, ou bien devoir écrire ce try/except et me poser la question de ce que je fais de ma CancelledError sont deux choses du même ordre, et ça me semble primordial de se poser ces question à chaque fois, quitte à choisir la concurrence structurée pour répondre à la première.

Justement, la concurrence structurée apporte une solution élégante à ce problème. D’une part, quand une tâche est annulée, on annule (par défaut) toutes les autres tâches de la nursery. Concernent les CancelledError, effectivement aujourd’hui Trio (et asyncio) ne nous y font pas penser assez. Il était prévu de corriger ça en ajoutant le support de MultiError dans la librairie standard (https://github.com/python-trio/trio/issues/611) mais ça n’a pas avancé autant qu’espéré. Mais c’est un problème d’implémentation, pas de philosophie. Donc oui, la concurrence structurée te fait penser à ce problème.

Si je me refuse à dire à quiconque : « ne fais JAMAIS ça », c’est parce qu’en toute rigueur, ça devrait être « ne fais JAMAIS ça sauf quand tu sais que ça vaut le coup », et donc « sache identifier les cas où ça vaut mieux de faire ça ».

Je préfère préciser quand même que je me refuse aussi à le dire. Je dis simplement qu’il vaut mieux en général éviter go et goto.

De la même façon que pour la gestion de la mémoire, on n’a aucune solution idéale : certains utilisent des exceptions, au prix de découvrir uniquement en production qu’ils ont oublié la clause except qui va bien, d’autres comme Go vont le faire explicitement à travers les valeurs de retour, ce qui a le mérite qu’on ne peut pas ignorer implicitement une erreur : soit on l’ignore explicitement avec _, soit on la traite, soit on oublie et dans ce cas le compilateur nous envoie ballader. D’autres encore, comme Rust, vont passer par leur système de types et une monade Maybe (le type Result), qui a le mérite d’être extrêmement expressive, mais, comme tout en Rust, rend le code beaucoup plus difficile à écrire, et peut-être même moins facile à réellement comprendre (du moins de mon point de vue).

Je n’ai pas d’avis fort entre exceptions et retour d’erreur, mais pour le coup la monade Maybe est très facile à utiliser en Rust. D’autant plus depuis que l’opératur ? a été rajouté.

+0 -0

L’analogie n’est peut-être pas parfaite, mais je peux faire précisément le même argument avec goto ! Si j’écris : … N’importe qui sachant lire du C comprend mon intention.

Justement, non. Ton exemple souffre de l’échelle : si tu ne lis pas la totalité du code, tu ne peux pas savoir quel est le modèle ni l’intention de ce goto (c’est un call ? une boucle ? un break ?). Le mien, non, parce que tu peux parfaitement prendre un sous-ensemble de ce code et comprendre l’intention, lancer une goroutine qui commence par defer close(c) indique précisément que le channel servira à communiquer sur son arrêt : si tu veux savoir où cette tâche est attendue tu as juste à chercher l’endroit où ce channel est consommé, ce que n’importe quel bon éditeur de code rend trivial dans le pire des cas.

La différence c’est qu’avec les nurseries tu ne peux pas séparer 1. et 3. Si ta pensée est déjà structurée, alors toutes choses égales par ailleurs, pourquoi ne pas écrire dans un langage qui correspond à ta pensée ?

Parce que le fait que 1. et 3. ne soient pas séparés ne m’apporte rien de particulier ? Parce qu’il existe des tas de cas dans lesquels je veux que 1 et 3 soient séparés ? Parce que toutes choses ne seront jamais égales par ailleurs ?

Les nurseries ne sont pas du tout un ramasse-miettes, au contraire la gestion des tâches est explicite, donc tu ne peux pas tomber sur un problème déplacé.

Tu n’as pas compris ce que j’ai dit. Les nurseries sont là pour s’assurer qu’à la fin du contexte, toutes les tâches concurrentes sont finies. En ce sens, elles font exactement le même travail qu’un ramasse-miette pour la mémoire : quand on sort du scope, alors on sait que les données de ce scope seront collectées. Que cette collecte soit explicite, implicite, immédiate ou différée ne change rien à la seule classe de problèmes qui reste dans les langages à GC pour contronter les développeurs aux problématiques de gestion de mémoire : les fuites de mémoire.

Cette classe de problèmes se produit parce que le ramasse-miettes fait nécessairement des hypothèses sur les ressources qu’il collecte (particulièrement, il suppose qu’il n’y a pas de dépendances circulaires, car s’il y en a, il ne peut rien libérer) : ce sont des problèmes rares, mais le jour où ils t’arrivent, le fait de ne plus t’être inquiété de la gestion de la mémoire depuis longtemps les rend extrêmement difficiles à aborder.

Pour les nurseries c’est strictement la même chose : une nursery va te garantir que toutes les tâches seront terminées, jusqu’au jour où tu t’en seras servi pour lancer des tâches qui attendent les unes après les autres pour terminer et que la loi de Murphy aura fini par produire une attente circulaire. C’est rare (mais tout à fait possible, je peux te sortir un cas d’école quand tu veux), mais le jour où ça t’arrive, le fait de ne plus t’être inquiété de l’arrêt de tes tâches et de ton modèle de concurrence rend ce problème extrêmement difficile à aborder, parce que tu n’es plus armé pour ça. Et pour cause, puisque la solution consiste à implémenter une abstraction du même ordre que la nursery dont tu as dépendu jusque-là, mais qui tracke les dépendances entre les tâches pour péter une erreur quand tu crées une attente récursive.

Avant même d’atteindre ce cas va d’ailleurs se poser un autre problème: je doute que les nurseries permettent de mutualiser les calculs (ne pas lancer une coroutine qui est déjà en train d’être exécutée avec les mêmes arguments et se contenter d’attendre le future correspondant), puisque pour faire ça, tu as besoin de manipuler le future retourné par create_task et que ne retourne pas trio.Nursery.start_soon. Très bien pourrait-on se dire, dans ce cas ça veut dire qu’on ne peut pas générer une attente circulaire avec une nursery. Certes, mais la fin prévaut sur les moyens : ça signifie aussi que je ne peux pas m’en servir pour implémenter ce dont j’ai besoin, et donc, contrairement à ce que tu dis plus haut, que cette solution a un coût en expressivité qu’on ne pourrait résoudre qu’au prix d’un coût en performances (retourner les Futures et détecter les attentes circulaires).

Ou alors, on admet simplement que ce n’est qu'une solution qui couvre un certain nombre de cas, et on en profite pour attirer l’attention des gens sur les cas qui lui échappent. Dans tous les cas, ça revient à former les gens sur la résolution de ce problème.

+0 -0

Ah, oui, avec les channels effectivement tu t’assures que le code ne continue pas tant que la goroutine n’est pas finie. Je te présente mes excuses, je suis allé un peu vite ici. La synchronisation via le chan est le fameux "3." qu’il faut penser à rajouter en Go.

Que cette collecte soit explicite, implicite, immédiate ou différée ne change rien à la seule classe de problèmes qui reste dans les langages à GC pour contronter les développeurs aux problématiques de gestion de mémoire : les fuites de mémoire.

Mais ça change tout pour les tâches concurrentes. Les nurseries te donnent la garantie que toutes les tâches sont terminées à la fermeture de la nursery, ce qui est essentiel pour s’assurer que le programme est correct, parce qu’une tâche qui continue à tourner dans le fond peut produire des effets de bord, contrairement à de la mémoire non libérée.

Pour les nurseries c’est strictement la même chose : une nursery va te garantir que toutes les tâches seront terminées, jusqu’au jour où tu t’en seras servi pour lancer des tâches qui attendent les unes après les autres pour terminer et que la loi de Murphy aura fini par produire une attente circulaire.

La concurrence structurée n’a jamais prétendu éviter tous les bugs possibles ! Il faut continuer à penser à la synchronisation et aux deadlocks. Trio propose divers outils de synchronisation, et pourrait continuer à en ajouter. Donc, oui, tu peux avoir deux tâches dans une nursery qui s’attendent toutes les deux. Par contre tu peux imposer un timeout aux deux avec un simple context manager, et si l’une lance une exception, l’autre sera annulée. Tu dis qu’avoir une garantie que les tâches terminent implique que les programmeurs vont oublier les autres problèmes de concurrence. Je ne suis pas d’accord, au contraire je dirais que la concurrence structurée permet de mieux s’en préoccuper.

Avant même d’atteindre ce cas va d’ailleurs se poser un autre problème: je doute que les nurseries permettent de mutualiser les calculs (ne pas lancer une coroutine qui est déjà en train d’être exécutée avec les mêmes arguments et se contenter d’attendre le future correspondant), puisque pour faire ça, tu as besoin de manipuler le future retourné par create_task et que ne retourne pas trio.Nursery.start_soon.

Je ne comprends pas de quoi tu parles, peux-tu donner un exemple simple qui illustre ce cas ?

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte