« December 2012 | Main | February 2013 »

January 30, 2013

内存异常排查

今天开电话会议,帮助合作方排查 C++ 开发的程序崩溃的原因。

现象是这样的:

有一个 C++ 对象,其中有一个 vector ,里面存放了大量对象指针。这个 C++ 对象在构造完毕后,没有任何改写操作,全部是只读的。

在某个退出环境中,C++ 对象被析构,析构函数需要调用 vector 中所有对象指针的删除方法。这时,这个长度大约为 100 多的 vector 第 95 个指针是错的。比它应该的指针位置偏移了一到三字节(多次运行现象不一,出错几率也不大)。

整个数组只有这一个指针错误。因为这个错误,引发了程序崩溃。

从电话中的描述,我推断:

这不太可能是由错误的调用此 C++ 对象的方法改写 vector 的内容导致的,因为这个对象全部是读方法,并加了 const 修饰。且,这个数组保存的是对象地址。一个由正常的内存分配器分配出来的地址都是对齐的,但此处错误的被改写为奇数。

这个错误也不太可能是由内存越界写造成的,因为在这一长片内存中,只有一个指针异常。

最大的可能是:某个对象被删除,但其指针还在使用造成的,所谓悬空指针问题。

那么,导致是这个出问题的对象指针悬空,还是另一个对象指针悬空影响的呢?

最大的可能是,这个出问题对象所占据的内存空间,曾经被别的对象使用过。但是前任使用者释放了内存,却在某处保留了指针。然后出问题的对象复用了这块地址。

做这个判断是因为,如果出问题的对象本身是悬空指针,那么后来者占用了它的内存的话,应该成片的改写。

从现象可以推断,内存异常一定是对原有数据进行递减造成的,而不是简单的覆盖了一个新的值。

从退出时出错可以判断,这个 C++ 对象在有效生命期间很有可能一直是正常的,否则指针错误很可能很早就暴露出来了。

那么,我认为最大的可能性就是:有另一个 C++ 采用了引用计数的机制。这个对象曾经引用到 0 而被正确的析构并释放掉了。但某种原因在另一个地方还保留了针对它的 raw 指针。

这个被释放掉的对象的内存被后来的出问题的这个 C++ 对象复用,一系列的退出析构操作导致了一系列的对象析构函数的调用。那个悬空的 raw 指针被调用了。这个指针指向的对象没有虚表,所以它的析构函数可以正确的执行。引用计数这个量存在的位置恰巧在后来出问题的 C++ 对象的中间。

减引用的函数把这个位置的值,也就是另一个对象的指针作为数值减了 1 ,发现不到 0 就跳过了。接下来的析构操作到这个位置时,访问了不正确的指针。这里期盼的指针引用的对象有虚表,偏移的差别导致跳转到虚析构函数出错。

从电话里可以获得的信息有限,我的推断只能到这里了。

为了进一步的盘查错误,我建议:把所有的对引用计数的操作,包括标准库中的智能指针的代码,都加上 assert 判断。断言所有的引用计数的值都应该在 0 到 10000 之间。

我并不是说智能指针的实现会有问题,尤其是采用标准库的实现一定没有问题。

但是,当一个对象本身被释放后,它的(悬空)指针还可以被调用析构函数却是一个隐患。悬空指针调用的对象中如果有一个智能指针的话,那么这个智能指针的析构函数依旧是会做递减操作的。


顺便吐槽 C++ 。

我现在看到的这个 C++ 项目,如果用一个标准的 windows 下的 malloc ,也就是一个性能比较低下的内存管理器,性能简直不能接受。你必须换一个非常优秀的内存管理器才能正常工作。这样的依赖内存分配器的性能,在我见过的 C 开发的项目中几乎不可能存在。

这是因为 C++ 的项目多半层次混乱。我说的混乱,不一定指开发逻辑层次上的混乱,而是假借高性能之名,看起来在源代码层次把软件的层次分清楚了,但是在二进制层面却是混杂在一起的。

一个小小的内存管理模块,就穿插于最底层到最上层。

