Lua连续教程之Lua垃圾收集

编程/开发
478
0
0
2023-02-12
标签   Lua

Lua语言使用自动内存管理。程序可以创建对象,但却没有函数来删除对象。Lua语言通过垃圾收集自动删除称为垃圾的对象,从而将程序员从内存管理的绝大部分负担中解放出来。

(adsbygoogle = window.adsbygoogle || []).push({});

更重要的是,将程序员从与内存管理相关的大多数Bug中解放出来。例如无效指针和内存泄露等问题。 在一个理想的环境中,垃圾收集器对程序员来说是不可见的,就像一个好的清洁工不会和其他工人打交道一样。不过,有时即使是智能的垃圾收集器也会需要我们的辅助。在某些关键的性能阶段,我们可能需要将其停止,或者让只在特定的时间运行。另外,一个垃圾收集器只能收集它确定是垃圾的内容,而不能猜测我们把什么当作垃圾。没有垃圾收集器能够做到让我们完全不用操心资源管理的问题,比如驻留内存和外部资源。 弱引用表、析构器和函数collectgarbage是在Lua语言中用来辅助垃圾收集器的主要机制。弱引用表允许收集Lua语言中还可以被程序访问的对象;析构器允许收集不在垃圾收集器直接控制下的外部对象;函数collectgarbage则允许我们控制垃圾收集器的步长。

弱引用表

我们知道,数组的有效部分总是向顶部扩展的,但Lua语言却不知道。如果弹出一个元素时只是简单地递减顶部索引,那么这个仍然留在数组中的对象对于Lua语言来说并不是垃圾。同理,即使是程序不会再用到的、存储在全局变量中的对象,对于Lua语言来说也不是垃圾。在这两种情况下,都需要我们将这些对象所在的位置赋值为nil,以便这些位置不会锁定可释放的对象。 不过,简单地清除引用可能还不够。在有些情况下,还需要程序和垃圾收集器之间的协作。一个典型的例子是,当我们要保存某些类型的活跃对象的列表时。这个需要看上去简单,我们只需要把每个新对象插入数组即可;但是,一旦一个对象成为了数组的一部分,它就再也无法被回收!虽然已经没有其他任何地方在引用它,但数组依然在引用它。除非我们告诉Lua语言数组对该对象的引用不应该阻碍对此对象的回收,否则Lua语言本身是无从知晓的。 弱引用表就是这样一个用来告知Lua语言一个引用不应阻止一个对象回收的机制。所谓所引用是一种不在垃圾收集器考虑范围内的对象引用。如果一个对象的所有引用都是弱引用,弱引用表就是元素均为弱引用的表,这意味着如果一个对象只被一个弱引用持有,那么Lua语言最终会回收这个对象。 表由键值对组成,其两者都可以容纳任何类型的对象。在正常情况下,垃圾收集器不会回收一个在可访问的表中作为键或值的对象。也就是说,键和值都是强引用,它们会阻止对其所指向对象的回收。在一个弱引用表中,键和值都可以是弱引用的。这就意味着有三种类型的弱引用表,即具有弱引用键的表、具有弱引用值的表及同时具有弱引用键和值的表。不论是哪种类型的弱引用表,只要有一个键或值被回收了,那么对应的整个键值对都会被从表中删除。 一个表是否为弱引用表是由其元表中的__mode字段决定的。当这个字段存在时,其值应为一个字符串:如果这个字符串是”k”,那么这个表的键是弱引用的;如果这个字符串是”v”,那么这个表的值是弱引用的;如果这个字符串是”kv”,那么这个表的键和值都是弱引用的。下面的示例虽然有些可以,但演示了弱引用表的基本行为:

a = {}
mt = {__mode = "k"}
setmetatable(a,mt)		-- 现在'a'的键是弱引用的了
key = {}
a[key] = 1				-- 创建第一个键
key = {}
a[key] = 2				-- 创建第二个键
collectgarbage()		-- 强制进行垃圾回收
for k, v in pairs(a) do print(v) end		-- 2

