web-dev-qa-db-fra.com

Haskell a-t-il besoin d'un ramasse-miettes?

Je suis curieux de savoir pourquoi les implémentations Haskell utilisent un GC.

Je ne peux pas penser à un cas où GC serait nécessaire dans un langage pur. Est-ce simplement une optimisation pour réduire le nombre de copies ou est-ce vraiment nécessaire?

Je cherche par exemple le code qui fuirait si un GC n'était pas présent.

112
Pubby

Comme d'autres l'ont déjà souligné, Haskell nécessite une gestion automatique , dynamique : la gestion automatique de la mémoire est nécessaire car la gestion manuelle de la mémoire est dangereuse; La gestion dynamique de la mémoire est nécessaire car, pour certains programmes, la durée de vie d'un objet ne peut être déterminée qu'au moment de l'exécution.

Par exemple, considérons le programme suivant:

main = loop (Just [1..1000]) where
  loop :: Maybe [Int] -> IO ()
  loop obj = do
    print obj
    resp <- getLine
    if resp == "clear"
     then loop Nothing
     else loop obj

Dans ce programme, la liste [1..1000] doit être conservée en mémoire jusqu'à ce que l'utilisateur tape "clear"; la durée de vie de ce doit donc être déterminée de manière dynamique, raison pour laquelle la gestion dynamique de la mémoire est nécessaire.

Donc, dans ce sens, il est nécessaire d’allouer automatiquement de la mémoire dynamique, ce qui signifie en pratique: yes , Haskell requiert un garbage collector, car garbage collection est le niveau le plus élevé. -performance automatique gestionnaire de mémoire dynamique.

Toutefois...

Bien qu'un ramasse-miettes soit nécessaire, nous pouvons essayer de trouver des cas particuliers dans lesquels le compilateur peut utiliser un schéma de gestion de la mémoire moins coûteux que le ramassage des ordures. Par exemple, étant donné

f :: Integer -> Integer
f x = let x2 = x*x in x2*x2