一个层次分明的系统,在物理上就应该是相互隔离的,这种隔离,仅仅存在于人阅读的源代码层是绝对不够的。这就好比 OS 管理下的应用进程,它绝对不依赖应用进程的程序的工作正常,不依赖应用进程准确的申请和释放资源。而是当应用进程结束后,干净的回收它申请过的所有东西。

小到同一进程下的软件,也理所当然要按这个思路进行才对。C++ 不强调这一点,反而鼓励程序员生产出庞大的软件,并美其名曰:信任程序员。但却把用 C++ 的程序员引向一条邪路。信任程序员和放任程序员是两码事。

温故而知新

我上次通读 Lua 的源代码时,Lua 还在 5.1 。当然 Lua 5.0 我也读过,4.0 和 3.2 则读的不多。

最近有一点空闲,想续写我那本 Lua 源码欣赏。按我心里的计划,还有大约 6 章。虚拟机、字节码持久化、C API 、解释器、GC、库函数。

新添了一章关于虚拟机的,所以重新读了一遍相关源码。发现 Lua 5.2 比上一版修改了不少,几乎每个位置都有修订。

自己读代码和写出来给人看又是不同,真的逐行推敲的话,之前的理解也是经不起琢磨的。为什么要写这一行;为什么这一行在这个位置,而不是在后面;为什么要这么实现,而不是那样实现……

一边写,一边发现对别处的引用会引发新的疑问,继而需要对之前已完成的章节做一些修补。

上一次发布 pdf 时,采用的是日后纸质书的版式。留白太多对于电子阅读其实是很浪费的,读代码尤其不好。所以这次重新排了一下。

这次主要是增加了关于 VM 的新章节。

有兴趣的同学可以下载:《Lua 源码欣赏》。但我不建议现在开始阅读,尤其是对不仅仅想随便翻翻的同学。因为我经常修改它,今天看到的版本,可能写完后已经改了不少了。

btw, 在我写完后,发现最近有另一个同学也在写类似的文章。这里给出一个链接,有兴趣的同学可以看看。

January 22, 2013

内存泄露排查小记

最近我们的游戏服务器又发生了一起内存泄露事故。 由于泄露速度极其缓慢,所以有很大的隐蔽性。

Bug 最后是这样确认并解决的:

由于 Skynet 本质上是由唯一的时钟模块驱动的,我们首先修改了时钟部分的代码,让系统可以以 10 倍速运作。这样,原本耗时几天的泄露现象,可以在半天内就确定了:一定存在某种内存泄露的 Bug, 而不是正常流程导致的内存占用持续上升。

我们原本就预留了各个独立服务的调试接口,大部分服务都是用 Lua 编写,利用调试接口,可以清楚的看出每个 Lua 虚拟机占用了多少内存。

但是原有的 Lua 调试接口有一定的缺陷,它以来虚拟机本身要能对外提供服务。一旦服务阻塞,就不能自行报告调试信息。这次事故和这点无关,但这次我们改进了 Lua 内部的内存占用汇报机制。

我们给 Lua 虚拟机定制了内存分配器,把使用报告汇总到一个全局的 C 数组中。这样,直接用 gdb 切入进程,也能直接看到报告。调试接口也不依赖服务自身的响应来汇报内存状态了。

这次排查事故的过程,我们排除了 Lua 对象的泄露可能。因为 Lua 虚拟机中占用的内存都没有呈现不正常上升的状态,所以利用之前编写的小工具 很难派上用场。

在 C 代码层面,我们使用的 jemalloc 可以很好的辅助分析。

这次的现象比较典型,在服务器正常关闭后,几乎所有的内存都正确释放了。我们给服务器退出过程增加了更详尽的 log ,确认在一张地图退出时,关闭 Lua state 的同时,C 里面有大量的内存被释放掉了,远超 Lua 占用的内存数量。

这几乎可以确定,在 Lua State 中大量引用了 C 对象,在运行过程中没有解引用,却在退出时正确释放了。可是,我们又没有观察到运行过程中 Lua State 内存的暴增。一开始,这种现象颇能迷惑人。

