Lua GC 的源码剖析 (3)
有了前几天的基础,我们可以从顶向下来读 lua gc 部分的代码了。
我们知道,lua 对外的 API 中,一切个 gc 打交道的都通过 lua_gc
。C 语言构建系统时,一般不讲设计模式。但模式还是存在的。若要按《设计模式》中的分类,这应该归于 Facade 模式。代码在 lapi.c 的 895 行:
/* ** Garbage-collection function */ LUA_API int lua_gc (lua_State *L, int what, int data) { int res = 0; global_State *g; lua_lock(L); g = G(L); switch (what) { case LUA_GCSTOP: { g->GCthreshold = MAX_LUMEM; break; } case LUA_GCRESTART: { g->GCthreshold = g->totalbytes; break; } case LUA_GCCOLLECT: { luaC_fullgc(L); break; } case LUA_GCCOUNT: { /* GC values are expressed in Kbytes: #bytes/2^10 */ res = cast_int(g->totalbytes >> 10); break; } case LUA_GCCOUNTB: { res = cast_int(g->totalbytes & 0x3ff); break; } case LUA_GCSTEP: { lu_mem a = (cast(lu_mem, data) << 10); if (a <= g->totalbytes) g->GCthreshold = g->totalbytes - a; else g->GCthreshold = 0; while (g->GCthreshold <= g->totalbytes) { luaC_step(L); if (g->gcstate == GCSpause) { /* end of cycle? */ res = 1; /* signal it */ break; } } break; } case LUA_GCSETPAUSE: { res = g->gcpause; g->gcpause = data; break; } case LUA_GCSETSTEPMUL: { res = g->gcstepmul; g->gcstepmul = data; break; } default: res = -1; /* invalid option */ } lua_unlock(L); return res; }
从代码可见,对内部状态的访问,都是直接访问 global state 表的。GC 控制则是调用内部 api 。lua 中对外的 api 和内部模块交互的 api 都是分开的。这样层次分明。内部子模块一般名为 luaX_xxx
X 为子模块代号。对于收集器相关的 api 一律以 luaC_xxx
命名。这些 api 定义在 lgc.h 中。
此间提到的 api 有两个:
LUAI_FUNC void luaC_step (lua_State *L); LUAI_FUNC void luaC_fullgc (lua_State *L);
用于分步 GC 已经完整 GC 。
另一个重要的 api 是:
#define luaC_checkGC(L) { \ condhardstacktests(luaD_reallocstack(L, L->stacksize - EXTRA_STACK - 1)); \ if (G(L)->totalbytes >= G(L)->GCthreshold) \ luaC_step(L); }
它以宏形式定义出来,用于自动的 GC 。如果我们审查 lapi.c ldo.c lvm.c ,会发现大部分会导致内存增长的 api 中,都调用了它。保证 gc 可以随内存使用增加而自动进行。
这里插几句。
使用自动 gc 会有一个问题。它很可能使系统的峰值内存占用远超过实际需求量。原因就在于,收集行为往往发生在调用栈很深的地方。当你的应用程序呈现出某种周期性(大多数包驱动的服务都是这样)。在一个服务周期内,往往会引用众多临时对象,这个时候做 mark 工作,会导致许多临时对象也被 mark 住。
一个经验方法是,调用 LUA_GCSTOP
停止自动 GC。在周期间定期调用 gcstep 且使用较大的 data 值,在有限个周期做完一整趟 gc 。
另,condhardstacktests 是一个宏,通常是不开启的。
先来看 luaC_fullgc
。它用来执行完整的一次 gc 动作。fullgc 并不是仅仅把当前的流程走完。因为之前的 gc 行为可能执行了一半,可能有一些半路加进来的需要回收的对象。所以在走完一趟流程后,fullgc 将阻塞着再完整跑一遍 gc 。整个流程有一些优化的余地。即,前半程的 gc 流程其实不必严格执行,它并不需要真的去清除什么。只需要把状态恢复。这个工作是如何做到的呢?见 lgc.c 的 637 行:
void luaC_fullgc (lua_State *L) { global_State *g = G(L); if (g->gcstate <= GCSpropagate) { /* reset sweep marks to sweep all elements (returning them to white) */ g->sweepstrgc = 0; g->sweepgc = &g->rootgc; /* reset other collector lists */ g->gray = NULL; g->grayagain = NULL; g->weak = NULL; g->gcstate = GCSsweepstring; } lua_assert(g->gcstate != GCSpause && g->gcstate != GCSpropagate); /* finish any pending sweep phase */ while (g->gcstate != GCSfinalize) { lua_assert(g->gcstate == GCSsweepstring || g->gcstate == GCSsweep); singlestep(L); }
比较耗时的 mark 步骤被简单跳过了(如果它还没进行完的话)。和正常的 mark 流程不同,正常的 mark 流程最后,会将白色标记反转。见 lgc.c 548 行,atomic 函数:
/* flip current white */ g->currentwhite = cast_byte(otherwhite(g));
在 fullgc 的前半程中,直接跳过了 GCSpropagate ,重置了内部状态,但没有翻转白色标记。这会导致后面的 sweep 流程不会真的释放那些白色对象。sweep 工作实际做的只是把所有对象又重新设置回白色而已。
接下来就是一个完整不被打断的 gc 过程了。
markroot(L); while (g->gcstate != GCSpause) { singlestep(L); } setthreshold(g);
从根开始 mark ,直到整个 gc 流程执行完毕。最后,重新设置了 GCthreshold 。注:调用 fullgc 会重置 GCthreshold ,所以如果你曾经调用 LUA_GCSTOP
暂停自动 GC 的话(也是通过修改 GCthreshold 实现) ,记得再调用一次。
stepgc 要相对复杂一些。在 lua 手册的 2.10 解释了 garbage-collector pause 和 step multiplier 的意义,却没有给出精确定义。lua_gc
的说明里,也只说“LUA_GCSTEP
: 发起一步增量垃圾收集。 步数由 data 控制(越大的值意味着越多步), 而其具体含义(具体数字表示了多少)并未标准化。 如果你想控制这个步数,必须实验性的测试 data 的值。 如果这一步结束了一个垃圾收集周期,返回返回 1 。 并没有给出准确的含义。实践中,我们也都是以经验取值。
回到源代码,我们就能搞清楚它们到底是什么了。
case LUA_GCSETPAUSE: { res = g->gcpause; g->gcpause = data; break; } case LUA_GCSETSTEPMUL: { res = g->gcstepmul; g->gcstepmul = data; break; }
这里只是设置 gcpause gcstepmul 。gcpause 实际只在 lgc.c 59 行的 setthreshold 宏中用到
#define setthreshold(g) (g->GCthreshold = (g->estimate/100) * g->gcpause)
看见,GCSETPAUSE 其实是通过调整 GCthreshold 来实现的。当 GCthreshold 足够大时,luaC_step
不会被 luaC_checkGC
自动触发。事实上,GCSTOP 正是通过设置一个很大的 GCthreshold 值来实现的。
case LUA_GCSTOP: { g->GCthreshold = MAX_LUMEM; break; }
gcpause 值的含义很文档一致,用来表示和实际内存使用量 estimate 的比值(放大 100 倍)。一旦内存使用量超过这个阀值,就会出发 GC 的工作。
要理解 gcstepmul ,就要从 lua_gc
的 LUA_GCSTEP
的实现看起。
case LUA_GCSTEP: { lu_mem a = (cast(lu_mem, data) << 10); if (a <= g->totalbytes) g->GCthreshold = g->totalbytes - a; else g->GCthreshold = 0; while (g->GCthreshold <= g->totalbytes) { luaC_step(L); if (g->gcstate == GCSpause) { /* end of cycle? */ res = 1; /* signal it */ break; } } break; }
step 的长度 data 被放大了 1024 倍。在 lgc.c 的 26 行,也可以看到
#define GCSTEPSIZE 1024u
我们姑且可以认为 data 的单位是 KBytes ,和 lua 总共占用的内存 totalbytes 有些关系。
ps. 这里 totalbytes 是严格通过 Alloc 管理的内存量。而前面提到的 estimate 则不同,它是一个估算量,比 totalbytes 要小。这是因为,前面也提到过,userdata 的回收比较特殊。被检测出已经访问不到的 userdata 占用的内存并不会马上释放(保证 gc 元方法的安全调用),但 estimate 会抛去这部分,不算在实际内存使用量内。
见 lgc.c 544 行
udsize = luaC_separateudata(L, 0); /* separate userdata to be finalized */
以及 lgc.c 553 行
g->estimate = g->totalbytes - udsize; /* first estimate */
从代码逻辑,我们暂时可以把 data 理解为,需要处理的字节数量(以 K bytes 为单位)。如果需要处理的数据量超过了 totalbytes ,自然就可以把 GCthreshold 设置为 0 了。
实际上不能完全这么理解。因为 GC 过程并不是一点点回收内存,同时可用内存越来越多。GC 分标记(mark) 清除(sweep) 调用 userdata 元方法等几个阶段。只有中间的清除阶段是真正释放内存的。所以可用内存的增加( totalbytes 减少)过程,时间上并不是线性的。通常标记的开销更大。为了让 gcstep 的每个步骤消耗的时间更平滑,就得有手段动态调整 GCthreshold 值。它和 totalbytes 最终影响了每个 step 的时间。
下面的关注焦点转向 luaC_step
,见 lgc.c 的 611 行:
void luaC_step (lua_State *L) { global_State *g = G(L); l_mem lim = (GCSTEPSIZE/100) * g->gcstepmul; if (lim == 0) lim = (MAX_LUMEM-1)/2; /* no limit */ g->gcdept += g->totalbytes - g->GCthreshold; do { lim -= singlestep(L); if (g->gcstate == GCSpause) break; } while (lim > 0); if (g->gcstate != GCSpause) { if (g->gcdept < GCSTEPSIZE) g->GCthreshold = g->totalbytes + GCSTEPSIZE; /* - lim/g->gcstepmul;*/ else { g->gcdept -= GCSTEPSIZE; g->GCthreshold = g->totalbytes; } } else { lua_assert(g->totalbytes >= g->estimate); setthreshold(g); } }
从代码我们可以看到,GC 的核心其实在于 singlestep 函数。luaC_step
每次调用多少次 singlestep 跟 gcstepmul 的值有关。
如果是自动进行的 GC ,当 totalbytes 大于等于 GCthreshold 时,就会触发 luaC_step
。每次 luaC_step
,GCthreshold 都会被调高 1K (GCSTEPSIZE) 直到 GCthreshold 追上 totalbytes 。这个追赶过程通常发生在 mark 流程。因为这个流程中,totalbytes 是只增不减的。
如果是手控 GC ,每次 gcstep 调用执行多少次 luaC_step
则跟 data 值有关。大体上是 1 就表示一次(在 mark 过程中就是这样)到了 sweep 流程就不一定了。这和 singlestep 调用次数,即 gcstepmul 的值有关。它影响了 totalbytes 的减小速度。
所以,一两句话很难严格定义出这些控制 GC 步进量的参数的含义,只能慢慢阅读代码,看看实现了。
在 lua 手册的 2.10 这样描述“step multiplier 控制了收集器相对内存分配的速度。 更大的数字将导致收集器工作的更主动的同时,也使每步收集的尺寸增加。 小于 1 的值会使收集器工作的非常慢,可能导致收集器永远都结束不了当前周期。 缺省值为 2 ,这意味着收集器将以内存分配器的两倍速运行。”
从代码看,这绝非严格定义。至少从今天已经分析的代码中还看不出这一点。
gcstepmul 的值和内存增涨速度如何产生联系?明天再写 :)
Comments
Posted by: guccang | (6) December 14, 2021 02:16 PM
Posted by: 凌 | (5) November 28, 2020 06:22 PM
Posted by: 极夜 | (4) September 18, 2015 12:13 AM
Posted by: 欧巴马 | (3) May 13, 2015 09:49 PM
Posted by: 小海 | (2) March 30, 2011 11:55 PM
Posted by: 临时玩家 | (1) March 29, 2011 11:07 PM