本文将对lua的gc源代码进行一行一行地解析。
gc(垃圾回收)几乎是在所有计算机的高级语言中都会遇到的问题,语言本身的gc机制可以让程序员在开发的过程中用更多的精力去关心工程的逻辑实现,少花时间去关注逻辑底层的内存模型。但是有些时候我们也会遇到一些内存瓶颈的问题,这样就有必要去了解语言本身的内存模型和gc机制了。因此本文会详细介绍lua的gc实现机制以及背后的内存管理机制,并从代码层面逐段逐行去解析这些秘密。
lua的gc本身使用的机制是三色标记法,在整个gc的过程中会有短暂的STW(stop the world),这对于应用层来说几乎是无感的。下面将详细介绍一下这个三色标记法。
在最初的gc处理过程中,当我们的程序需要去做gc的时候,首先会将程序所有的运行机制都停止(STW),然后开始标记,将用到的内存结点进行标记,等所有的结点标记完成之后再去清除那些没有被标记的结点,等结点全部处理之后再去停止STW让程序继续运行。
1、添加内存数据结点,程序刚启动的时候创建了1号元素,这里我们理解这个程序的根元素。此时程序只有一个结点,内存逻辑如下:
每次创建的一个元素,都会将其放置在rootgc这一条链中,这样程序gc的时候去遍历这条链,这样所有开辟的元素都不会漏掉,因为每一个占内存空间的元素都会在这条链中。所有逻辑相关的元素(程序使用的)根结点的子孙结点中存在,而程序使用的和没有使用的所有结点都在rootgc这条链中,所以两个集合的差集就是需要被释放的对象。插入1号元素后rootgc链表的情况如下图,这个时候只有一个元素。
2、继续在根节点1下插入元素2、3、4。这里可以理解为1是一个结构体的指针,该指针指向的结构体里面有三根指针,三根指针分别指向了元素2、3、4。数据关系模型如下图所示:
其中rootgc链表中的结构如下图所示,每一次插入元素都是会插在链表头部,这样这个插入过程复杂度只有O(1),这样效率最高,如果插在链表尾部复杂度就是O(N)了。
3、继续在2号结点下面插入5号和6号元素,数据关系图如下:
rootgc链表如下:
4、继续在4号元素下面插入7、8、9号元素,数据关系图如下:
rootgc链表如下:
5、此时程序继续运行,由于某种逻辑原因,2号和3号元素,与1号元素失去了关联,如1号元素中的这两个指针指向了null,数据关系图如下:
rootgc链表如下:
6、开始gc,此时根节点1上只有一个4号元素。首先将根节点加入灰色链表中,并将根节点1标记为灰色。数据关系图如下:
链表结构如下图所示,此时添加了一条灰色链表,指向第一个灰色结点。
7、继续从在1号结点中找到其他元素,看是否引用了其他元素,找到了只有4号元素。此时将4号元素加入到灰色队列中,将1号元素宠灰色队列中移除,并标记为黑色(表示该结点已经被考察过了),数据关系图和链表如下:
8、继续从灰色队列中拿出对头结点4,看是否引用了其他结点,发现了7、8、9,于是将这些元素放入到灰色队列中,然后将4号元素从灰色队列中移除,并标记为黑色。关系图和链表如下:
9、继续从灰色队列中拿出对头结点9,发现结点9没有其他结点,于是只将9移除,回合,如下图。
10、继续处理灰链头部元素8,如下图:
11、继续处理灰链头部元素9,如下图,此时灰色链表为空,整个标记过程结束了,开始清理。我们从图中也可以看到,只有2、3、5、6号元素为白色,其他都为黑色,左图中正好这些白色也是不需要的,所以使用sweep再一次遍历就可以将白色去掉,这样一个简单的gc过程就完成了。实际上,lua的gc在这个过程中还加了其他一种机制,这个机制会将新添加的结点另外一种白色结点,那么在清除了时候这些白色结点就不会被清除了,否者就不能加入白色结点了,当然还有一种屏障操作,后续继续讲解。先讲解两种白色机制。
12、从sweep开始清理,只要是白色结点就开始清除掉,先清除结点6,如下图:
13、继续清理结点5,如下图
14、在清除结点5的时候结点1又重新引用了结点3,那么将结点3标记为另一种白色,这样在这一次gc过程中就不会被清除了,如下图:
14、清除结点3,发现结点3不能清除,所以sweep指针指向下一个元素2,继续清除
15、清除结点2
16、清除完整之后会将所有的黑色结点置为另一种白色,这样方便下次清理。
上述,就是lua的过程整个逻辑实现机制,二和三两个部分将从源码的角度对第一部分进行详细的解析。
gc对象的结构体如下,这是一个只包含头部的结构体。因为C语言本身不是面向对象的语言,所以gc对象头部的结构体不能是其他对象的父类,所以其他的对象同样包含这个头部,那么这些对象就可以强制转换为gc对象。
struct GCObject {
//头部仅仅只包含一个共同体
CommonHeader;
};
#define CommonHeader GCObject *next; lu_byte tt; lu_byte marked
typedef unsigned char lu_byte;
typedef unsigned char lu_byte;
该头部有三个信息,其中next指针是第一部分提到的rootgc中指向下一个gc对象的指针;tt表示该对象的类型,所有类型如下所示,marked表示gc过程中对象的颜色。整个头部共占用8个字节。
#define LUA_TNONE (-1)
#define LUA_TNIL 0
#define LUA_TBOOLEAN 1
#define LUA_TLIGHTUSERDATA 2
#define LUA_TNUMBER 3
#define LUA_TSTRING 4
#define LUA_TTABLE 5
#define LUA_TFUNCTION 6
#define LUA_TUSERDATA 7
#define LUA_TTHREAD 8
#define LUA_NUMTAGS 9
如下图所示是一个table表对象的数据结构,该结构有一个共同的头部结构,具体解析到table表源码的时候我们再具体解析这个结构。
typedef struct Table {
CommonHeader;
lu_byte flags;
lu_byte lsizenode;
unsigned int sizearray;
TValue *array;
Node *node;
Node *lastfree;
struct Table *metatable;
GCObject *gclist;
} Table;
说完了一个对象的头部信息,我们来看下如何创建一个可被回收的对象,代码如下所示。
//L:创建该对象的协程, tt:新对象的类型,sz:新对象的大小
GCObject *luaC_newobj (lua_State *L, int tt, size_t sz) {
//获得全局g表,这个g表在整个虚拟机中只有一个,所有公共的数据都存在这个g表里面
global_State *g = G(L);
//创建一个对象,并将这个对象强制转换为GCObject
GCObject *o = cast(GCObject *, luaM_newobject(L, novariant(tt), sz));
//将对象标记为白色
o->marked = luaC_white(g);
//赋值该对象的类型
o->tt = tt;
//将新建的对象放置在allgc链表的第一个位置。
o->next = g->allgc;
g->allgc = o;
return o;
}
下面具体一行一行解析这些代码,每一个协程里面都有一个字段指向全局g表。
#define G(L) (L->l_G)
case是一个宏定义,将exp对象强制转换为t对象。
#define cast(t, exp) ((t)(exp))
luaM_newobject 创建一个s大小的tag对象,这里主要用到的是s,对象类型会在外层进行强转。
#define luaM_newobject(L,tag,s) luaM_realloc_(L, NULL, tag, (s))
对象创建过程中最复杂的过程也就是这个函数了,下面我们一行一行地去解释
//L:创建的对象所属的协程
//block:不仅仅是新对象,有可能会在原有的对象上线增减,block是指向原有对象的指针
//osize:原有对象大小
//nsize:新建对象大小
void *luaM_realloc_ (lua_State *L, void *block, size_t osize, size_t nsize) {
void *newblock;
//获取全局g表
global_State *g = G(L);
size_t realosize = (block) ? osize : 0;
//断言
lua_assert((realosize == 0) == (block == NULL));
#if defined(HARDMEMTESTS)
//这个是硬件层面,暂不考虑,如果新建对象过大且在gc状态,强制gc
if (nsize > realosize && g->gcrunning)
//完全gc函数,在第二个部分中详细介绍
luaC_fullgc(L, 1);
#endif
//创建一个指定大小的堆内存,frealloc将在讲完本函数后详细讲解
newblock = (*g->frealloc)(g->ud, block, osize, nsize);
//创建对象是否成功
if (newblock == NULL && nsize > 0) {
//如果创建不成功,那么新对象一定会比原有对象大,所以这里有一个断言。
lua_assert(nsize > realosize);
if (g->version) {
//gc后再次尝试创建这个对象
luaC_fullgc(L, 1);
newblock = (*g->frealloc)(g->ud, block, osize, nsize);
}
//如果这个对象还没有创建成功,那么就是内存空间不足了,抛出异常
if (newblock == NULL)
luaD_throw(L, LUA_ERRMEM);
}
lua_assert((nsize == 0) == (newblock == NULL));
//创建对象成功后,改变需要gc的内存数量。
g->GCdebt = (g->GCdebt + nsize) - realosize;
return newblock;
}
在创建主线程的时候有一个参数是开辟内存参数f,
LUA_API lua_State *lua_newstate (lua_Alloc f, void *ud) {
//... 次数删除若干代码
g->frealloc = f;
//... 次数删除若干代码
}
看代码上一层,实际使用的是l_alloc这个函数,继续分析这个函数。
LUALIB_API lua_State *luaL_newstate (void) {
lua_State *L = lua_newstate(l_alloc, NULL);
if (L) lua_atpanic(L, &panic);
return L;
}
创建对象的大体过程如上描述,下面看看开辟内存函数frealloc。
//第一个和第三个参数无用,我们这里不讨论
//ptr 指向原对象的指针
//nsize 新建对象的大小
static void *l_alloc (void *ud, void *ptr, size_t osize, size_t nsize) {
(void)ud; (void)osize; /* not used */
//如果新建对象大小为0,就释放该对象
if (nsize == 0) {
free(ptr);
return NULL;
}
else
//使用C语言的库函数realloc 在ptr原有的基础上总共开辟nsize,并返回这个指针
return realloc(ptr, nsize);
}
整个数据的内存结构和开辟数据的源码讲完了。
在上面过程中我们提到了很多不同类型的对象相互转换,可能有人会觉得这样转过来转过去会有内存数据的丢失,其实是不会的。以为在我们使用realloc这种底层接口去创建对象的时候,虽然反回来的只有一个指针,但是这个指针做指向的空间还存在着头部和尾部,其中头部有一个字段是本地开辟内存的大小,所以在类型转换的时候都是这个大小,同时在用free释放内存的时候也是这个大小。如图所示,当我开辟一个34个内存单元的时候,指针头部有一个十六进制的数字22,转换十进制后正好是34。
其中o对应的是开辟内存返回的指针,34是开辟的内存,当然其实底层有一个内存对齐机制。