在本例中,第二句赋值key = {}覆盖了指向第一个键的索引。调用collectgarbage强制垃圾收集器进行一次完整的垃圾手机。由于已经没有指向第一个键的其他引用个,因此Lua语言会回收这个键并从表中删除对应的元素。然而,由于第二个键仍然被变量key所引用,因此Lua不会回收它。

请注意,只有对象可以从弱引用表中被移除,而像数字和布尔这样的“值”是不可回收的。例如,如果我们在表a中插入一个数值类型的键,那么垃圾收集器永远不会回收它。当然,如果在一个值为弱引用的弱引用表中,一个数值类型键相关联的值被回收了,那么整个元素都会从这个弱引用表中被删除。

字符串在这里表现了一些细微的差别,虽然从实现的角度看字符串是可回收的,但字符串又与其他的可回收对象不同。其他的对象,例如表和闭包,都是被显式创建的。例如,当Lua语言对表达式{}求值时会创建一个新表。

记忆函数

空间换时间是一种常见的编程技巧。我们可以通过记忆函数的执行结果,在后续使用相同参数再次调用该函数时直接返回之前记忆的结果,来加快函数的运行速度。 假设有一个通用的服务器,该服务器接收的请求是以字符串形式表示的Lua语言代码。每当服务器接收到一个请求时,它就对字符串运行load函数,然后再调用编译后的函数。不过,函数load的开销很昂贵,而且发送给服务器的某些命令的出现频率可能很高。这样,与其每次收到一条诸如”closeconnection()”这样的常见命令就重复地调用函数load,还不如让服务器用一个辅助表记忆所有函数load的执行结果。在调用函数load前,服务器先在表中检查指定的字符串是否已经被处理过。如果没有,就调用函数load并将返回值保存到表中。我们可以将这种行为封装为一个新的函数:

local results = {}
function mem_loadstring(s)
	local res = results[s]
	if res == nli then
		res = assert(laod(s))
		results[s] = res
	end
	return res
end

这种模式节省的开销非常可观。但是,它也可能导致不易察觉的资源浪费。虽然有些命令会重复出现,但也有很多命令可能就出现一次。渐渐地,表results会堆积上服务器收到的所有命令及编译结果;在运行一段足够长的时间后,这种行为会耗尽服务器的内存。

弱引用表为解决这个问题提供了一种简单的方案,如果表results具有弱引用的值,那么每个垃圾收集周期都会删除所有那个时刻未使用的编译结果:

local results = {}
setmetatable(results,{__mode = "v"})		-- 让值称为弱引用
funciton mem_loadstring(s)
--下面内容同前

实际上,因为索引永远是字符串,所以如果愿意的话,我们可以让这个表变成完全弱引用的:

setmetatable(results,{__mode = "kv"})

最终达到的效果是完全一样的。

记忆技术还可以用来确保某类对象的唯一性。例如,假设一个系统用具有三个相同取值范围的字段red、green和blue的表来表示颜色,一个简单的颜色工厂函数每被调用一次就生成一个颜色:

function createRGB(r,g,b)
	return {red = r, green = g ,blue = b}
end

使用记忆技术,我们就可以为相同的颜色复用相同的表。要为每一种颜色创建一个唯一的键,只需要使用分隔符把颜色的索引连接起来即可:

local results = {}
setmetatable(results,{__mode = "v"})		-- 让值称为弱引用
function createRGB( r,g,b )
	local key = string.format("%d-%d-%d",r,g,b)
	local color = results[key]
	if color == nil then
		color = {red = r, green = g, blue = b}
		results[key] = color
	end
	return color
end

这种实现的一个有趣结果是,由于两种颜色存在的颜色必定是由同一个表来表示,所以用户可以使用基本的相等运算符比较两种颜色。因为随着时间的迁移垃圾收集器会清理表results,所以一种指定的颜色在不同的时间内可能由不同的表来表示。不过,只要一种颜色正在被使用,它就不会从results中被移除。因此,一种颜色与一种新颜色相比已经存在了多长时间,这种颜色对应的表也存在了对应长度的时间,也可以被新颜色复用。

