当计算机程序使用的内存超出必要量时,就会发生空间泄漏。与内存泄漏(泄漏的内存永远不会被释放)不同,空间泄漏消耗的内存会被释放,但释放时间晚于预期。本文介绍了空间泄漏的示例,以及如何发现和消除它们。
首先考虑两个
在这个例子中,西娜存储了大量信息,但只对其中的一小部分感兴趣。
西娜的朋友肖恩是一位统计学家,他很好奇西娜存储了多少冗余页面。为了确定百科全书的总页数,肖恩买了一本
在这个例子中,肖恩存储了大量信息,虽然每卷都包含有用的信息,但结果可以更紧凑地存储。
图 1 草绘了如果西娜和肖恩是计算机程序,他们可能表示的内存布局。在这两种情况下,实线蓝色箭头指向百科全书,表示正在保留的内存。虚线红色箭头指向实际有用的信息。
如果程序加载了百科全书,但没有立即将其缩小到感兴趣的部分,从而导致百科全书在内存中保留的时间超过必要时间,则会发生空间泄漏。消除空间泄漏的关键在于控制何时进行求值,减少分配内存和丢弃内存之间的时间。毫不奇怪,使求值顺序复杂化的特性尤其容易受到空间泄漏的影响。本文重点关注的两个例子是惰性求值(表达式的求值被延迟到需要其值时)和闭包(函数值与其环境的组合)。这两种特性都可以在惰性函数式语言(如 Haskell)中找到。
惰性求值如何导致空间泄漏?考虑以下 Haskell 定义
xs = delete dead [alive, dead]
此代码片段创建一个变量xs和一个
然而,Haskell 使用惰性求值(也称为
如前所述,length xs将返回 1,但由于计算长度,它将求值delete。求值length xs的行为将xs简化为一个值,从而消除空间泄漏。如果程序频繁地添加和删除元素,但从不使用列表来计算长度或查找值,则使用列表的程序最终可能会出现空间泄漏。
更普遍地说,当内存包含一个
消除空间泄漏通常需要比正常情况下更早地强制求值表达式。在描述如何强制求值之前,有必要定义表达式的求值方式
• 如果表达式不能进一步求值,则该表达式处于范式。例如,列表[1,2]处于范式。列表由[](发音为“nil”)表示空列表,以及(:)(发音为“cons”)将头元素与列表的尾部组合,因此[1,2]可以等效地写成1:2:[].
• 如果最外层部分不需要进一步求值,则表达式处于 WHNF(弱头范式)。例如,(1+2):[]处于 WHNF,因为最外层部分是(:),但它不处于范式,因为(+)可以求值以产生3。范式中的所有值根据定义也处于 WHNF。
为了强制求值到 WHNF,Haskell 提供了严格性注解,使用了常用的 bang patterns 扩展3。您可以定义一个函数output,它将“Output”打印到控制台,后跟其参数
output x = do
print “Output”
print x
打印x会将x求值到范式,因此函数output将首先打印“Output”,然后将x求值到范式并打印它。添加感叹号作为严格性注解将强制x更快地
求值
output !x = ...现在求值output xx将首先将x求值到 WHNF,然后打印“Output”,然后将
鉴于严格性避免了示例
• 在严格语言中定义新的控制结构通常需要宏或将其构建到编译器中,而惰性求值允许直接表达此类模式。
• 惰性允许在不考虑哪些绑定在哪些代码路径中求值的情况下进行变量绑定,这在与复杂的条件语句结合使用时会大大简化。
• 在严格语言中模拟惰性通常比在惰性语言中强制严格性更困难,因此惰性可能是一个更好的默认选择。
• 当组合函数时,严格语言通常需要比惰性语言占用更多内存的简单组合,这将在示例 2 中演示。
考虑以下代码
sum [1..n]
在 Haskell 中,此表达式创建一个包含数字 1 到 n 的列表,然后将它们相加。在严格语言中,此操作占用 O(n) 空间:它首先生成一个长度为 n 的列表,然后调用sum。然而,在惰性语言中,列表中的项可以在sum需要时一次生成一个,从而导致 O(1) 空间使用量。即使您将[1..n]替换为从文件中读取的数字,程序仍然可以在 O(1) 空间中运行,因为惰性会自动交错从文件中读取数字和计算总和。
不幸的是,当使用 GHC(Glasgow Haskell Compiler)编译此代码时,由于空间泄漏,它会占用 O(n) 空间,但当使用
为什么会出现空间泄漏?考虑以下sum:
sum1 (x:xs) = x + sum1 xs
sum1 [] = 0
的定义。第一个方程表示,如果列表至少包含一项,则将第一项绑定到x,并将包含剩余项的列表绑定到xs。然后通过将第一个元素添加到剩余元素的总和来递归定义总和。第二个方程表示基本情况,空列表的总和为 0。让我们考虑求值sum1 [1..n],对于某个较大的n值,其过程如下
sum1 [1..n] -- 初始值
sum1 (1:[2..n]) -- sum1 需要列表
1 + sum1 [2..n] -- sum1 根据方程简化
1 + sum1 (2:[3..n]) -- + 需要它的两个参数
1 + (2 + sum1 [3..n])
您可以通过查看程序接下来需要什么来跟踪求值,从表达式的左上部分开始。例如,最初sum1查看列表以确定要匹配哪个表达式:哪个表达式需要求值[1..n]以产生1:[2..n]。随着求值的进行,它构建了项1 + 2 + 3 + 4 ...,占用 O(n) 空间。虽然程序永远不会一次将整个列表放在内存中,但它会将列表的所有项与“+”运算连接起来。
在识别出空间泄漏后,可以使用严格性来消除它。给定表达式1 + 2,它可以立即简化为3;并且如果程序在计算过程中不断执行加法运算,它将仅使用恒定内存。唉,使用sum1的定义,表达式实际上是1 + (2 + (3 ...,这意味着1和2无法简化。幸运的是,加法是结合律的,因此sum可以重新定义以构建((1 + 2) + 3) ...:
sum2 xs = sum2’ 0 xs
where
sum2’ a (x:xs) = sum2’ (a+x) xs
sum2’ a [] = a
通过辅助函数sum2来定义sum2’,它接受一个额外的累加器a,它是到目前为止处理的列表的所有元素的值。跟踪求值看起来更有希望
sum2 [1..n]
sum2’ 0 [1..n]
sum2’ 0 (1:[2..n])
sum2’ (0+1) [2..n]
sum2’ (0+1) (2:[3..n])
sum2’ ((0+1)+2) [3..n]
现在文字数字被应用于加法,但空间泄漏仍然存在。幸运的是,现在有一个合适的严格性注解目标。您可以定义
sum3 xs = sum3’ 0 xs
where
sum3’ !a (x:xs) = sum3’ (a+x) xs
sum3’ !a [] = a
累加器参数a上的严格性注解导致在处理列表的下一个元素之前对累加器进行求值。重新审视跟踪
sum3 [1..n]
sum3’ 0 [1..n]
sum3’ 0 (1:[2..n])
sum3’ (0+1) [2..n]
sum3’ 1 [2..n]
sum3’ 1 (2:[3..n])
sum3’ (1+2) [3..n]
sum3’ 3 [3..n]
表明sum3占用 O(1) 空间,并且没有空间泄漏。标准 Haskell 库中的sum的定义等效于sum2;但在启用优化的情况下,编译器会推断出严格性注解,使其等效于sum3.
考虑另一个示例
mean xs = sum xs `div` length xs
此函数计算列表平均值通过取xs并除以sum长度(div周围的反引号允许将函数用作中缀运算符)。假设是
求值——即
在此示例中,更智能的求值策略可以消除空间泄漏。如果程序求值xs的第一个元素,然后将sum和(应用于它,则该函数将占用恒定空间。计算将占用多少空间?的另一种方法是删除列表的共享
sum [1..n] `div` length [1..n]
。这里列表已被复制,并且周围的反引号允许将函数用作中缀运算符)。假设的两个参数都在恒定空间中运行,从而允许整个计算在恒定空间中运行。不幸的是,计算列表所需的任何工作都将被复制。
真正的解决方案是采用用于sum3的模式并对其进行扩展,以便除了累积总和之外,还可以累积长度。完整定义是
mean xs = mean’ 0 0 xs
where
mean’ !s !l (x:xs) = mean’ (s+x) (l+1) xs
mean’ !s !l [] = s `div` l
这会将总和 (s) 和长度 (l) 累积为局部参数,这些参数是辅助函数的严格参数。生成的定义没有空间泄漏,并且在 O(1) 中运行。
前面的示例插入了严格性注解以消除空间泄漏。然而,并非所有空间泄漏都可以通过严格性注解来消除5;有时,垃圾回收器需要特殊的行为10。例如,让我们通过在标题末尾添加感叹号来提高学术论文的影响力
improve xs = fst pair ++ “!”++ snd pair
where pair = firstLine xs
firstLine (‘\n’:ys) = ([], ‘\n’:ys)
firstLine (y:ys) = (y:fst rest, snd rest)
where rest = firstLine ys
firstLine [] = ([], [])
函数improve接受论文的源代码并生成一篇新论文。它使用辅助函数pair将文本拆分为一个变量firstLine,该变量由第一行和剩余文本组成。然后,该函数使用fst获取对的第一个元素,并使用snd获取第二个元素,并使用字符串追加运算符“++”在它们之间插入感叹号。的第一个方程firstLine匹配带有前导换行符的字符串,并生成一个空的第一行,后跟文本。第二个方程递归调用firstLine,除了第一个字符之外的所有内容,然后创建一个结果,其中第一个字符位于第一行的前面。最后一个方程确保空输入产生空输出。
对于improve来说,应该有可能在 O(1) 空间中运行,在检查每个输入字符后生成一个输出字符,并且只需要少量内存。在firstLine的第二个方程中,在匹配y:ys(即,消耗一个输入字符)之后,程序立即生成(y:_, _),在进行递归调用之前,通过惰性求值使输出字符可用。不幸的是,使用明显的实现技术,此函数需要与xs的第一行成比例的空间,因此为 O(fst pair)。
为了理解空间使用情况,请考虑对improve “abc...”/p>
let rest4 = firstLine “...”
let rest3 = (‘c’:fst rest4, snd rest4)
let rest2 = (‘b’:fst rest3, snd rest3)
let rest1 = (‘a’:fst rest2, snd rest2)
‘a’:’b’:’c’:fst rest4 ++ “!”++ snd rest1
的每个步骤中firstLine都会生成一个对,其第二个组件只是递归调用的第二个组件。结果既是一个snd调用的线性链,又是所有被对每个rest变量的第一个组件的引用所保留的字符。
如果snd函数被强制执行,那么这个空间泄漏将被消除,从而产生
let rest4 = firstLine “...”
‘a’:’b’:’c’:fst rest4 ++ “!\n”++ snd rest4
。不幸的是,没有地方可以放置严格性注解来执行适当的简化。虽然您想强制求值snd,但您也依赖于firstLine的递归调用中对的惰性来实现 O(1) 空间。幸运的是,垃圾回收器可以解决这个问题。函数snd是一个
到目前为止的所有示例都是在 Haskell 中,但其他
以下 JavaScript 代码使用 Web Audio API8 来检索 MP3 文件并计算其持续时间
function LoadAudio(mp3)
{
// 将 ‘mp3’ 文件加载到 ‘request.response’ 中
var request = new XMLHttpRequest();
request.open(‘GET’, mp3);
request.responseType = ‘arraybuffer’;
request.onreadystatechange = function(){
if (request.readyState != 4) return;
// 解码音频数据
window.AudioContext = window.AudioContext || window.webkitAudioContext;
var context = new AudioContext();
context.decodeAudioData(request.response, function(audio){
document.getElementById(“status”).onclick = function(){
alert(“MP3 时长为 “ + audio.duration + “ 秒”);
}
});
};
request.send();
}
此函数使用XMLHttpRequestAPI 加载 MP3 文件,然后使用 Web Audio API 解码该文件。使用解码后的audio值,您可以添加一个操作,在单击状态按钮时告诉用户 MP3 的持续时间。
该实现使用了三个局部函数,其中两个引用了在LoadAudio本地定义的变量。当引用局部函数时,这些变量将被捕获在闭包内。例如,第一个函数被分配给onreadystatechange,并捕获了三行之前定义的request变量。
在LoadAudio运行后,“status”按钮具有一个onclick事件,该事件运行以下代码
alert(“MP3 时长为 “ + audio.duration + “ 秒”);
此代码引用了audio对象,该对象存储音频
此空间泄漏在许多方面与惰性求值空间泄漏相似。代码引用了一个表达式audio.duration,它使大量内存保持活动状态,但求值时仅使用少量内存。与之前一样,解决方案是比必要时更早地强制求值
var duration = audio.duration;
document.getElementById(“status”).onclick = function(){
alert(“MP3 时长为 “ + duration + “ 秒”);
};
现在持续时间在onclick事件注册之前计算,并且audio元素不再被引用,从而允许它被
虽然您可以修改代码以消除空间泄漏,但垃圾回收器是否可以消除空间泄漏?答案是肯定的,前提是audio.duration计算成本低,未来不会更改,并且不会导致任何副作用。由于没有对audio的其他引用,因此audio引用的值不会更改;并且由于audio.duration是一个
不幸的是,选择器优化在 JavaScript 中的适用性不如在 Haskell 中,因为大多数值都是可变的。作为一个小例子,考虑
var constants = {pi : 3.142, fiveDigitPrimes : [10007,10009,10037,...]};
document.getElementById(“fire”).onclick = function(){
alert(constants.pi);
};
此代码定义了一个字典,其中包含pi(一个数字)和fiveDigitPrimes(一个大型数组),然后添加一个事件处理程序,该处理程序仅使用pi。如果constants是不可变的,那么垃圾回收器可以简化constants.pi并删除对constants的引用。唉,用户可以编写constants = {pi : 3}来改变constants,或者constants.pi = 3来改变pi字段,这意味着提前求值是不安全的。
虽然突变的困难意味着 JavaScript 实际上不会简化此类函数,但这并不是不可逾越的障碍。考虑一种内存布局,您知道哪些引用用作
。在 Haskell 中,惰性求值很常见(默认),并且由选择器引起的空间泄漏是不可避免的,因此应用选择器优化的决定是显而易见的。在 JavaScript 等语言中,添加代码来解决可修复的空间泄漏,但以牺牲正常代码的速度或复杂性为代价,可能不是一个明智的权衡。
此处介绍的五个空间泄漏示例提供了一些关于空间泄漏发生的位置以及如何修复它们的指导。然而,所有示例都只包含几行代码;对于大型程序中的空间泄漏,挑战通常在于找到有问题的代码。由于 Haskell 特别容易受到空间泄漏的影响,因此编译器提供了许多
空间泄漏与内存
相反,在整个执行过程中的中间点检查内存,查找内存使用量中的峰值通常很有用。频繁地捕获整个内存可能需要太多的磁盘空间,因此一种解决方案是以固定的时间间隔记录摘要统计信息,例如每个函数分配了多少内存。
Haskell 编译器提供了几种性能分析模式,这些模式生成汇总内存使用情况的图表。要生成性能分析图,请首先使用以下标志编译程序
ghc
这些标志是
•ghc
•
•
生成的程序可以像往常一样运行,但使用额外的标志,它还可以生成性能分析信息
main +RTS
hp2ps
使用平均值前面介绍的示例生成图 3 中显示的第一个图。X 轴是时间(以秒为单位),Y 轴是内存(以 MB 为单位)。第一个命令使用一些标志运行生成的main可执行文件到运行时系统(+RTS之后的任何内容)。
Haskell 有多种性能分析模式,最简单的方法是尝试所有模式,看看哪种模式产生最有用的信息。图 3 中显示的四种标准类型的性能分析图是
•
•
•
•
从这些图表的组合中,您可以看到模块main中的函数Main分配了一个大型数字列表。它在 0.4 秒内分配列表,然后在 0.1 秒内快速消耗列表。此内存使用情况描述了从平均值.
的原始定义中预期的结果。对于较大的程序,该图通常会包含大量
正如在sum示例中看到的,使用不同的优化设置进行编译可能会导致空间泄漏出现或消失,并且,可悲的是,为性能分析进行编译也可能具有类似的效果(尽管这种情况相对罕见)。作为后备方案,任何 Haskell 可执行文件都可以使用+RTS
在使用性能分析工具之前,请阅读 GHC 手册的“性能分析”部分,其中涵盖了性能分析的几种其他风格。为了更好地了解性能分析工具如何应用于大型程序以及如何解释结果,我推荐以下两个来自 Edward Yang 和我本人的“战壕故事”
• http://blog.ezyang.com/2011/06/pinpointing-space-leaks-in-big-programs/
• http://neilmitchell.blogspot.com/2013/02/chasing-space-leak-in-shake.html
Haskell 缺少的一个工具是在某个特定点暂停执行并探索内存的能力。此功能在某些 JavaScript 实现中可用,包括 Chrome 中的堆分析器。
Chrome 堆分析器允许拍摄内存快照并进行探索。分析器显示内存树,显示哪些值指向彼此。您可以按对象类型进行汇总,查看有关特定值消耗和引用的内存量的统计信息,并按名称进行筛选。一个特别有用的功能,用于诊断空间泄漏,是能够查看哪些引用使值保持活动状态。本文中的两个 JavaScript 空间泄漏产生堆快照,可以轻松地查明问题。
垃圾回收使程序员从手动管理内存的单调工作中解放出来,使语言更容易包含惰性求值或闭包等高级功能。这些高级功能导致更复杂的内存布局,使得预测内存外观变得更加困难,可能导致空间泄漏。
惰性函数式语言的编译器已经处理空间泄漏超过 30 年,并开发了许多策略来帮助解决。编译技术和垃圾收集器以及分析器都进行了更改,以便在空间泄漏发生时能够查明。其中一些策略可能适用于其他语言。尽管进行了所有改进,但空间泄漏仍然是惰性求值的眼中钉,产生了与好处相权衡的重大缺点。
虽然空间泄漏令人担忧,但它们并非致命,并且可以被检测和消除。惰性求值的存在并没有阻止 Haskell 在许多项目中成功使用(您可以在函数式商业用户会议的会议记录中找到许多示例
编程)。虽然没有明显的解决空间泄漏的灵丹妙药,但有三种方法可以提供帮助
• 一些复杂的问题领域拥有库,这些库通过设计消除了大量空间泄漏。
一个例子是函数式响应式编程,它用于构建交互式应用程序,例如用户界面和声音合成器。通过更改库的定义方式,您可以保证某些时间属性并消除常见的空间泄漏来源。7 另一个例子是流处理,它在 Web 服务器中被大量使用,用于消耗流(例如,一个
JavaScript 文件)并生成新的流(例如,一个最小化的 JavaScript 文件),而无需将整个流保存在内存中。Haskell 有几个竞争的流库可用。它们都确保内存保留的时间不超过必要的时间,并且结果会尽快流式传输给用户。
• 空间泄漏通常在开发过程的后期才被检测到,有时在代码编写和部署后的几年,并且通常仅在响应用户对高内存使用率的投诉时才被检测到。如果空间泄漏可以被检测到
• 用于查明空间泄漏的工具功能强大,但肯定不是完美的。交互式查看器可以探索现有的图表11,但用户仍然需要指定在运行程序之前如何对内存进行分组。如果可以一次捕获所有四个分组,那就容易得多。Haskell 程序缺少一项功能,即拍摄内存快照以供以后检查,如果与在内存超过某个阈值时拍摄快照的功能相结合,这将更加强大。查明空间泄漏是一项需要实践和毅力的技能。更好的工具可以大大简化这个过程。
1. Augustsson, L. 2011. 实用 Haskell。在 CUFP(函数式编程商业用户)会议上的演讲; http://www.youtube.com/watch?v=hgOzYZDrXL0。
2. Augustsson, L. 2011. 惰性求值的更多要点。让我开心的事; http://augustss.blogspot.co.uk/2011/05/more-points-for-lazy-evaluation-in.html。
3. Glasgow Haskell 编译器团队。2013. 光荣的 Glasgow Haskell 编译系统用户指南,版本 7.6.3; http://www.haskell.org/ghc/docs/latest/html/users_guide/index.html。
4. Hofmann, M., Jost, S. 2003. 一阶函数式程序堆空间使用量的静态预测。第 30 届 SIGPLAN-SIGACT 编程语言原理研讨会 (POPL) 会议论文集:185-197。
5. Hughes, J. 1983. 编程语言的设计与实现。博士论文。牛津大学。
6. Hughes, J. 1989. 为什么函数式编程很重要。计算机杂志 32(2):98-107。
7. Liu, H., Hudak, P. 2007. 使用箭头堵住空间泄漏。理论计算机科学电子笔记 193:29-45。
8. Rogers, C. 2012. Web Audio API; http://www.w3.org/TR/2012/WD-webaudio-20120802/。
9. Röjemo, N., Runciman, C. 1996. 滞后、拖拽、空隙和使用——堆分析和空间高效编译再探。第 1 届 SIGPLAN 国际函数式编程会议 (ICFP) 会议论文集:34-41。
10. Wadler, P. 1987. 使用垃圾收集器修复一些空间泄漏。软件:实践与经验 17(9):595-608。
11. Yang, E. 2013. hp/D3.js; http://heap.ezyang.com/。
喜欢它,讨厌它?请告诉我们
[email protected]
Neil Mitchell 是一位 Haskell 程序员,在渣打银行工作。他于 2008 年在约克大学获得函数式编程博士学位。他的开源项目包括搜索引擎 (Hoogle)、代码建议工具 (HLint) 和用于编写构建系统 (Shake) 的库。
© 2013
最初发表于 Queue vol. 11, no. 9——
在 数字图书馆 中评论本文