« September 2019 | Main | November 2019 »

October 20, 2019

程序员修炼之道第二版开始翻译了

The Pragmatic Programmer 这本书居然在上个月出了第二版,离第一版过去已经 20 年了。

十多年前,在我第一次看到这本书时,就相当的喜欢。后来在中文版再刷的时候,应邀写了一篇书评 。一晃又是十年过去,没想到这次因为这本书找上我的是第二版的翻译工作。

我只花了 10 分钟,把 Amazon 上第二版的页面稍微浏览了一遍,就答应了这项工作。如果只是为了赚点外快,我是肯定不会接翻译书的活的。比起《代码大全》那种大部头,这本书可谓短小精悍。不过 300 来页,但我现在业余时间大多交给了家庭,就算只有 30 万字的量,起码也需要几百小时来做。

但这次,我真的希望借翻译的机会,重新精读一遍。而且看目录,第二版修订了许多内容,可以先睹为快了。这些年拒绝了很多出版社写书的邀约,除了时间不太够外,主要还是觉得自己沉淀不够,很难系统总结好自己的思想。孔子也说述而不作,信而好古。其实前人总结的实在是太好了,我想我的能力可以发挥在此也不错:尽自己的理解,把这本经典翻译好。高质量的译本对中文开发社区的贡献会远超过自己写一本不成熟的书。

我在 github 上开了个私有仓库,和编辑一起,已经开工一周了。初步翻译完了第一章,深感不易。比我想象的难度大很多,完全没有自己读书那样轻松。侯杰老师说过,好的技术书籍的翻译,考验的并不是你的英文能力,中文表达力更重要。自己读英文书时,其实脑子里已经转到了英文的思维模式上,理解就够了,但翻译成中文,就需要转换为中文的表达,很多情况下,不是脑子转个弯就好了。一不小心,就满满的翻译腔。

当然更难的是双关语的翻译,还怕作者掉书袋。

比如第一章有一小节叫 You Have Agency ,这个词有点宗教意味,大概只指神给了人选择的权利,你可以主导你的生活。这一节中里面引用了 Martin Fowler 说的,"You can change your organization or change your organization" 。指你的公司很糟糕时,你可以去 change 它,也可以 change 一个公司干。前后两个短语完全一样,但意思不同。在汉语中找个对应的双关语就很难了。只好加点译注。

还有一节,讲投资自己的知识组合时,作者引用了一句富兰克林的名言:“投资知识,收益最佳。”。但篇头却没头没脑的写了句:我们只要早起早睡,就能做个好程序员吗?我刚读到这里的时候很懵逼。加上前后有些英文短语属于俚语用法,不是很懂其语境,google 了好久。直到我突然醒悟,“早起的鸟儿有虫吃” 也是富兰克林的名言,原来作者在这里是略有嘲讽的意思。

原作者隔几行就玩个梗,让我这个没在西方社会生活过的人,很难接得住他所有的梗啊。只能认真的一点点 google 。


我就不过多剧透了。目前在公司内部小范围给一些同事试读了一下,大家都很喜欢,天天催更。如果顺利,我希望能在 3 个月内完工。

btw. 这次出版社同时拿到了电子版的版权。所以,大家应该能在 kindle 上买到我翻译的电子版了。

October 10, 2019

三国志战略版服务器卡顿问题

我们的新作品 三国志战略版 上线有一小段时间了。市场反应不错,获得了许多玩家。随着玩家数量增加,服务器也产生了严重的卡顿问题,在每天高峰期尤其严重。

这个产品的服务器开发团队在立项之初并没有接触过 skynet ,可以说是从头学习起,在很短的时间内就完成了项目,还是很不错的。我没有参加过这个项目的开发,在问题显露的那几天正巧国庆假期在国外度假,远程参与了一些讨论。帮助分析了问题,等休假完毕后,昨天又开了一整天的会,大致了解了游戏的玩法(需求),结合前两天的思考,给了几套不同的改进方案。

目前遇到的直接问题是,skynet 中有个巨大的服务,管理了整个游戏场景的数据,大约有 20G 。所有的地块、部队、建筑对象都在这个服务中。且注册了大量的 timer 用来更新这些对象。最终导致在游戏繁忙时,该服务会以大约每分钟 500M 的速度生成临时数据。

