Haskell是否需要垃圾收集器?


118

我很好奇为什么Haskell实现使用GC。

我想不出用纯语言来进行GC的必要性。仅仅是减少复制的优化,还是实际上有必要?

我正在寻找示例代码,如果不存在GC,这些代码可能会泄漏。


14
您可能会发现本系列很有启发性;它涵盖了如何生成(并随后收集)垃圾:blog.ezyang.com/2011/04/the-haskell-heap
Tom Crockett

5
到处都有纯语言的引用!只是不可变的参考。
汤姆·克罗基特

1
@pelotom对不可变数据的引用还是不可变的引用?
Pubby 2012年

3
都。所引用的数据是不可变的,这是因为所有引用一直都是不可变的。
汤姆·克罗基特

4
对于暂停问题,您肯定会感兴趣,因为将这种推理应用于内存分配有助于了解为什么在一般情况下无法静态预测释放。但是,有些程序可以预测释放,就像有些程序可以终止而不实际运行一样。
Paul R

Answers:


218

正如其他人已经指出,哈斯克尔需要自动动态内存管理:自动内存管理是必要的,因为手动内存管理是不安全的; 动态内存管理是必需的,因为对于某些程序,对象的生存期只能在运行时确定。

例如,考虑以下程序:

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

在此程序中,列表[1..1000]必须保留在内存中,直到用户键入“ clear”为止。因此必须动态确定其生命周期,这就是为什么必须进行动态内存管理的原因。

因此,从这种意义上讲,必须进行自动动态内存分配,并且在实践中这意味着:是的,Haskell需要使用垃圾收集器,因为垃圾收集是性能最高的自动动态内存管理器。

然而...

尽管垃圾收集器是必需的,但我们可能会尝试找到一些特殊情况,其中编译器可以使用比垃圾收集便宜的内存管理方案。例如,给定

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

我们可能希望编译器能够检测到返回x2时可以安全地释放f(而不是等待垃圾回收器释放x2)。本质上,我们要求编译器执行转义分析,以尽可能将分配到垃圾回收堆中的分配转换为堆栈上的分配

这不是太合理的要求:jhc haskell编译器可以做到这一点,尽管GHC却没有。西蒙·马洛 Simon Marlow),GHC的世代垃圾收集器使逃逸分析变得几乎没有必要。

jhc实际上使用了一种复杂的转义分析形式,即区域推断。考虑

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

在这种情况下,简单的转义分析将得出x2转义来自f(因为它在元组中返回)的结论,因此x2必须在垃圾回收堆上分配转义。另一方面,区域推断能够检测到返回x2时可以释放的区域g。这里的想法是x2应该在g的区域而不是f的区域中进行分配。

超越Haskell

如上所述,尽管在某些情况下区域推断是有帮助的,但似乎很难通过懒惰的评估来有效地加以调和(请参阅Edward KmettSimon Peyton Jones的评论)。例如,考虑

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

可能会试图将列表分配到[1..n]堆栈上并在f返回之后将其释放,但这将是灾难性的:它将f从使用O(1)内存(在垃圾回收下)变为O(n)内存。

在1990年代和2000年代初期,针对严格的功能语言ML 进行了有关区域推理的大量工作。Mads Tofte,Lars Birkedal,Martin Elsman,Niels Hallenberg 就他们在区域推理方面的工作撰写了颇具可读性的回顾展,其中大部分已集成到MLKit编译器中。他们对纯基于区域的内存管理(即无垃圾收集器)以及基于混合区域/垃圾收集的内存管理进行了实验,并报告说,他们的测试程序比纯垃圾-运行速度“快10倍至慢4倍”。收集的版本。


2
Haskell是否需要共享?如果不是,则在第一个示例中,您可以将列表的副本(分别为Nothing)传递给的递归调用,loop并取消分配旧列表-没有未知的生存期。当然,没有人想要非共享的Haskell实现,因为对于大型数据结构而言,它的运行速度极其慢。
妮咪

3
我真的很喜欢这个答案,尽管我唯一的困惑是第一个例子。显然,如果用户从未输入过“ clear”,则它可以使用无限内存(没有GC),但这并不是完全泄漏,因为仍在跟踪内存。
Pubby 2012年

3
C ++ 11具有出色的智能指针实现。基本上,它使用引用计数。我猜Haskell可以放弃垃圾回收,而支持类似的东西,因此变得确定性。
intrepidis

3
@ChrisNash-不起作用。智能指针在后台使用引用计数。引用计数不能处理具有周期的数据结构。Haskell可以生成具有周期的数据结构。
斯蒂芬·C

