Benötigt Haskell einen Müllsammler?


118

Ich bin gespannt, warum Haskell-Implementierungen einen GC verwenden.

Ich kann mir keinen Fall vorstellen, in dem GC in einer reinen Sprache notwendig wäre. Ist es nur eine Optimierung, um das Kopieren zu reduzieren, oder ist es tatsächlich notwendig?

Ich suche nach Beispielcode, der auslaufen würde, wenn kein GC vorhanden wäre.


14
Vielleicht finden Sie diese Serie aufschlussreich; Es behandelt, wie Müll erzeugt (und anschließend gesammelt) wird: blog.ezyang.com/2011/04/the-haskell-heap
Tom Crockett

5
Es gibt überall Referenzen in reinen Sprachen! nur keine veränderlichen Referenzen.
Tom Crockett

1
@pelotom Verweise auf unveränderliche Daten oder unveränderliche Verweise?
Pubby

3
Beide. Die Tatsache, dass die genannten Daten unveränderlich sind, ergibt sich aus der Tatsache, dass alle Referenzen bis zum Ende unveränderlich sind.
Tom Crockett

4
Das Problem des Anhaltens wird Sie sicherlich interessieren , da die Anwendung dieser Argumentation auf die Speicherzuweisung hilft, zu verstehen, warum die Freigabe im allgemeinen Fall nicht statisch vorhergesagt werden kann . Es gibt jedoch einige Programme, für die eine Freigabe vorhergesagt werden kann, genauso wie es einige Programme sind, von denen bekannt ist, dass sie beendet werden, ohne sie tatsächlich auszuführen.
Paul R

Antworten:


218

Wie andere bereits betont haben, erfordert Haskell eine automatische , dynamische Speicherverwaltung: Eine automatische Speicherverwaltung ist erforderlich, da die manuelle Speicherverwaltung nicht sicher ist. Eine dynamische Speicherverwaltung ist erforderlich, da bei einigen Programmen die Lebensdauer eines Objekts nur zur Laufzeit bestimmt werden kann.

Betrachten Sie beispielsweise das folgende Programm:

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

In diesem Programm muss die Liste [1..1000]gespeichert bleiben, bis der Benutzer "clear" eingibt. Daher muss die Lebensdauer dynamisch bestimmt werden. Aus diesem Grund ist eine dynamische Speicherverwaltung erforderlich.

In diesem Sinne ist eine automatisierte dynamische Speicherzuweisung erforderlich. In der Praxis bedeutet dies: Ja , Haskell benötigt einen Garbage Collector, da die Garbage Collection der leistungsstärkste automatische dynamische Speichermanager ist.

Jedoch...

Obwohl ein Garbage Collector erforderlich ist, versuchen wir möglicherweise, einige Sonderfälle zu finden, in denen der Compiler ein billigeres Speicherverwaltungsschema als die Garbage Collection verwenden kann. Zum Beispiel gegeben

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

Wir können hoffen, dass der Compiler x2erkennt, dass die Zuordnung bei der fRückgabe sicher aufgehoben werden kann (anstatt darauf zu warten, dass der Garbage Collector die Zuordnung aufhebt x2). Im Wesentlichen bitten wir den Compiler, eine Escape-Analyse durchzuführen , um Zuweisungen in Zuordnungen auf dem Stapel zu konvertieren , wo immer dies möglich ist.

Dies ist nicht zu unangemessen, um danach zu fragen: Der jhc-Hashkell-Compiler tut dies, GHC jedoch nicht. Simon Marlow sagt, dass der Garbage Collector von GHC die Fluchtanalyse größtenteils unnötig macht.

jhc verwendet tatsächlich eine ausgeklügelte Form der Fluchtanalyse, die als Regionsinferenz bekannt ist . Erwägen

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

In diesem Fall würde eine vereinfachte Escape-Analyse ergeben, dass Escape x2von f(weil es im Tupel zurückgegeben wird) und daher x2auf dem durch Müll gesammelten Heap zugewiesen werden muss. Die Regionsinferenz hingegen kann erkennen, dass x2die Zuordnung bei der gRückkehr aufgehoben werden kann . Die Idee hier ist, dass x2die Zuordnung eher in gder Region als in fder Region erfolgen sollte.

Jenseits von Haskell