这给 gc 带来的极大的负担。gc 会造成该服务的卡顿。而其它业务逻辑反而不太占用 cpu 。

通过监控数据的分析,我认为,gc 的原子操作阶段时间过长是罪魁祸首。这个阶段是不可分割的,真正的 stop the world 。而导致这个步骤过长的原因是,该服务大量使用了弱表。当弱表项高达几十万时,清理重置被影响的弱表,就需要很长的时间。

而实现中几乎把所有的对象都关联在了弱表中,仅仅是为了追踪每个类型的对象在内存中的存活情况,方便排查内存泄漏。我认为这是对弱表的滥用。在真的有这类需求时,通过遍历 vm 一样方便查找,不必为了监控而加大 gc 的负担。

去掉这些无谓的弱表后,情况得到了改观。

另外,一个意外的发现是,在 gc 的 sweep 阶段,每个 step 消耗的时间是 mark 阶段的 10 倍。这让我颇为不解。因为 sweep 的工作仅仅是遍历 gcobject 的双向链表而已。每轮 gc 大约有 1/6 的垃圾需要回收。最多的时间消耗在遍历已有对象上。我的解释是,lua gc 的步进单位,mark 阶段是用对象大小估算的,而 sweep 阶段,每个对象则是一个固定值(GCSWEEPCOST)。其实,mark 一个对象和 sweep 扫描一个对象的成本其实相差无几,尤其是在消耗内存很大时,内存 cache 几乎无效,此时,sweep 阶段的一个步骤就真的比 mark 阶段多访问了大约 10 倍的内存。

解决这个问题,我认为应该结合我们的实际情况,将 GCSWEEPCOST 调大,平衡 gc step 的停顿时间。

在这个项目中,采用的是定时主动 gc step 的策略,而不是默认用分配内存器推动 gc 。我认为,在内存使用情况有明显规律的情况下,通过调整默认参数效果更好。目前我们服务器总共用了 40G 内存,而硬件配备了 128G 内存,这显然是浪费。不如把 gc pause 调大,减慢 gc 单轮的周期,让长期 gc 的总开销减少:因为,gc 越激进,不断地遍历 vm 是一种浪费。


不过,我认为根本原因是开发者没有好好的设计服务器的结构,制造出一个数十 G 的单个 vm (实现也有极大的优化空间,不过这个需要有长期的 lua 使用经验,没有简单的银弹去优化)是根本问题。在 skynet 的结构下,我们通常倾向于合理的切割服务,避免出现单个负担过重的服务。

经过对游戏规则的了解,我意识到源头是策划设计时的含糊不清。在我看来,这类游戏本质上是一个回合游戏。它的大部分事件都以分钟为单位,和过去的类似游戏不同,这次策划让部队在行军时真正在棋盘格上移动,部队之间可以在路途上相遇发生遭遇战。这有点像一个拖慢了的 RTS ,但行动依然是按数秒左右为单位的。

如果把游戏简化为回合,那么规则上就应该明确出同一回合在同一地点发生的事件如何决定次序。但现在游戏规则是没有定出次序,靠程序在处理时的天然持续决定。我们在实现中使用了大量的定时器,事实上这些次序无法确定。这给测试,业务分割都造成了麻烦。

如果拿桌游比较,任何桌面游戏在行动回合中,都会规定不同的事件的处理次序,结算规则。而这个只是普通战棋的放大版,却没有统一的结算规则。依靠着程序处理的次序来决定,QA 感觉是对的就是对的,感觉有问题就添加一些例外处理。不同服务间同一时刻 timer 消息的先后不一致,业务种类(行军,对战等)处理复杂度的不同,相关服务的不同时期的负载不同,都会引起相同初始量导致的结果不同。


在了解完现状后,我笑道,现在策划其实给了服务器极大的自由,各种结算次序都不太所谓了。只要看起来正确就行。有什么理由不直接把单一场景拆分成多个服务呢?如果这样做,无非面临三个问题:

第一,部队可能在某个时刻从一个场景格移动到另一个场景上的格子。对于这种跨服务行军,简单的修改成服务间远程调用即可。如果不做额外的工作,的确存在一些一致性问题。

