Lua5.3版GC机制理解


lua垃圾回收(Garbage Collect)是lua中一个比较重要的部分。由于lua源码版本变迁,目前大多数有关这个方面的文章都还是基于lua5.1版本,有一定的滞后性。因此本文通过参考当前的5.3.4版本的Lua源码,希望对Lua的GC算法有一个较为详尽的探讨。

1.Lua垃圾回收算法原理简述

lua采用了标记清除式(Mark and Sweep)GC算法,算法简述:
标记:每次执行GC时,先以若干根节点开始,逐个把直接或间接和它们相关的节点都做上标记;
清除:当标记完成后,遍历整个对象链表,把被标记为需要删除的节点一一删除即可。

2.Lua垃圾回收中的三种颜色

所谓的颜色就是上文中“算法简述”提到过的标记,lua用白、灰、黑三色来标记一个对象的可回收状态。(白色又分为白1、白2)
白色:可回收状态。
详解:如果该对象未被GC标记过则此时白色代表当前对象为待访问状态。举例:新创建的对象的初始状态就应该被设定为白色,因为该对象还没有被GC标记到,所以保持初始状态颜色不变,仍然为白色。如果该对象在GC标记阶段结束后,仍然为白色则此时白色代表当前对象为可回收状态。但其实本质上白色的设定就是为了标识可回收。
灰色:中间状态。
详解:当前对象为待标记状态。举例:当前对象已经被GC访问过,但是该对象引用的其他对象还没有被标记。
黑色:不可回收状态。
详解:当前对象为已标记状态。举例:当前对象已经被GC访问过,并且对象引用的其他对象也被标记了。
备注:白色分为白1和白2。原因:在GC标记阶段结束而清除阶段尚未开始时,如果新建一个对象,由于其未被发现引用关系,原则上应该被标记为白色,于是之后的清除阶段就会按照白色被清除的规则将新建的对象清除。这是不合理的。于是lua用两种白色进行标识,如果发生上述情况,lua依然会将新建对象标识为白色,不过是“当前白”(比如白1)。而lua在清扫阶段只会清扫“旧白”(比如白2),在清扫结束之后,则会更新“当前白”,即将白2作为当前白。下一轮GC将会清扫作为“旧白”的白1标识对象。通过这样的一个技巧解决上述的问题。如下图:(下图中为了方便颜色变换的理解,没有考虑barrier的影响)
颜色转换图2018-12-26-Color_conversion_chart

3.Lua垃圾回收详细过程

算法流程1/2018-12-26-algorithm_flow1
算法流程2/2018-12-26-algorithm_flow2

4.步骤源码详解

4.1新建对象阶段

STEP1:新建可回收对象,将其颜色标记为白色
解释:首先lua对数据类型进行了抽象,具体数据结构可见如下源码:
(lobject.h)

** Common type for all collectable objects
*/
typedef struct GCObject GCObject;

/*
** Common Header for all collectable objects (in macro form, to be
** included in other objects)
*/
#define CommonHeader  GCObject *next; lu_byte tt; lu_byte marked

/*
** Common type has only the common header
*/
struct GCObject {
  CommonHeader;
};


/*
** Tagged Values. This is the basic representation of values in Lua,
** an actual value plus a tag with its type.
*/
/*
** Union of all Lua values
*/
typedef union Value {
  GCObject *gc;    /* collectable objects */
  void *p;         /* light userdata */
  int b;           /* booleans */
  lua_CFunction f; /* light C functions */
  lua_Integer i;   /* integer numbers */
  lua_Number n;    /* float numbers */
} Value;

#define TValuefields  Value value_; int tt_

typedef struct lua_TValue {
  TValuefields;
} TValue;

由代码分析可知,Lua的实现中,数据类型可以分为需要被GC管理回收的对象、按值存储的对象。而STEP1中提及的“可回收对象”(GCObject)就是指那些需要被GC管理回收的对象,在lua5.3中,具体是指:TString、Udata、Cloure、Table、Proto、lua_State(可通过查看GCUnion)这些数据类型。这些数据类型都有一个共同的部分CommonHeader:
(lobject.h)