经过一番思考,我确定了问题。这是因为,地图服务应用了一个 AOI 管理的 C 模块。这个 C 模块以整数 handle 的形式对内部对象做了封装。在写 Lua 封装层的时候,简单的提供了几个 api ,用于创建和删除 handle 。所以,C 对象在 Lua 虚拟机中是以 handle 的形式存在的。使用的同学不慎忘记了在合适的时机,显式去删除这个 handle 。

具体到这个问题上,应该在怪物死亡的时候删除怪物的 aoi 对象 handle 。这个 bug 存留了很久,之所以没有被发现,是因为怪物死亡频率不高,且 AOI 模块中相应的 C 对象消耗内存很小的缘故。直到策划重新布了怪,不同阵营的怪相互厮杀,血流成河的缘故。


这次事故提醒我,永远不要把显式销毁对象的责任追加到动态语言使用者上。尽量利用 gc 的机制,当然还需要更贴切的使用。这对 lua 的 C 绑定库的实现者要提高要求。

比如这次 C API 使用的是整数 handle 来引用对象,为这些 handle 创建独立的 userdata 虽然运行成本略高,但更安全一些。即,我们分配 4 字节的 userdata ,里面放上 C handle ,并给出 __gc 方法确保对象即时销毁。

关于封装库的话题,恰巧最近刚刚写过

January 14, 2013

Pixel light 中的场景管理

这几天无意中发现一款开源的 3d engine ,名为 pixel light文档 虽然不多,但写的很漂亮。从源码仓库 clone 了一份,读了几天,感觉设计上有许多可圈可点的地方,颇为有趣。今天简略写一篇 blog 和大家分享。

ps. 在官方主页上,pixel light 是基于 OpenGL 的,但实际上,它将渲染层剥离的很好。如果你取的是源代码,而不是下载的 SDK 的话,会发现它也支持了 Direct3D 。另,从 2013 年开始,这个项目将 License 改为了 MIT ,而不是之前的 LGPL 。对于商业游戏开发来说,GPL 的确不是个很好的选择。

这款引擎开发历史并不短(从 2002 年开始),但公开时间较晚(2010 年),远不如 OGRE 等引擎有名。暂时我也没有看到有什么成熟的游戏项目正在使用。对于没有太多项目推动的引擎项目,可靠性和完备性是存疑的。不推荐马上在商业游戏项目中使用。但是,他的构架设计和源代码绝对有学习价值。

对于 3d 游戏这种技术演化迅速,需求多变的领域。我的个人看法是,越晚起步的引擎有更少的历史包袱,通常会比历史悠久的 engine 更清爽一些(虽然历史悠久的 engine 可能要结实一点)。


这次仅谈谈 pixel light 中的场景管理部分。

Pixel Light 中和 3d 渲染有关的类分落在 PLRenderer PLMesh PLScene 三个模块中。从名字上就可以了解,PLRenderer 主要承担系统 API 的接口粘合层;而 PLMesh 负责单个模块的渲染,包括和模块相关的骨骼动画的数据维护;而 PLScene 则负责把多个模块联系起来。

还有一些底层辅助模块,比如 PLMath 进行数学运算,PLGraphics 处理图片。上层的图形界面由 PLGui 承担,物理系统则是 PLPhysics 。

其中最难设计的莫过于场景管理。到底哪些东西是属于它的管理范畴,该如何组织,都是个难题。

Pixel light 中,和绝大多数 3d engine 的场景管理方式一样,所有的物件都是以场景节点 scene node 的形式存在于若干个场景树中。它没有给出所谓世界坐标空间,因为现在很多游戏的场景都做的非常巨大,需要动态组建。

scene node 都是场景树上的叶结点,只有 scene container 才可以组合其它 scene node 。这点和其它一些 engine 的设计有些许不同。对于场景元素,比如 mesh , light 等等,是从 scene node 对象继承下来的类,而不是附着( attach )在 scene node 上。这决定了,scene container 节点一定是一个纯粹的容器,一切可能参与渲染的实际元素都处于叶节点上。

