lua5.3 gc 元方法__gc分析(6)
定义
如果我们给一个普通表 t,设置元表时,上带 __gc 的元方法,就会让这个表 t 在被回收时,触发 __gc 方法。例如下面的例子:
local mt = {__gc = function ()
print("call __gc ...")
end}
local t1 = setmetatable({}, mt)
t1 = nil
collectgarbage("collect")
print("end ...")
--[[
运行结果:
call __gc ...
end ...
]]
实现分析
我们在调用 setmetatable() API 时,看看里面的具体实现:
LUA_API int lua_setmetatable (lua_State *L, int objindex) {
TValue *obj;
Table *mt;
lua_lock(L);
api_checknelems(L, 1);
obj = index2addr(L, objindex);
if (ttisnil(L->top - 1))
mt = NULL;
else {
api_check(L, ttistable(L->top - 1), "table expected");
mt = hvalue(L->top - 1);
}
switch (ttnov(obj)) {
case LUA_TTABLE: {
hvalue(obj)->metatable = mt;
if (mt) {
luaC_objbarrier(L, gcvalue(obj), mt);
luaC_checkfinalizer(L, gcvalue(obj), mt);
}
break;
}
...
}
/*
** if object 'o' has a finalizer, remove it from 'allgc' list (must
** search the list to find it) and link it in 'finobj' list.
*/
void luaC_checkfinalizer (lua_State *L, GCObject *o, Table *mt) {
global_State *g = G(L);
if (tofinalize(o) || /* obj. is already marked... */
gfasttm(g, mt, TM_GC) == NULL) /* or has no finalizer? */
return; /* nothing to be done */
else { /* move 'o' to 'finobj' list */
GCObject **p;
if (issweepphase(g)) {
makewhite(g, o); /* "sweep" object 'o' */
if (g->sweepgc == &o->next) /* should not remove 'sweepgc' object */
g->sweepgc = sweeptolive(L, g->sweepgc); /* change 'sweepgc' */
}
/* search for pointer pointing to 'o' */
for (p = &g->allgc; *p != o; p = &(*p)->next) { /* empty */ }
*p = o->next; /* remove 'o' from 'allgc' list */
o->next = g->finobj; /* link it in 'finobj' list */
g->finobj = o;
l_setbit(o->marked, FINALIZEDBIT); /* mark it as such */
}
}
在给 普通表设置元表时,会触发 luaC_checkfinalizer() 函数调用,这个函数就会把具有 __gc 元方法行为的普通表从 g->allgc 链表中摘下来,放到 g->finobj 链表中,如果已经加入过了,就直接返回。
从中我们也可以了解到,如果是在 g->allgc 已经有很多对象了的时候,才去设置 __gc 的话,42 行 for 循环查找的开销可不少。所以,如果可以,还是在程序启动时,尽早的设置 __gc 元方法比较好,当然,也要看需求,并不绝对。
这里有个注意点,就是如果当前处于清除阶段(清除阶段是可以分步执行的),这个对象有可能是死亡的,需要把这个对象从清除链表 g->sweepgc 中移除出去。实现起来也比较简单,就是先将对象设置成当前白,如果当前 g->sweepgc 链表正要清除的就是这个对象,还需要调用 sweeptolive() 函数找到这个对象,然后从 g->sweepgc 链表中移除。
local mt = nil
mt = {__gc = function (t)
print("__gc:", t, mt)
setmetatable(t, mt)
end}
local t1 = setmetatable({}, mt)
print("create t1", t1)
t1 = nil
collectgarbage()
print("end ...")
比如上面,在 gc 清除阶段执行 __gc 方法时,再对表 t1 设置一次元方法 setmetatable(t, mt),触发 luaC_checkfinalizer 36~40 行的代码。
GCSatomic 原子阶段
接着看看原子阶段,做了些什么事。
static l_mem atomic (lua_State *L) {
global_State *g = G(L);
...
separatetobefnz(g, 0); /* separate objects to be finalized */
g->gcfinnum = 1; /* there may be objects to be finalized */
markbeingfnz(g); /* mark objects that will be finalized */
...
return work; /* estimate of memory marked by 'atomic' */
}
/*
** find last 'next' field in list 'p' list (to add elements in its end)
*/
static GCObject **findlast (GCObject **p) {
while (*p != NULL)
p = &(*p)->next;
return p;
}
/*
** move all unreachable objects (or 'all' objects) that need
** finalization from list 'finobj' to list 'tobefnz' (to be finalized)
*/
static void separatetobefnz (global_State *g, int all) {
GCObject *curr;
GCObject **p = &g->finobj;
GCObject **lastnext = findlast(&g->tobefnz);
while ((curr = *p) != NULL) { /* traverse all finalizable objects */
lua_assert(tofinalize(curr));
if (!(iswhite(curr) || all)) /* not being collected? */
p = &curr->next; /* don't bother with it */
else {
*p = curr->next; /* remove 'curr' from 'finobj' list */
curr->next = *lastnext; /* link at the end of 'tobefnz' list */
*lastnext = curr;
lastnext = &curr->next;
}
}
}
/*
** mark all objects in list of being-finalized
*/
static void markbeingfnz (global_State *g) {
GCObject *o;
for (o = g->tobefnz; o != NULL; o = o->next)
markobject(g, o);
}
在原子操作 atomic() 调用 separatetobefnz() 时,遍历 g->finobj 链表,如果节点是白色的,就加入到 g->tobefnz 链表中 ,表示没有再被引用了,需要调用 __gc 指向的方法,否则就跳过,指向下一个对象。
在原子阶段,会调用markbeingfnz() 函数,需要对 g->tobefnz 链表上的对象进行 mark 操作,因为这些对象在调用 __gc 方法前是不能被回收的,而且它们引用到的其他 gc 可回收对象,不能在本轮 gc 中回收,所以,需要不断的遍历 mark。
GCScallfin 阶段
在singlestep() 函数中我们看到 从 GCSswpfinobj 到 GCSswptobefnz 阶段,都是对 g->finobj,和 g->tobefnz 链表的处理,同 g->allgc 链表清理流程一样,最终都是调用 sweeplist() 函数,检测链表上的对象,如果对象是标记阶段的白色,就清除释放对象,回收内存,如果不是,就重置下对象颜色为当前白色。
重点看下 GCScallfin 阶段,runafewfinalizers() 函数处理:
/*
** call a few (up to 'g->gcfinnum') finalizers
*/
static int runafewfinalizers (lua_State *L) {
global_State *g = G(L);
unsigned int i;
lua_assert(!g->tobefnz || g->gcfinnum > 0);
for (i = 0; g->tobefnz && i < g->gcfinnum; i++)
GCTM(L, 1); /* call one finalizer */
g->gcfinnum = (!g->tobefnz) ? 0 /* nothing more to finalize? */
: g->gcfinnum * 2; /* else call a few more next time */
return i;
}
static GCObject *udata2finalize (global_State *g) {
GCObject *o = g->tobefnz; /* get first element */
lua_assert(tofinalize(o));
g->tobefnz = o->next; /* remove it from 'tobefnz' list */
o->next = g->allgc; /* return it to 'allgc' list */
g->allgc = o;
resetbit(o->marked, FINALIZEDBIT); /* object is "normal" again */
if (issweepphase(g))
makewhite(g, o); /* "sweep" object */
return o;
}
static void dothecall (lua_State *L, void *ud) {
UNUSED(ud);
luaD_callnoyield(L, L->top - 2, 0);
}
static void GCTM (lua_State *L, int propagateerrors) {
global_State *g = G(L);
const TValue *tm;
TValue v;
setgcovalue(L, &v, udata2finalize(g));
tm = luaT_gettmbyobj(L, &v, TM_GC);
if (tm != NULL && ttisfunction(tm)) { /* is there a finalizer? */
int status;
...
setobj2s(L, L->top, tm); /* push finalizer... */
setobj2s(L, L->top + 1, &v); /* ... and its argument */
L->top += 2; /* and (next line) call the finalizer */
L->ci->callstatus |= CIST_FIN; /* will run a finalizer */
status = luaD_pcall(L, dothecall, NULL, savestack(L, L->top - 2), 0);
...
if (status != LUA_OK && propagateerrors) { /* error while running __gc? */
if (status == LUA_ERRRUN) { /* is there an error object? */
const char *msg = (ttisstring(L->top - 1))
? svalue(L->top - 1)
: "no message";
luaO_pushfstring(L, "error in __gc metamethod (%s)", msg);
status = LUA_ERRGCMM; /* error in __gc metamethod */
}
luaD_throw(L, status); /* re-throw error */
}
}
}
在 runafewfinalizers() 函数里面,遍历所有已经没有在被引用的,且带有 __gc 元方法的对象(表对象,或者用户数据对象),执行 GCTM() 方法。在 GCTM() 方法中,会先调用 udata2finalize() 方法 将对象从 g->tobefnz 链表中摘下来,重新放回到 g->allgc 链表中,再判断对象当前是否还存在 __gc 元方法,如果存在,就调用 __gc 元方法,注意,调用这个 __gc 元方法是采用 pcall 方式调用的,目的是为了在函数里头要是出现异常情况,能回到当前环境,status = LUA_ERRGCMM; 标记当前异常是发生在 __gc 里头的。但如果其中某个对象在执行 __gc 的元方法时,pcall 失败了,GCScallfin 阶段就会被中断,执行55代码,抛出异常,然后得等下次进入 singlestep() 时,再继续执行剩余对象的 __gc 元方法。
例如下面的例子:
local function f( )
local t1 = setmetatable({}, {__gc = function (t)
print("aaa", t)
end})
local t2 = setmetatable({}, {__gc = function (t)
print("bbb", t)
t = 12+t.a
end})
print("----------------- gc start", t1, t2)
t1 = nil
t2 = nil
collectgarbage()
print("----------------- gc end")
end
local ok, err = pcall(f)
print(ok, err)
print("----- call gc again")
collectgarbage()
print("main end")
--[[
运行结果:
----------------- gc start table: 00da85f0 table: 00da8668
bbb table: 00da8668
false error in __gc metamethod (..\aaaa.lua:9: attempt to perform arithmetic on a nil value (field 'a'))
----- call gc again
aaa table: 00da85f0
main end
]]
我们可以看到,表 t2 在第1次17行全量 gc 时,报错了,导致表 t1 的元方法,在第2次26行全量 gc 的时候才被执行到。
总的来说,在 gc 跑到 GCScallfin 阶段时,会取出 g->tobefnz 链表里的对象去执行 __gc 元方法。并且在udata2finalize() 中会把对象重新放回到 g->allgc 链表中,标记为当前白,等待下一轮gc,才回收对象。也就是说,本轮 gc 只会执行 __gc 元方法,等到下一轮,才去执行释放操作,如下图所示:

将对象重新放回 g->allgc 链表的目的也很简单,因为这个对象,有可能会在 __gc 指向的方法里头再次被引用,所以,不能在本轮 gc 释放,得放到下一轮 gc 去做检查是否需要释放。对于一个已经不在栈上,或者全局表等其他地方引用了,只有在 __gc 指向的方法里头被局部变量引用这种,例如下面的例子:
local mt = nil
mt = {__gc = function (tb)
print("__gc tb:", tb, tb.str)
setmetatable(tb, mt)
end}
local t = setmetatable({str = "abc"}, mt)
print("---- t:", t)
t = nil
print("========= gc1 start")
collectgarbage()
print("========= gc1 end")
print("========= gc2 start")
collectgarbage()
print("========= gc2 end")
--[[
运行结果:
---- t: table: 00000000006d9a90
========= gc1 start
__gc tb: table: 00000000006d9a90 abc
========= gc1 end
========= gc2 start
__gc tb: table: 00000000006d9a90 abc
========= gc2 end
__gc tb: table: 00000000006d9a90 abc
]]
在例子中,我们看到,表 t 引用着 str 字符串对象,所以,在原子阶段的 markbeingfnz() 函数中 mark 字符串str,使其不被 gc 回收。在 GCScallfin 阶段,调用 __gc 元方法,把表对象 t 又重新插回 g->allgc 链表。接着我们看到 __gc 指向的函数内,又一次调用 setmetatable(tb, mt) 设置元表,流程又回到了开篇介绍 setmetatable()的实现了。
还有一种情况,在设置完 t 的元表 mt 后,在之后的某一刻时间,t 表已经在 g->finobj 或 g->tobefnz 链表里了,如果此时,将 mt.__gc 置为 nil,那么是不会主动从 g->finobj 或 g->tobefnz 链表中摘下来的,只有在本轮 gc 的 GCScallfin 清除阶段的 GCTM() 方法中,把这个对象 t 重新插回 g->allgc 链表里,等待下一轮 gc 再做清除释放。也就是说,带__gc 元方法的对象,已经处在清除阶段了,即使该对象把元表设置为nil,本轮 gc 也不会清除这个对象,需要等下一轮 gc 才会去清除。
比较有意思的是 __gc 元方法能否被调用,取决于在setmetatable()那刻,是否有设置 __gc 元方法。例如下面的例子:
local mt = {}
local t = setmetatable({str = "abc"}, mt)
mt.__gc = function (tb)
print("__gc tb:", tb, tb.str)
end
print("---- t:", t)
t = nil
print("========= gc start")
collectgarbage()
print("========= gc end")
--[[
运行结果:
---- t: table: 00000000006d96d0
========= gc start
========= gc end
]]
我们发现,在打印 gc start 到 gc end 之间,并没有执行到 __gc 元方法,而我们只要把 local t = setmetatable({str = "abc"}, mt) 这行代码往下移到到 mt.__gc = ... 之后,我们再次运行程序,发现就能打印 __gc 元方法里的内容了。
说明想要执行对象的 __gc 元方法,只能在调用API setmetatable() 接口之前,就要把 __gc 元方法设置好值。
当然还有一种比较取巧的方法,如下:
local mt = {}
mt.__gc = 1
local t = setmetatable({str = "abc"}, mt)
mt.__gc = function (tb)
print("__gc tb:", tb, tb.str)
end
print("---- t:", t)
t = nil
print("========= gc1 start")
collectgarbage()
print("========= gc1 end")
--[[
运行结果:
---- t: table: 00000000007598d0
========= gc1 start
__gc tb: table: 00000000007598d0 abc
========= gc1 end
]]
就是先对 mt.__gc 赋值 1,其实不管赋什么值都可以,我们在 setmetatable()之后,再重新对 __gc 赋值,看到运行结果里有第 7 行的打印: __gc tb: table: 00000000007598d0 abc。从中得出结论,想要执行对象的 __gc 元方法,只能在调用 API setmetatable() 接口之前,就要把 __gc 值设置好,只是我们可以在真正触发 __gc 元方法调用之前,有机会改变其值,但这个时机是不可控的,所以建议,能提前就提前准备好。
额外补充
我们知道,在 lua gc 中,标记,清除阶段,都是可以分步执行的,可以被中断,而只有原子阶段的 atomic()才是不可中断的,得一次性走完,才能进入到下一个阶段。那么如果在清除阶段,调用 setmetatable()设置元方法 __gc ,会在本轮的 GCScallfin 阶段执行对象的 __gc 元方法吗?
答案是不会,因为我们只有在 atomic() 函数里才有机会把对象放到 g->tobefnz 链表中,只有放到 g->tobefnz 链表的对象,才会最终执行 __gc 元方法。所以,清除阶段设置 __gc 元方法,只能错过本轮 gc,待到下一轮 gc 的 GCScallfin 阶段,才会执行 __gc 元方法,这对用户来说,不会有什么影响,只要最终能调用到 __gc 元方法就可以了。
为了试验我说的是否正确,我专门查看了 lua gc 相关API,发现 lua gc 并没有对外开放当前是处于哪个阶段的API。所以,我的测试方法,是额外加多一个API接口,改写 lua 源码(当然也可以使用动态库的方式,但懒得那样做了),在 lua 层也能查看到当前 gc 处于哪个阶段,这样,我们就可以准确的在清除阶段 GCSswpend 设置 __gc 元方法了。
还有一个有趣的发现,就是设置了 __gc 元方法的对象,如果在本轮 gc 中,还一直有被引用着的话,最终是被挂在 g->finobj 链表中的,而不会放到 g->tobefnz 或者 g->allgc 链表中(我们仔细看原子阶段调用的 separatetobefnz(g, 0); 第2个传的参数是0,表明对象只有白色的,才会被加入到 g->tobefnz,而不是白色的,还是处在 g->finobj 链表中),那么放在 g->finobj 的对象,在本轮清除阶段,或者在新的一轮 gc 中,对象不在 g->allgc 链表中,会不会被 mark,会被清除吗,会有什么影响吗?
答案是不会有什么影响的,如果是在本轮 gc 中,只要对象有被引用着,在标记阶段,不管是在 g->allgc 链表,还是在 g->finobj 链表,都最终会被标记为黑色,在清除阶段,sweeplist() 在清除 g->finobj 链表时,发现对象是非标记阶段的白色,就不会被清除。而如果是清除阶段调用 setmetatable(),把对象加入到 g->finobj 链表的话,也不会有任何问题,因为在 luaC_checkfinalizer() 中发现,清除阶段加入的对象,会标记为当前白,所以,也同样不会有什么问题。
如果是到了下一轮 gc 标记阶段,g->finobj 链表的对象,会和 g->allgc 链表的对象一样,有同等待遇,链表中的对象是否被 mark,取决于对象是否有在线程、全局注册表、全局元表这些根对象有直接引用,或者间接引用。有的话,会被 g->gray,g->grayagain 等链表引用,原子阶段,判断对象没有再被引用了,才会被放入到 g->tobefnz 链表中,最后在调用完 __gc 元方法后,才放回到 g->allgc 链表中,完成对象从 g->finobj 链表到 g->allgc 链表的转移,这个过程,就是周期会有点长。所以,对象是在 g->finobj,还是在 g->allgc 中,都不会有什么影响,最终都能回到 g->allgc 链表中,再新一轮 gc 中走向释放。
通过以上对 __gc 元方法实现的分析,我们最终可以得出结论,g->finobj 和 g->tobefnz 链表是不可能存在死对象的,也就是挂在这两个链表上的对象,本轮 gc 是不会被释放的,g->tobefnz 链表可能会在下一轮 gc 进行释放,而 g->finobj 链表的对象,则需要到下下轮,才有机会回到 g->allgc 链表进行释放。

浙公网安备 33010602011771号