lua 5.4分代研究及luajit分代移植
lua垃圾回收发展历程
为什么不用引用计数?
- 引用计数有循环引用问题,会导致内存泄漏
- 应用计数嗯外开销大,赋值的时候会带来额外引用计数计算的开销。尤其是在内存比较稳定,没有新内存申请和释放的时候,你也需要承担这部分开销。
5.0及以前使用的是stop the world的标记清除法,会卡住所有操作,直到gc完成
5.1引入了分步gc,整个gc过程可以分为好几步,穿插在运行过程中
5.2引入了分代gc,属于实验性质,实际效果不好,在5.3的时候又移除了
5.4重新分代了分代gc,具体实现方式会在后面介绍
分代gc原理
经验表明,大部分对象在被分配之后很快就被回收掉了,长时间存活的对象很大可能会一直存活下去。所以,垃圾回收可以集中精力去回收刚刚造出来的对象。
将所有gc对象分成两种,young和old。当young节点活过了两次gc过程,就会变成old。old节点将不再被清除和遍历。
活过两次gc过程也是与5.2 gc过程的最大不同点。5.2 gc只需要活过一次,就算做old。但是往往可能当前在某个函数执行过程中申请了临时变量触发了gc,这时候如果gc完成的话,当前函数堆栈上面申请的所有临时变量都会被误标记为old,导致内存一直在上涨。
如何确保往old节点加入新的节点也能正确的遍历?新增touched状态,如果old节点加入新的节点,则该old节点变成touched状态,经过两次gc之后,touched节点又重新变为old节点。
5.4分代具体实现
需要对分步gc有一定了解
新增一个old标记的概念,存放在marked的低三位,标记的具体值为:
1 | /* object age in generational mode */ |
整个age标记变化过程:
- G_NEW -> G_SURVIVAL -> G_OLD1 -> G_OLD
从G_NEW到G_OLD实际上经过了三次gc,但是被打上G_OLD1标记的节点在gc开始前会被重新mark,所以肯定是会进入到G_OLD状态。为什么会有这个过程?因为节点在G_SURVIVAL的状态时候可能会被插入子节点,这时候无论是barrier forward或者barrier back都不会触发isold判断,所以经过这次gc如果G_SURVIVAL直接进入到G_OLD的话,子节点则处在G_SURVIVAL,那下次gc触发的时候子节点可能会被误删。那为什么要增加一个G_SURVIVAL状态?我讲讲自己的理解,可能不太正确。因为lua 5.2证明了一次gc直接进入到old是不理想的,所以如果G_NEW经过一次gc直接进入到G_OLD1,那如果在这时候触发barrier back的时候,该节点无论如何最终会随着没有新的子节点加入,会直接进入到G_OLD标记。这就相当于一次gc之后就判断为old了。所以最佳方案就是引入了G_OLD1节点. - G_OLD0 -> G_OLD1 -> G_OLD
G_OLD0只会在barrier_forward过程中,如果父节点被isold判断成立,则进行mark并且标记为G_OLD0,但是这里会有问题,被mark之后肯定能活过当前gc,那状态肯定能变为G_OLD1,而G_OLD1会在gc开始之前进行一次标记,所以G_OLD1的节点肯定能活过gc过程,这样G_OLD1就一定会变成G_OLD。也就是说打上G_OLD0的节点肯定会变为G_OLD(疑惑?) - G_OLD0/G_OLD1/G_OLD2/G_TOUCHED2 -> G_TOUCHED1 -> G_TOUCHED2 -> G_OLD
因为isold判断函数使用了age > G_SURVIVAL,所以G_OLD0/G_OLD1/G_OLD2/G_TOUCHED2节点如果插入子节点的话,在barrier back过程中都会变为G_TOUCHED1
新增gc类型,表示当前处于分步gc还是分代gc,存放在g->gckind
1 | /* kinds of Garbage Collection */ |
新增分代使用的全局变量
1 | typedef struct global_State { |
两种gc之间的转换
1 | // 进入到分步模式 |
分代gc过程
触发gc step,根据gc类型进行不同的操作
1
2
3
4
5
6
7
8
9
10
11void luaC_step (lua_State *L) {
global_State *g = G(L);
if (g->gcrunning) { /* running? */
// 根据当前gc处于什么类型进入到不同的step中;
if (g->gckind == KGC_INC)
incstep(L, g);
else
genstep(L, g);
}
}分代step
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25static void genstep (lua_State *L, global_State *g) {
// 保存基础值,该基础值会在entergen初始化;
lu_mem majorbase = g->GCestimate;
int majormul = getgcparam(g->genmajormul);
// 判断是否该进行full gen gc;
// 判断条件为当前总内存值是否为上次full gc完成内存的(100+genmajormul)%;
if (g->GCdebt > 0 &&
gettotalbytes(g) > (majorbase / 100) * (100 + majormul)) {
fullgen(L, g);
}
else {
// 执行一次young gc;
lu_mem mem;
youngcollection(L, g);
mem = gettotalbytes(g);
// 重新设置下次进行小gc的时机
luaE_setdebt(g, -(cast(l_mem, (mem / 100)) * g->genminormul));
// 重新赋值为基础值;
g->GCestimate = majorbase; /* preserve base value */
}
}full gen gc,很简单的利用enterinc和entergen两个函数
1
2
3
4static void fullgen (lua_State *L, global_State *g) {
enterinc(g);
entergen(L, g);
}young gc
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37static void youngcollection (lua_State *L, global_State *g) {
GCObject **psurvival; /* to point to first non-dead survival object */
lua_assert(g->gcstate == GCSpropagate);
// 将G_OLD1的所有的黑色节点,重新mark一次,原因在age变化过程中有写
markold(g, g->survival, g->reallyold);
markold(g, g->finobj, g->finobjrold);
// 标记节点
atomic(L);
// 遍历此次创建的节点,free掉没被引用的节点,设置被引用的节点到新的age状态;
// 返回的psurvival指向的是g->survival的上一个节点的next指针;
// 这样可以确保g->survival能进行正确的删除操作
psurvival = sweepgen(L, g, &g->allgc, g->survival);
// 对psurvival到g->reallyold的节点进行相同操作;
sweepgen(L, g, psurvival, g->reallyold);
// 重新设置三个指针
g->reallyold = g->old;
g->old = *psurvival; /* 'survival' survivals are old now */
g->survival = g->allgc; /* all news are survivals */
/* repeat for 'finobj' lists */
psurvival = sweepgen(L, g, &g->finobj, g->finobjsur);
/* sweep 'survival' and 'old' */
sweepgen(L, g, psurvival, g->finobjrold);
g->finobjrold = g->finobjold;
g->finobjold = *psurvival; /* 'survival' survivals are old now */
g->finobjsur = g->finobj; /* all news are survivals */
sweepgen(L, g, &g->tobefnz, NULL);
// 进行分代gc的后续操作
finishgencycle(L, g);
}markold
1
2
3
4
5
6
7
8
9
10
11
12
13// 从from到to,如果节点为G_OLD1并且为黑色,则进行mark,不是黑色表明,该节点已经被mark,所以不用在mark
static void markold (global_State *g, GCObject *from, GCObject *to) {
GCObject *p;
for (p = from; p != to; p = p->next) {
if (getage(p) == G_OLD1) {
lua_assert(!iswhite(p));
if (isblack(p)) {
black2gray(p); /* should be '2white', but gray works too */
reallymarkobject(g, p);
}
}
}
}sweepgen
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30// 从p开始到limit,如果该节点没有被mark过则清除,否则将age设置到下一状态
static GCObject **sweepgen (lua_State *L, global_State *g, GCObject **p,
GCObject *limit) {
static lu_byte nextage[] = {
G_SURVIVAL, /* from G_NEW */
G_OLD1, /* from G_SURVIVAL */
G_OLD1, /* from G_OLD0 */
G_OLD, /* from G_OLD1 */
G_OLD, /* from G_OLD (do not change) */
G_TOUCHED1, /* from G_TOUCHED1 (do not change) */
G_TOUCHED2 /* from G_TOUCHED2 (do not change) */
};
int white = luaC_white(g);
GCObject *curr;
while ((curr = *p) != limit) {
if (iswhite(curr)) { /* is 'curr' dead? */
lua_assert(!isold(curr) && isdead(g, curr));
*p = curr->next; /* remove 'curr' from list */
freeobj(L, curr); /* erase 'curr' */
}
else { /* correct mark and age */
if (getage(curr) == G_NEW)
curr->marked = cast_byte((curr->marked & maskgencolors) | white);
setage(curr, nextage[getage(curr)]);
p = &curr->next; /* go to next element */
}
}
return p;
}一些细节
分代模式充分利用了grayagain这个链表,在atomic开始之处会把该链表先保存起来,然后置空。因为在atomic的遍历过程中会有新的节点加入到grayagain,例如线程,table或者弱key的table。在gen结束的时候会再次遍历grayagain链表,进行touched状态的相关操作
分代只有才gc过程中会处于atomic节点,其他清空下都是一直处于GCSpropagate状态finishgencycle
1
2
3
4
5
6
7
8
9
10
11
12
13static void finishgencycle (lua_State *L, global_State *g) {
// 遍历所有grayagain以及weak系table链表进行操作
correctgraylists(g);
// 查看是否需要进行string表resize操作
checkSizes(L, g);
g->gcstate = GCSpropagate; /* skip restart */
// 调用所有的table或者userdata的"__gc"元方法
if (!g->gcemergency)
callallpendingfinalizers(L, 1);
}correctgraylist
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40// 对列表进行操作,前面说了graygain链表会在最后进行touched相关的操作
static GCObject **correctgraylist (GCObject **p) {
GCObject *curr;
while ((curr = *p) != NULL) {
switch (curr->tt) {
case LUA_TTABLE: case LUA_TUSERDATA: {
GCObject **next = getgclist(curr);
if (getage(curr) == G_TOUCHED1) { /* touched in this cycle? */
// 处于G_TOUCHED1状态,则变为G_TOUCHED2,并且保留在grayagain链表
lua_assert(isgray(curr));
gray2black(curr); /* make it black, for next barrier */
changeage(curr, G_TOUCHED1, G_TOUCHED2);
p = next; /* go to next element */
}
else {
if (!iswhite(curr)) {
// 处于G_TOUCHED2状态,则变为G_OLD,并且保留从grayagain链表移除
lua_assert(isold(curr));
if (getage(curr) == G_TOUCHED2)
changeage(curr, G_TOUCHED2, G_OLD);
gray2black(curr); /* make it black */
}
*p = *next; /* remove 'curr' from gray list */
}
break;
}
case LUA_TTHREAD: {
lua_State *th = gco2th(curr);
lua_assert(!isblack(th));
if (iswhite(th)) /* new object? */
*p = th->gclist; /* remove from gray list */
else /* old threads remain gray */
p = &th->gclist; /* go to next element */
break;
}
default: lua_assert(0); /* nothing more could be gray here */
}
}
return p;
}barrier
barrier forward直接将子节点设置为G_OLD0,但我不太理解的是,最终一定会到G_OLD,设置为G_OLD0并且又mark,则第一次gc之后肯定会变为G_OLD1,而G_OLD1是肯定会变成G_OLD,不太理解这个流程1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16void luaC_barrier_ (lua_State *L, GCObject *o, GCObject *v) {
global_State *g = G(L);
lua_assert(isblack(o) && iswhite(v) && !isdead(g, v) && !isdead(g, o));
if (keepinvariant(g)) { /* must keep invariant? */
reallymarkobject(g, v); /* restore invariant */
// 分代模式下,如果该节点为old状态;
if (isold(o)) {
lua_assert(!isold(v)); /* white object could not be old */
setage(v, G_OLD0); // 如果插入的节点是旧的节点,则标记位OLD0状态;
}
}
else { /* sweep phase */
lua_assert(issweepphase(g));
makewhite(g, o); /* mark main obj. as white to avoid other barriers */ // 如果处于清除阶段,标记被插入的节点为白色,阻止再次被触发barrier;
}
}barrier back流程比较好懂,操作的是父节点,将父节点设置为G_TOUCHED1即可
1
2
3
4
5
6
7
8
9
10void luaC_barrierback_ (lua_State *L, GCObject *o) {
global_State *g = G(L);
lua_assert(isblack(o) && !isdead(g, o));
lua_assert(g->gckind != KGC_GEN || (isold(o) && getage(o) != G_TOUCHED1));
// 从G_TOUCHED1转到G_TOUCHED2不会从grayagain移除,所以判断下是否等于G_TOUCHED2;
if (getage(o) != G_TOUCHED2) /* not already in gray list? */
linkobjgclist(o, g->grayagain); /* link it in 'grayagain' */
black2gray(o); /* make table gray (again) */
setage(o, G_TOUCHED1); /* touched in current cycle */
}
luajit移植遇到的问题
注意:我只考虑了jit.off()情况下的分代移植,不考虑jit模式
按照它的思路,很容易将代码移植到luajit,不过luajit指针使用的是CRef结构,导致不论是写和读都带来些问题,但这个熟悉了之后问题也不大。源代码参考我的github。下面给出一些我移植遇到的一些问题,希望对以后修改luajit代码又一定的帮助:
luajit汇编下的lj_gc_barrierback问题
luajit有部分是写入到汇编里面其中就包括一段barrierback代码,在vm_x64.dasc文件中有如下代码:
1 | |// Move table write barrier back. Overwrites reg. |
可以看到用汇编写了一个简陋版的barrierback代码,实现了black2gray和把tab加入到grayagain链表,对应修改后的c barrierback代码,将该代码进行修改:
1 | |// Move table write barrier back. Overwrites reg. |
这里有两个坑点:
- 第一个是操作符后面的byte,因为age是uint8_t,所以只能以byte进行操作。
- 第二个是用到了一个跳转标记‘9’,而这段代码是.macro的,所以是直接内嵌的,所以一开始我使用‘1’作为跳转标记就会与源代码冲突,导致跳转的位置不对
luajit字符串存储
luajit字符串实现方式还是5.1的方式,即将所有字符串的gc对象计算hash值之后,存放在专门的hash表中。而lua 5.4有两个改进(有些改进不一定是5.4才引入的,我这里只比较5.4和luajit的差别):
- 一个是将所有字符串分为长和短,短的才会计算hash值,被string hash表存储
- 第二个不论长短,字符串都会挂载到大的gc链表中。所以luajit不能简单的像lua 5.4一样只处理gc链表,字符串这边需要在每个地方单独处理。
为此引入了:系列函数,这些函数会在操作gc链表的地方再操作一次字符串1
2
3void sweepstringsold(lua_State *L);
void sweepstringsgen(lua_State *L);
void whiltestrings(global_State *g);
字符串还需要注意个问题就是lua 5.4 fixed抽出来通用,对所有GCObject都适用,实现方式就是把该节点从gc链表中摘除,加入到一个专门的fixedgc链表中。而我们luajit还是5.1的方式,所以判断是不是可以删除的时候需要增加一个宏#define isfixed(x) ((x)->gch.marked & LJ_GC_FIXED)
来判断是否是!fixed1
2
3
4
5
6if (iswhite(o) && !isfixed(o)) {
//可删除
}
else {
//不可删除
}
luajit udata存储
同样udata实现方式还是5.1的方式,将所有udata存储在mainthread(G)这个gc对象的后面,lua 5.4对于udata和table有一定的改进(有些改进不一定是5.4才引入的,我这里只比较5.4和luajit的差别):
- 一个是5.1只有udata才能设置”__gc”元方法,5.4 table也可以设置”__gc”元方法
- 第二个5.4普通的udata和table没有区别,挂在在gc链表中,但是一旦设置了带有”__gc”的元方法之后,会被加入到finobj链表中。好处就是不用再遍历原链表去找哪些udata或者table有元方法
为此在lua 5.4操作finobj链表的时候,luajit有细微差别,例如:
1 | psurvival = sweepgen(L, g, &mainthread(g)->nextgc, g->gc.udatasur, NULL); |
udata还有一个问题,luajit在atomic阶段,会从mainthread(g)后面开始遍历所有udata链表,将有元方法的”__gc”udata从gc链表中拆除,放入到mmudata链表中。移除的有可能会使udatasur或者udataold节点,所以移除的时候需要判断
1 | if (LJ_UNLIKELY(o == gcref(g->gc.udatasur))) |
atomic修改
一开始需要保存grayagain链表,并将该链表置为空,后面遍历的时候使用保存的grayagain链表
1
2
3
4
5
6
7
8
9
10
11
12
13// ...
// 保存当前的grayagain链表;
GCRef grayagain;
setgcrefr(grayagain, g->gc.grayagain);
setgcrefnull(g->gc.grayagain);
g->gc.state = GCSatomic;
// ...
setgcrefr(g->gc.gray, grayagain);
gc_propagate_gray(g); /* Propagate it. */需要设置为GCSatomic状态,在分步gc时候这一步使在完成GCSpropagate设置,而分代的时候是直接调用atomic,所以需要设置下这个状态
luajit新增了的GCtrace结构
虽然关闭了jit.off(),但在ffi时候还是会有GCtrace创建和删除,所以还是需要考虑GCtrace这个结构。
这个结构有个特殊的barrier forward方法,而GCtrace只会包含在GCproto,所以参照普通的barrier forward改进该方法为:
1 | /* Mark a trace if it's saved during the propagation phase. */ |
一些调试经验
gc很难调试,我分享下我移植过程中调试的一些经验
打log
在各个关键点,增加log,我代码里面穿插着各种gc_debug的log,通过分析log,来分析哪些对象可能在free之后还再被使用,为什么会被free
gdb调试技巧
- break xxx if条件断点命令,方便更快的找到条件发生时候的堆栈
- watch 断点命令,可以查看感兴趣的内存,方便查找内存被篡改的问题查找,但是设置了这个断点之后执行会变的很慢
- 适当了解一些汇编,查问题的时候经常会直接宕机在汇编里,这时候可以使用disassemble显示当前汇编进行分析,在查找
luajit汇编下的lj_gc_barrierback问题
的时候,我就查看输出的汇编代码来找问题的