Haskell richiede un garbage collector?


118

Sono curioso di sapere perché le implementazioni Haskell utilizzano un GC.

Non riesco a pensare a un caso in cui GC sarebbe necessario in un linguaggio puro. È solo un'ottimizzazione per ridurre la copia o è effettivamente necessaria?

Sto cercando un codice di esempio che potrebbe perdere se un GC non fosse presente.


14
Potresti trovare questa serie illuminante; spiega come vengono generati i rifiuti (e successivamente raccolti): blog.ezyang.com/2011/04/the-haskell-heap
Tom Crockett

5
ci sono riferimenti ovunque in lingue pure! solo riferimenti non mutabili .
Tom Crockett

1
@pelotom Riferimenti a dati immutabili o riferimenti immutabili?
Pubby

3
Tutti e due. Il fatto che i dati a cui si fa riferimento siano immutabili deriva dal fatto che tutti i riferimenti sono immutabili, fino in fondo.
Tom Crockett

4
Sarai sicuramente interessato al problema dell'arresto , poiché l'applicazione di questo ragionamento all'allocazione della memoria aiuta a capire perché la deallocazione non può essere prevista staticamente nel caso generale . Tuttavia ci sono alcuni programmi per i quali è possibile prevedere la deallocazione, proprio come lo sono alcuni programmi che possono essere noti per terminare senza effettivamente eseguirli.
Paul R

Risposte:


218

Come altri hanno già sottolineato, Haskell richiede una gestione della memoria automatica e dinamica : la gestione automatica della memoria è necessaria perché la gestione manuale della memoria non è sicura; la gestione dinamica della memoria è necessaria perché per alcuni programmi la durata di un oggetto può essere determinata solo in fase di runtime.

Ad esempio, considera il seguente programma:

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 questo programma, l'elenco [1..1000]deve essere mantenuto in memoria fino a quando l'utente non digita "cancella"; quindi la durata di questo deve essere determinata dinamicamente, ed è per questo che è necessaria la gestione dinamica della memoria.

Quindi, in questo senso, è necessaria l'allocazione dinamica della memoria automatizzata, e in pratica questo significa: , Haskell richiede un garbage collector, dal momento che la garbage collection è il gestore di memoria dinamica automatica più performante.

Però...

Sebbene sia necessario un garbage collector, potremmo provare a trovare alcuni casi speciali in cui il compilatore può utilizzare uno schema di gestione della memoria più economico rispetto alla garbage collection. Ad esempio, dato

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

potremmo sperare che il compilatore rilevi che x2può essere tranquillamente deallocato quando fritorna (piuttosto che aspettare che il garbage collector effettui la deallocazione x2). In sostanza, chiediamo al compilatore di eseguire l' analisi di escape per convertire le allocazioni in un heap raccolto in modo indesiderato in allocazioni nello stack, ove possibile.

Questo non è troppo irragionevole da chiedere: il compilatore haskell jhc lo fa, sebbene GHC non lo faccia. Simon Marlow afferma che il garbage collector generazionale di GHC rende per lo più inutile l'analisi della fuga.

jhc utilizza in realtà una forma sofisticata di analisi di fuga nota come inferenza della regione . Tener conto di

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 questo caso, un'analisi di escape semplicistica concluderebbe che x2fuoriesce da f(perché viene restituito nella tupla) e quindi x2deve essere allocato nell'heap di Garbage Collection. L'inferenza della regione, d'altra parte, è in grado di rilevare che x2può essere deallocata al gritorno; l'idea qui è che x2dovrebbe essere assegnato nella gregione di piuttosto che fnella regione di.

Oltre Haskell

Sebbene l'inferenza regionale sia utile in alcuni casi come discusso sopra, sembra essere difficile conciliare efficacemente con la valutazione pigra (vedere i commenti di Edward Kmett e Simon Peyton Jones ). Ad esempio, considera

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

Si potrebbe essere tentati di allocare l'elenco [1..n]nello stack e di rilasciarlo dopo i fritorni, ma ciò sarebbe catastrofico: cambierebbe fdall'utilizzo della memoria O (1) (sotto garbage collection) alla memoria O (n).

Negli anni '90 e all'inizio degli anni 2000 è stato svolto un ampio lavoro sull'inferenza regionale per il linguaggio funzionale rigoroso ML. Mads Tofte, Lars Birkedal, Martin Elsman, Niels Hallenberg hanno scritto una retrospettiva abbastanza leggibile sul loro lavoro sull'inferenza regionale, gran parte della quale hanno integrato nel compilatore MLKit . Hanno sperimentato una gestione della memoria puramente basata sulla regione (cioè nessun Garbage Collector) e una gestione ibrida della memoria basata sulla regione / raccolta dei rifiuti, e hanno riferito che i loro programmi di test funzionavano "tra 10 volte più velocemente e 4 volte più lentamente" della pura spazzatura- versioni raccolte.