Während die Inferenz von Regionen in bestimmten Fällen hilfreich ist, wie oben erläutert, scheint es schwierig zu sein, sie effektiv mit einer verzögerten Bewertung in Einklang zu bringen (siehe die Kommentare von Edward Kmett und Simon Peyton Jones ). Betrachten Sie zum Beispiel

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

Man könnte versucht sein, die Liste [1..n]auf dem Stapel zuzuweisen und sie nach der fRückgabe freizugeben , aber dies wäre katastrophal: Sie würde sich fvon der Verwendung von O (1) -Speicher (unter Garbage Collection) zu O (n) -Speicher ändern .

In den 1990er und frühen 2000er Jahren wurden umfangreiche Arbeiten zur regionalen Inferenz für die strenge funktionale Sprache ML durchgeführt. Mads Tofte, Lars Birkedal, Martin Elsman und Niels Hallenberg haben eine gut lesbare Retrospektive über ihre Arbeit zur Regionsinferenz geschrieben , von der sie einen Großteil in den MLKit-Compiler integriert haben . Sie experimentierten mit einer rein region-basierten Speicherverwaltung (dh ohne Garbage Collector) sowie einer hybriden region-basierten / Garbage-Collected-Speicherverwaltung und berichteten, dass ihre Testprogramme "zwischen 10-mal schneller und 4-mal langsamer" liefen als reiner Garbage- gesammelte Versionen.


2
Muss Haskell geteilt werden? Wenn nicht, können Sie in Ihrem ersten Beispiel eine Kopie der Liste (bzw. Nothing) an den rekursiven Aufruf von übergeben loopund die alte freigeben - keine unbekannte Lebensdauer. Natürlich möchte niemand eine nicht gemeinsam genutzte Implementierung von Haskell, da dies für große Datenstrukturen schrecklich langsam ist.
Nimi

3
Diese Antwort gefällt mir sehr gut, obwohl ich nur mit dem ersten Beispiel verwechselt werde. Wenn der Benutzer niemals "clear" eingegeben hat, könnte er natürlich unendlichen Speicher (ohne GC) verwenden, aber das ist nicht gerade ein Leck, da der Speicher noch verfolgt wird.
Pubby

3
C ++ 11 bietet eine wunderbare Implementierung von intelligenten Zeigern. Grundsätzlich wird die Referenzzählung verwendet. Ich denke, Haskell könnte die Müllabfuhr zugunsten von etwas Ähnlichem fallen lassen und daher deterministisch werden.
Intrepidis

3
@ ChrisNash - Funktioniert nicht. Intelligente Zeiger verwenden die Referenzzählung unter der Haube. Die Referenzzählung kann keine Datenstrukturen mit Zyklen behandeln. Haskell kann Datenstrukturen mit Zyklen erzeugen.
Stephen C

3
Ich bin mir nicht sicher, ob ich mit dem dynamischen Speicherzuweisungsteil dieser Antwort einverstanden bin. Nur weil das Programm nicht weiß, wann ein Benutzer die zeitliche Schleife beendet, sollte es nicht dynamisch werden. Dies hängt davon ab, ob der Compiler weiß, ob etwas aus dem Kontext gerät. In Haskells Fall, in dem dies formal durch die Sprachgrammatik selbst definiert ist, ist der Lebenskontext bekannt. Der Speicher kann jedoch weiterhin dynamisch sein, da die Listenausdrücke und der Typ innerhalb der Sprache dynamisch generiert werden.
Timothy Swan

27

Nehmen wir ein triviales Beispiel. Angesichts dessen

f (x, y)

Sie müssen das Paar (x, y)irgendwo zuweisen, bevor Sie anrufen f. Wann können Sie das Paar freigeben? Sie haben keine Ahnung. Es kann nicht freigegeben werden, wenn fzurückgegeben wird, da fdas Paar möglicherweise in eine Datenstruktur (z. B. f p = [p]) eingefügt wurde , sodass die Lebensdauer des Paares möglicherweise länger sein muss als die Rückgabe von f. Angenommen, das Paar wurde in eine Liste aufgenommen. Kann jemand, der die Liste auseinander nimmt, das Paar freigeben? Nein, da das Paar möglicherweise gemeinsam genutzt wird (z let p = (x, y) in (f p, p). B. ). Es ist also sehr schwer zu sagen, wann das Paar freigegeben werden kann.