3
我不确定是否同意此答案的动态内存分配部分。仅仅因为程序不知道用户何时会暂时停止循环,就不应使其动态。这取决于编译器是否知道某些内容是否会脱离上下文。在Haskell的情况下,这是由语言语法本身正式定义的,生命上下文是已知的。但是,由于列表表达式和类型是在语言内动态生成的,因此内存可能仍然是动态的。
蒂莫西·斯旺

27

让我们举一个简单的例子。鉴于这种

f (x, y)

您需要(x, y)在致电之前在某处分配该对f。您什么时候可以取消分配那对?你不知道。f返回时无法将其释放,因为f可能已将该对放入数据结构中(例如f p = [p]),因此该对的生存期可能必须长于from的返回f。现在,假设该对已放入列表中,谁能将列表拆开,谁能取消分配该对?否,因为该对可能是共享的(例如let p = (x, y) in (f p, p))。因此,很难分辨何时可以解除分配。

Haskell中的几乎所有分配都适用。就是说,有可能进行分析(区域分析)以给出生命周期的上限。这在严格的语言中相当有效,但在懒惰的语言中却不太好(在实现中,懒惰的语言比严格的语言所做的变异要多得多)。

所以我想解决这个问题。为什么您认为Haskell不需要GC。您如何建议进行内存分配?


18

您的直觉与纯真有关,这有一定道理。

Haskell被认为是纯粹的,部分原因是在类型签名中考虑了函数的副作用。因此,如果函数具有打印某些内容的副作用,则必须有一个IO它的返回类型中地方。

但是在Haskell的每个地方都有一个隐式使用的函数,它的类型签名不能解释某种意义上的副作用。即复制一些数据并给您两个版本的函数。实际上,这可以通过复制内存中的数据从字面上进行工作,也可以通过增加需要稍后偿还的债务来“虚拟地”进行。

可以使用限制更大的类型系统(纯粹是“线性”系统)来设计语言,这些系统不允许使用复制功能。从使用这种语言的程序员的角度来看,Haskell看起来有些不纯。

实际上,Clean(是Haskell的亲戚)具有线性(更严格地说是唯一)类型,这可以使您了解禁止复制的方式。但是Clean仍允许复制“非唯一”类型。

这个领域有很多研究,如果您对Google足够了解,就会发现不需要垃圾收集的纯线性代码示例。您会找到各种类型的系统,它们可以向编译器发出信号,表明可能使用了什么内存,从而使编译器可以消除某些GC。

从某种意义上说,量子算法也是纯线性的。每个操作都是可逆的,因此无法创建,复制任何数据或销毁。(它们在通常的数学意义上也是线性的。)

与Forth(或其他基于堆栈的语言)进行比较也很有趣,这些语言具有显式的DUP操作,可以清楚何时进行复制。

对此的另一种(更抽象的)思考方式是,注意Haskell是基于笛卡尔封闭类别理论的简单类型的lambda演算建立的,并且这些类别配备了对角函数diag :: X -> (X, X)。基于另一类类别的语言可能没有这种东西。

但是总的来说,纯线性编程太难用了,因此我们选择使用GC。


3
自从我写了这个答案以来,Rust编程语言已经流行了很多。因此,值得一提的是,Rust使用线性ish类型的系统来控制对内存的访问,如果您想了解我在实践中使用的想法,则值得一看。
sigfpe

14

实际上,应用于Haskell的标准实现技术比大多数其他语言更需要GC,因为它们从不改变以前的值,而是根据以前的值创建新的,修改后的值。由于这意味着程序会不断分配并使用更多的内存,因此随着时间的流逝,大量的值将被丢弃。

这就是为什么GHC程序倾向于具有如此高的总分配数字(从GB到TB)的原因:它们一直在分配内存,这仅归功于高效的GC在用尽之前回收了它。


2
“它们永远不会改变先前的值”:您可以检查haskell.org/haskellwiki/HaskellImplementorsWorkshop/2011/Takano,它是关于实验性GHC扩展,它可以重用内存。
gfour

11

如果一种语言(任何一种语言)都允许您动态分配对象,则有三种实用的方法来处理内存管理:

  1. 该语言只能允许您在堆栈上或启动时分配内存。但是这些限制严重限制了程序可以执行的计算类型。(在实践中。从理论上讲,您可以通过将Fortran动态数据结构以大数组表示来模拟动态数据结构。这是可怕的,与本讨论无关。)

  2. 语言可以提供显式freedispose机制。但这依赖于程序员才能正确完成。存储管理中的任何错误都可能导致内存泄漏……甚至更糟。

  3. 语言(或更严格地说,是语言实现)可以为动态分配的存储提供自动存储管理器;即某种形式的垃圾收集器。