nous pourrions espérer que le compilateur détecte que x2 peut être désalloué en toute sécurité lorsque f revient (plutôt que d'attendre que le récupérateur de mémoire libère x2). Pour l’essentiel, nous demandons au compilateur d’effectuer analyse d’échappement pour convertir les allocations en tas amassées en ordures en allocations sur la pile chaque fois que possible.

Ce n’est pas trop déraisonnable de demander: le compilateur jhc haskell le fait, bien que GHC ne le fasse pas. Simon Marlow dit que le ramasse-miettes générationnel de GHC rend l'analyse d'évasion généralement inutile.

jhc utilise en réalité une forme sophistiquée d'analyse d'évasion connue sous le nom de inférence de région . Considérer

f :: Integer -> (Integer, Integer)
f x = let x2 = x * x in (x2, x2+1)

g :: Integer -> Integer
g x = case f x of (y, z) -> y + z

Dans ce cas, une analyse d'échappement simpliste conclurait que x2 s'échappe de f (car il est retourné dans le tuple) et que, par conséquent, x2 doit être alloué sur le segment de mémoire récupéré. L'inférence de région, en revanche, est capable de détecter que x2 peut être désalloué lorsque g revient; L'idée ici est que x2 devrait être alloué dans la région de g plutôt que dans la région de f.

Au-delà de Haskell

Bien que l'inférence de région soit utile dans certains cas, comme indiqué ci-dessus, il semble difficile de concilier efficacement les évaluations paresseuses (voir (Edward Kmett) et Simon Peyton Jones commentaires). Par exemple, considérons

f :: Integer -> Integer
f n = product [1..n]

On pourrait être tenté d’allouer la liste [1..n] sur la pile et de la désallouer après le retour de f, mais cela serait catastrophique: cela changerait f si O(1) mémoire (sous garbage collection) sur O(n) mémoire.

Un travail approfondi a été effectué dans les années 1990 et au début des années 2000 sur l'inférence de région pour le langage fonctionnel strict ML. Mads Tofte, Lars Birkedal, Martin Elsman et Niels Hallenberg ont écrit un rétrospective lisible sur leur travail sur l'inférence de région, qu'ils ont en grande partie intégré au compilateur MLKit . Ils ont expérimenté la gestion de la mémoire purement basée sur les régions (c’est-à-dire pas de ramasse-miettes) ainsi que la gestion de la mémoire hybride basée sur les régions/récupérée, et ont indiqué que leurs programmes de test fonctionnaient "entre 10 fois plus rapidement et 4 fois plus lentement" que la pure ordures. versions collectées.

214
reinerp

Prenons un exemple trivial. Compte tenu de cela

f (x, y)

vous devez attribuer la paire (x, y) quelque part avant d'appeler f. Quand pouvez-vous désallouer cette paire? Tu n'as aucune idée. Il ne peut pas être désalloué lorsque f est renvoyé, car f a peut-être placé la paire dans une structure de données (par exemple, f p = [p]), de sorte que la durée de vie de la paire peut être plus longue que celle renvoyée par f. Maintenant, disons que la paire a été mise dans une liste, celui qui la séparera peut-il la désallouer? Non, car la paire peut être partagée (par exemple, let p = (x, y) in (f p, p)). Il est donc très difficile de dire quand la paire peut être désallouée.

Il en va de même pour presque toutes les attributions en Haskell. Cela dit, il est possible de disposer d’une analyse (analyse de région) qui donne une limite supérieure sur la durée de vie. Cela fonctionne assez bien dans les langues strictes, mais moins dans les langues paresseuses (les langues paresseuses ont tendance à faire beaucoup plus de mutations que les langues strictes dans la mise en œuvre).

J'aimerais donc inverser la question. Pourquoi pensez-vous que Haskell n'a pas besoin de GC? Comment suggéreriez-vous l'allocation de mémoire?

26
augustss

Votre intuition que cela a quelque chose à voir avec la pureté a quelque vérité.

Haskell est considéré comme pur en partie parce que les effets secondaires des fonctions sont pris en compte dans la signature de type. Donc, si une fonction a pour effet secondaire d'imprimer quelque chose, il doit y avoir une IO quelque part dans son type de retour.

Mais il y a une fonction qui est utilisée implicitement partout dans Haskell et dont la signature de type ne rend pas compte, ce qui est en quelque sorte un effet secondaire. À savoir la fonction qui copie certaines données et vous rend deux versions. Sous le capot, cela peut fonctionner littéralement, en dupliquant les données en mémoire, ou «virtuellement» en augmentant une dette qui doit être remboursée plus tard.

Il est possible de concevoir des langages avec des systèmes de types encore plus restrictifs (ceux purement "linéaires") qui interdisent la fonction de copie. Du point de vue d'un programmeur dans un tel langage, Haskell a l'air un peu impur.

En fait, Clean , un membre de la famille de Haskell, a des types linéaires (plus strictement: uniques), ce qui peut donner une idée de ce à quoi ce serait de ne pas autoriser la copie. Mais Nettoyer permet toujours de copier pour des types "non uniques".

Il y a beaucoup de research dans ce domaine et si vous en avez assez sur Google, vous trouverez des exemples de code purement linéaire qui ne nécessite aucune récupération de place. Vous trouverez toutes sortes de systèmes de types pouvant indiquer au compilateur quelle mémoire peut être utilisée, ce qui permet au compilateur d'éliminer une partie du GC.

En un sens, les algorithmes quantiques sont également purement linéaires. Chaque opération étant réversible, aucune donnée ne peut être créée, copiée , ni détruite. (Ils sont également linéaires dans le sens mathématique habituel.)

Il est également intéressant de comparer avec Forth (ou d'autres langages basés sur la pile) qui ont des opérations DUP explicites qui indiquent clairement le moment où une duplication est en cours.

Une autre façon de penser (plus abstraite) est de noter que Haskell est construit à partir d’un calcul lambda simplement typé basé sur la théorie des catégories fermées cartésiennes et que ces catégories sont équipées d’une fonction diagonale diag :: X -> (X, X). Une langue basée sur une autre classe de catégorie pourrait ne pas avoir une telle chose.

Mais en général, la programmation purement linéaire est trop difficile pour être utile, nous nous contentons donc de GC.

15
sigfpe

Les techniques de mise en œuvre standard appliquées à Haskell requièrent en réalité un peu plus de GC que la plupart des autres langages, car elles ne mutent jamais les valeurs précédentes, créant plutôt de nouvelles valeurs modifiées basées sur les précédentes. Dans la mesure où cela signifie que le programme alloue en permanence et utilise plus de mémoire, un grand nombre de valeurs seront ignorées au fil du temps.

C'est pourquoi les programmes GHC ont tendance à avoir des chiffres d'allocation totale aussi élevés (de giga-octets à téraoctets): ils allouent en permanence de la mémoire, et c'est uniquement grâce à l'efficacité du GC qu'ils la récupèrent avant de s'épuiser.

14
ehird

Si un langage (n'importe quel langage) vous permet d'allouer des objets de manière dynamique, il existe trois façons pratiques de gérer la mémoire:

  1. Le langage peut uniquement vous permettre d'allouer de la mémoire sur la pile ou au démarrage. Mais ces restrictions limitent sévèrement les types de calcul qu'un programme peut effectuer. (En pratique. En théorie, vous pouvez émuler des structures de données dynamiques dans (par exemple, Fortran) en les représentant dans un grand tableau. C'est horrible ... et n'est pas pertinent pour cette discussion.)

  2. Le langage peut fournir un mécanisme explicite free ou dispose. Mais cela dépend du programmeur pour bien faire les choses. Toute erreur dans la gestion du stockage peut entraîner une fuite de mémoire ... ou pire.

  3. Le langage (ou plus strictement, l'implémentation du langage) peut fournir un gestionnaire de stockage automatique pour le stockage alloué dynamiquement; c'est-à-dire une forme de ramasse-miettes.

La seule autre option est de ne jamais récupérer le stockage alloué dynamiquement. Ce n'est pas une solution pratique, sauf pour les petits programmes effectuant de petits calculs.

En appliquant cela à Haskell, le langage n’a pas la limite de 1, et il n’existe pas d’opération de désallocation manuelle selon le point 2. Par conséquent, pour être utilisable à des fins non triviales, une implémentation de Haskell doit inclure un ramasse-miettes .

Je ne peux pas penser à un cas où GC serait nécessaire dans un langage pur.

Vous voulez probablement dire un langage purement fonctionnel.

La réponse est qu'un GC est nécessaire sous le capot pour récupérer les objets de tas que le langage DOIT créer. Par exemple.

  • Une fonction pure doit créer des objets de tas, car elle doit parfois les renvoyer. Cela signifie qu'ils ne peuvent pas être alloués sur la pile.

  • Le fait qu'il puisse y avoir des cycles (résultant d'un let rec par exemple) signifie qu'une approche de comptage de références ne fonctionnera pas pour les objets de tas.

  • Ensuite, il y a les fermetures de fonctions ... qui ne peuvent pas non plus être allouées sur la pile car leur durée de vie est (généralement) indépendante du cadre de la pile dans lequel elles ont été créées.

Je cherche un exemple de code qui fuirait si un GC n'était pas présent.

À peu près tous les exemples impliquant des fermetures ou des structures de données en forme de graphique présenteraient des fuites dans ces conditions. 

10
Stephen C

Un ramasse-miettes n'est jamais nécessaire, à condition que vous disposiez d'une mémoire suffisante. Cependant, en réalité, nous n'avons pas de mémoire infinie et nous avons donc besoin d'une méthode pour récupérer de la mémoire qui n'est plus nécessaire. Dans des langages impurs comme C, vous pouvez indiquer explicitement que vous en avez assez avec de la mémoire pour la libérer - mais il s'agit d'une opération de mutation (la mémoire que vous venez de libérer n'est plus sécurisée pour la lecture). une langue pure. Donc, il faut soit analyser statiquement où vous pouvez libérer la mémoire (probablement impossible dans le cas général), fuir la mémoire comme un tamis (fonctionne très bien jusqu'à épuisement) ou utiliser un CPG.

8
bdonlan

Haskell est un langage de programmation non strict, mais la plupart des implémentations utilisent appel par besoin (paresse) pour implémenter la non-stricte. Appel par besoin, vous n'évaluez les éléments que lorsqu'ils sont atteints en cours d'exécution à l'aide de la machinerie de "thunks" (expressions qui attendent d'être évaluées puis se réécrivent elles-mêmes, en restant visibles pour que leur valeur soit réutilisée en cas de besoin).

Ainsi, si vous implémentez votre langage avec parcimonie à l'aide de thunks, vous avez reporté tout raisonnement relatif à la durée de vie des objets jusqu'au dernier moment, qui correspond à l'exécution. Puisque vous ne connaissez plus rien de la durée de vie, la seule chose que vous pouvez raisonnablement faire est de ramasser les ordures ...

1
gfour

GC est "indispensable" dans les langues FP pures. Pourquoi? Les opérations allouées et gratuites sont impures! Et la deuxième raison est que les structures de données récursives immuables ont besoin de la GC pour exister, car la liaison en amont crée des structures abstruses et impossibles à maintenir pour l'esprit humain. Bien sûr, le backlinking est une bénédiction, car la copie des structures qui l'utilisent est très bon marché.

Quoi qu'il en soit, si vous ne me croyez pas, essayez simplement de mettre en œuvre le langage FP et vous verrez que j'ai raison.

EDIT: j'ai oublié. La paresse est un enfer sans GC. Ne me crois pas? Essayez-le simplement sans GC, par exemple en C++. Vous allez voir ... des choses

0
Seraph