lua5.3 gc弱表分析(4)
前言
虽然平时用到弱表的地方不是很多,但去了解下它的设计实现也是好的。
我们是在标记阶段,才会去触发 propagatemark()
,将一个灰色对象标记为黑色,然后再去遍历其引用到的其他对象,将其他对象都 mark gray,并加入灰色链表,如此反复,直到 灰色链表为空,才进入到原子阶段。
/* ** traverse one gray object, turning it to black (except for threads, ** which are always gray). */ static void propagatemark (global_State *g) { lu_mem size; GCObject *o = g->gray; lua_assert(isgray(o)); gray2black(o); switch (o->tt) { case LUA_TTABLE: { Table *h = gco2t(o); g->gray = h->gclist; /* remove from 'gray' list */ size = traversetable(g, h); break; } ... } static lu_mem traversetable (global_State *g, Table *h) { const char *weakkey, *weakvalue; const TValue *mode = gfasttm(g, h->metatable, TM_MODE); markobjectN(g, h->metatable); if (mode && ttisstring(mode) && /* is there a weak mode? */ ((weakkey = strchr(svalue(mode), 'k')), (weakvalue = strchr(svalue(mode), 'v')), (weakkey || weakvalue))) { /* is really weak? */ black2gray(h); /* keep table gray */ if (!weakkey) /* strong keys? */ traverseweakvalue(g, h); else if (!weakvalue) /* strong values? */ traverseephemeron(g, h); else /* all weak */ linkgclist(h, g->allweak); /* nothing to traverse now */ } else /* not weak */ traversestrongtable(g, h); return sizeof(Table) + sizeof(TValue) * h->sizearray + sizeof(Node) * cast(size_t, allocsizenode(h)); }
从 traversetable()
中看出,表的遍历分为四种情况,弱值、弱建、弱键弱值、强引用。强引用,之前有介绍过,只要值不为空,就对其 key,value 标记。现在看看前三种是怎么处理的。
弱键表分析
/* ** Traverse an ephemeron table and link it to proper list. Returns true ** iff any object was marked during this traversal (which implies that ** convergence has to continue). During propagation phase, keep table ** in 'grayagain' list, to be visited again in the atomic phase. In ** the atomic phase, if table has any white->white entry, it has to ** be revisited during ephemeron convergence (as that key may turn ** black). Otherwise, if it has any white key, table has to be cleared ** (in the atomic phase). */ static int traverseephemeron (global_State *g, Table *h) { int marked = 0; /* true if an object is marked in this traversal */ int hasclears = 0; /* true if table has white keys */ int hasww = 0; /* true if table has entry "white-key -> white-value" */ Node *n, *limit = gnodelast(h); unsigned int i; /* traverse array part */ for (i = 0; i < h->sizearray; i++) { if (valiswhite(&h->array[i])) { marked = 1; reallymarkobject(g, gcvalue(&h->array[i])); } } /* traverse hash part */ for (n = gnode(h, 0); n < limit; n++) { checkdeadkey(n); if (ttisnil(gval(n))) /* entry is empty? */ removeentry(n); /* remove it */ else if (iscleared(g, gkey(n))) { /* key is not marked (yet)? */ hasclears = 1; /* table must be cleared */ if (valiswhite(gval(n))) /* value not marked yet? */ hasww = 1; /* white-white entry */ } else if (valiswhite(gval(n))) { /* value not marked yet? */ marked = 1; reallymarkobject(g, gcvalue(gval(n))); /* mark it now */ } } /* link table into proper list */ if (g->gcstate == GCSpropagate) linkgclist(h, g->grayagain); /* must retraverse it in atomic phase */ else if (hasww) /* table has white->white entries? */ linkgclist(h, g->ephemeron); /* have to propagate again */ else if (hasclears) /* table has white keys? */ linkgclist(h, g->allweak); /* may have to clean white keys */ return marked; }
弱键扫描,主要分为数组部分,以及哈希表部分,对于数组部分,因为 key 都是数字,不是 gc 对象,不可回收,所以,不需要考虑 value 是否有被引用的情况,直接对 value 标记灰色,加入灰色链表就可以了。如果是哈希表部分,就稍微复杂点,我们看到,在遍历哈希表部分时,先判断了节点值是否为空,如果为空,那么就调用 removeentry()
标记节点 key 是死亡的(注意,这里并没有对 key 置为 nil,因为有可能这个 key 在 for ... pairs
循环时迭代用到)。如果节点 value 不为空,接下来就要对 key 进行分析了。
/* ** tells whether a key or value can be cleared from a weak ** table. Non-collectable objects are never removed from weak ** tables. Strings behave as 'values', so are never removed too. for ** other objects: if really collected, cannot keep them; for objects ** being finalized, keep them in keys, but not in values */ static int iscleared (global_State *g, const TValue *o) { if (!iscollectable(o)) return 0; else if (ttisstring(o)) { markobject(g, tsvalue(o)); /* strings are 'values', so are never weak */ return 0; } else return iswhite(gcvalue(o)); }
我们再看看iscleared()
实现,它的作用是判断对象是不是可回收类型的,且是否是白色的,如果是,就返回1,不是就返回0,针对字符串的弱键表,我们从这里就可以看出,字符串是会被当做不可回收类型(像类型为 Number,Boolean 这些)来对待的。接下来,接着回到traverseephemeron()
实现继续看,对键是可回收的,白色的,且还未被标记的,则将 hasclears 设置为1。在进一步判断,如果值也是未被标记的,则将 hasww 变量设置为1。如果 key 是不可回收对象,或者以及有地方引用着了,标记为黑色,对其值 mark。
对于 hasww 变量值为1的含义:key 和 value 都是可回收对象,且是白色,简称双白,从变量定义处的注释也可以说明。然后需要对存在双白的表放到一个 ephemeron 链表中,如果是 GCSpropagate 阶段,会放到 g->grayagain 链表中,待原子阶段,再重新扫描一次。如果不是双白,且不在 GCSpropagate 阶段的,table 对象放到 g->allweak 链表中。
从traverseephemeron()
中不难得出结论,如果一个表存在弱键,那么就将该表对象放到 g->grayagain 或 g->ephemeron 或 g->allweak 中的一个链表里,然后待到原子阶段atomic()
函数再统一处理。
如上图所示,弱键表,对数组部分,直接 mark,最终会变成 black(省略画灰色了)。对于哈希表部分,不可回收的对象,如数字,Boolean 这些,最终也会被标记为黑色 black,而字符串 "name" 则被当做不可回收的类型来对待,被mark black。第3个节点,因为值为 nil,所以,key 不会被 mark,但会被标记为死亡状态。第4个节点,因为 key/value 都没有被其他地方引用到,双白,最终放到 g->ephemeron 链表中。第5个节点,key 没有被引用,为白色,值被栈上的 t2 引用着,为黑色,最终会被放到 g->allweak 链表中。最后一个节点,因为 key 被栈上的 t1 变量引用着,为黑色,值最终也会跟着被 mark black。
如上图所示,如果当前 gc 处于 GCSpropagate 阶段,则这个弱键表就会被放到 g->grayagain 链表中,而不会放到 g->ephemeron 或 g->allweak 链表中。
那么弱键表放到 g->ephemeron 和 放到 g->allweak 的区别在哪呢,我们从表现上来看,不管放到哪个链表上,如果 key 最终还是白色的话,节点引用的 key 和 value 对象都会被清除。
之所以双白节点需要放到 g->ephemeron 链表中,是因为双白节点,会存在环形相互引用的情况,还无法最终确定这个节点是否真的可达。所以,我们需要在确保除了 g->ephemeron 集合以外的对象都被 mark 完了,知道当前环境下,可达的对象有哪些,不可达的对象还有哪些,再取出 g->ephemeron 链表里的 table 对象,遍历一次,看看此刻,双白节点是否真的存在可达的,如果可达,则不会被 gc 回收。这也是原子阶段 atomic()
里 mark 完了 g->grayagain,和 g->tobefnz 链表后,分两次调用convergeephemerons()
的原因。
我们可以通过一个例子来说明,可能会更直观点。
local o1 = setmetatable({}, {__mode = "k"}) local o2 = setmetatable({}, {__mode = "k"}) local v1 = {} local v2 = {} local v3 = {} o1[v1] = v2 o2[v2] = v3 local t = setmetatable({[v1] = "aaa"}, {__gc = function ( ) print("__gc function call ...") end}) v1 = nil v2 = nil v3 = nil t = nil collectgarbage() print("------------- o1:") for k,v in pairs(o1) do print("== o1", k,v) end print("------------- o2:") for k,v in pairs(o2) do print("== o2", k,v) end collectgarbage() print("------------- o1:") for k,v in pairs(o1) do print("== o1", k,v) end print("------------- o2:") for k,v in pairs(o2) do print("== o2", k,v) end --[[ 运行结果: __gc function call ... ------------- o1: == o1 table: 00000000001b98d0 table: 00000000001b9a50 ------------- o2: == o2 table: 00000000001b9a50 table: 00000000001b9ad0 ------------- o1: ------------- o2: ]]
我们创建了两个弱键表 o1,o2,一个带 __gc 元方法的表 t。然后 o1[v1] = v2,o2[v2] = v3,表 t 引用着 v1。
最后,我们把 v1,v2,v3,t 都置为 nil,执行一次全量 gc,打印结果,发现除了调用到 t 的元方法 __gc 外,弱键表 o1, o2 还能访问到 v1、v2、v3 ,再当我们执行一次全量 gc 时,发现此时就访问不到 v1、v2 、v3 了。
我们先看下 convergeephemerons(g)
实现:
static void convergeephemerons (global_State *g) { int changed; do { GCObject *w; GCObject *next = g->ephemeron; /* get ephemeron list */ g->ephemeron = NULL; /* tables may return to this list when traversed */ changed = 0; while ((w = next) != NULL) { next = gco2t(w)->gclist; if (traverseephemeron(g, gco2t(w))) { /* traverse marked some value? */ propagateall(g); /* propagate changes */ changed = 1; /* will have to revisit all ephemeron tables */ } } } while (changed); }
分析:在设置 __gc 元方法时,我们会把表 t 从 g->allgc 链表中摘下来,单独放到一个新的链表 g->finobj 中(后面再仔细介绍下,带 __gc 元方法的表 t,gc是如何处理的),而 g->finobj 链表是放在原子阶段 atomic()
最后才处理的。在执行全量 gc 的时候,我们能确保在处理 g->finobj 链表前,也就是在 mark v1 前,v1 和 v2 都是白色的,在 traverseephemeron()
函数中,存在双白的节点的表 o1,o2 都会被放到 g->ephemeron 中,然后在原子阶段,我们调用 separatetobefnz(g, 0);markbeingfnz(g);propagateall(g);
mark 标记带 __gc 的表对象 t 后(也就是 v1 被 mark 为黑色后),再执行 convergeephemerons(g);
遍历 g->ephemeron 链表中存在双白节点的表对象 o1,o2。
先访问 o1 对象,调用traverseephemeron()
时,我们发现 o1 的键 v1 不再是白色了,我们需要 mark 其值 v2,v2 此时是灰色的,需要再调用一次 propagateall(g);
才会变成黑色,具体可以看下 convergeephemerons(g);
实现。当访问完弱键表 o1 后,紧接着调 traverseephemeron()
访问弱键表 o2,发现此时 v2 为黑色,对其值 v3 也进行 mark,最终也会被 mark 黑色。
如果是先访问 o2 对象,我们在调用 traverseephemeron()
后,v2,v3 都是白色,没有改变,还是会被插回 g->ephemeron 链表中。然后接着调 traverseephemeron()
访问表 o1 ,发现 v1 为黑色,就对值 v2 mark gray,函数结果返回1,证明有新的灰色对象产生了,回到 convergeephemerons()
中,调用 propagateall(g);
将 v2 mark black,在紧接着的下一行代码,将 changed 设置为 1,表明需要重新遍历一次 g->ephemeron 链表。再次遍历 g->ephemeron 链表时,就会再次访问到 o2对象了,发现键 v2 为黑色,也同样 mark 值 v3。最终, g->ephemeron 链表为 NULL,所有弱键表扫描完成,这就是第1轮全量 gc,为啥还能访问到 v1,v2,v3的原因。
再第2轮全量 gc 后,因为表 t 是白色,会被回收,v1,v2,v3 也都是白色,没有其他地方引用了,同样被回收掉,所以,在 lua 层在第2次遍历弱键表 o1,o2 时,什么打印也没有了,如下图所示。
通过上面的介绍,大家应该对 g->ephemeron 链表有了一些认识,如果我把双白的对象,也放到 g->allweak 链表中,结果会怎么样呢,我们不妨改造下 lgc.c 文件的 traverseephemeron()
函数,如下:
static int traverseephemeron (global_State *g, Table *h) { ... /* traverse hash part */ for (n = gnode(h, 0); n < limit; n++) { checkdeadkey(n); if (ttisnil(gval(n))) /* entry is empty? */ removeentry(n); /* remove it */ else if (iscleared(g, gkey(n))) { /* key is not marked (yet)? */ hasclears = 1; /* table must be cleared */ if (valiswhite(gval(n))) /* value not marked yet? */ hasww = 0; /* 标记1 white-white entry */ } else if (valiswhite(gval(n))) { /* value not marked yet? */ marked = 1; reallymarkobject(g, gcvalue(gval(n))); /* mark it now */ } } ... }
我们就改动该函数一行代码,hasww = 0;
这里之前是1,我把它改成0,这样双白的节点,就不会被放到 g->ephemeron 中,而是放到 g->allweak 中,接着我们编译源码,再次执行上面的 lua 代码,看看结果:
--[[ 运行结果: __gc function call ... ------------- o1: ------------- o2: ------------- o1: ------------- o2: ]]
我们发现,弱键表 o1,o2 无论是在第1轮 gc 后,还是第2轮 gc 后,都没有输出内容了。
至于原因,我们可以再看一下, atomic()
函数对 g->allweak 链表的处理,调用 clearkeys(g, g->allweak, NULL);
/* ** clear entries with unmarked keys from all weaktables in list 'l' up ** to element 'f' */ static void clearkeys (global_State *g, GCObject *l, GCObject *f) { for (; l != f; l = gco2t(l)->gclist) { Table *h = gco2t(l); Node *n, *limit = gnodelast(h); for (n = gnode(h, 0); n < limit; n++) { if (!ttisnil(gval(n)) && (iscleared(g, gkey(n)))) { setnilvalue(gval(n)); /* remove value ... */ } if (ttisnil(gval(n))) /* is entry empty? */ removeentry(n); /* remove entry from table */ } } }
clearkeys()
函数的处理也很简单了,如果值不为 nil,且键是白色的可回收对象,那么就把值也置为 nil。如果值为 nil,那么对键标记为死亡状态。这样,在 for ... pairs
时,就访问不到节点元素了。这就双白放到 g->ephemeron 和放到 g->allweak 之间的区别,前者会反复检测链表上的对象是否有颜色改变,如果有会重新遍历链表,以免循环引用key,导致错过对象标记,而后者只会对 allweak 链表遍历一次,清除节点。
弱值表分析
接着看下弱值表,是怎么 mark 的,先再贴一下代码看看。
static lu_mem traversetable (global_State *g, Table *h) { const char *weakkey, *weakvalue; const TValue *mode = gfasttm(g, h->metatable, TM_MODE); markobjectN(g, h->metatable); if (mode && ttisstring(mode) && /* is there a weak mode? */ ((weakkey = strchr(svalue(mode), 'k')), (weakvalue = strchr(svalue(mode), 'v')), (weakkey || weakvalue))) { /* is really weak? */ black2gray(h); /* keep table gray */ if (!weakkey) /* strong keys? */ traverseweakvalue(g, h); ... } /* ** Traverse a table with weak values and link it to proper list. During ** propagate phase, keep it in 'grayagain' list, to be revisited in the ** atomic phase. In the atomic phase, if table has any white value, ** put it in 'weak' list, to be cleared. */ static void traverseweakvalue (global_State *g, Table *h) { Node *n, *limit = gnodelast(h); /* if there is array part, assume it may have white values (it is not worth traversing it now just to check) */ int hasclears = (h->sizearray > 0); for (n = gnode(h, 0); n < limit; n++) { /* traverse hash part */ checkdeadkey(n); if (ttisnil(gval(n))) /* entry is empty? */ removeentry(n); /* remove it */ else { lua_assert(!ttisnil(gkey(n))); markvalue(g, gkey(n)); /* mark key */ if (!hasclears && iscleared(g, gval(n))) /* is there a white value? */ hasclears = 1; /* table will have to be cleared */ } } if (g->gcstate == GCSpropagate) linkgclist(h, g->grayagain); /* must retraverse it in atomic phase */ else if (hasclears) linkgclist(h, g->weak); /* has to be cleared later */ }
从上面的traverseweakvalue()
函数实现可以看出来,如果存在数组部分,就会把弱键表直接放到 g->weak 链表中,对于哈希表部分,如果值不为 nil,直接mark 键 key。
如果节点的值是白色的,可回收的对象,那么这个表也会被放到 g->weak 链表中,待到原子阶段atomic()
再做重新扫描一次。当然如果当前 gc 是在 GCSpropagate 阶段的,则放到 g->grayagain 链表中,同样是待原子阶段再做重新扫描一次。
在原子阶段 atomic()
函数实现里头,我们看到对 g->weak 的处理方式是:clearvalues(g, g->weak, origweak);
/* ** clear entries with unmarked values from all weaktables in list 'l' up ** to element 'f' */ static void clearvalues (global_State *g, GCObject *l, GCObject *f) { for (; l != f; l = gco2t(l)->gclist) { Table *h = gco2t(l); Node *n, *limit = gnodelast(h); unsigned int i; for (i = 0; i < h->sizearray; i++) { TValue *o = &h->array[i]; if (iscleared(g, o)) /* value was collected? */ setnilvalue(o); /* remove value */ } for (n = gnode(h, 0); n < limit; n++) { if (!ttisnil(gval(n)) && iscleared(g, gval(n))) { setnilvalue(gval(n)); /* remove value ... */ removeentry(n); /* and remove entry from table */ } } } }
从 clearvalues()
实现上看,不难发现,弱值表,主要是针对值 value 是可回收的白色对象。如果是可回收的白色对象,就把节点值置为 nil,key 打上死亡状态。
我们再举个简单的例子,看下弱键表和弱值表的区别:
local t = setmetatable({}, {__mode = "v"}) k1 = {} k2 = {} t[k1] = k2 t[k2] = k1 k1 = nil k2 = nil collectgarbage() print("------------1") for k, v in pairs(t) do print(k, v) end collectgarbage() print("------------2") for k, v in pairs(t) do print(k, v) end --[[ 运行结果: ------------1 table: 0000000000dc9750 table: 0000000000dc9610 table: 0000000000dc9610 table: 0000000000dc9750 ------------2 table: 0000000000dc9750 table: 0000000000dc9610 table: 0000000000dc9610 table: 0000000000dc9750 ]]
如果把 __mode = "k",弱键表,此时,输出结果:
------------1 ------------2
通过对比,可以看出,弱值表不管键值是否是双白,循环引用,只要值不为 nil,就会被 mark key, 所以如果是使用弱值表的,千万要避免出现这种可回收对象循环引用的情况出现,因为相互引用的键值对,是不会释放的,不管 gc 多少次,也容易造成内存泄漏。
关于弱值表,我有一个疑问,为啥只要值不为 nil,就会被 mark key 呢,这样不就有可能把一些已经没有再被引用也 mark 上了吗,从而导致这些 key 不能再被 free 了,就像上面循环引用的例子,对象即使没有再被外部引用了,也不会释放。为啥弱值表,不能像弱键表那样处理呢,弄个类似 ephemeron 链表呢。这个目前还不知道,留到以后想明白了,再补充把。
又或者,大家会不会有疑问,弱键表能不能仿照弱值表的处理方式来扫描对象呢,只要值不为nil,且是可回收对象,就不管三七二一,直接 mark 值。这样也就不用管什么循环引用问题了,也不需要 ephemeron 链表了,反正值都被 mark 了,在 atomic 原子阶段,总能知道哪些 key 还是处于白色的,这样就直接 remove 节点就好了把。为啥不这么处理呢。
这个我猜测,如果弱键表也像弱值表那样处理,那么循环引用这种情况,就永远处理不了了。循环引用对象下,mark value,最终也会导致 key 被 mark 上了,可能和作者的意图相悖把。作者可能就是弱键表能够处理循环引用问题,而弱键表,不需要处理这种问题把。当然,这也只是我个人观点,不一定对。
弱键弱值表分析
static lu_mem traversetable (global_State *g, Table *h) { const char *weakkey, *weakvalue; const TValue *mode = gfasttm(g, h->metatable, TM_MODE); markobjectN(g, h->metatable); if (mode && ttisstring(mode) && /* is there a weak mode? */ ((weakkey = strchr(svalue(mode), 'k')), (weakvalue = strchr(svalue(mode), 'v')), (weakkey || weakvalue))) { /* is really weak? */ black2gray(h); /* keep table gray */ if (!weakkey) /* strong keys? */ traverseweakvalue(g, h); else if (!weakvalue) /* strong values? */ traverseephemeron(g, h); else /* all weak */ linkgclist(h, g->allweak); /* nothing to traverse now */ ... }
对于弱键弱值的表,处理起来就更简单了,直接加入到 g->allweak 链表中,待原子阶段 atomic()
在做统一 mark 处理,因为处在 GCSpropagate 阶段,gc 是分步的,弱表可能会比较频繁多变,所以,只能留到原子阶段再观察处理,这个也比较好理解。
在原子阶段中,由于不断的 mark 对象,我们就需要不断的去调用 clearvalues()
和 clearkeys()
去再次检测弱键弱值表对象上的节点。
static l_mem atomic (lua_State *L) { ... clearvalues(g, g->allweak, NULL); ... clearkeys(g, g->allweak, NULL); /* clear keys from all 'allweak' tables */ ... clearvalues(g, g->allweak, origall); ... }
我们可以再通过一张图来回忆下,弱表不同的模式下,会放在不同的链表上,在原子阶段,会处理完所有链表上的弱表对象,其中弱键表应该是比较复杂的一种。所以,大家平时开发,在不同的场景,选择合适的弱表类型,减少 gc 在原子阶段的处理时间。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· DeepSeek在M芯片Mac上本地化部署