唯一的其他选择是从不回收动态分配的存储。除了执行少量计算的小型程序以外,这不是实际的解决方案。

将其应用于Haskell,该语言没有1.的限制,并且没有按照2进行的手动释放操作。因此,为了可用于非平凡的事物,Haskell实现需要包含垃圾回收器。

我想不出用纯语言需要GC的情况。

想必您是指纯功能语言。

答案是在后台需要GC来回收该语言必须创建的堆对象。例如。

  • 一个纯函数需要创建堆对象,因为在某些情况下它必须返回它们。这意味着它们不能在堆栈上分配。

  • 可能存在循环的事实(let rec例如,产生于a )意味着引用计数方法不适用于堆对象。

  • 然后是函数闭包...也不能在堆栈上分配,因为它们的生存期(通常)与创建它们的堆栈框架无关。

我正在寻找示例代码,如果不存在GC,这些代码可能会泄漏。

在这种情况下,几乎所有涉及闭包或图形数据结构的示例都会泄漏。


2
您为什么认为您的选择清单详尽无遗?目标C中的ARC,MLKit和DDC中的区域推断,Mercury中的编译时垃圾收集-它们都不适合此列表。

@DeeMon-它们都属于这些类别之一。如果您认为它们并非如此,那是因为您过于严格地划分了类别边界。当我说“某种形式的垃圾收集”时,我指的是任何自动回收存储的机制
斯蒂芬·C

1
C ++ 11使用智能指针。基本上,它使用引用计数。它是确定性的和自动的。我希望看到Haskell的实现使用此方法。
intrepidis

2
@ChrisNash-1)无效。如果存在循环,则基于引用计数的回收将泄漏数据...除非您可以依靠应用程序代码来中断循环。2)众所周知(对于研究这些问题的人而言),与现代(真实)垃圾收集器相比,引用计数的性能较差。
斯蒂芬C

@DeeMon-除此之外,请参阅Reinerp的答案,说明为什么Haskell无法进行区域推断。
斯蒂芬C

8

只要您有足够的内存,就永远不需要垃圾收集器。但是,实际上,我们没有无限的内存,因此我们需要某种方法来回收不再需要的内存。在诸如C之类的不纯语言中,您可以明确声明已完成一些内存释放操作-但这是一个变异操作(刚释放的内存不再安全读取),因此您不能在这种情况下使用此方法一种纯净的语言。因此,它要么以某种方式静态分析了可以释放内存的位置(在一般情况下可能是不可能的),像筛子一样的泄漏内存(在用完之前可以很好地工作)或使用GC。


这回答了为什么通常不需要GC,但是我对Haskell尤其感兴趣。
Pubby

10
如果从理论上讲GC在一般上是不必要的,那么它就从根本上遵循了Haskell在理论上也是不必要的。
ehird

@ehird我想说的是必要的,我认为我的拼写检查器已将其含义颠倒了。
Pubby发表

1
Ehird评论仍然成立:-)
Paul R

2

在纯FP语言中,GC是“必须具备”的。为什么?操作alloc和free是不纯的!第二个原因是,不可变的递归数据结构需要GC才能存在,因为反向链接会为人的心灵造成深刻而难以维护的结构。当然,反向链接是很幸运的,因为使用它进行结构的复制非常便宜。

无论如何,如果您不相信我,只需尝试实现FP语言,您就会发现我是对的。

编辑:我忘了。懒惰是没有GC的地狱。不相信我吗 只需在不使用GC的情况下(例如C ++)尝试。你会看到...事情


1

Haskell是一种非严格的编程语言,但是大多数实现都使用按需调用(惰性)来实现非严格性。在按需调用中,您仅在运行时使用“ thunk”机制评估事物(表示等待评估然后覆盖自身的表达式,在需要时可重复使用的值保持可见)。

因此,如果您使用thunk懒惰地实现语言,则将有关对象生存期的所有推理推迟到最后一个时刻,即运行时。由于您现在对生命周期一无所知,因此您唯一可以合理做的就是垃圾回收...


1
在某些情况下,可以将静态分析插入这些thunk代码中,从而在评估thunk之后释放一些数据。解除分配将在运行时发生,但不是GC。这类似于C ++中引用计数智能指针的想法。关于对象生存期的推理发生在运行时,但没有使用GC。
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.