Gleiches gilt für fast alle Zuteilungen in Haskell. Es ist jedoch möglich, eine Analyse (Regionsanalyse) durchzuführen, die eine Obergrenze für die Lebensdauer angibt. Dies funktioniert ziemlich gut in strengen Sprachen, aber weniger in faulen Sprachen (faule Sprachen neigen dazu, in der Implementierung viel mehr Mutationen zu bewirken als strenge Sprachen).

Also möchte ich die Frage umdrehen. Warum braucht Haskell Ihrer Meinung nach keine GC? Wie würden Sie die Speicherzuweisung vorschlagen?


18

Ihre Intuition, dass dies etwas mit Reinheit zu tun hat, hat etwas Wahres.

Haskell wird teilweise als rein angesehen, da Nebenwirkungen von Funktionen in der Typensignatur berücksichtigt werden. Wenn eine Funktion den Nebeneffekt hat, etwas zu drucken, muss sich IOirgendwo in ihrem Rückgabetyp eine befinden.

Es gibt jedoch eine Funktion, die implizit überall in Haskell verwendet wird und deren Typensignatur in gewisser Weise keinen Nebeneffekt berücksichtigt. Nämlich die Funktion, die einige Daten kopiert und Ihnen zwei Versionen zurückgibt. Unter der Haube kann dies entweder buchstäblich funktionieren, indem die Daten im Speicher dupliziert werden, oder "virtuell", indem eine Schuld erhöht wird, die später zurückgezahlt werden muss.

Es ist möglich, Sprachen mit noch restriktiveren Typsystemen (rein "lineare") zu entwerfen, die die Kopierfunktion nicht zulassen. Aus der Sicht eines Programmierers in einer solchen Sprache sieht Haskell etwas unrein aus.

Tatsächlich hat Clean , ein Verwandter von Haskell, lineare (genauer: eindeutige) Typen, und das kann eine Vorstellung davon geben, wie es wäre, das Kopieren zu verbieten. Clean ermöglicht jedoch weiterhin das Kopieren für "nicht eindeutige" Typen.

In diesem Bereich gibt es viele Recherchen. Wenn Sie genug googeln, finden Sie Beispiele für reinen linearen Code, für den keine Speicherbereinigung erforderlich ist. Sie finden alle Arten von Typsystemen, die dem Compiler signalisieren können, welcher Speicher verwendet werden kann, sodass der Compiler einen Teil des GC eliminieren kann.

In gewisser Weise sind Quantenalgorithmen auch rein linear. Jeder Vorgang ist umkehrbar, sodass keine Daten erstellt, kopiert oder zerstört werden können. (Sie sind auch linear im üblichen mathematischen Sinne.)

Es ist auch interessant, mit Forth (oder anderen stapelbasierten Sprachen) zu vergleichen, die explizite DUP-Operationen haben, die deutlich machen, wann eine Duplizierung stattfindet.

Eine andere (abstraktere) Art, darüber nachzudenken, besteht darin, festzustellen, dass Haskell aus einer einfach getippten Lambda-Rechnung aufgebaut ist, die auf der Theorie der kartesischen geschlossenen Kategorien basiert, und dass solche Kategorien mit einer diagonalen Funktion ausgestattet sind diag :: X -> (X, X). Eine Sprache, die auf einer anderen Klassenklasse basiert, hat möglicherweise keine solche Sprache.

Im Allgemeinen ist eine rein lineare Programmierung jedoch zu schwierig, um nützlich zu sein. Deshalb entscheiden wir uns für GC.


3
Seit ich diese Antwort geschrieben habe, hat die Programmiersprache Rust an Popularität gewonnen. Erwähnenswert ist also, dass Rust ein lineares System zur Steuerung des Speicherzugriffs verwendet, und es lohnt sich, einen Blick darauf zu werfen, ob Sie die in der Praxis verwendeten Ideen sehen möchten.
Sigfpe

14

Die auf Haskell angewendeten Standardimplementierungstechniken erfordern tatsächlich mehr GC als die meisten anderen Sprachen, da sie niemals vorherige Werte mutieren, sondern neue, modifizierte Werte basierend auf den vorherigen erstellen. Da dies bedeutet, dass das Programm ständig mehr Speicher reserviert und verwendet, wird eine große Anzahl der Werte im Laufe der Zeit verworfen.

