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 在原子阶段的处理时间。

posted @   墨色山水  阅读(34)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· DeepSeek在M芯片Mac上本地化部署
点击右上角即可分享
微信分享提示