2
Haskell richiede la condivisione? In caso contrario, nel tuo primo esempio, puoi passare una copia della lista (risp. Nothing) Alla chiamata ricorsiva di loope deallocare quella vecchia - nessuna durata sconosciuta. Ovviamente nessuno vuole un'implementazione senza condivisione di Haskell, perché è terribilmente lenta per strutture di dati di grandi dimensioni.
nimi

3
Mi piace molto questa risposta, anche se la mia unica confusione è con il primo esempio. Ovviamente se l'utente non ha mai digitato "clear" allora potrebbe usare una memoria infinita (senza un GC), ma non è esattamente una perdita in quanto la memoria è ancora monitorata.
Pubby

3
C ++ 11 ha una meravigliosa implementazione di puntatori intelligenti. Fondamentalmente utilizza il conteggio dei riferimenti. Immagino che Haskell potrebbe abbandonare la raccolta dei rifiuti a favore di qualcosa di simile e quindi diventare deterministico.
intrepidi

3
@ChrisNash - Non funziona. I puntatori intelligenti utilizzano il conteggio dei riferimenti sotto il cofano. Il conteggio dei riferimenti non può gestire le strutture di dati con i cicli. Haskell può generare strutture dati con cicli.
Stephen C

3
Non sono sicuro di essere d'accordo con la parte di allocazione dinamica della memoria di questa risposta. Solo perché il programma non sa quando un utente interromperà temporaneamente il ciclo non dovrebbe renderlo dinamico. Ciò è determinato dal fatto che il compilatore sappia se qualcosa andrà fuori contesto. Nel caso di Haskell, dove ciò è formalmente definito dalla grammatica linguistica stessa, il contesto di vita è noto. Tuttavia, la memoria potrebbe essere ancora dinamica perché le espressioni e il tipo di elenco vengono generati dinamicamente all'interno del linguaggio.
Timothy Swan

27

Facciamo un esempio banale. Dato ciò

f (x, y)

devi allocare la coppia (x, y)da qualche parte prima di chiamare f. Quando puoi deallocare quella coppia? Non avete idea. Non può essere deallocato quando fritorna, perché fpotrebbe aver inserito la coppia in una struttura dati (ad es. f p = [p]), Quindi la durata della coppia potrebbe dover essere più lunga del ritorno da f. Ora, supponiamo che la coppia sia stata inserita in una lista, chi ha smontato la lista può deallocare la coppia? No, perché la coppia potrebbe essere condivisa (ad esempio let p = (x, y) in (f p, p)). Quindi è davvero difficile dire quando la coppia può essere deallocata.

Lo stesso vale per quasi tutte le assegnazioni a Haskell. Detto questo, è possibile avere un'analisi (analisi della regione) che dia un limite superiore alla durata. Questo funziona ragionevolmente bene nei linguaggi rigorosi, ma meno nei linguaggi pigri (i linguaggi pigri tendono a cambiare molto di più rispetto ai linguaggi rigorosi nell'implementazione).

Quindi vorrei ribaltare la domanda. Perché pensi che Haskell non abbia bisogno di GC. Come suggeriresti di fare l'allocazione della memoria?


18

La tua intuizione che questo abbia qualcosa a che fare con la purezza ha qualcosa di vero.

Haskell è considerato puro in parte perché gli effetti collaterali delle funzioni sono considerati nella firma del tipo. Quindi, se una funzione ha l'effetto collaterale di stampare qualcosa, deve esserci un IOda qualche parte nel suo tipo restituito.

Ma c'è una funzione che viene usata implicitamente ovunque in Haskell e la cui firma del tipo non tiene conto, in un certo senso, di un effetto collaterale. Vale a dire la funzione che copia alcuni dati e ti restituisce due versioni. Sotto il cofano questo può funzionare letteralmente, duplicando i dati in memoria, o "virtualmente" aumentando un debito che deve essere ripagato in seguito.

È possibile progettare linguaggi con sistemi di tipi ancora più restrittivi (puramente "lineari") che non consentono la funzione di copia. Dal punto di vista di un programmatore in un tale linguaggio, Haskell sembra un po 'impuro.

In effetti, Clean , un parente di Haskell, ha tipi lineari (più rigorosamente: unici) e questo può dare un'idea di come sarebbe non consentire la copia. Ma Clean consente ancora la copia per tipi "non univoci".