Aus diesem Grund weisen GHC-Programme in der Regel so hohe Gesamtzuweisungszahlen (von Gigabyte bis Terabyte) auf: Sie weisen ständig Speicher zu, und nur dank des effizienten GC können sie ihn vor dem Auslaufen zurückfordern.


2
"Sie mutieren niemals vorherige Werte": Sie können haskell.org/haskellwiki/HaskellImplementorsWorkshop/2011/Takano überprüfen . Es handelt sich um eine experimentelle GHC-Erweiterung, die Speicher wiederverwendet.
Vier

11

Wenn Sie mit einer Sprache (einer beliebigen Sprache) Objekte dynamisch zuweisen können, gibt es drei praktische Möglichkeiten, um mit der Speicherverwaltung umzugehen:

  1. Mit der Sprache können Sie nur Speicher auf dem Stapel oder beim Start zuweisen. Diese Einschränkungen schränken jedoch die Arten von Berechnungen, die ein Programm ausführen kann, stark ein. (In der Praxis. Theoretisch können Sie dynamische Datenstrukturen in (sagen wir) Fortran emulieren, indem Sie sie in einem großen Array darstellen. Es ist SCHRECKLICH ... und für diese Diskussion nicht relevant.)

  2. Die Sprache kann einen expliziten freeoder disposeMechanismus bereitstellen . Dies hängt jedoch vom Programmierer ab, um es richtig zu machen. Jeder Fehler in der Speicherverwaltung kann zu einem Speicherverlust führen ... oder schlimmer.

  3. Die Sprache (oder genauer gesagt die Sprachimplementierung) kann einen automatischen Speichermanager für den dynamisch zugewiesenen Speicher bereitstellen. dh irgendeine Form von Müllsammler.

Die einzige andere Möglichkeit besteht darin, niemals dynamisch zugewiesenen Speicher zurückzugewinnen. Dies ist keine praktische Lösung, außer für kleine Programme, die kleine Berechnungen durchführen.

Wenn Sie dies auf Haskell anwenden, hat die Sprache nicht die Einschränkung von 1. Es gibt keine manuelle Freigabeoperation gemäß 2. Um für nicht triviale Dinge verwendet werden zu können, muss eine Haskell-Implementierung einen Garbage Collector enthalten .

Ich kann mir keinen Fall vorstellen, in dem GC in einer reinen Sprache notwendig wäre.

Vermutlich meinst du eine reine funktionale Sprache.

Die Antwort ist, dass ein GC unter der Haube erforderlich ist, um die Heap-Objekte zurückzugewinnen, die die Sprache erstellen muss. Beispielsweise.

  • Eine reine Funktion muss Heap-Objekte erstellen, da sie in einigen Fällen zurückgegeben werden muss. Das bedeutet, dass sie nicht auf dem Stapel zugeordnet werden können.

  • Die Tatsache, dass es Zyklen geben kann (die sich beispielsweise aus einem ergeben let rec), bedeutet, dass ein Referenzzählungsansatz für Heap-Objekte nicht funktioniert.

  • Dann gibt es Funktionsabschlüsse ... die auch nicht auf dem Stapel zugeordnet werden können, da sie eine Lebensdauer haben, die (normalerweise) unabhängig von dem Stapelrahmen ist, in dem sie erstellt wurden.

Ich suche nach Beispielcode, der auslaufen würde, wenn kein GC vorhanden wäre.

Nahezu jedes Beispiel, bei dem es um Schließungen oder grafische Datenstrukturen ging, würde unter diesen Bedingungen auslaufen.


2
Warum ist Ihre Liste der Optionen Ihrer Meinung nach vollständig? ARC in Ziel C, Regionsinferenz in MLKit und DDC, Garbage Collection zur Kompilierungszeit in Mercury - alle passen nicht in diese Liste.
Dee Mon

@DeeMon - alle passen in eine dieser Kategorien. Wenn Sie glauben, dass dies nicht der Fall ist, liegt dies daran, dass Sie die Kategoriengrenzen zu eng ziehen. Wenn ich "irgendeine Form der Speicherbereinigung " sage, meine ich jeden Mechanismus, bei dem Speicher automatisch zurückgefordert wird.
Stephen C

1
C ++ 11 verwendet intelligente Zeiger. Grundsätzlich wird die Referenzzählung verwendet. Es ist deterministisch und automatisch. Ich würde gerne sehen, dass eine Implementierung von Haskell diese Methode verwendet.
Intrepidis