Scene container 类的公开方法中,只有 Create 方法,而不能把已存在的 scene node 挂接到容器中。也就是说,你无法把一个 scene node 从容器中取出来,然后放到另一个容器中。这在一定程度上简化了 scene node 的生命期管理。也不太会在 scene node 间发生不合法的引用关系。

场景树在一定程度上描述了空间层次关系,在渲染流程中,每个 scene container 会影响子节点的空间状态(位置、旋转、缩放)。同时,也影响渲染行为,比如,SCRenderToTexture 就是一种特殊的容器,放在这个容器中的元素都会被渲染到一张贴图上。

游戏中对可渲染物件的空间管理,从性能因素考虑,仅仅依靠场景树的层次来管理是远远不够的。当我们需要和 3d 场景中的物件交互,从镜头中选取某个物件。渲染场景时,需要剔除不可见的物件。这些,依赖简单的 scene node 的层次管理是很低效的。Pixel light 引入了 Scene Hierarchy 来用不同的算法管理空间层次。每个容器可以根据需要有不同种类的 Hierarchy (简单的链表结构,或是复杂的 K-D 树)。

那么,针对 scene container 的处理过程,Pixel light 把它们称之为 Scene Query 。从字面上的理解,就是对一个 Scene (以某个 scene container 为根)所有节点的选取过程。Scene Query 的基类仅仅定义了对 scene container 一种操作方式,对其中符合要求的节点唯一的发出一个 touched 的信号。从这个意义来说,渲染本身也含有一个 query 过程。遍历一个 container ,touch 可视节点。这些被实现在 SQCull 类中。

容器的存在本身是由于 Query 的需求存在(Container 是 Query 操作的对象),其中 scene renderer 也是一种 query 。和直觉不一样,我们通常不会利用 scene node 的树层次关系来组织可渲染物件的空间关系。例如,一块面包放在桌子上,我们不必让面包挂在桌子上。面包和桌子都属于同一个容器。如果它们真的有从属关系的话,也仅存在于场景编辑器中。同样,即使是做第一人称视角的游戏,摄像机对象也不需要在 scene node 的层次是归属于主角模型。它应该和主角模型在同一个 scene container 中。

那么,总存在有一些需求,某些 scene node 的空间关系和另一个 node 有关。一个节点动了,另一个应该跟着移动,等等。这些相互关系甚至很难用树结构去表达。在 Pixel light 中,解决这一问题的方法是叫做 scene node modifier 的东西。每个节点可以有多个 modifier ,这些 modifier 会根据需要改变 node 的状态。modifier 可以利用物理系统作用于结点,可以让模型播放动画,也可以容一个结点随另一个运动。

比如让摄像机跟随一个节点,只需要在跟随节点上添加一个 SNMAnchor ,然后设置它的 AttachedNode 属性为摄像机节点。这样当跟随节点的坐标变化时,会自动修改摄像机的坐标。SNMAnchor 只能让同一个容器内的两个 scene node 相互起作用,也不能产生有循环引用的关系。这一切基于 Event 机制起作用的,在 Pixel light 里 Event 并没有放在一个消息队列中,而是在 Signal 发生时,Event 立刻传递到 Slot 中产生一次函数调用。

如果想把一把剑挂接在人物模型的手上,同样可以利用 SNMAnchor 。除了设置 AttachedNode 属性外,还需要设置 SkeletonJoint 属性为骨骼数据中的手这个关节。这一切通过名字字符串来耦合。

January 10, 2013

为 Lua 绑定 C/C++ 对象

如何绑定 C/C++ 对象到 Lua 里?通常是创建一个 userdata ,存放 C/C++ 对象指针,然后给 userdata 添加元表,用 index 元方法映射 C/C++ 中的对象方法。

也有另一个手段,直接用 lightuserdata 保存 C/C++ 对象指针放到 Lua 中,在 Lua 中创建一个 table 附加元表来来包装这个指针,效果是类似的。区别在于对象生命期的管理方式有所不同。就这个问题,几年前我写过一篇 blog

