Lua 5.3.4 的一个 bug
昨天我们一个项目发现了一处死循环的 bug ,经过一整晚的排查,终于确认是 lua 5.3.4 的问题。
起因是最近项目中接入了我前段时间写的一个库,用来给客户端加载大量配置表格数据 。它的原理是将数据表先转换为 C 结构,放在一块连续内存里。在运行时,可以根据需要提取出其中用到的部分加载都虚拟机中。这样做可以极大的提高加载速度。项目在用的时候还做了一点点小修改,把数据表都设置成 weaktable ,可以让暂时不用的数据项可以回收掉。
正式后面这个小修改触发了 bug 。
排除掉是我这个库引起的 bug 后,我们把注意力集中在 lua 的实现上。
bug 的现象是:运行一段时间后,某次 table copy 的过程中,对一个 table 的 set 操作陷入了死循环。我们知道 lua 的 table 中有一个闭散列 hash 表,如果在插入新项目时,发现 hash 冲突,则需要重新找到一个空的 slot 并将其串在 hash 查询时所在的 slot 上的链表中。
而 bug 发生时,这个链表损坏了,指向了一个空 slot ,空 slot 的 next 指针指向自己,导致死循环遍历。
从 coredump 上分析,我认为是 hash 查询出来的冲突对象(一个 long string )的数据结构受损。原本在 long string 结构中有一个 extra 变量指示这个对象是否有计算过 hash ,它的值只能是 0 或 1 ,但这里却是 67 。而 hash 值则为 0 (通常 hash 值是 0 的概率非常小),导致重新索引 hash slot 时指向了 slot 0 ,那里是空的。
我们自定义了 lua 的分配器,在分配器中输出 log 显示,在访问这个 slot 前,那个受损的 long string key 对象其实已经被 lua vm 释放过了。
一开始我们怀疑是自定义的内存分配器有 bug ,但很快放弃了这个想法,转而去追查 lua 的 gc 过程。这个 table 是 key value 都是弱的弱表,若只设置 value 为弱则不会触发 bug 。
确认问题出在清除弱表项的环节,也就是 lgc.c 中的 GCSatomic 阶段的 atomic 函数中。它有一个步骤是调用 clearkeys(g, g->allweak, NULL);
清除在扫描过程标记出来的弱表,并检查 key 是否需要清除。
该函数是这样的:
/* ** clear entries with unmarked keys from all weaktables in list 'l' up ** to element 'f' */ static void clearkeys (global_State *g, GCObject *l, GCObject *f) { for (; l != f; l = gco2t(l)->gclist) { Table *h = gco2t(l); Node *n, *limit = gnodelast(h); for (n = gnode(h, 0); n < limit; n++) { if (!ttisnil(gval(n)) && (iscleared(g, gkey(n)))) { setnilvalue(gval(n)); /* remove value ... */ removeentry(n); /* and remove entry from table */ } } } }
遍历 hash 表,当 value 不为空,且 key 可以被清除的时候,将 slot 清空。
string 对于 gc 是一个特殊的对象,因为它即是一个 GCObject ,但又被视为值而不是引用。string 并不会因为在 vm 中没有 weak table 之外的地方引用而被清除。对 string 的特殊处理是在 iscleared 函数中完成的。
/* ** tells whether a key or value can be cleared from a weak ** table. Non-collectable objects are never removed from weak ** tables. Strings behave as 'values', so are never removed too. for ** other objects: if really collected, cannot keep them; for objects ** being finalized, keep them in keys, but not in values */ static int iscleared (global_State *g, const TValue *o) { if (!iscollectable(o)) return 0; else if (ttisstring(o)) { markobject(g, tsvalue(o)); /* strings are 'values', so are never weak */ return 0; } else return iswhite(gcvalue(o)); }
如果发现 key 是一个 string 则会将其标黑。
但是在 clearkeys 里漏掉了一点,如果 value 为 nil 是不会执行 iscleared 函数的。而什么时候 key 为 string , value 为 nil 呢?最简单的途径是主动给 table 的表项设置为 nil 。这样,在 gc 一轮后,hash 表中就可能残留一个已经被释放的 GCObject 指针。
如果这个 string 是一个短 string 其实不会引起问题,因为再次设置 hash 表的时候,short string 是按指针比较的,不会访问其内容;但是 long string 不一样,hash set 时真的会比较对象的内容:两个 long string 是否相等取决于 string 的值相同,而不必是对象内存地址相同。
制作一个纯 lua 的 MWE 很困难,所以我写了一段 C 代码来演示这个问题:
#include <lua.h> #include <lauxlib.h> #include <lualib.h> #include <stdlib.h> #include <lstring.h> static void * l_alloc (void *ud, void *ptr, size_t osize, size_t nsize) { if (nsize == 0) { printf("free %p\n", ptr); free(ptr); return NULL; } else { return realloc(ptr, nsize); } } static int lpointer(lua_State *L) { const char * str = luaL_checkstring(L, 1); const TString *ts = (const TString *)str - 1; lua_pushlightuserdata(L, (void *)ts); return 1; } const char *source = "\n\ local a = setmetatable({} , { __mode = 'kv' })\n\ a['ABCDEFGHIJKLMNOPQRSTUVWXYZ' .. 'abcdefghijklmnopqrstuvwxyz' ] = {}\n\ print(pointer((next(a))))\n\ a[next(a)] = nil\n\ collectgarbage 'collect'\n\ local key = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' .. 'abcdefghijklmnopqrstuvwxyz'\n\ print(pointer(key))\n\ a[key] = {}\n\ print(pointer((next(a))))\n\ "; int main() { lua_State *L = lua_newstate (l_alloc, NULL); luaL_openlibs(L); lua_pushcfunction(L, lpointer); lua_setglobal(L, "pointer"); luaL_dostring(L, source); return 0; }
运行输出:
... userdata: 00000000006fedd0 这里是ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz free 00000000006FAB50 这里进入 GC 开始释放不用的对象 free 00000000006FA890 free 00000000006FE940 free 00000000006FE910 free 0000000000000000 free 00000000006FA650 free 00000000006FEDD0 这里显示前面那个长字符串 6FEDD0 已经释放了。 free 00000000006FEBC0 free 0000000000000000 free 00000000006FAA50 free 00000000006F9770 userdata: 00000000006f1eb0 这里构造了一个新的字符串 ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz userdata: 00000000006fedd0 这里显示前面那个已经被释放的 6FEDD0 字符串又回来了。
这个 bug 最简单的修改方法是把 clearkeys 中的 !ttisnil(gval(n)) 条件判断去掉。不过或许还有更完善的解决方案。
我已经将 bug report 到 lua 的邮件列表,暂时尚未被官方确认修正。
8 月 24 日:
官方已确认这个 bug ,见邮件列表 。
Comments
Posted by: 凌 | (7) October 15, 2017 04:55 PM
Posted by: Cloud | (6) September 11, 2017 03:51 PM
Posted by: yunf | (5) September 11, 2017 11:38 AM
Posted by: Cloud | (4) August 19, 2017 10:59 AM
Posted by: leon | (3) August 18, 2017 05:30 PM
Posted by: Chunlin | (2) August 16, 2017 03:03 PM
Posted by: fanfeilong | (1) August 16, 2017 11:57 AM