J'ai lu à plusieurs reprises que les langages fonctionnels sont idéaux (ou du moins très souvent utiles) pour le parallélisme. Pourquoi est-ce? Quels concepts et paradigmes fondamentaux sont généralement employés et quels problèmes spécifiques résolvent-ils?
À un niveau abstrait, par exemple, je peux voir comment l'immuabilité pourrait être utile pour prévenir les conditions de course ou d'autres problèmes découlant de la concurrence des ressources, mais je ne peux pas l'exprimer plus spécifiquement que cela. Veuillez noter, cependant, que ma question a une portée plus large que juste immuabilité - une bonne réponse fournira des exemples de plusieurs concepts pertinents.
La raison principale est que la transparence référentielle (et plus encore la paresse) fait abstraction de l'ordre d'exécution. Cela rend trivial la parallélisation de l'évaluation.
Par exemple, si les deux a
, b
et ||
sont référentiellement transparents, alors peu importe si dans
a || b
a
est évalué en premier, b
est évalué en premier, ou b
n'est pas évalué du tout (car a
a été évalué en true
).
Dans
a || a
cela n'a pas d'importance si a
est évalué une ou deux fois (ou, diable, même 5 fois… ce qui n'aurait pas de sens, mais n'a pas d'importance quand même).
Donc, si peu importe l'ordre dans lequel ils sont évalués et peu importe s'ils sont évalués inutilement, alors vous pouvez simplement évaluer chaque sous-expression en parallèle. Ainsi, nous pourrions évaluer a
et b
en parallèle, puis, ||
pourrait attendre la fin de l'un des deux threads, regarder ce qu'il a renvoyé, et s'il renvoyait true
, il pourrait même annuler l'autre et retourner immédiatement true
.
Chaque sous-expression peut être évaluée en parallèle. Trivialement.
Notez, cependant, que ce n'est pas une solution miracle. Certaines premières versions expérimentales de GHC l'ont fait, et ce fut un désastre: il y avait juste trop parallélisme potentiel. Même un simple programme peut engendrer des centaines, des milliers, des millions de threads et pour l'écrasante majorité des sous-expressions, la génération du thread prend beaucoup plus de temps que l'évaluation de l'expression en premier lieu. Avec autant de threads, le temps de changement de contexte domine complètement tout calcul utile.
On pourrait dire que la programmation fonctionnelle tourne le problème sur sa tête: généralement, le problème est de savoir comment séparer un programme série en juste la bonne taille de "morceaux" parallèles, alors qu'avec la programmation fonctionnelle, le problème est de savoir comment regrouper les sous-parallèles -programmes en "morceaux" en série.
La façon dont GHC le fait aujourd'hui est que vous pouvez annoter manuellement deux sous-expressions à évaluer en parallèle. Ceci est en fait similaire à la façon dont vous le feriez dans un langage impératif également, en mettant les deux expressions dans des threads séparés. Mais il y a une différence importante: l'ajout de cette annotation ne peut jamais changer le résultat du programme! Il peut le rendre plus rapide, il peut le ralentir, il peut lui faire utiliser plus de mémoire, mais il ne peut pas changer son résultat. Cela rend façon plus facile d'expérimenter avec le parallélisme pour trouver juste la bonne quantité de parallélisme et la bonne taille de morceaux.
Voyons d'abord pourquoi la programmation procédurale est si mauvaise sur les threads simultanés.
Avec un modèle de programmation simultanée, vous écrivez des instructions séquentielles qui (par défaut) s'attendent à être exécutées de manière isolée. Lorsque vous introduisez plusieurs threads, vous devez contrôler explicitement l'accès pour empêcher l'accès simultané à une variable partagée lorsque ces modifications peuvent s'influencer mutuellement. Il s'agit d'une programmation difficile à réaliser correctement et, lors des tests, il est impossible de prouver qu'elle a été effectuée en toute sécurité. Au mieux, vous ne pouvez que confirmer que lors de ce test, aucun problème observable ne s'est produit.
En programmation fonctionnelle, le problème est différent. Il n'y a pas de données partagées. Il n'y a pas d'accès simultané à la même variable. En effet, vous ne pouvez définir une variable qu'une seule fois, il n'y a pas de boucles "for", il n'y a que des blocs de code qui, lorsqu'ils sont exécutés avec un ensemble de valeurs donné, produiront toujours le même résultat. Cela rend les tests à la fois prévisibles et un bon indicateur d'exactitude.
Le problème que le développeur doit résoudre en programmation fonctionnelle est de savoir comment concevoir la solution en minimisant l'état commun. Lorsque le problème de conception nécessite des niveaux élevés de simultanéité et un état partagé minimal, les implémentations fonctionnelles sont une stratégie très efficace.
État partagé minimisé
En quoi la programmation fonctionnelle la rend-elle intrinsèquement adaptée à l'exécution parallèle?
La nature pure des fonctions ( transparence référentielle ), c'est-à-dire n'ayant pas d'effets secondaires, conduit à moins d'objets partagés et donc à moins d'état partagé.
Un exemple simple est;
double CircleCircumference(double radius)
{
return 2 * 3.14 * radius; // constants for illustration
}
La sortie dépend uniquement de l'entrée, aucun état n'est inclus; contrairement à une fonction telle que GetNextStep();
où la sortie dépend de ce qu'est l'étape en cours (généralement conservée en tant que membre de données d'un objet).
C'est l'état partagé qui doit être contrôlé via des mutex et des verrous. Le fait d'avoir des garanties plus solides sur l'état partagé permet de meilleures optimisations parallèles et une meilleure composition parallèle.
Transparence référentielle et expressions pures
Plus de détails sur ce qu'est la transparence référentielle peuvent être trouvés ici sur Programmers.SE .
[Cela] signifie que vous pouvez remplacer n'importe quelle expression dans le programme avec le résultat de l'évaluation de cette expression (ou vice versa) sans changer la signification du programme. Jörg W Mittag
Ce qui permet à son tour expressions pures qui sont utilisées pour construire des fonctions pures - des fonctions qui évaluent le même résultat étant donné les mêmes arguments d'entrée. Chaque expression peut ainsi être évaluée en parallèle.
La partie la plus difficile de l'écriture de code parallèle consiste à empêcher un thread de lire les données mises à jour par un autre thread.
Une solution courante consiste à utiliser des objets immuables , de sorte qu'une fois qu'un objet est créé, il ne soit jamais mis à jour. Mais dans la vie réelle, les données doivent être modifiées, donc données de "persistance" est utilisé, où chaque mise à jour renvoie un nouvel objet - cela peut être rendu efficace en choisissant soigneusement les structures de données.
Comme la programmation fonctionnelle ne permet aucun effet secondaire, les "objets de persistance" sont normalement utilisés lors de la programmation fonctionnelle. Ainsi, la douleur qu'un programmeur fonctionnel est obligé de subir en raison du manque d'effets secondaires, conduit à une solution qui fonctionne bien pour la programmation parallèle.
Un avantage supplémentaire étant que les systèmes de langage fonctionnels vérifient que vous respectez les règles et disposent de nombreux outils pour vous y aider.
Le code fonctionnel pur est thread-safe par défaut.
En soi, c'est déjà une énorme victoire. Dans d'autres langages de programmation, la conception de blocs de code qui sont complètement thread-safe peut être vraiment, vraiment difficile. Mais un langage fonctionnel pur oblige à rendre tout thread-safe, sauf dans les rares endroits où vous faites explicitement quelque chose qui n'est pas thread-safe. Vous pourriez dire que dans le code impératif, être thread-safe est explicite [et donc généralement rare], tandis que dans le code fonctionnel pur, thread-unsafe est explicite [et donc généralement rare].
Dans un langage impératif, la plupart du problème est de savoir comment ajouter suffisamment de verrouillage pour éviter des courses de données étranges, mais pas trop pour tuer les performances ou provoquer des blocages aléatoires. Dans un langage purement fonctionnel, la plupart du temps, ces considérations sont sans objet. Le problème est maintenant de savoir "seulement" comment répartir le travail de manière égale.
À un extrême, vous avez un petit nombre de tâches parallèles qui n'utilisent pas tous les cœurs disponibles. À l'autre extrême, vous avez des milliards de petites tâches qui introduisent tellement de frais généraux que tout ralentit. Bien sûr, il est souvent très difficile d'essayer de déterminer "la quantité de travail" d'un appel de fonction particulier.
Tous ces problèmes existent également dans le code impératif; c'est juste que les programmeurs impératifs sont généralement trop occupés à simplement essayer de faire fonctionner la chose du tout pour se soucier d'un réglage fin et délicat de la taille des tâches.