引用计数的问题
Perl使用一种简单的垃圾回收(GC)形式,称为引用计数。Perl程序创建的每个变量都与一个refcnt相关联。如果程序创建了一个变量的引用,Perl会递增其refcnt
。每当Perl退出一个代码块时,它会回收属于该代码块作用域的任何变量。如果其中任何一个是引用,它们的引用值的refcnt
会被递减,如果没有其他引用指向它们,它们也会被回收。
优点
引用计数有一些很好的特性。由于GC是确定的,所以它通常不是程序性能从运行到运行变化的因素。每当Perl离开一个子程序或一个代码块时,它都会检查要回收的变量。这把GC的成本分摊到程序运行时间上,使Perl保持响应。
及时回收的另一个好处是它最小化了内存碎片,因为同一作用域中创建的变量往往同时被回收,这使得Perl能够更有效地重用内存(它表现出良好的空间局部性引用)。
可预测和及时的GC为析构函数提供了一个有用的机制。一个流行的例子是,“词法”文件句柄在超出作用域时自动关闭;Perl程序不需要在文件句柄上调用close,因为文件句柄立即关闭,所以在原始句柄被回收之前,不会因为打开新的文件句柄而出现竞争。
引用计数的开销有多大?
不同的引用计数操作有不同的开销。考虑以下Perl子程序:
sub update_customer {
my ($customer, $values) = @_;
...
}
它使用两个参数调用;一个客户对象和一个值哈希引用。my
声明导致Perl将词法变量$customer
和$values
添加到保存堆栈中(这里它执行了一个优化,将它们作为一个组条目添加,而不是两个)。每个变量都使用refcnt
的1进行初始化。然后,每个参数被分配给相应的词法变量,这会增加引用的对应值的refcnt
。这很便宜,因为Perl解释器只需要在它的头结构中递增值的refcnt
。
当子程序返回时,发生作用域退出,并且需要回收$customer
和$values
。它们的组从保存堆栈中弹出。Perl获取$customer
的refcnt
,将其保存到局部变量中,并检查它是否大于1。由于$customer
的refcnt
为1,它必须被回收。在这里,Perl执行另一个优化,本质上执行undef $customer
,使其准备好在下一次调用子程序时重用。由于$customer
是一个引用,引用的客户对象的refcnt
也必须被获取并检查。在这种情况下,它大于1,所以Perl递减局部refcnt
并将其存储在客户对象头结构中。Perl然后对$values
执行相同的递减程序。递减的多个步骤使其比递增稍微昂贵一些,但作用域管理在保存堆栈上的推和弹相对昂贵。
我们没有关于每个操作耗时多久的任何数据,也没有关于Perl在程序执行过程中在引用计数活动上花费多长时间的估计。对于其他像Python和PHP这样的引用计数动态语言,也没有此类数据。一些研究表明,与跟踪相比,引用计数可以将GC运行时间增加30%1, 2,但并不清楚这一点在Perl优化例程中的代表性如何。
缺点
引用计数在创建的每个变量都增加GC开销方面是线性扩展的。在编程中,我们通常能做得更好,例如通过批处理等方式最小化函数调用次数。
只有具有DESTROY方法的对象才需要及时回收,但Perl将每个变量都视为需要它,实时增加和减少引用计数。每次Perl退出一个块时,它必须检查并清理任何未引用的变量。
引用计数通常将GC的成本分散到运行时,但确定性和及时回收意味着任何给定作用域退出的潜在成本是无界的。想象一下Perl从一个子程序返回,回收对一个大数据图的最后一个引用,触发一系列回收。Perl必须立即清理所有这些;跟踪GC可以选择不这样做。
引用计数使内存使用略有增加,因为每个变量都有一个与其关联的refcnt
整数。与跟踪方案相比,引用计数实际上通过不需要更大的堆来避免颠簸3而节省内存。然而,循环引用可以通过内存泄漏(如果检测到,开发者可以通过weaken来修复)大量增加内存使用。
引用计数可能会引发不必要的Copy-On-Write。想象一个子进程循环遍历从其父进程继承的数据集:for my $foo (@foos) { ... }
。这暂时增加了每个元素的refcnt
,引发内存复制。这并不像听起来那么灾难性,因为每个变量的头部结构是16字节。由于页面通常为4KB,因此每个296个对象只需要一个复制(假设它们是连续的)。也可以通过直接访问每个成员而不是创建词法引用来避免复制:for my $i (0..$#foos) { $foos[$i] ... }
。
稍作推测,引用计数可能会增加缓存未命中,因为频繁更改计数会替换有价值的数据。
机会?
乍一看,似乎Perl可以通过切换到跟踪GC方案并不过于检查或更新引用计数,而是定期回收未使用的变量来节省运行时。观察一下,大多数变量都是短命的;因此,跟踪的成本应该比线性扩展得更好(因为只有长寿命的变量是可跟踪的)。
然而,为了避免破坏大量代码,Perl仍然需要尊重具有DESTROY
方法的对象的及时回收。也许它可以遵循混合模型,只对需要它的对象进行引用计数,但这会降低跟踪GC的性能优势,并使解释器复杂化,需要为引用计数的变量添加条件分支。由于对象可能在运行时获得或失去DESTROY
方法,解释器还需要能够动态地将变量添加到引用计数方案中或从中删除。
另一个复杂之处在于,具有DESTROY
方法的对象的引用也必须同样进行引用计数(以及这些引用的引用等等)。想象一个数据库句柄的数组:该数组本身必须进行引用计数,以便在它被回收时,Perl可以递减数据库句柄的refcnt
,并可能回收它们。
探索一条更有前景的途径可能是对Perl的引用计数代码进行审查,以进一步优化机会。提高引用计数的常见技术是众所周知的4,研究表明,上述30%的运行时差距可以被缩小2。
在开始这项工作之前,我们应该收集Perl在GC上花费的时间数据。两位Perl核心开发者Todd Rinaldo和Tony Cook告诉我,他们认为Perl在GC上的时间相对于内存分配、IO等其他操作非常少。如果2%的运行时间花在GC上,减少30%也就不足为奇。可能至少对于Perl来说,更好的机会在其他地方。
感谢Tony Cook、Dave Mitchell和Todd Rinaldo对Perl GC行为的见解。
参考文献
- 神话与现实:垃圾回收的性能影响,Blackburn, Cheng & McKinley 2004。
- 引用计数真的有问题吗?让引用计数重返赛场,Shahriyar, Blackburn & Frampton 2012。
- 垃圾回收:自动动态内存管理的算法,Jones & Lins 1999 pp 43。
- 同上,pp 44-74。
标签
反馈
这篇文章有什么问题吗?请通过在GitHub上打开问题或pull request来帮助我们。