Ci sono molte ricerche in questo settore e se usi abbastanza Google troverai esempi di codice lineare puro che non richiede la raccolta dei rifiuti. Troverai tutti i tipi di sistemi di tipi che possono segnalare al compilatore quale memoria potrebbe essere utilizzata, consentendo al compilatore di eliminare parte del GC.

C'è un senso in cui anche gli algoritmi quantistici sono puramente lineari. Ogni operazione è reversibile e quindi nessun dato può essere creato, copiato o distrutto. (Sono anche lineari nel solito senso matematico.)

È anche interessante il confronto con Forth (o altri linguaggi basati su stack) che hanno operazioni DUP esplicite che chiariscono quando si verifica la duplicazione.

Un altro modo (più astratto) di pensare a questo è notare che Haskell è costruito dal lambda calcolo semplicemente digitato che si basa sulla teoria delle categorie chiuse cartesiane e che tali categorie sono dotate di una funzione diagonale diag :: X -> (X, X). Una lingua basata su un'altra classe di categoria potrebbe non avere nulla di simile.

Ma in generale, la programmazione puramente lineare è troppo difficile per essere utile, quindi ci accontentiamo di GC.


3
Da quando ho scritto questa risposta, il linguaggio di programmazione Rust è diventato molto popolare. Quindi vale la pena ricordare che Rust usa un sistema di tipo lineare per controllare l'accesso alla memoria e vale la pena dare un'occhiata se vuoi vedere le idee che ho menzionato usate nella pratica.
sigfpe

14

Le tecniche di implementazione standard applicate a Haskell richiedono effettivamente un GC più della maggior parte degli altri linguaggi, poiché non mutano mai i valori precedenti, ma creano valori nuovi e modificati basati sui precedenti. Poiché questo significa che il programma alloca costantemente e utilizza più memoria, un gran numero di valori verrà scartato col passare del tempo.

Questo è il motivo per cui i programmi GHC tendono ad avere cifre di allocazione totale così elevate (da gigabyte a terabyte): allocano costantemente memoria ed è solo grazie all'efficiente GC che la recuperano prima che si esaurisca.


2
"non mutano mai i valori precedenti": puoi controllare haskell.org/haskellwiki/HaskellImplementorsWorkshop/2011/Takano , si tratta di un'estensione GHC sperimentale che riutilizza la memoria.
gfour

11

Se una lingua (qualsiasi lingua) consente di allocare gli oggetti in modo dinamico, allora ci sono tre modi pratici per affrontare la gestione della memoria:

  1. La lingua può consentire solo di allocare memoria nello stack o all'avvio. Ma queste restrizioni limitano fortemente i tipi di calcoli che un programma può eseguire. (In pratica. In teoria, puoi emulare strutture dati dinamiche in (diciamo) Fortran rappresentandole in un grande array. È ORRIBILE ... e non rilevante per questa discussione.)

  2. La lingua può fornire un esplicito freeo un disposemeccanismo. Ma questo dipende dal programmatore per farlo bene. Qualsiasi errore nella gestione della memoria può provocare una perdita di memoria ... o peggio.

  3. Il linguaggio (o più strettamente, l'implementazione del linguaggio) può fornire un gestore automatico della memoria per la memoria allocata dinamicamente; cioè una qualche forma di garbage collector.

L'unica altra opzione è quella di non recuperare mai lo spazio di archiviazione allocato dinamicamente. Questa non è una soluzione pratica, fatta eccezione per piccoli programmi che eseguono piccoli calcoli.

Applicandolo a Haskell, il linguaggio non ha la limitazione di 1. e non c'è alcuna operazione di deallocazione manuale come per 2. Pertanto, per essere utilizzabile per cose non banali, un'implementazione Haskell deve includere un garbage collector .

Non riesco a pensare a un caso in cui GC sarebbe necessario in un linguaggio puro.

Presumibilmente intendi un linguaggio funzionale puro.

La risposta è che dietro le quinte è necessario un GC per recuperare gli oggetti heap che il linguaggio DEVE creare. Per esempio.

  • Una funzione pura deve creare oggetti heap perché in alcuni casi deve restituirli. Ciò significa che non possono essere allocati in pila.

  • Il fatto che possano esserci dei cicli (risultanti da un, let recad esempio) significa che un approccio di conteggio dei riferimenti non funzionerà per gli oggetti heap.

  • Poi ci sono le chiusure di funzioni ... che non possono essere allocate nello stack perché hanno una durata che è (tipicamente) indipendente dallo stack frame in cui sono state create.

Sto cercando un codice di esempio che potrebbe perdere se un GC non fosse presente.

Quasi tutti gli esempi che prevedevano chiusure o strutture di dati a forma di grafico avrebbero perdite in quelle condizioni.


2
Perché pensi che il tuo elenco di opzioni sia esaustivo? ARC in Objective C, inferenza di regioni in MLKit e DDC, garbage collection in fase di compilazione in Mercury: non rientrano tutti in questo elenco.
Dee lunedì

@DeeMon - rientrano tutti in una di queste categorie. Se pensi che non lo facciano è perché stai disegnando i confini della categoria troppo strettamente. Quando dico "una qualche forma di raccolta dei rifiuti", intendo qualsiasi meccanismo in cui lo storage viene recuperato automaticamente.
Stephen C,

1
C ++ 11 utilizza puntatori intelligenti. Fondamentalmente utilizza il conteggio dei riferimenti. È deterministico e automatico. Mi piacerebbe vedere un'implementazione di Haskell utilizzare questo metodo.
intrepidi

2
@ChrisNash - 1) Non funzionerebbe. Il recupero della base di conteggio dei riferimenti perde dati se ci sono cicli ... a meno che non si possa fare affidamento sul codice dell'applicazione per interrompere i cicli. 2) È ben noto (alle persone che studiano queste cose) che il conteggio dei riferimenti funziona male se confrontato con un moderno (reale) netturbino.
Stephen C

