【问题标题】:Does Haskell require a garbage collector?Haskell 需要垃圾收集器吗?
【发布时间】:2012-04-14 16:57:58
【问题描述】:

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

我想不出在纯语言中需要 GC 的情况。只是减少复制的优化,还是真的有必要?

我正在寻找在不存在 GC 时会泄漏的示例代码。

【问题讨论】:

  • 你可能会发现这个系列很有启发性;它涵盖了垃圾是如何产生(以及随后被收集)的:blog.ezyang.com/2011/04/the-haskell-heap
  • 纯语言中到处都有引用!只是不是可变的引用。
  • @pelotom 引用不可变数据还是不可变引用?
  • 两者。引用的数据是不可变的,这一事实源于所有引用都是不可变的,一直向下。
  • 您肯定会对the halting problem 感兴趣,因为将此推理应用于内存分配有助于理解为什么不能静态预测释放在一般情况下。但是,有 一些 程序可以预测其释放,就像它们是 一些 程序可以在不实际运行的情况下终止。

标签: haskell garbage-collection


【解决方案1】:

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

例如,考虑以下程序:

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

我们可能希望编译器检测到当f 返回时可以安全地释放x2(而不是等待垃圾收集器释放x2)。本质上,我们要求编译器执行escape analysis 以尽可能将分配到垃圾收集堆转换为allocations on the stack

这不是太不合理的要求:jhc haskell compiler 这样做,尽管 GHC 没有。 Simon Marlow says 认为 GHC 的分代垃圾收集器使得逃逸分析几乎没有必要。

jhc 实际上使用了一种复杂的转义分析形式,称为region inference。考虑

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

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

超越 Haskell

虽然区域推断在上述某些情况下很有帮助,但似乎很难与惰性求值有效协调(参见Edward Kmett'sSimon Peyton Jones' cmets)。例如,考虑

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

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

在 1990 年代和 2000 年代初期,针对 strict 功能语言 ML 的区域推断进行了大量工作。 Mads Tofte、Lars Birkedal、Martin Elsman、Niels Hallenberg 就他们在区域推断方面的工作写了一个可读性很强的retrospective,其中大部分都集成到了MLKit compiler。他们试验了纯基于区域的内存管理(即没有垃圾收集器)以及混合的基于区域/垃圾收集的内存管理,并报告说他们的测试程序比纯垃圾运行“快 10 倍和慢 4 倍”-收集的版本。

【讨论】:

  • Haskell 需要共享吗?如果不是,在您的第一个示例中,您可以将列表的副本(resp。Nothing)传递给loop 的递归调用并释放旧的 - 没有未知的生命周期。当然没有人想要 Haskell 的非共享实现,因为它对于大型数据结构来说非常慢。
  • 我真的很喜欢这个答案,尽管我唯一的困惑是第一个例子。显然,如果用户从不输入“clear”,那么它可以使用无限内存(没有 GC),但这并不完全是泄漏,因为内存仍在被跟踪。
  • C++11 有一个很好的智能指针实现。基本上它采用引用计数。我猜 Haskell 可能会放弃垃圾收集以支持类似的东西,因此变得确定性。
  • @ChrisNash - 不起作用。智能指针在底层使用引用计数。引用计数不能处理带有循环的数据结构。 Haskell 可以生成带循环的数据结构。
  • 我不确定我是否同意这个答案的动态内存分配部分。仅仅因为程序不知道用户何时会暂时停止循环不应该使其成为动态的。这取决于编译器是否知道某些内容是否会脱离上下文。在 Haskell 的案例中,这是由语言语法本身正式定义的,生活上下文是已知的。但是,由于列表表达式和类型是在语言中动态生成的,因此内存可能仍然是动态的。
【解决方案2】:

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

f (x, y)

在调用f 之前,您需要在某处分配(x, y) 对。你什么时候可以解除分配那对?你不知道。当f 返回时,它不能被释放,因为f 可能已经将该对放入数据结构中(例如,f p = [p]),因此该对的生命周期可能必须比从f 返回的时间长。现在,假设这对被放在一个列表中,谁把列表拆开可以释放这对吗?不,因为这对可能是共享的(例如,let p = (x, y) in (f p, p))。所以真的很难判断什么时候可以解除分配。

Haskell 中几乎所有的分配都是如此。也就是说,可能有一个分析(区域分析)给出了寿命​​的上限。这在严格语言中工作得相当好,但在惰性语言中效果较差(在实现中,惰性语言往往比严格语言做更多的突变)。

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

