« November 2021 | Main | January 2022 »

December 24, 2021

难产的 Lua 5.4.4

2021 年 12 月 21 日,Lua 发布了 Lua 5.4.4 rc2 。

Lua 5.4.4 这个版本相当难产。之前几个小版本从 rc1 到正式发布最多只有十几天,而这次这个版本已经过去一个月,看起来还不能正式发布。

因为,就在 rc2 发布几个小时后,在邮件列表的讨论中,有人就发现了一个 bug 。有趣的是,这个 bug 并不是 Lua 5.4.4 引入的,而已经存在了 10 年以上。

在 Lua 5.4.4 rc1 发布后,也曾发现 bug ,同样也不是新版本引入的。似乎最近两年,给 Lua 做测试的人群多了起来,新的测试方法找到了很多过去未曾发现的 bug 。这些隐藏颇深的 bug 几乎都和 GC 有关。GC 的 bug 难以通过单元测试的方式提前找到,即使被定位到,也很难理解,彻底修复更加困难。

在 Lua 5.4 刚发布不久,新引入的分代 gc 中的一个 bug 就被讨论了很久才彻底解决 。Bug 发布两天后,Lua 的作者表示对彻底解决 bug 还没有线索

Until now, I have no clues about this one. It seems to be a real problem in the GC, but it is hard to reproduce. Applying the previous fixes makes it disappear, but I cannot see how they could solve the bug.

我当时也饶有兴趣的分析了很久。拨茧抽丝般理解这种复杂 Bug 能带来极大的愉悦感,同时也深感彻底避免出问题是多么的不易。

Lua 5.4.4 这次遇到的问题不那么难,但也有 gc 有关。主要有两个,都和 finalizer 有关。

在 Lua 的 finalizer ,即 gc 元方法里调用 gc 的 api 有可能引发重入,而 Lua 的 gc 被设计成不能重入的。我相信这是一个正确的设计选择。gc 已经够复杂了,如果设计一个允许重入的 gc 会更难不出问题。

这个问题最终的解决方案是彻底禁止在 gc 元方法里调用 collectgarbage 。

gc 无法重入这一点,很多人都没有意识到。实际上,如果你在 finalizer 里使用内存,是退出 finalizer 前,是无法被回收的。也不可能在 finalizer 运行过程中,触发另一个 finalizer 的运行。从实现角度上讲,这个限制理所当然。但对使用者来说,限制似乎难以理解。在公布解决方案时,很多人质疑说,一旦在 finalizer 里禁止 gc ,会不会导致更容易发生 oom 错误?实际上,Lua 从加入 finalizer 特性开始到现在,已经是这样的。

另一个问题是在 finalizer 里构造了新对象,并添加了新的 finalizer 后,新添加的 finalizer 会排在已经在准备运行但尚未运行的其它 finalizer 之后运行。

如果你在 Lua 虚拟机关闭过程中,让这个新的 finalizer 运行 C 动态库中的函数,就可能因为 C 动态库先被 unload 而出错。这是因为,C 动态库是由最开始注册的 finalizer 负责卸载的。原本 finalizer 遵循先入后出次序运行,卸载 C 动态库通常是最后,就相安无事。但在关闭虚拟机的最后阶段,新对象在全部老对象释放之后生成了,它引用了被卸载的 C 动态库中的代码。

而 C 函数本身是一个 light C function ,实现上就是一个指针,是无法 mark 动态库对象的。

目前的解决方案是在虚拟机关闭的最后阶段,忽略新创建的 finalizer 。我认为可能还可以有更好的解决方案:比如,在最后阶段,新创建出来的 finalizer 不要等新一轮的 mark sweep 再进入 finalizer 链表,而直接串在finalizer 链表的最前面,这样也能保证最初创建用来卸载 C 动态库的 finalizer 最后运行。

目前 Lua 5.4.4 尚未正式发布,不知道还会不会进一步改动。

我认为这件事说明了几个道理:

从直接的方面说,我们在使用 finalizer 时,应该保证只在里面做非常简单的事情,除了释放 C 代码管理的资源外,什么都别做。这个原则不仅仅针对 Lua ,我想对其它带 GC 的语言也同样适用。对象的构建和释放往往是系统中最复杂的部分,Bug 的滋生之所,简单是对付复杂性的唯一良药。

从更宽泛的意义上讲,软件保持健壮是一件非常难的事情。对于基础设施,我们应保持足够简单,并尽量争取更多人的目光。正如 Eric S.Raymond 在《大教堂与集市》中提出的 Linus 定律:"given enough eyeballs, all bugs are shallow”。我觉得这里的 eyeballs 不是指绝对意义上的人数,毕竟程序员的水平可以差上数个数量级。在特定领域上找出特定问题的潜在人选,一共可能也没几个,只有在软件的用户有庞大基数的情况下,才偶尔能吸引那么几个重要的眼球过来看。

这里引用一下去年学到的草台班子理论:“我工作以后才发现,大家都是草台班子。政府草台,企业草台,我也草台,大家都草台,凑合赚钱过日子。一个企业,看着像一台奔驰在高速公路上的豪华轿车,里面其实是几个人蹬着自行车顶个壳。路上的车都是这样,大家谁都不戳破。”

对于大多数开发团体来说,都是草台班子构成的。你几乎不要指望他们不犯错误。这就是为什么我认为软件基础设施必须开源的原因:我们需要为那些最优秀的人提供机会,让他们能分出一些精力来对草台班子搭出来的草台修修补补。


恰巧前两天有网易的老同事来访,谈及新创业的游戏项目。我建议他们新的服务器基于 skynet 来做。倒不是说 skynet 设计的有多好,而是它已经被搭建了 10 年,已经拥有了很多很多不同的团队的眼球。它已经很久没有加新的特性、但已有的那些代码中,不断有发现新的问题。每解决一个 bug ,就增添一份信心。这是闭门造车的服务器框架很难取代的。

