lua 给userdata设置元表_深入Lua:垃圾回收

标记和清扫

Lua是一门自动内存管理的语言,它使用的是经典的标记和清扫算法。这个算法的原理其实非常简单,在我们的编程实践中或多或少都有类似的做法,一个理想的实现是这样的:

明确对象模型和关系:

  • 统一对象模型,比如Lua所有类型的对象都统一为TValue。
  • 所有动态分配的对象串连成一个链表(或多个)。
  • 程序中有少数几个根集对象,比如Lua里的注册表,主线程等,这些根集对象再去引用其他对象,由此展开成对象的关系结构。

标记和清扫

  • 在标记阶段,从根集对象开始标记,把整个对象层级都标记完,这些被标记到的对象称为可到达的对象。
  • 在清扫阶段,遍历上面提到的对象链表,如果对象被标记过则擦除标记并跳过,如果对象没被标记,说明它不可到达,那就把它释放掉。

原理总是很简单,但现实又常常不是那么简单。如果遇到对象数量特别多的情况,GC就会成为一个很大的负担。

Lua 5.0之前,垃圾回收是一个stop the world的过程,即在执行GC的时候要一次性完成,它的好处是代码相对简单,5.0的GC代码不过几百行,如果你想了解GC算法本身,看一下5.0的lgc.h|c是非常好的。

这种stop the world的GC在轻量的应用中不是什么问题,如果遇到程序非常大,对象特别多的情况,效率就很成问题了。因此在5.1之后,Lua进化成一个步进的GC,原理还是标记和清扫,但是把一个GC周期分成很多步,一次只执行一步。这对GC的效率是一个很大的提升,代价就是GC代码变得很复杂,Lua 5.3已经有一千多行。

从代码上看,除了GC分步,还有upvalues,弱表,终结对象(有__gc元方法的对象)的处理,这些都加大了垃圾回收的复杂度。

三色标记

一个GC对象分成三种颜色(三种状态):

  • 白色:表示对象未被标记,在GC周期开始之前对象为白色,如果到清扫阶段还是白色,那就说明该对象不可到达,要被清除掉。
  • 灰色:表示对象本身已标记,但它引用的对象未被标记,比如一个表已被标记,它的键值对象还未标记。这表示对象标记的一个中间状态。
  • 黑色:表示对象和它引用的对象都已被标记,在清扫阶段黑色对象为可到达对象。

一个GC对象的“基类”是这个结构:

struct GCObject {
  GCObject *next; 
  lu_byte tt; 
  lu_byte marked;
};

其中marked就是对象的标记状态,它的位含义如下:

  • 第0位:第1种白
  • 第1位:第2种白
  • 第2位:黑
  • 第3位:标记为终结对象

灰色为0, 1, 2位都清0的情况。

我们注意到白色有两个状态,这是为了解决分步GC的中间新建对象的问题。比如在标记阶段和清扫阶段的中间,有一个新建的对象,它默认为白色;清扫的时候会因为它是白色而把它给释放掉。引入两种白色之后这样处理:

  • g->currentwhite表示当前白,它是两种白色中的任意一种,我们假定为白1,在清扫阶段之前,所有白对象都是白1。
  • 在标记阶段结束的时候,g->currentwhite切换为白2,现在之前那些白对象和g->currentwhite就是不同白了(因为它们是白1)。
  • 在标记结束之后,清扫开始之前,新建一个对象,默认标记当g->currentwhite,也就是白2。
  • 在清扫的时候,只会清扫和g->currentwhite不同的白,即白1。这样那些新建的对象就不会在清扫阶段被释放。而在清扫结束之后,这些新建的对象也会变成白2。等待下一个周期。

要被终结对象就是那些有__gc元方法的对象。

对象链表

global_State有好多和GC相关的字段,其中有一些重要链表。所有GC对象总是在下面的一个链表中:

  • allgc 所有未被标记为终结的对象
  • finobj 所有被标记为终结的对象
  • tobefnz 所有准备终结的对象(准备调用__gc的对象)
  • fixedgc 不会被回收的对象

我们一个个稍微细讲一下:

正常情况下,新建对象都会进入allgc链表。