@DeeMon - inoltre, vedi la risposta di Reinerp sul perché l'inferenza della regione non sarebbe pratica con Haskell.
Stephen C

8

Un garbage collector non è mai necessario, a patto di avere memoria sufficiente. Tuttavia, in realtà, non abbiamo una memoria infinita, quindi abbiamo bisogno di un metodo per recuperare la memoria che non è più necessaria. In linguaggi impuri come il C, puoi dichiarare esplicitamente che hai finito con un po 'di memoria per liberarla, ma questa è un'operazione di mutazione (la memoria che hai appena liberato non è più sicura da leggere), quindi non puoi usare questo approccio in una lingua pura. Quindi è in qualche modo analizzare staticamente dove è possibile liberare la memoria (probabilmente impossibile nel caso generale), perdere memoria come un setaccio (funziona alla grande finché non si esaurisce) o utilizzare un GC.


Questo risponde al motivo per cui un GC non è necessario in generale, ma sono più interessato a Haskell in particolare.
Pubby

10
Se un GC è teoricamente non necessario in generale, ne consegue banalmente che è teoricamente non necessario anche per Haskell.
terzo

@ehird volevo dire necessario , penso che il mio correttore ortografico abbia capovolto il significato.
Pubby

1
Il terzo commento vale ancora :-)
Paul R

2

GC è "indispensabile" nei linguaggi FP puri. Perché? Le operazioni allocate e gratuite sono impure! E la seconda ragione è che le strutture di dati ricorsive immutabili necessitano di GC per l'esistenza perché il backlinking crea strutture astruse e non mantenibili per la mente umana. Naturalmente, il backlinking è una benedizione, perché la copia di strutture che lo utilizzano è molto economica.

Comunque, se non mi credi, prova ad implementare il linguaggio FP e vedrai che ho ragione.

EDIT: ho dimenticato. La pigrizia è INFERNO senza GC. Non mi credi? Provalo senza GC, ad esempio, C ++. Vedrai ... cose


1

Haskell è un linguaggio di programmazione non rigoroso, ma la maggior parte delle implementazioni utilizza call-by-need (pigrizia) per implementare la non-rigidità. In call-by-need si valuta solo qualcosa quando viene raggiunto in runtime utilizzando il meccanismo dei "thunk" (espressioni che aspettano di essere valutate e poi si sovrascrivono, restando visibili per poter riutilizzare il loro valore quando necessario).

Quindi, se si implementa pigramente il proprio linguaggio utilizzando i thunk, tutti i ragionamenti sulla durata degli oggetti sono stati rimandati all'ultimo momento, ovvero il runtime. Poiché ora non sai nulla delle vite, l'unica cosa che puoi ragionevolmente fare è raccogliere i rifiuti ...


1
In alcuni casi, l'analisi statica può inserire in quei thunk il codice che libera alcuni dati dopo la valutazione del thunk. La deallocazione avverrà in fase di esecuzione ma non è GC. Questo è simile all'idea del conteggio dei riferimenti puntatori intelligenti in C ++. Il ragionamento sulla durata degli oggetti avviene in runtime ma non viene utilizzato alcun GC.
Dee lunedì
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.