【讨论】:

    【解决方案3】:

    你认为这与纯洁有关的直觉是有道理的。

    Haskell 被认为是纯的,部分原因是函数的副作用在类型签名中被考虑在内。所以如果一个函数有打印某些东西的副作用,那么它的返回类型中一定有一个IO

    但是有一个函数在 Haskell 的任何地方都隐式使用,并且它的类型签名没有说明,在某种意义上,副作用是什么。即复制一些数据并返回两个版本的功能。在幕后,这既可以通过复制内存中的数据实现字面意义,也可以通过增加需要稍后偿还的债务来“虚拟”实现。

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

    事实上,Clean 是 Haskell 的一个亲戚,具有线性(更严格地说:唯一)类型,这可以让您了解禁止复制会是什么样子。但是 Clean 仍然允许复制“非唯一”类型。

    在这个领域有很多research,如果你用足够多的谷歌搜索,你会找到不需要垃圾收集的纯线性代码的例子。您会发现各种类型系统都可以向编译器发出信号,告知编译器可能使用了哪些内存,从而允许编译器消除一些 GC。

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

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

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

    但总的来说,纯线性编程太难用了,所以我们选择了 GC。

    【讨论】:

    • 自从我写了这个答案后,Rust 编程语言的流行度大大提高了。所以值得一提的是,Rust 使用线性类型系统来控制对内存的访问,如果你想看看我提到的在实践中使用的想法,值得一看。
    【解决方案4】:

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

    这就是为什么 GHC 程序往往具有如此高的总分配数字(从 GB 到 TB):它们不断地分配内存,这要归功于高效的 GC,它们会在用完之前回收它。

    【讨论】:

    【解决方案5】:

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

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

    2. 该语言可以提供明确的freedispose 机制。但这取决于程序员是否正确。存储管理中的任何错误都可能导致内存泄漏......或更糟。

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

    唯一的其他选择是永远不要回收动态分配的存储。这不是一个实用的解决方案,除了执行小计算的小程序。

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

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

    大概你的意思是一种纯函数式语言。

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

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

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

    • 然后是函数闭包......也不能在堆栈上分配,因为它们的生命周期(通常)独立于创建它们的堆栈帧。

    我正在寻找在不存在 GC 时会泄漏的示例代码。

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

    【讨论】:

    • 为什么您认为您的选项列表是详尽无遗的? Objective C 中的 ARC,MLKit 和 DDC 中的区域推断,Mercury 中的编译时垃圾收集——它们都不适合这个列表。
    • @DeeMon - 它们都属于这些类别之一。如果您认为他们不这样做,那是因为您将类别边界绘制得太紧了。当我说“某种形式的垃圾回收”时,我的意思是任何自动回收存储的机制
    • C++11 使用智能指针。基本上它采用引用计数。它是确定性和自动的。我很想看到使用这种方法的 Haskell 实现。
    • @ChrisNash - 1) 它行不通。如果存在循环,则基于引用计数的回收会泄漏数据……除非您可以依靠应用程序代码来打破循环。 2) 众所周知(研究这些东西的人)与现代(真正的)垃圾收集器相比,引用计数的性能很差。
    • @DeeMon - 此外,请参阅 Reinerp 关于为什么使用 Haskell 进行区域推断不实用的回答。
    【解决方案6】:

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

    【讨论】:

    • 这回答了为什么通常不需要 GC,但我对 Haskell 更感兴趣。
    • 如果理论上一般来说 GC 是不必要的,那么理论上 Haskell 也是不必要的。
    • @ehird 我的意思是说必要,我觉得我的拼写检查器把意思搞错了。
    • Ehird 的评论仍然有效 :-)
    【解决方案7】:

    GC 是纯 FP 语言中的“必备”。为什么?操作 alloc 和 free 是不纯的!第二个原因是,不可变的递归数据结构需要 GC 才能存在,因为反向链接会为人类思维创建深奥且不可维护的结构。当然,反向链接是一件好事,因为复制使用它的结构非常便宜。

    不管怎样,如果你不相信我,只要尝试实现 FP 语言,你就会发现我是对的。

    编辑:我忘了。没有 GC,懒惰是地狱。不相信我?只需在不使用 GC 的情况下尝试它,例如 C++。你会看到……东西

    【讨论】:

    • “没有 GC 的懒惰是地狱” - 你能解释一下为什么吗?我在考虑 Rust,afaik 有很多懒惰而且绝对没有垃圾收集器
    【解决方案8】:

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

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

    【讨论】:

    • 在某些情况下,静态分析可以插入到这些 thunk 代码中,从而在评估 thunk 后释放一些数据。释放将在运行时发生,但不是 GC。这类似于 C++ 中引用计数智能指针的想法。关于对象生命周期的推理发生在运行时,但没有使用 GC。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-10-12
    • 1970-01-01
    • 1970-01-01
    • 2023-03-30
    相关资源
    最近更新 更多