对象属性

弱引用表的另外一种重要应用是将属性与对象关联起来。在各种各样的情况下,我们都需要把某种属性绑定到某个对象,例如函数名、表的默认值及数组的大小等。 当对象是一个表时,可以通过适当的唯一键把属性存储在这个表自身中。不过,如果对象不是一个表,那么它就不能保存它自己的属性。另外,即使是表,有时我们也不想把属性保存在原始的对象。例如,当想保持属性的私有性时,或不想让属性干扰表的遍历时,就需要用其他办法来关联对象和属性。 当然,外部表为对象和属性的映射提供了一种理性的方法,即对偶表示,其中将对象用作键、将对象的属性用作值。由于Lua语言允许使用任意类型的对象作为键,因此一个外部表可以保存任意类型对象的属性。此外,存储在外部表中的属性不会干扰其他对象,并且可以像表本身一样是私有的。 不过,这个看似完美的方案有一个重大缺陷:一旦我们把一个对象当作表中的一个键,那么就是引用了它。Lua语言无法回收一个正在被用作键的对象。例如,如果使用一个普通的表来映射函数和函数名,那么这些函数就永远无法被回收。

回顾具有默认值的表

在第一种解决方案中,我们使用了一个弱引用表来映射每一个表和它的默认值:

local defaults = {}
setmetatable(defaults,{__mode = "k"})
local mt = {__index = function(t) return defaults[t] end}
function setDefault (t,d)
	defaults[t] = d
	setmetatable(t,mt)
end

这是对偶表示的一种典型应用,其中使用了defaults[t]来表示t.defaults。如果表defaults没有弱引用的键,那么所有具有默认值的表就会永远存在下去。

在第二种解决方案中,我们对不同的默认值使用了不同的元素,在遇到重复的默认值时会复用相同的元表。这是记忆技术的一种典型应用:

local metas = {}
setmetatable(metas,{__mode = "v"})
function setDefault(t,d)
	local mt = metas[d]
	if mt == nil then
		mt = {__index = function() return d end}
		metas[d] = mt
	end
	setmetatable(t,mt)
end

在这种情况下,我们使用弱引用的值使得不再被使用的元表能够回收。

这两种实现哪种更好取决于具体的情况。这两种实现具有类似的复杂度和性能表现,第一种实现需要为每个具有默认值的表分配几个字节的内存,而第二种实现则需要为每个不同的默认值分配若干内存。因此,如果应用中有上千个具有少量不同默认值的表,那么第二种实现明显更好。不过,如果只有少量共享默认值的表,那么就应该选择第一种实现。

瞬表

一种棘手的情况是,一个具有弱引用键的表中的值又引用了对应的键。 这种情况看上去更加常见。一个典型的示例是常量函数工厂。这种工厂的参数是一个对象,返回值是一个被调用时返回传入对象的函数:

function factory(o)
	return (function() return o end)
end

这种工厂时实现记忆的一种很好的手段,可以避免在闭包已经存在时又创建新的闭包。

示例 使用记忆技术的常量函数工厂
do 
	local mem = {}
	setmetatable(mem, {__mode = "k"})
	function factory(o)
		local res = mem[o]
		if not res then
			res = (function() return o end)
			mem[o] = res
		end
		return res
	end