如果给对象设置一个元表,且元表有__gc元方法,那么它会从allgc取出,加入到finobj去,并设置对象的FINALIZEDBIT标记位(就是上面说的第3个marked位)。

在标记阶段,finobj中的白对象会移到tobefnz链表去,然后标记这些对象,这样当前周期不会释放这些对象;清扫完之后会进入GCScallfin阶段,在这里调用tobefnz对象的gc方法,同时把对象移回allgc链表;如果gc中使对象重新变成可到达,则对象复活过来;否则下个周期这个对象就会被正常清除。

fixedgc 是那些不会被回收的对象,在新建完对象后,必须马上调用luaC_fix把对象从allgc移到fixedgc去。GC的过程不会对fixedgc进行清扫。

对于灰对象还有好几个链表:

  • gray 普通的灰对象链表
  • grayagain 在GCSatomic阶段(是标记阶段的一个子阶段)中重新访问的灰对象,这些灰对象包括:
    • 进行写屏蔽的黑对象
    • 在传播阶段的弱表
    • 所有线程对象
  • weak 待清理的弱值表
  • allweak 待清理的弱键值表
  • ephemeron 弱键表

可以看出这些灰对象链表是由分步和弱表引入的附加数据结构,这也是GC中较为复杂的部分。

标记阶段

GC的核心函数是singlestep,它表示执行一步GC,每一步都在重复其中一个阶段,这些阶段是:

#define GCSpropagate    0       // 传播阶段:标记对象
#define GCSatomic   1           // 原子阶段:一次性标记
#define GCSswpallgc 2           // 清扫allgc
#define GCSswpfinobj    3       // 清扫finobj
#define GCSswptobefnz   4       // 清扫tobefnz
#define GCSswpend   5           // 清扫结束
#define GCScallfin  6           // 调用终结函数(__gc)
#define GCSpause    7           // 停止

GCSpause

GC始于GCSpause阶段,这一步主要标记几个根集对象,如主线程,注册表,基础类型的元表。

标记的核心宏是markobject

#define markobject(g,t) { if (iswhite(t)) reallymarkobject(g, obj2gco(t)); }

只有白对象才会调用reallymarkobject进行实际的标记,所以重复调用markobject是没有问题的。

reallymarkobject进行实际的标记,代码比较长这里就不列了,只说明它的逻辑:

  • 调用white2gray把白色去掉,变成灰色。
  • 如果是字符串,用户数据,调用gray2black直接变黑色
  • 用户数据还要标记它的元表,和关联的用户值。
  • 如果是线程,函数,函数原型和表,保留灰色,并链接到g->gray去。

标记完根集对象,进行GCSpropagate阶段。

GCSpropagate

GCSpropagate的任务是不断从gray链表中取对象出来,把它们链接到合适的链表去,并标志它们的引用对象。这个阶段的分步执行的,一次调用只处理一个对象,核心函数是propagatemark:

  • 先从g->gray弹出一个对象,把这个对象变成黑色。根据对象的类型作不同的处理:
  • 如果是C闭包:标记闭包中的upvalue。
  • 如果是Lua闭包,由于里面的upvalue分成open和close状态,所以要分情况处理:
    • 直接标记close状态的upvalue。
    • 如果是open的状态,说明upvalue在栈程中栈上,这里不标记,等线程去标记。
  • 如果是函数原型:标记原型里面的各种字符串对象,和常量数组。
  • 如果是线程:把线程变为灰,移到grayagain链表,标记线程的栈上使用的元素。
  • 如果是表:
    • 先标记它的元表
    • 如果不是弱表,标记它的键值
    • 如果是弱表,先把表变成灰色。
      • 如果是弱值表:只标记键对象,把表移到grayagain链表
      • 如果是全弱表:什么也不用标记,把表移到allweak链表
      • 如果是弱键表:如果键已标记则标记值对象;否则不标记值对象;把表移到grayagain链表。

这个过程看起来是很复杂的,如果去掉弱表来看就变简单了,所以我们现在隐约知道,弱表会增大GC的负担。

当g->gray为空时,该阶段完毕,现在g->grayagain和g>allweak链表有值,且都是灰对象

GCSatomic

下一个阶段是GCSatomic,这是一次性完成的处理,它的代码是这样的:

case GCSatomic: {
    lu_mem work;
    propagateall(g);  /* make sure gray list is empty */
    work = atomic(L);  /* work is what was traversed by 'atomic' */
    entersweep(L);
    g->GCestimate = gettotalbytes(g);  /* first estimate */;
    return work;
}

propagateall保存g->gray为空,因为Lua的GC是分步的,在每一步的中间都有可能产生新对象,或者发生对象关系的变化:

static void propagateall (global_State *g) {
  while (g->gray) propagatemark(g);
}

接下来调用atomic函数,这个函数处理的东西非常多:

  • 将状态设为GCSinsideatomic,在标记过程中根据这个状态有一些不同的处理。
  • 调用remarkupvals,那些未标记的线程的栈上,存有open状态的upvalue,所以要在这里标记;那些已标记的线程,则已经在traversethread函数中标记过。
  • 把grayagain赋给g->gray,然后调用propagateall再一次标记,这次那些待清理的弱表会到g->weak, g->allweak, g->ephemeron去。
  • 将g->finobj中的白对象移动到g->tobefnz,标记它们使它们短暂复活,后面将调用它们的终结函数
  • 调用clearkeys和clearvalues清理弱表的白项。
  • 最后一步切换当前的白色值g->currentwhite,这样在清扫之前新建的对象就不会被清除。

虽然代码很多,但总结起来最复杂的部分就是处理弱表,特别是ephemeron表;在日常游戏逻辑中,一定要减少弱表的使用,大量的弱表会使GC的负担大大加重。

最后调用entersweep进行清扫阶段:

static void entersweep (lua_State *L) {
  global_State *g = G(L);
  g->gcstate = GCSswpallgc;
  lua_assert(g->sweepgc == NULL);
  g->sweepgc = sweeplist(L, &g->allgc, 1);
}

清扫阶段

GCSswpallgc..GCSswpend

从这里开始进入清扫阶段,核心函数是sweepstep,清扫的逻辑相对标记就简单得多了,分别遍历g->allgc, g->finobj, g->tobefnz把里面是其他白的对象释放掉:

  • 对象必须是其他白,因为当前白表示新创建的对象,不应该在这个周期释放,判断一个对象死亡的宏是这个:
#define isdeadm(ow,m)   (!(((m) ^ WHITEBITS) & (ow)))
#define isdead(g,v) isdeadm(otherwhite(g), (v)->marked)
  • g->tobefnz也要遍历 ,因为在遍历的过程中,要把黑对象修改为当前白。

sweeplist的函数代码如下:

static GCObject **sweeplist (lua_State *L, GCObject **p, lu_mem count) {
  global_State *g = G(L);
  // 其他白
  int ow = otherwhite(g);
  // 当前白
  int white = luaC_white(g);  /* current white */
  while (*p != NULL && count-- > 0) {
    GCObject *curr = *p;
    int marked = curr->marked;
    // 如果是其他白,说明对象死亡,释放它,并从链表移除
    if (isdeadm(ow, marked)) {  /* is 'curr' dead? */
      *p = curr->next;  /* remove 'curr' from list */
      freeobj(L, curr);  /* erase 'curr' */
    }
    else {  /* change mark to 'white' */
      // 否则将当前对象标记为“当前白”
      curr->marked = cast_byte((marked & maskcolors) | white);
      p = &curr->next;  /* go to next element */
    }
  }
  return (*p == NULL) ? NULL : p;
}

freeobj根据对象类型释放里面的内存,其中:

  • Lua闭包:在释放时,要对UpVal减引用,如果引用为0且为Close状态,则释放掉UpVal。从这可看出,Lua在局部的地方也使用了引用计数的内存管理。
  • 线程:在释放之前要调用luaF_close把线上的open upvalue关闭掉。

GCScallfin

这个阶段主要任务是调用终结对象的__gc函数,核心函数是runafewfinalizers,一开始调用极少数终结函数,后面步骤慢慢增多。调用完毕后,tobefnz的对象清除FINALIZEDBIT标记位,并移动到allgc去,这样一来这个对象就变成一个普通的对象,并且它是不可到达的,下个GC周期就能正常被回收。

关于写屏障和触发GC的条件,请看下回分析。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值