2
@ ChrisNash - 1) Es würde nicht funktionieren. Bei der Reklamation auf Basis der Referenzzählung werden Daten verloren, wenn Zyklen vorhanden sind ... es sei denn, Sie können sich auf den Anwendungscode verlassen, um die Zyklen zu unterbrechen. 2) Es ist bekannt (für Leute, die diese Dinge studieren), dass die Referenzzählung im Vergleich zu einem modernen (echten) Müllsammler schlecht funktioniert.
Stephen C

@DeeMon - siehe auch Reinerps Antwort, warum Regionsinferenz bei Haskell nicht praktikabel wäre.
Stephen C

8

Ein Garbage Collector ist niemals erforderlich, sofern Sie über ausreichend Speicher verfügen. In Wirklichkeit haben wir jedoch kein unendliches Gedächtnis, und daher benötigen wir eine Methode, um nicht mehr benötigtes Gedächtnis zurückzugewinnen. In unreinen Sprachen wie C können Sie explizit angeben, dass Sie mit etwas Speicher fertig sind, um ihn freizugeben. Dies ist jedoch eine mutierende Operation (der gerade freigegebene Speicher ist nicht mehr sicher zu lesen), sodass Sie diesen Ansatz nicht verwenden können eine reine Sprache. Es ist also entweder statisch zu analysieren, wo Sie den Speicher freigeben können (im allgemeinen Fall wahrscheinlich unmöglich), Speicher wie ein Sieb zu verlieren (funktioniert hervorragend, bis Sie leer sind) oder einen GC zu verwenden.


Dies antwortet, warum ein GC im Allgemeinen nicht notwendig ist, aber ich interessiere mich mehr für Haskell im Besonderen.
Pubby

10
Wenn ein GC im Allgemeinen theoretisch unnötig ist, folgt daraus trivial, dass er auch für Haskell theoretisch unnötig ist.
ehird

@ehird Ich wollte sagen, notwendig , ich denke, meine Rechtschreibprüfung hat die Bedeutung umgedreht.
Pubby

1
Der dritte Kommentar gilt noch :-)
Paul R

2

GC ist in reinen FP-Sprachen ein "Muss". Warum? Operations Allokation und Free sind unrein! Und der zweite Grund ist, dass unveränderliche rekursive Datenstrukturen GC für ihre Existenz benötigen, da durch die Verknüpfung abstruse und nicht wartbare Strukturen für den menschlichen Geist entstehen. Backlinking ist natürlich ein Segen, denn das Kopieren von Strukturen, die es verwenden, ist sehr billig.

Wie auch immer, wenn Sie mir nicht glauben, versuchen Sie einfach, die FP-Sprache zu implementieren, und Sie werden sehen, dass ich Recht habe.

EDIT: Ich habe es vergessen. Faulheit ist HÖLLE ohne GC. Glaubst du mir nicht? Versuchen Sie es einfach ohne GC, zum Beispiel in C ++. Sie werden ... Dinge sehen


1

Haskell ist eine nicht strenge Programmiersprache, aber die meisten Implementierungen verwenden Call-by-Need (Faulheit), um Nicht-Strenge zu implementieren. In Call-by-Need bewerten Sie Inhalte nur dann, wenn sie zur Laufzeit mithilfe der Maschinerie von "Thunks" erreicht werden (Ausdrücke, die darauf warten, ausgewertet zu werden, und sich dann selbst überschreiben und sichtbar bleiben, damit ihr Wert bei Bedarf wiederverwendet werden kann).

Wenn Sie Ihre Sprache also träge mit Thunks implementieren, haben Sie alle Überlegungen zur Objektlebensdauer bis zum letzten Moment, der Laufzeit, verschoben. Da Sie jetzt nichts über Lebenszeiten wissen, können Sie vernünftigerweise nur Müll sammeln ...


1
In einigen Fällen kann eine statische Analyse in den Thunks-Code eingefügt werden, wodurch einige Daten freigegeben werden, nachdem der Thunk ausgewertet wurde. Die Freigabe erfolgt zur Laufzeit, es handelt sich jedoch nicht um GC. Dies ähnelt der Idee, intelligente Zeiger in C ++ zu zählen. Das Nachdenken über die Lebensdauer von Objekten erfolgt dort zur Laufzeit, es wird jedoch kein GC verwendet.
Dee Mon
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.