#define CommonHeader
GCObject *next; lu_byte tt; lu_byte marked

其中next链接下一个GCObject,tt表明数据类型,marked用于存储之前提到的颜色。
创建上述数据类型对象是通过lua虚拟机调用对应的数据类型创建函数完成的。在创建过程中,总会调用(lgc.c)luaC_newobj这个函数,来完成GCObject的初始化。
以Table为例,会依次调用:
(lvm.c)[luaV_execute]->(ltable.c)[luaH_new]->(lgc.c)[luaC_newobj]
下面我们来看luaC_newobj:
(lgc.c)

/*
** create a new collectable object (with given type and size) and link
** it to 'allgc' list.
*/
GCObject *luaC_newobj (lua_State *L, int tt, size_t sz) {
  global_State *g = G(L);
  GCObject *o = cast(GCObject *, luaM_newobject(L, novariant(tt), sz));
  o->marked = luaC_white(g);
  o->tt = tt;
  o->next = g->allgc;
  g->allgc = o;
  return o;
}

该函数依次完成:

  • 1.通过luaM_newobject进行内存分配,并更新GCdebt(内存使用相关的参数)
  • 2.将GCObject置为“当前白”,设置数据类型,将GCObject挂载到alloc链表上

注意:alloc链表就是清除阶段,被GC依次遍历链表上的对象标志并根据标志进行清除的重要数据结构。
如此便从创建一个可回收对象开始了我们的GC之旅。

4.2触发条件

STEP2:达到GC条件
Lua分为自动、手动两种GC方式。
手动式:
(lapi.h)

 LUAI_FUNC void luaC_step (lua_State *L);

自动式:
(lgc.h)

#define luaC_condGC(L,pre,pos) \
  { if (G(L)->GCdebt > 0) { pre; luaC_step(L); pos;}; \
    condchangemem(L,pre,pos); }
/* more often than not, 'pre'/'pos' are empty */
#define luaC_checkGC(L)   luaC_condGC(L,(void)0,(void)0)

手动方式就是通过调用collectgarbage ([opt [, arg]])来启动GC。而自动方式则需要满足GC的条件。如果我们审查lapi.c/ldo.c/lvm.c ,会发现大部分会引起内存增长的API中,都调用了luaC_checkGC。从而实现GC可以随内存使用增加而自动进行。而这些触发条件是通过g-> GCdebt、g-> totalbytes等参数计算得来。由于参数的意义,参数的计算方式等问题需要进一步阐述,限于篇幅,我准备在之后的关于手动GC调参方面的文章中详细阐述。但目前可以概括的说明luaGC的触发条件:当lua使用的内存达到阀值,便会触发GC。当然这个阀值是动态设定的。

4.3 GC函数状态机

当满足GC条件后,进入了真正的GC过程。
在开始这个过程之前,需要阐述一个问题。lua5.0之前的GC因为只采用了白、黑两种标识色,所以GC时需要stop the world,即GC过程需要一次性完成。而lua5.1之后采用了三标识色,最大的的一个改进就是实现了分步。
**这个改进的意义在于提高了lua系统的实时性,**使GC过程可以分段执行。这个改进是由一系列变化引起的,比如:灰色的引入、barrier机制的引入、singlestep函数(GC过程中最重要的一个函数)的状态细分等。但是我认为真正从原理上可以实现分步,是在于灰色的引入。这是因为如果只有黑、白两色,每个对象的状态就仅是“二元的”,不能有中间态,所以GC操作时需要不可被打断。
接下来分析luaGC机制中最重要的一个函数singlestep。所有的GC流程,都是从singlestep函数开始的。
(lgc.h)

static lu_mem singlestep (lua_State *L)

**singlestep实际上就是一个简单的状态机。**具体如下:
GC状态机/2018-12-26-GC_StateMachine

4.4标记阶段

STEP3:从根对象开始标记,将白色置为灰色,并加入到灰色链表中
该步骤实际对应状态:GCSpause
(lgc.c)[singlestep]

