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 :
- j’initialise une structure qui permet de gérer l’arrêt,
- je lance ma tâche concurrente,
- 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 à :
- j’initialise ma nursery
- je lance ma tâche concurrente
- 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:
- j’initialise un thread,
- je le lance,
- 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é.