绑定 C/C++ 对象到 Lua 里的设计难点往往在这个正确的生命期管理上。因为 C/C++ 没有 GC 系统,依赖手工管理资源;而 Lua 则是利用 GC 做自动回收。这两者的差异容易导致在 Lua 中的对象对应的 C/C++ 对象已经销毁而 Lua 层不自知,或 Lua 层中已无对象之引用,而 C/C++ 层中却未能及时回收资源而造成内存泄露。

理清这个问题,首先你要确定,你打算以 Lua 为主干来维护对象的生命期,还是以 C/C++ 层为主干 Lua 部分只是做一些对这些对象的行为控制。

我个人主张围绕 Lua 来开发,C/C++ 只是写一些性能相关的库供 Lua 调用,即框架层在 Lua 中。这样,C/C++ 层只提供对象的创建和销毁函数,不要用 C 指针做对象的相互引用。Lua 中对象被回收时,销毁对应的 C 对象即可。

但是,也有相当多的项目做不到这点。Lua 是在后期引入的,之前 C/C++ 框架层中已做好了相当之复杂的对象管理。或者构架师不希望把脚本层过多的侵入引擎的设计。

那么,下面给出另一个方案。

我们将包装进 Lua 的 C 对象称为 script object ,那么只需要提供三个函数即可。

int
script_pushobject(lua_State *L, void * object) {
    void **ud;
    if (luaL_newmetatable(L, "script")) {
        // 在注册表中创建一个表存放所有的 object 指针到 userdata 的关系。
        // 这个表应该是一个 weak table ,当 Lua 中不再存在对 C 对象的引用会删除对应的记录。
        lua_newtable(L);
        lua_pushliteral(L, "kv");
        lua_setfield(L, -2, "__mode");
        lua_setmetatable(L, -2);
    }
    lua_rawgetp(L,-1,object);
    if (lua_type(L,-1)==LUA_TUSERDATA) {
        ud = (void **)lua_touserdata(L,-1);
        if (*ud == object) {
            lua_replace(L, -2);
            return 0;
        }
        // C 对象指针被释放后,有可能地址被重用。
        // 这个时候,可能取到曾经保存起来的 userdata ,里面的指针必然为空。
        assert(*ud == NULL);
    }
    ud = (void **)lua_newuserdata(L, sizeof(void*));
    *ud = object;
    lua_pushvalue(L, -1);
    lua_rawsetp(L, -4, object);
    lua_replace(L, -3);
    lua_pop(L,1);
    return 1;
}

这个函数把一个 C 对象指针置入对应的 userdata ,如果是第一次 push 则创建出新的 userdata ,否则复用曾经创建过的。

void *
script_toobject(lua_State *L, int index) {
    void **ud = (void **)lua_touserdata(L,index);
    if (ud == NULL)
        return NULL;
    // 如果 object 已在 C 代码中销毁,*ud 为 NULL 。
    return *ud;
}

这个函数把 index 处的 userdata 转换为一个 C 对象。如果对象已经销毁,则返回 NULL 指针。 在给这个对象绑定 C 方法时,应注意在 toobject 调用后,全部对指针做检查,空指针应该被正确处理。

void
script_deleteobject(lua_State *L, void *object) {
    luaL_getmetatable(L, "script");
    if (lua_istable(L,-1)) {
        lua_rawgetp(L, -1, object);
        if (lua_type(L,-1) == LUA_TUSERDATA) {
            void **ud = (void **)lua_touserdata(L,-1);
            // 这个 assert 防止 deleteobject 被重复调用。
            assert(*ud == object);
            // 销毁一个被 Lua 引用住的对象,只需要把 *ud 置为 NULL 。
            *ud = NULL;
        }
        lua_pop(L,2);
    } else {
        // 有可能从未调用过 pushobject ,此时注册表中 script 项尚未建立。
        lua_pop(L,1);
    }
}

这个函数会解除 C 对象在 Lua 中的引用,后续在 Lua 中对这个对象的访问,都将得到 NULL 指针。


这些代码是在我写这篇 blog 的同时随手写的,并未经过严格测试。它们也有许多改进空间,比如给 C 对象加入类型,对 userdata 做更严格的检查,等等。