switch (g->gcstate) {
      case GCSpause: {
      g->GCmemtrav = g->strt.size * sizeof(GCObject*);
      restartcollection(g);
      g->gcstate = GCSpropagate;
      return g->GCmemtrav;
      }

主要工作由restartcollection函数完成:
(lgc.c)

/*
** mark root set and reset all gray lists, to start a new collection
*/
static void restartcollection (global_State *g) {
  g->gray = g->grayagain = NULL;
  g->weak = g->allweak = g->ephemeron = NULL;
  markobject(g, g->mainthread);
  markvalue(g, &g->l_registry);
  markmt(g);
  markbeingfnz(g);  /* mark any finalizing object left from previous cycle */
}
  • 1.将用于辅助标记的各类型对象链表进行初始化清空,其中g->gray是灰色节点链;g->grayagain是需要原子操作标记的灰色节点链;g->weak、g->allweak、g->ephemeron是与弱表相关的链。
  • 2.然后依次利用markobject、markvalue、markmt、markbeingfnz标记根(全局)对象:mainthread(主线程(协程), 注册表(registry), 全局元表(metatable), 上次GC循环中剩余的finalize中的对象,并将其加入对应的辅助标记链中。

STEP4:灰色链表是否为空
STEP5:从灰色链表中取出一个对象将其标记为黑色,并遍历和这个对象相关联的其他对象
上述步骤对应状态:GCSpropagate,这是标记阶段的核心内容。
(lgc.c)[singlestep]

case GCSpropagate: {
      g->GCmemtrav = 0;
      lua_assert(g->gray);
      propagatemark(g);
      if (g->gray == NULL)  /* no more gray objects? */
       g->gcstate = GCSatomic;  /* finish propagate phase */
      return g->GCmemtrav;  /* memory traversed in this step */
  }

主要工作由propagatemark函数完成:
(lgc.c)

/*
** 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;default: lua_assert(0); return;
  }
  g->GCmemtrav += size;
}

步骤四中循环判断灰色链表,其实并不是通过循环实现的。而是如果灰色表不为空,状态将不会发生改变。进而每次进入状态机时,由于状态未发生改变,而反复执行这个状态对应的处理函数。直到状态发生改变后,进入下一个状态。
设计的原因可以结合propagetemark这个函数一同理解,在propagatemark中,每次只会从灰色链表中取一个灰色节点,将其置为黑(与lua5.1的GC有区别),从灰色链表中出去,遍历与此节点相关的其他节点,并将有关节点加入到灰色链中,至此就完成了一次GCSpropagate状态处理。不难发现,这个过程只是处理了一个原先灰色链表中的灰色节点。这是因为标记与对应节点有关的节点实际上是通过遍历完成的,这个过程的开销会很大,所以lua只希望每次GCSpropagate时,处理一个这样的节点。
这样的好处就是将开销大的步骤通过多次调用,减少每次阻塞的时间。而同时带来了一个新的问题,如果lua创建分配对象的速度远大于GCSpropagate处理的速度,那么lua的GC过程将会阻塞在GCSpropagate这个状态。解决方法就留给读者思考了
在propagetemark方法中,会针对不同的数据类型进行分类操作,但是最终都会落脚到reallymarkobject这个方法上:
(lgc.c)

static void reallymarkobject (global_State *g, GCObject *o) {
 reentry:
  white2gray(o);
  switch (o->tt) {
    case LUA_TSHRSTR: {
      gray2black(o);
      g->GCmemtrav += sizelstring(gco2ts(o)->shrlen);
      break;
    }case LUA_TTABLE: {
      linkgclist(gco2t(o), g->gray);
      break;
    }default: lua_assert(0); break;
  }
}

如果是字符串类型,因为其特殊性,可以直接判定为黑。其他类型加入灰色链或其他的辅助标记链。
STEP6:最后对灰色链表进行一次清除且保证是原子操作。
该步骤实际对应状态:GCSatomic
(lgc.c)[singlestep]

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;
    }

1.Propagateall
(lgc.c)

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

先检测g->gray,因为luaC_barrier函数(用于处理新建对象的一种机制)的存在,它调用reallymarkobject时有可能会操作变量g->gray.
2.atomic完成需要原子操作的步骤,主要如下:

  • 1重新遍历(跟踪)根对象。
  • 2遍历之前的grayagain(grayagain上会有弱表的存在), 并清理弱表的空间。
  • 3调用separatetobefnz函数将带__gc函数的需要回收的(白色)对象放到global_State.tobefnz表中,留待以后清理。
  • 4.使global_State.tobefnz上的所有对象全部可达。
  • 5.将当前白色值切换到新一轮的白色值。

以上内容只是对atomic简述,有很多具体的细节会牵扯过多的内容,限于篇幅就不具体展开了。

4.5清除阶段

STEP7:据不同类型的对象,进行分步回收。回收中遍历不同类型对象的存储链表
STEP8:该对象存储链表是否到达链尾
STEP9:逐个判断对象颜色是否为白
STEP10:释放对象所占用的空间
STEP11:将对象颜色置为白
上述步骤实际对应状态:GCSswpallgcGCSswpfinobjGCSswptobefnzGCSswpend
(lgc.c)[singlestep]

case GCSswpallgc: {  /* sweep "regular" objects */
      return sweepstep(L, g, GCSswpfinobj, &g->finobj);
    }
  case GCSswpfinobj: {  /* sweep objects with finalizers */
      return sweepstep(L, g, GCSswptobefnz, &g->tobefnz);
    }
  case GCSswptobefnz: {  /* sweep objects to be finalized */
      return sweepstep(L, g, GCSswpend, NULL);
    }
  case GCSswpend: {  /* finish sweeps */
      makewhite(g, g->mainthread);  /* sweep main thread */
      checkSizes(L, g);
      g->gcstate = GCScallfin;
      return 0;
}

这个过程可以理解成对垃圾分类回收
GCSswpallgc将通过sweepstep将g->allgc上的所有死对象释放(GCSatomic状态以前的白色值的对象),并将活对象重新标记为当前白色值。
GCSswpfinobj和GCSswptobefnz两个状态也调用了sweepstep函数。但是g->finobj和g->tobefnz链表上是不可能有死对象的(原因留给读者思考),因此它们的作用仅仅是将这些对象重新设置为新一轮的白色。
GCSswpend用来释放mainthread上的一些空间,如:字符串表,连接缓冲区等。
这些状态的切换是通过判定当前类型的对象链表是否到达尾部实现的。
上述的状态真正清扫操作是通过sweepstep来调用sweeplist完成的。
(lgc.c)

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;
}