end
不过,这里另有玄机。请注意,表mem中与一个对象关联的值回指了它自己的键。虽然表中的键是弱引用的,但是表中的值却不是弱引用的。从一个弱引用表的标准理解看,记忆表中没有任何东西会被移除。由于值不是弱引用的,所以对于每一个函数来说都存在一个强引用。每一个函数都指向其对应的对象,因而对于每一个键来说都存在一个强应用。因此,即使有弱引用的键,这些对象也不会被回收。 不过,这种严格的理解不是特别有用。大多数人希望一个表中的值只能通过对应的键来访问。我们可以认为之前的情况是某种环,其中闭包引用了指回闭包的对象。 Lua语言通过瞬表的概念来解决上述问题。在Lua语言中,一个具有弱引用键和强引用值的表是一个瞬表。在一个顺表中,一个键的可访问性控制着对应值的可访问性。更确切地说,考虑瞬表中的一个元素,指向的v的引用只有当存在某些指向k的其他外部引用存在时才是强引用,否则,即使v引用了k,垃圾收集器最终会收集k并把元素从表中移除。

析构器

虽然垃圾收集器的目标是回收对象,但是它可以帮助程序员来释放外部资源。处于这种目的,几种编程语言提供了析构器。析构器是一个与对象关联的函数,当该对象即将被回收时该函数会被调用。 Lua语言通过元方法gc实现析构器,如下例所示:

o = {x = "hi"}
setmetatable(o,{__gc = function(o) print(o.x) end})
o = nil
collectgarbage()		-- nil

在本例中,我们首先创建一个带有gc元方法元表的表。然后,抹去与这个表的唯一联系,再强制进行一次完整的垃圾回收。在垃圾回收期间,Lua语言发现表已经不再是可访问的了,因此调用表的析构器,也就是元方法gc。

Lua语言中,析构器的一个微妙之处在于“将一个对象标记为需要析构”的概念。通过给对象设置一个具有非空gc元方法的元表,就可以把一个对象标记为需要进行析构处理。如果不标记对象,那么对象就不会被析构。我们编写的大多数代码会正常运行,但会发生某些奇怪的行为,比如:

o = {x = "hi"}
mt = {}
setmetatable(o,mt)
mt.__gc = function(o) print(o.x) end
o = nil
collectgarbage()		-- (print nothing)

这里,我们确实要给对象o设置了元表,但是这个元表没有gc方法,因此对象没有被标记为需要进行析构处理。即使我们后续给元表增加了元方法gc,Lua语言也发现不了这种赋值的特殊之处,因此不会把对象标记为需要进行析构处理。

正如我们所提到的,这很少会有问题。在设置元表后,很少会改变元方法。如果真的需要在后续设置元方法,那么可以给字段gc先赋一个任意值作为占位符:

o = {x = "hi"}
mt = {__gc = true}
setmetatable(o,mt)
mt.__gc = function(o) print(o.x) end
o = nil
collectgarbage()		-- nil

现在,由于元表有了gc字段,因此对象会被正确地标记为需要析构处理。如果后续再设置元方法也不会有问题,只要元方法时一个正确的函数,Lua语言就能够调用它。

当垃圾收集器在同一个周期中析构多个对象时,它会按照对象被标记为需要析构处理的顺序逆序调用这些对象的析构器。请考虑如下的示例,该示例创建了一个由带有析构器的对象所组成的链表:

mt = {__gc = function(o) print(o[1]) end}
list = nil
for i = 1 , 3 do
	list = setmetatable({i,link = list},mt)
end
list = nil
collectgarbage()

第一个被析构的对象是3,也就是最后一个被标记的对象。

一种常见的误解是认为正在被回收的对象之间的关联会影响对象析构的顺序。例如,有些人可能认为上例中的对象2必须在对象1之前被析构,因为存在从2到1的关联。但是,关联会行程环。所以,关联并不会影响析构器执行的顺序。

有关析构器的另个一微妙之处是复苏。当一个析构器被调用时,它的参数是正在被析构的对象。因此,这个对象会至少在析构期间重新编程活跃的。笔者把这称为临时复苏。在析构器执行期间,我们无法阻止析构器把该对象存储在全局变量中,使得该对象在析构器返回后仍然可以访问,笔者把这称为永久复苏。

复苏必须是可传递的。考虑如下代码:

A = {x = "this is A"}
B = {f = A}
setmetatable(B,{__gc = function(o) print(o.f.x) end})
A,B = nil
collectgarbage() 		-- this is A

B的析构器访问了A,因此A在B析构前不能被回收,Lua语言在运行析构器之前必须同时复苏B和A。

由于复苏的存在,Lua语言会在两个阶段中回收具有析构器的对象。当垃圾收集器首次发生某个具有析构器的对象不可达时,垃圾收集器就把这个对象复苏并将其放入等待被析构的队列中。一旦析构器开始执行,Lua语言就将对该对象标记为已被析构。当下一次垃圾收集器又发现这个对象不可达时,它就将这个对象删除。如果想保证我们程序中的所有垃圾都被真正地释放了的话,那么必须调用collectgarbage两次,第二次调用才会删除第一次调用中被析构的对象。

由于Lua语言在析构对象上设置了标记,每一个对象的析构器都会精确地运行一次。如果一个对象直到程序运行结束还没有被回收,那么Lua语言就会在整个Lua虚拟机关闭后调用它的析构器。这种特行在Lua语言中实现了某种形式的atexit函数,即在程序终结前立即运行的函数。我们所要做的就是创建一个带有析构器的表,然后把它锚定在某处,例如锚定到全局表中:

local t = {__gc = function()
	print("finishing Lua program")
	end}
setmetatable(t,t)
_G["*AA*"] = t

另外一个有趣的技巧会允许程序在每次完成垃圾回收后调用指定的函数。由于析构器只运行一次,所以这种技巧是让每个析构器创建一个用来运行下一个析构器的新对象,参考示例:

示例 在每次GC后运行一个函数
do
	local mt = {__gc = function(o)
		print("new cycle")
		setmetatable({},getmetatable(o))
	end}
	setmetatable({},mt)
end

collectgarbage()		-- 一次垃圾收集
collectgarbage()		-- 一次垃圾收集
collectgarbage()		-- 一次垃圾收集
具有析构器的对象和弱引用表之间的交互也有些微妙。在每个垃圾收集周期内,垃圾收集器会在调用析构器前清理弱应用表中的值,在调用析构器之后再清理键。这种行为的原理在于我们经常使用带有弱引用键的表来保存对象的属性,因此,析构器可能需要访问那些属性。不过,我们也会使用具有弱引用值的表来重用活跃的对象,在这种情况下,正在被析构的对象就不再有用了。

垃圾收集器

一直到Lua5.0,Lua语言使用的都是一个简单的标记-清除式垃圾收集器。这种收集器又被称为“stop-the-world(全局暂停)”式的收集器,意味着Lua语言会时不时地停止主程序的运行来执行一次完整的垃圾收集周期。每一个垃圾收集周期由四个阶段组成:标记、清理、清除和析构。 标记阶段把根节点集合标记为活跃,根结点集合由Lua语言可以直接访问的对象组成。在Lua语言中,这个集合只包括C注册表。 保存在一个活跃对象中的对象是程序可达的,因此也会被标记活跃。当所有可达对象都被标记为活跃后,标记阶段完成。 在开始清除阶段前,Lua语言先执行清理阶段,在这个阶段中处理析构器和弱引用表。首先,Lua语言遍历所有被标记为需要进行析构、但又没有被标记为活跃状态的对象。这些没有标记为活跃状态的对象会被标记为活跃,并被放在一个单独的列表中,这个列表会在析构阶段用到。然后,Lua语言遍历弱引用表并从中移除键或值未被标记的元素。 清理阶段遍历所有对象。如果一个对象没有被标记为活跃,Lua语言就将其回收,否则,Lua语言清理标记,然后准备进行下一个清理周期。 最后,在析构阶段,Lua语言调用清理阶段被分离出的对象的析构器。 使用真正的垃圾收集器意味着Lua语言能够处理对象引用之间的环。在使用环形数据结构时,我们不需要花费外的精力,它们会像其他数据一样被回收。 Lua5.1使用了增量式垃圾收集器。这种垃圾收集器像老版的垃圾收集器一样执行相同的步骤,但是不需要在垃圾收集期间停止主程序的运行。相反,它与解释器一起交替运行。每当解释器分配了一定数量的内存时,垃圾收集器也执行一小步(这意味着,在垃圾收集器工作期间,解释器可能会改变一个对象的可达性。为了保证垃圾收集器的正确性,垃圾收集器中的有些操作具有发现危险改动和纠正所涉及对象标记的内存屏障)。 Lua5.2引入了紧急垃圾收集。当内存分配失败时,Lua语言会强制进行一次完整的垃圾收集,然后再次尝试分配。这些紧急情况可以发生在Lua语言进行内存分配的任意时刻,包括Lua语言处于不一致的代码执行状态时,因此,这些收集动作不能运行析构器。