比如,如果一只军队从 A 场景服务的边界移动到 B 场景服务;同时另一只军队从它的目的地移动到 A 。两件事情同时发生时,他们就错过了。而在同一场景下,由于移动都是串行处理的,所以不会错过。

但实际上,现在由于使用了大量不确定次序的定时器,以及将寻路,战斗计算等分离,也存在某些边界情况没有考虑。由于策划在游戏规则上并没有严格的按回合推演,其实有大量的小概率不符合规则的例外都被容忍了(原因可能是计算服务的负载过高,或是同时刻定时器的执行次序不确定等)。换句话说,新出现的实际情况和规则的不一致,严重程度并没有超过原有的情况。

当然,如果肯花心思,上面这个问题是可以解决的,这里就不展开。

潜在的更重要的问题是多场景服务的数据落地。如果涉及军队对象的迁移,就可能发生军队对象同时存在在多个服务的情况。但这个问题是好解决的,只需要给军队对象加一个版本号,发生迁移时递增版本就可以防止多份对象数据同时落地的冲突。(落地服务永远以最新版本号为优先)

第二,某些大建筑会覆盖多个地块格,如果恰巧在边界上处理起来会比较麻烦。

比较简单的方法是:在修建跨边界的大建筑时,先由一个场景去另一个场景索要地块的管理权。能修建筑的都是空地,所以不存在数据的迁移,仅仅是所有权的转移。在所有权转移之后,再把建筑盖下去即可。

第三,同盟关系需要共享。

这个很容易解决,只需要相互同步即可。同步时效性也不是那么重要。

总结一下就是,把场景切分开,分到多个服务中并不是太困难的工作。带来的一致性问题会有,但出现最坏的情况并不比现在的设计下的潜在问题更糟糕。所以它是一个可行的,可以很快实施的方案。


不过,如果让我从头做设计,我肯定不喜欢现在的方案。

首先,我认为这个游戏本质上就是一个电子化的战棋类桌游。应该有严谨的结算规则。

比如群星,它的游戏推演规则其实有两个不同的周期。单位的移动和战斗是按天威单位推演的,发展和资源结算是按月结算的。一个月内的数值变化都不会在中途影响结算,而仅以月末的状态来决定。这就是很好的简化,让核心规则可以更加严谨。

我们这个游戏也一样,如果抛开行军,其它机制的结算周期都是以分钟这个数量级为周期的,可以说是推演的非常缓慢。只要我们规则制定清楚,无非就是给每个地块一个事件发生队列,以分钟这个长周期步进。同一回合内,不同的事件应该有严格的结算次序:屯田,占领,战斗……这样有一个规则明确的结算序列,才好做到同一输入可以得到确定的输出,从软件角度讲,也更利于测试;从玩家角度讲,战略规划也更加清楚。

而且,所有的地块都是相互独立的,非常适合切分,独立运算。由于大家都遵循一致的回合数,同步规则也相当清晰:每个回合必须所有地块都处理完,再进入下个回合。少数建筑会影响多个地块,比如防御箭塔,简单复制到受影响的每个地块即可。跨地块的大建筑也可以简单的生成多个副本,仅仅是在摧毁判定时,再累加伤害即可。

关于军队在地图上的运动,我调查过我们现在最活跃的服务器,同时在行军的部队,也不超过 10K 这个数量级。所以,我们完全可以制作一个单独的行军服务,管理所有在运动中的部队。这个服务可以有秒级的心跳,专门处理军队的运动。它最关键的职责在于触发部队的遭遇,而这类事件,仅需要部队 id 和路径即可。所需内存不多,cpu 的开销也不大。

btw, 去掉军队的移动,让行军服务空转,那么这个游戏就退化成类似战略游戏中部队点对点移动方式。游戏还是可玩的。而加上行军服务处理行军过程,并不会增加已有规则地复杂程度。复杂度被限制在该服务内部。


最后,我认为以现在 CPU 能力,以秒级别的周期处理区区 1 M 数量级的对象,游戏有卡顿的体验,这是不太正常的。我认为背后一定有大量质量不高的代码。一定有很大的优化余地。考虑到这个项目完成的速度颇快,这个现状可以理解。目前还没有时间去 review 代码,那是未来的工作。