上文中提到的各种可回收数据类型对象链表都是很长的,所以清除也是分段完成的,比如可以通过sweeplist中的count参数来控制每次清理的数量。(这个值的设定以及动态变换的过程,我希望在下一篇关于GC参数调节的文章中阐述)。从上述的清除过程中,可以明白lua的GC过程是非搬迁式的,即没有对数据进行迁移,不做内存整理。
最后一个GC状态:GCScallfin
(lgc.c)

case GCScallfin: {  /* call remaining finalizers */
      if (g->tobefnz && g->gckind != KGC_EMERGENCY) {
        int n = runafewfinalizers(L);
        return (n * GCFINALIZECOST);
      }
      else {  /* emergency mode or no more finalizers */
        g->gcstate = GCSpause;  /* finish collection */
        return 0;
      }
}

在这个状态会逐个取出g->tobefnz链表上的对象,然后调用其__gc函数,并将其放入g->allgc链表中,准备在下个GC循环回正式回收此对象。

5.总结

至此,其实lua的GC算法已经讲述完毕了。但是为了在解释完源码之后,重新呼应文章开头的算法简述的内容,也方便读者能够有一个宏观的理解。所以我想最后用一句话来完成对这个算法的描述:(当然表述只是针对了主干情况)
Lua通过借助grey链表,依次利用reallymarkobject对对象进行了颜色的标记,之后通过遍历alloc链表,依次利用sweeplist清除需要回收的对象。
本篇文章是我这个后学晚辈仓促成文,实在是诚惶诚恐的。希望文中错误之处,大家能够多多批评指正,我一定认真及时纠正相关的错误。感谢大家的阅读。

参考资料

codedump-lua设计与实现
lua5.3.4源码
云风的博客
Programming in Lua

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章