控制垃圾收集的步长

通过函数collectgarbage可以对垃圾收集器进行一些额外的控制,该函数实际上是几个函数的集合体:第一个参数是一个可选的字符串,用来说明进行何种操作;有些选项使用一个整型作为第二个参数,称为data。 第一个参数的选项包括如下七个。 “stop”:停止垃圾收集器,知道使用选项”restart”再次调用collectgarbage。 “restart”:重启垃圾收集器。 “collect”:执行一次完整的垃圾收集,回收和析构所有不可达的对象。这是默认的选项。 “step”:执行某些垃圾收集工作,第二个参数data指明工作量,即在分配了data字节后垃圾收集器应该做什么。 “count”:以KB为单位返回当前自己已用内存数,该结果是一个浮点数,乘以1024得到的就是精确的字节数。该值包括了尚未被回收的死对象。 “setpause”:设置收集器的stepmul参数。参数data给出新值,也是以百分比为单位。 两个参数pause和stepmul控制着垃圾收集器的角色。任何垃圾收集器都是使用CPU时间换内存空间。在极端情况下,垃圾收集器可能根本不会运行。但是,不耗费CPU时间是以巨大的内存消耗为代价的。在另外一种极端的情况下,收集器可能每进行一次赋值就得运行一次完整的垃圾收集。程序能够使用尽可能少的内存,但是是以巨大的CPU消耗为代价的。pause和stepmul的默认值正式试图在这两个极端之间找到的对大多数应用来说足够好的平衡点。不过,在某些情况下,还是值得试着对它们进行优化。 参数pause用于控制垃圾收集器在一次手机完成后等待多久再开始新的一次手机。当值为零时,表示Lua语言在上一次垃圾回收结束后立即开始一次新的收集。当值为200%时表示在重启垃圾收集器前等待内存使用翻番。如果想消耗更多的CPU时间换取更低的内存消耗,那么可以把这个值设置得小一点。通常,我们应该把这个值设在0到200%之间。 参数stepmul控制对于每分配1KB内存,垃圾收集器应该进行多少工作。这个值越高,垃圾收集器使用的增量越小。一个像10000000%一样巨大的值会让收集器表现得像一个非增量的垃圾收集器。默认值是200%,地于100%的值会让收集器运行得很慢,以至于可能一次收集也完不成。 函数collectgarbage的另外一些参数用来在垃圾收集器运行时控制它的行为。同样,对于大多数程序员来说,默认值已经足够好了,但是对于一些特殊的应用,用手工控制可能会更好,游戏就经常需要这种类型的控制。例如,如果我们不想让垃圾收集在某些阶段运行,那么可以通过调用函数collectgarbage(“stop”)停止垃圾收集器,然后再调用collectgarbage(“restart”)重新启动垃圾收集器。在一些具有周期性休眠阶段的代码中,可以让垃圾收集器停止,然后在程序休眠期间调用collectgarbage(“step”,n)。要设置在每一个休眠期间进行多少工作,要么为n实验性地选择一个恰当的值,要么把n设成零,然后在一个循环中调用函数collectgarbage直到休眠结束。