December 16, 2021

异星工厂的机械臂模块

最近在做一个类似异星工厂的以自动化生产为主题的游戏。虽然我们的设计中,虽然我们的物流系统和异星工厂有许多差异(暂时没有设计传送带),但是我们也有类似的机械臂系统。

机械臂以一个固定的方向,让物品短距离移动,从一端转移到另一端。它用来把物品在物流网络和生产机械之间转移。我们在实现这个系统时,修改了几次方案,我觉得有点意思,值得记录一下。

首先,我觉得应该把机械臂系统和物流系统、生产系统隔离开。单独抽象它有利于降低整体实现的复杂度,也适合以后单独做性能优化。所以,一开始,我们就抽象出了物品容器和机械臂,把它们放在一起,单独做成一个黑盒。

游戏逻辑的大框架是基于 ECS 的,但机械臂系统没有用 ECS 实现。每个物品容器都有一个唯一的 id ,主框架上,有物品容器的 Entity 都记录这个 id ,可供索引到具体的容器。比如箱子、机器的输入箱、输出箱、燃烧室的燃料仓库,都是物品容器。机械臂系统被 ECS 框架的一个特定环节更新,每个 tick 在更新之后,其它系统都可以审视自身物品容器中的物品,而不必理会容器中物品的扭转过程。

机械臂初看挺简单,就是从 A 容器转移一个(一叠相同)物品到 B 容器。但熟悉异星工厂的玩家都知道其逻辑并不简单。

对于箱子这样的容器,可以放入任意物品;但箱子中每个格子可以堆叠物品的数量,是由物品的属性决定的。

对于组装机这样的机器,它有一个输入箱、一个输出箱。机械臂可从外部放置物品到输入箱,同时可从输出箱把物品取出;这种情况下,机器作为一个 Entity ,它被机械臂当作输入端和当作输出端时,其实是两个容器。

组装机的输入输出箱会有物品限制,也就是只能放特定物品;而机械臂在连接两个带物品限制的容器时,需要根据限制来决定移动哪个物品。换句话说,机械臂需要有黑白名单;当然对于筛选机械臂来说,这是是一个显式的特性,只不过普通机械臂其实也有隐式的规则。

更复杂的是燃烧器和熔炉,它们还会对可抓的物品按类型做一个复杂的筛选。

机械臂的运动本身分成四个阶段:

  1. 在没有物品可抓,或缺少目标位置需要的物品类型时,处于待机阶段。
  2. 否则抓取一叠物品。
  3. 然后进入(旋转)移动阶段。
  4. 最后在到达目的地后,如果可以放置就把物品放下。

以上,2 阶段和 4 阶段是瞬时完成的,完成后在当前 tick 立刻转到下个阶段,或不满足条件挺在上个阶段;1 阶段和 3 阶段则会持续若干游戏 tick 。

机械臂系统就是对容器和机械臂的一个抽象。从玩家角度看,每个机械臂的完整小系统都包含了一个机械臂和两端的两个容器;但实际上并不只输入和输出两个容器。

因为一个 Entity 身上很可能有多个容器。比如带燃料箱的生产机器,我们在实现时,它是有独立的组件:燃烧室和组装机构成的,两者均有自己的容器。所以在机械臂系统看来,机械臂的每一端都应该是一个容器列表,而不是唯一容器。

考虑到这一点,我觉得与其在机械臂系统中抽象容器,不如抽象容器中的槽位。

每个容器都是由若干槽位(格子)构成的,我们可以用一个链表把一个容器中的所有槽位串起来。

槽位(slot)的数据结构可以定义为:

ID       : dword
NextID   : dword
Filter   : word
ItemType : word
Count    : word
Capacity : word

一共 16 字节。我们可以把游戏中一切容器的槽位都管理在大数组中,用下标作为 ID 。用 freelist 回收再利用。绝大部分情况下,同一个容器中的槽位都会在连续内存中,对 Cache 非常友好。

当一个 Entity 创建时,它可能关联了多个容器,我们可以把所有关联容器的所有槽位都用 NextID 串起来,并首尾相接,形成一个循环链表。Entity 的每个组件都记录它自己拥有的那个容器的第一个槽位的 ID 以及槽位的数量(这个在原型表中,同种东西只有一份),这样就可以找到它自己拥有的那个容器的所有槽位了。

而对于机械臂系统来说,机械臂只记录它最近一次访问的两个(输入/输出)槽位的 ID ,而不需要有容器这个概念。

绝大部分情况,机械臂在需要抓取,或放置物品时,检查对应的槽位就够了。放置物品或抓取物品,只需要对 Count 字段做一次加减法。只有抓取时槽位为空、放置时槽位满,或物品不匹配(同一槽位只能相同物品叠加),才需要看其它关联槽位。这用 NextID 遍历循环链表即可。

关于 Filter ,它表示了槽位的过滤行为。大多数槽位是不需要过滤的,什么都可以放,这时 filter 为 0 ;部分槽位一开始就决定了只能放制定物品,filter 为 1 ,同时从 ItemType 可以查到具体是什么东西。剩下的情况会比较复杂,比如这个槽位可以放置燃料、矿石、等等。这种情况通常是游戏定义好的,有限种类的,我们可以实现对应的过滤器对象,并用这个 filter id 去索引到过滤器对象。

槽位的容量 Capacity 一般可以通过 filter 和 itemtype 查到。它通常就是物品的堆叠上限(配置在物品原型表中),但有时也有一些例外规则:比如组装机的输入箱的堆叠上限是有配方决定的。所以,对于独立系统来说,额外记录一个会性能友好一些,也可以隔绝系统间的复杂度。