« April 2023 | Main | June 2023 »

May 29, 2023

Apple watch 折腾记

云豆想要一块电话手表。我了解到现在的小孩大多用小天才儿童手表,但考虑到云豆其实没有什么社交需求,我自己也有一块 apple watch ,所以想试试给他买一块。最初的想法是,儿童手表过两年估计娃就不愿意带了,而 apple watch (系列)可以用到成年。而似乎 apple 似乎也愿意做儿童市场,现在 apple watch 有家人使用的模式,不妨试试。

周六,网上订购的 apple watch se 到了。我给他创建了一个苹果的儿童账号,很顺利的就设置好了手表。但简单适用了之后就被劝退。apple watch 的家人模式功能上大打折扣,手表其实还是挂在我账号下的,而表上多绑了一个儿童账号。这导致除了苹果官方默认 app 之外,几乎所有的 app 都不能安装。包括微信支付宝这些。除了打电话,能用的通讯就只剩下 iMessage ,对小孩几乎没什么用。我想在手表上装个交通卡也未能成功:因为年龄不够(苹果要求 14 周岁),不能在钱包里添加卡片。苹果官网查了一下,儿童账号的支付功能只在美国才能开。

折腾了一晚上儿童账号后,只能说这玩意想得很美好,极度的不好用,尤其不适合我家的现状。iPAD 换上儿童账号后,我差点自己都不会用了。动不动就锁死 app ,不少国产的 app 的年龄限制做得不好,导致在儿童账号下体验不佳。儿童账号下,不少设置功能的入口我都走不到了,想删个 app 都需要 google 慢慢查。云豆每天在多邻国上打卡英语,一不小心就超过了屏幕使用额度被锁了 :(

最后,我还是决定给他注册一个完整的成人账号(虚填年龄)。btw, 儿童账号加入家庭后是不允许退出的。必须删掉账号,而删除儿童账号的流程非常长,至少要 7 天,步骤很多。所以创建苹果儿童账号要谨慎,小心超过每个家庭 6 个成员的额度。

之前了解过,apple watch 所使用的 esim 卡在国内三大运营商都没完整支持。移动和电信只支持一卡多机。也就是你申请 esim 必须同时办张实体卡插在手机上。联通支持独立 esim 卡,其实就是为 apple watch 这种家人模式用的。如果使用独立账号的话,其实还是得用回一卡多机的模式。

我尝试用我疫情中专门刷健康码的备用手机和新的 apple watch 配对。结果那台 iphone se 1 已经被苹果淘汰了,升级不了 iOS 16 ,也就不会使用新版的 apple watch 了。

周日早上,我决定买一台新的 iphone 13 。家附近的苹果店 10 点开门,店里买机器的体验还是不错,5 分钟搞定。原以为后面也会很顺利,结果在配置 esim 卡上耗费了一天的精力,跑齐了三大运营商的营业厅,弄得我筋疲力尽。


我一开始想用云豆自己的身份证开手机号。想着手机是实名认证的,如果用我的身份证开的话,以后还要过户比较麻烦。而现在几乎所有的网络服务都是绑定手机号的,能不换号还是别换了。

网上查询的说法是:一般不能以未成年人身份办理手机号,但是同时登记未成年人和监护人的身份证以及户口本(证明监护人身份)还是可以办理的。但在运营商官网没有找到明确的说明。

我带云豆去了附近的移动营业厅,等了大约半小时轮到了窗口。移动的业务员说小孩不可以办,我坚持说我查到的规定是可以,并出具了户口本。业务员想了下,说可以用户口本办理,但是叮嘱我说,等小孩成年后还是要来营业厅变更,从户口本变更为身份证。

接下来我表示需要办理 esim 卡业务。她倒是业务熟练,立刻告诉我(广东)移动现在已经没有相关资源了,申请不了 esim 号。并指了指她自己带的手表,说她自己都只能用蓝牙连手机。既然这样,就没必要在移动这里浪费时间了。想来其它两家也不会允许以儿童身份开户的,我就把云豆送回家,继续跑后面的业务。

因为隔壁有一家联通营业厅,我顺道进去问了一下是否可以开 esim 卡,得到了明确的答复。不过我自己已经有电信的账户(而没有联通的)所以就先退了出来。去电信营业厅之前,先打了 10000 咨询了 esim 业务情况。10000 的人工客户明确告诉我,去营业厅柜台就能办理。

在地图上找到最近的电信服务厅,就在我住的小区里。找过去后发现挂了张“稍后回来”的牌子,屋里没人值班。打墙上的电话问了一下,这个小服务厅只办宽带业务,指点我去一家大的营业厅。

到了之后,发现有六个柜台,取的号只差两人,感觉很快就能办好。结果等了两个半小时。电信的这个效率真的是令人发指。我看着前面一个号坐下去,整整折腾了两小时才搞完,不知道在说些什么。而其它柜台坐着人也没有结束的意思。不知道为啥大家都显得很悠闲,营业厅里大把的人蹭着免费充电设施和无线网络在那玩王者荣耀,一点没有急迫的气氛。我都不好意思着急。我前面是个空号,叫过了两遍没人上去,原以为马上就轮到我了,结果柜台里的人开始自己操作电脑起来,等了半个多小时都不叫下个号。

我后面的那位有点不耐烦了,跑上去问能不能办他的,结果被告知,这里(空座位)有客户在……

终于轮到我了。当我提出 esim 卡业务时,业务员告诉我,esim 是不能线下办理的,必须自己在网上营业厅 app 上办。我说我刚打了 10000 让我来营业厅,她说 10000 号搞错了。我说好吧,但我不想白排两个多小时的队。我家宽带是用的电信的,当时还送了我几张 sim 卡没怎么用。不知道为什么最近两年费用越来越高了。在排队时我就自己查了历史账单,发现不知道什么时候被绑了个七彩铃音的业务。我让她帮我把那些没用的业务都取消掉。

柜台要走了我的身份证和装有电信 sim 卡的手机后,开始了神奇的操作:她开始拿我的电信手机打 10000 ,稍做等待后,非常娴熟的和 10000 号客户沟通,帮我取消了彩铃,期间还读了我的身份证号码。接下来,我从柜台退了下来,在旁边自己下载电信营业厅 app 自己寻找怎么申请 esim 卡。

折腾了十分钟后,我发现 app 里那个 esim 业务上需要输入设备厂商,其中列举了华为 oppo 等一大堆国产厂商名录,就是没有 apple 的踪影。给柜台看过后,她表示无能为力,说估计 apple watch 是用不了电信的 esim 服务了。

我不太甘心,又自行 google 了一番,发现 apple watch 申请电信 esim 的入口在苹果自己的 watch app 内,点击那个设置移动蜂窝网络,会引导到(电信)服务商的页面,然后就可以执行申请了。但是申请结果是一堆错误码和有限的提示文字。这次我没有问柜台里的人,估计她也不懂。但我自己判断就是和移动的情况一样:esim 号资源用完了,无法申请。


我最后又回到联通营业厅。这里倒是不用排队。四个柜台一个客户都没有。服务相当热情,只是一上来就给我推荐 100 元以上的套餐。我坚持说我只需要最基本的服务,网上说有 10 元套餐加上 esim 卡的 10 元费用就够了。柜台里表示很为难,说最便宜的套餐也要 40 块了。这时我已经精疲力竭,只想早点办完。接受了对方的开价,最后一堆我不太明白的优惠叠加后,预充值了 300 块,换来了 30 块每月的套餐价格。同时我顺口问了联通关于未成年人开户的政策,回答是需要满 16 周岁才可以本人实名开户;我问为什么移动可以用户口本给儿童开户,答曰那也需要 12 周岁(不知道是不是胡诌的),反正云豆的情况肯定是不可以。

联通的人业务倒是很娴熟,给我看了一个不知道哪个网站上(不是联通官网)介绍的 esim 卡的申请方法,也是需要本人自己操作。有了前面的经验,其实我自己也轻车熟路了。接下来的操作也是很奇幻:开户过程,我没有和联通签正式的书面文件,办理过程都是在业务员的手机上完成的(虽然他面前柜台上摆着电脑和刷卡机)。最后预付款居然是打去他个人支付宝户头。他强调说你可以自行在联通网站查询到账情况。最后还指导我(在某个莫名其妙的网站)领了一年的腾讯视频 vip 会员的礼品。

最后,不管怎样,还是把 apple watch 折腾好了。真的是不推荐用作儿童手表。不过,云豆还是很喜欢的。


关于三大运营商,我的评价:

  1. 相比而言,最规范的是移动。
  2. 无论是内部软件平台,还是服务人员,电信都真的是太滥了(营业厅装修的最气派)。
  3. 联通服务最好,资源最充沛(估计是因为用户最少),但路子太野。

另:我们国家关于儿童权益这方面做的远远不够。整个社会的规则都是给成年人设计的。明面上的保护未成年人的规则简单粗暴,几乎没有可行性(等价于不准儿童以他自己的身份参与,这样只能劝退用户)。像苹果这样的跨国公司企图仔细为儿童考虑设计软硬件(同时遵守法律)反而会水土不服。

May 15, 2023

带娃玩桌游的第二篇

今天有同学在老帖子 上留言,看到下面杨博去年问这方面有什么进展,想想应该补上这么一篇。

最近几年,因为疫情的缘故,在家的时间比较长。孩子也长大了不少,能开的桌游多了许多。我现在几乎很少在外面和朋友玩桌游了,而在家几乎每周都会开三到四次。

云豆马上九岁了,可可也已经六岁半。兄妹俩的喜好已经明显有区别,我们能一起玩的桌游不多,一般我都是分开带哥哥玩,或单独带妹妹玩。不过有一款游戏大家一起玩的很多,那就是 Splendor (宝石商人)。

这款游戏发行商标注的是 10 岁+ ,在 bgg 上用户投票以 8 岁+ 居多,我感觉也是。7,8 岁的年龄完全可以充分理解游戏规则,制定整局规划。可可不到 6 岁多点也能玩,但是年纪比较小,仅能理解规则,无法深度思考怎样赢得游戏。好在她的胜负心很弱,每次输掉都能自我开解:玩个游戏嘛,输赢无所谓的。

在家开四人局时,可可就是陪玩。她每次都只看当下的目标。因为游戏是靠拿 15 分胜利,她就几乎不拿无分的初级卡,每次都攒够资源直取二级卡。就这样,有一盘居然运气好到离谱获得过一次最终胜利,被她津津乐道了很久。

云豆则非常想赢,而她妈妈从来不让步。我们家经常开三人局,云豆拿第二次数最多。每次输掉后,我就找机会帮他复盘。引导他大致记住卡牌的分布,教一点点概率知识,教他制定长远一点的计划。这两年我们估计开了数十盘,他都快输的没信心了,但最近一局扎扎实实的得了一次第一。终于完成了打败妈妈的目标。

我认为这个游戏颇有技巧,又容易接受。是近几年给小孩(或新人)普及桌游的首选。游戏的扩展包,可以丰富游戏的玩法,但我们开的不多。原版比较简单也有挺高的天花板,玩起来更纯粹。

去年底,Splendor 出了两人对决版 (Splendor Dual)也是非常的不错。云豆和他妈妈玩过十多盘,胜率可以五五开。


最近我们还开了几盘 Ticket to Ride : Europe (车票之旅)。豆比较在乎输赢,输了差点掀桌,赢了也异常开心。可可也能玩,她不太所谓完不成线路被扣分,只要连完一两条线路就很开心。因为她加法还算不清楚,所以最后需要别人帮她算总分。不过这个游戏单局时间很长,可可得到最后会有点坚持不下去。

可可比较喜欢简单一点的游戏。她最喜欢 Chromino (骨米洛)。有段时间我几乎每天都和她开一局。这个游戏初看纯粹是碰运气,细玩下还是有一些技巧的。可可似乎掌握了一些技巧,玩起来越来越熟练,经常能赢得游戏。

另一个开的很多是是 Set ,中文翻译作 神奇形色牌 。我觉得对训练大脑的模式匹配能力非常有帮助。我玩这个对小孩是碾压的,所以一般我以主持人身份带两个孩子玩,观察他们为主。可可有段时间一个人天天和我一起玩,我能感受到她能力成长飞快。她在状态好的那几天可以胜过哥哥。不过云豆不服气也练习了几天后,毕竟年龄优势还是迅速占优了。

我个人认为,我们平时需要解决的大多数问题都是在大脑中不断的做模式匹配。Set 这个游戏就是多很多问题的简化和抽象。没事多练练还是很不错的。

除此之外,可可就不大愿意玩其它桌游了。她更愿意自己拼乐高,过家家,画画。


有时候,我们会把一些过去开过的游戏重新翻出来再玩几天。可能是云豆长大了一点,重玩小时候的游戏就会有一些新的发现,自己能找到一些策略。

前段时间我们玩 Azul 就很明显,他明显找到了一些得分的诀窍。除了自己拿高分,陷害对手也需要仔细计算,云豆能掌握一些,但不多。如果我真心想让他扣分的话,他还是不太算得过来。

Azul: Summer Pavilion (夏日行宫)的对抗性要弱一些,我觉得更适合陪小孩子玩(如果不想偷偷让棋的话)。云豆可以更专注达成自己的目标,不用太担心对手陷害。

Century: Spice Road (香料之路 )一共有三个版本,从复杂度来说,后面的版本逐步加强。如果是我自己和朋友开的话,我觉得第三作最好玩。但是对小孩有点难。第一作简单易上手,规则和 Splendor 类似。小孩理解玩法非常快,如果不考虑对抗的话,讲一遍基本就上手了。

在我家,香料之路比宝石商人先开。不过长期玩下来,宝石商人的接受程度更大一些。但最近一段时间,可能是因为云豆在宝石商人上输的太多,所以想换个口味,我们又开了几局香料。我给他讲解了卡牌组合的效率问题,他有点似懂非懂。不过几局下来进步还是很明显的。只不过还是偏重自己目标的达成,很难同时关注对手。

关于对抗性的游戏,我比较推荐 Lost city (失落之城)和 Battle Line (古战阵)。尤其是后者,必须考虑对手的布阵。Battle Line 标准的两人模式有战略卡,加进来比较复杂,我们玩的比较多的是三人变体,只使用数字卡。我在说明书上没见过这种变体的介绍,这里可以简单写一下:

把 9 面旗帜放成 Y 字形,三人围坐,每个人都会面对 6 面旗帜,分别是 3-3 针对两个对手。不使用战略卡,而只使用数字卡。轮流行动,只在自己回合才可以宣布夺旗。首先赢得连续三面旗帜或不连续四面旗帜的人获胜。

借这个游戏,我给云豆讲了田忌赛马的故事。他听得饶有趣味,我连续好多天睡前给他讲了许多成语,从春秋战国到秦汉三国。算是历史启蒙了。

不管是 Lost City 还是 Battle Line ,运气成分都比较大。这对小孩来说不是坏事。因为他总能凭运气赢几盘,毕竟胜利总是让人开心的。不像下围棋,挫败感就太强了。

和 Lost City 类似的有 Arboretum 树木园,可供最多 4 人游玩。计分更复杂一些,我自己玩的时候特别容易陷入既要又要,结果总共拿不到几分的陷阱。反而小孩子想法比较简单,冲着一种树木拼命攒分而拿到第一。


云豆特别喜欢的另一个游戏是 Cartographers 王国制图师。自己画地图非常开心。一开始玩这个游戏时,是我帮他讲解计分卡的规则。后来我慢慢让他自己读卡片上的规则文字。他的阅读能力很差,平时考试和做作业经常理解不了题目而出错。我觉得自己理解桌游卡片或许是个不错的训练。

需要注意的是:如果想买王国制图师的完整扩展的话,一定要买那个大盒装。我就是先买了原版,然后单独买了英雄扩展。结果发现有三个地图小扩展只包含在大盒里不单卖的。若是只想体验一下的话,原版也够玩了。

对于这种需要每局胜利条件可变的游戏,我们还开过几局 Cascadia (卡斯卡迪亚之旅)。一开始也是由我来讲解每局抽到的胜利条件,后来我让云豆自己读规则,玩过几盘后,就那几个胜利条件都记熟了。这种抽卡拼图游戏还是挺有趣的,和王国制图师有点异曲同工。

在 Cascadia 之前,试过 Calico 猫毯。两个小孩都一起玩过。但完整规则对可可太难理解了,只能开简化的家庭版。如果两款游戏放一起,我选择 Cascadia ,要更有趣一些。


最后谈谈合作游戏。

之前 The Mind 算是玩熟了。云豆莫名其妙的对这个游戏特别热衷,隔几天就要和我开一局。因为玩得太多,都成套路了。大家在心里默默数数,调整几次节奏就对齐了。我们已经默契到可以一次都不失误就通过第一阶段,第二阶段偶尔也能完成。即,每次出牌都背过来打,等桌上放满一顺溜卡片再一张张翻开,颇有成就感。

另一个值得推荐的是 The Crew: Mission Deep Sea 潜航员。我们是从前作 The Crew: The Quest for Planet Nine 宇航员开始玩的。如果现在才玩的话,完全可以跳过前作。

这个有点像打桥牌,但是没有对抗,只有协作。云豆玩了几盘后才慢慢理解该怎么避免失误。可可则完全玩不了,所以我们一般开三人局。我们一次都没有通关,因为后期的任务需要很高的打牌技巧,对他难度还是太高了。但这不妨碍我们隔断时间就从头再开始一盘。

因为妈妈陪娃的时间比较少,主要是我带云豆玩。所以我们开两人变体也比较多。这个运气成分更大,但并没有消减太多乐趣。

还有一款 Forbidden Island 也很有趣。有点瘟疫危机的感觉。难度比较低,可可也能玩。这个游戏相比前面几款玩有个优势,当娃不会玩的时候你可以指挥他。可可玩的时候就没太多主见,不过参与进去就很开心。

云豆更喜欢另一个科幻题材的 Star Realms 星域奇航。准确说这并不是一款协作游戏,原版更注重两人对战。扩展版中有一个合作完成任务的模式,云豆要更喜欢一些,他更愿意和我一起去打败异形。

May 01, 2023

记一次艰难的 debug 历程

五一前,我遇到了一个非常难缠的 bug ,前后花了两天时间才把它解决。其刁钻程度,可以列入我职业生涯的前三,非常值得记录一下。

问题发现在节前两天,很多同事都请了假,我也打算好好休息一下,陪孩子玩几天。就在我例行更新游戏项目的仓库后,突然发现程序崩溃了。一开始,我并不以为意。因为我们的游戏是用 Lua 为主开发的,并不需要在更新后重新编译。我大约一周才会构建一次项目。或许这只是因为我太久没有 build 了,所以我随手构建了一下。但问题依然存在,只不过发生的概率偏低,大约启动三次有一次会出问题。这不寻常,因为我已经很久没见过 Segmentation fault 了。

我们的游戏引擎是专门为手机设计的。为了开发时可以实时关注在手机上的效果,我们不像别的游戏引擎那样,开发时在开发机上跑一个 Windows 或 Mac 版本,只在需要的时候输出一个手机版。我们用 C/C++/Obj-C 编写了引擎的内核,编译了一个 iOS 上的 App ,在引擎中内置了一个虚拟文件系统。开发时,在开发机上运行一个叫做 fileserver 的进程,把开发机本地的一个目录映射到虚拟文件系统上。这样,手机上的引擎内核在运行时,就会直接读取开发机的目录,我们在开发机上所做的任何修改,就能即刻反应到手机的 App 上。

手机上运行的引擎内核,是项目组统一定期编译发布的,一般我并不自行编译。这次崩溃的是我本地的这个 fileserver 进程。它其实并不运行任何游戏业务逻辑,只是以一个简单的协议向引擎内核提供 Lua 代码和资源而已。虽然它还会做一些更复杂的工作:例如运行时调试手机上的 Lua 程序、按需编译本地的 shader ,贴图、模型、动画等资源,但这次崩溃发生在启动阶段,似乎还未有涉及这些。

奇怪的是,整个项目组只有我一人的开发机出现了问题。调试工作看起来必须我来做了。

fileserver 本身也是 Lua 编写的,只依赖了少量的 C 模块。一开始我觉得几分钟就能查出来,但开始 debug 一个小时后,我意识到问题没那么简单。

首先,我日常运行的是去掉调试信息的 release 版本。因为这个程序已经稳定运行很久了,没想过需要在 C 级别调试它。当我重新编译一个带调试信息的 debug 版本后,几乎就不崩溃了。(从 1/3 的出现概率降低到了 1/20 以下)

我检查了距离上一次稳定运行的版本,最后引发 bug 的提交只是简单的修改了几行看起来完全无害的 Lua 代码。当我试图在相关 Lua 文件中插入几行 log 后,bug 消失了。甚至我只要插入一行完全无副作用的 lua 代码,例如 local x = 1 ,bug 也会消失不见。我迅速就放弃了这种调试方法,开始思考背后的原因。

为什么 bug 只在我的机器上出现,而项目组那么多开发机都没遇到?一个比较明显的区别是,我开发用的 mingw64 ,而其他人使用的是 vc 构建项目。但随后,另一个同事尝试在他开发机上用 mingw 构建,还是未能重现。他怀疑我的 windows 开发环境有所差异。正好我的 windows 提示要升级,我便重新重启系统,顺便冷静了一下。

问题依旧。我排除了开发环境有问题的念头,毕竟之前几个月都是正常的,且我用 git 回退了几个 commits 反复比对确定问题就是最近发生的。即使新更新的 Lua 代码再无害,它也确实改变了程序的行为,偶发的 Segmentation fault 是事实,一定是在某种条件下,C 代码访问了无效的内存,这一定有原因,我不能视而不见。至于增加一行无害的 Lua 代码就能掩盖问题,我只能解释为不同长度的 Lua 代码,在 parser 阶段行为有微妙的不同,这种不同会改变 parser 阶段 lua gc 的时机。

我们的引擎基于 ltask 这个多线程库。它十分类似 skynet ,但更简单一点,同时我针对客户端做了一些不同的优化。我们的程序是多个 Lua 虚拟机并行运行的,这是导致这个 bug 概率出现的原因。我怀疑最终崩溃和 lua gc 有关,所以尝试在启动时关闭了几个虚拟机的 gc ,果然就不崩溃了。

因为最近更新了 lua 5.4.5 rc2 ,所以首先应该排除 Lua 升级的影响。回退 Lua 版本后,问题依旧。而且我认为如果是 Lua 本身的问题,崩溃应该更频繁一些。这明显和多线程相关,和 Lua 无关。

是否有可能是 ltask 隐藏已久的问题被触发了呢?我觉得有可能,毕竟它是一个多线程框架,并发代码很容易滋生 bug 。但另一方面,我又觉得问题出在 ltask 里面的可能性很小。因为它已经两年没做过大的修改了,一直在稳定运行。这次崩溃的 fileserver 是一个业务非常单一的程序。我们的游戏引擎同样运行在 ltask 之上,本身要复杂得多,却毫无问题。


或许,利用 git bisect 确定引发 bug 的 commit 可能会有更多线索。

前面提到,有一个 commit A 在我的机器上直接引发了这个 bug 。但这个 commit 看起来完全无害,它甚至不会在 fileserver 启动阶段运行(属于渲染相关代码)。所以 bug 一定在此之前。我先简化了 commit A 中的大部分修改,只保留了几行 Lua 变更确定能引起问题,重新生成了一个 patch 。然后开始向一周前做 bisect 。每次 bisect 后,就重新 cherry-pick 回新做的 patch 做验证。就这样,定位到三天前的另一次提交 B 引发了问题。

这个 commit B 是同事优化了虚拟文件系统的批量文件读取协议。感觉这次有点接近了,因为它会影响 fileserver 的工作。值得一提的是, 我们的引擎内核的 bootstrap 过程相当复杂。这是因为虚拟文件系统 vfs 本身也是 lua 编写的 ltask 服务,lua 代码却保存在 vfs 中。这就有一个先有鸡还是先有蛋的问题。bootstrap 本身也是 lua 编写的,它还需要有能力自己更新自己。

bootstrap 一开始并不运行在 ltask 中,它需要自举得到一个简易的 vfs 模块,然后通过这个 vfs 模块和 fileserver 通讯,获取最新版本的 bootstrap 代码,如果发现不同,则把自身更新后以新版本再运行一次。然后,ltask 框架被加载进来,并替换掉 bootstrap 用的那个简易 vfs 模块,正式更替为 ltask 自身的一个服务,供后续代码运行时使用。

由此可见,bootstrap 可能被反复执行,且它所属的虚拟机在线程间不断迁移。任何对 vfs 模块的修改势必对整个自举过程有所影响。

仔细检查过 commit B 后,我依然认为它是无害的。只是增加了若干行 Lua 代码。但即使不运行这些新增加的代码,仅仅只是增加代码本身,就足以引起 bug 了。我又裁剪了 commit B 和之前 commit A 一起合并了一个足以引发 bug 的新 patch ,继续用 git bisect 向前回滚。每次回退后都把 patch 打上测试。

同时,我改写了 fileserver 的启动代码,去掉尽量无关的业务,只可能保留引起 bug 的模块。做过一番尝试后,甚至增加了 bug 出现的概率(大约 1/2 的几率出现),如果不出现问题,也可以迅速正常退出,这样可以方便我自动化测试。

当我回滚到 2 月的引擎版本时,我意识到这是一个效率很低的 debug 方案(即使我已经用脚本把测试过程自动化了)。而且难以为继。我已经累积了好几次可能引起 bug 的修改,但它们都是普通的 Lua 代码,表面上完全无害。但缺少任一修改, bug 都会消失不见。真正的原因还是不见踪迹。而我这样不断退回去,总会到一个版本无法正常运行(因为要打太多后来的修改 patch 了)。

这个过程虽然没有找到问题,但至少我确认了几点:

  1. bug 已经潜藏很久了,只是这几个月一系列的(无关)修改才暴露出来。
  2. 直接引起崩溃的是 Lua 虚拟机所管理的内存被意外修改了,导致 lua 代码的崩溃。多发于 lua gc (因为 gc 会遍历虚拟机的数据),少数情况崩溃在 lua 的其它运行过程。
  3. 这是一个并发相关的 bug 。

最后一点是我尝试修改 ltask 的配置中发现的。默认我会为 fileserver 开启 8 个工作线程,而减少工作线程数量后,bug 就消失不见了。这可能也是同事机器上无法重现的原因。我的开发机没有安装云壳,而同事的开发机都运行有一个叫作云壳的后台监控程序。它经常占满一个 cpu 核心,这可能导致实际运行 fileserver 时并发量不足。

另外,我发现了 ltask 最近一次小修改也会干扰 bug 。上个约,同事建议我在 windows 上用 SRWLOCK 代替 CRITICAL SECTION。这固然是一个无关紧要的小修改。但当我回退回 CRITICAL SECTION 版本后, bug 消失了。这佐证了 bug 是并发引起的。也能说明为何我在 C 代码中添加几行 log 也可能让 bug 消失不见,更别说我开启 gdb 调试时就碰不上它了。


五一假期前的最后一天,我陷入了困境。只要稍微修改一下 Lua 代码,崩溃就可能消失不见。但确确实实,几行分散在多个文件,多个虚拟机内的 Lua 代码就有概率引起 Segmentation fault 。用 gdb 调试时 bug 躲了起来,在 C 中增加 log ,bug 出现的概率会降低。随便加几行 lua 代码看起来就相安无事了,但我知道 bug 就在那里,无法视而不见。

假期第一天,我起了个早床,七点就到了公司。头天陪孩子玩到很晚,他们应该到中午才会起来。我想我有一上午的时间解开心结。

走路去的办公室,路程有半个小时。在珠江边漫步,我整理了思路。通过 git bisect 找到出错的地方效率太低了。调试看起来也很困难。看起来必须依靠读代码这条路了,这也是我最擅长的 debug 方法。但太多代码是同事写的,我的大脑中还没有建立起相关模型,且相关的人都放假了,没有人问,这是最大的障碍。

本来,查找 Segmentation fault 最直接的工具是 Address Sanitizer 。但 mingw 还不支持,用 VC 编译虽然支持,但 VC 编译的版本却不出错。我有一上午的时间,估算了一下,够做一个简易版的 Address Sanitizer 辅助调试。

我们的项目是以 lua 为主框架,C/C++ 只实现了必要的模块。这可以简化内存越界问题的检查手段。我可以假定 lua 以及 ltask 都和这次的 bug 无关。那么问题只会出在 fileserver 引导阶段加载的 C 模块里,而导入 lua 的 C 模块都有清晰的边界。

Lua 管理的内存和 C/C++ 管理的内存也是清晰分开的。我自己实现的 C 模块有完善的内存 hook ,略麻烦的是同事用 C++ 写的几个模块,比较难准确 hook 内存管理函数。

如果我能 hook C/C++ 模块的内存管理,把 CRT 中的内存分配从堆上改为每块直接申请虚拟地址(两头再留下无效页),并在不用后注销,这样就能更快的定位问题了。至少,我更有可能在 gdb 中观察到 bug 。Address Sanitizer 就是用类似的方法做到的,自己针对具体情况做一个并不麻烦。

查了一下 windows 下似乎有一个叫 _CrtSetAllocHooK 的 api ,之前没用过;在 mingw 以及 windows SDK 的 .h 文件中还发现了一个叫 _setheaphook(_HEAPHOOK _NewHook) 的东西,但是 google 了一下,居然完全没有文档说明。

我觉得一上午的时间不太够搞清楚 windows 上这些 CRT 内存 hook 的用法,而我并不想整个五一假期耗在里面,被孩子抱怨。还是换个工作量更少的方法。

既然,Lua 管理的内存和 C/C++ 管理的内存是有清晰边界的,而 C/C++ 这边的内存管理比较难 hook ,不妨从 Lua 下手。Lua VM 允许我们自定义内存管理函数。我用 Windows 的 HeapAlloc 重新定义了一个 lua Alloc ,让每个虚拟机都使用独立的 heap ,并和 C/C++ 模块分开。果然,当我把 Lua VM 申请的内存剥离出去后,崩溃就消失了。

这也佐证了 bug 和 Lua 解释器本身的代码无关。一定是 C/C++ 模块写坏了 Lua VM 所使用的内存。

这时,我突然有了一个有趣的点子:我可以在自定义的 lua Alloc 中正常用 malloc 申请内存,这就能保持和之前的流程完全一致。但这次,我不使用申请到的内存,而是另外从额外的 heap 中再申请一块两倍大的。其中一半留给程序使用,另一半对 malloc 申请的内存做一个快照。

如果程序一切正常,那么 malloc 申请的内存将一直和我额外做的快照完全一致,而若有 bug 改动它的话,我也能准确发现。换句话说,那块 malloc 申请的内存是我提供给 bug 的蜜罐。

实现这个想法并不难,只花了一个多小时。然后,我在 gdb 中捕获了 bug 。捕获点在程序退出时,我的校对代码检查到了对蜜罐不正常的修改。有一小块内存中间的一个字节被加了 1 。

因为相关导入 Lua 的 C++ 模块都不是我实现的,所以一下子我无法联想到会是哪行代码的行为。我在钉钉上给同事留了言,描述了调试思路和我的新发现。倒不是期望他能想起点什么,只是做一次小黄鸭调试法吧。

第二步,我想我应该再接再厉,让 bug 更容易暴露出来,并伺机缩小可能存在 bug 的 C++ 模块的范围。我做了两件事情:

  1. 完善了 lua Alloc ,把所有的内存块用双向链表串了起来。但我们有多个虚拟机是并发运行的,需要维护多个链表。这样,我就可以在 ltask 的调度器中检查所有的蜜罐。
  2. 修改了同事写的三个相关 C++ 模块的 lua binding 。用宏给所有导入 Lua 的 C 函数加了个壳。这个壳可以 log 下每次 Lua 到 C 的函数调用情况。

在第一件工作中,我顺便加了并发检查,如果同一个 Lua 虚拟机的 lua Alloc 函数被并发执行了,就可以被感知到,这可以说明 ltask 中出现了并发相关的 bug 。和预想的一致,这个并发检查没有被触发。

第二件工作涉及三个 C++ 模块,分别是对 os 文件系统函数的封装(为了实现 vfs )、对 socket api 的封装、绕过 ltask 做线程间通讯的 api (用于 bootstrap 流程,因为有部分时机早于 ltask)。

我在 ltask 调度器中启用了蜜罐检查,果然提前发现了问题(那个神秘的特定字节加一又出现了)。与之同时,有几个 C++ api 调用在附近发生,它们的嫌疑最大。

我仔细阅读了相关代码。其中之一是 socket select 的实现,实现的略有问题,但没有 bug 。这个等节后再和同事讨论。另一个是线程间通讯的函数,第一眼差点就放过去了,但过了半小时再看却发现了端倪。“如果排除了一切不可能的情况,剩下的,不管多难以置信 ……” 。

流程大约是这样的:当一个 lua vm 想绕过 ltask 给另外一个线程上的 vm 通讯时,同事写了这么一个东西:把需要发送的消息序列化,用 malloc 打包成一个数据块,然后把地址放在一个 C 结构中。随后等在了一个 std::binary_semaphore 上。接受方收取数据块指针后通过这个信号量把发送方唤醒。发送方就可以继续做后续的处理。

这是一个开源的模块,所以我可以展示一下代码。

这是 8 个月以前的一次修改,初看没有问题。但我有幸在 skynet 开发中做过类似的事情,所以我意识到 bug 出在这里。这个案例也可以很好的解释为什么 pthread_cond_wait() 需要传一个 mutex 进去。

传递消息的 C 结构放在发送方的 lua vm 的一个 userdata 中,它没有 gc 方法,但依赖 lua 的 gc 回收 userdata 的内存。当发送方收到接收方触发的信号后,逻辑上讲接收方已经用完了这个 C 结构,但实现上并没有。所以发送方如果回收内存够快的话,有可能会先于接收方对这块内存最后的修改。

lua 5.4 增加了分代 gc 后,这种临时申请的 userdata 可能在出了作用域立刻被回收,机缘巧合的话,会比接收方快那么几个指令周期。这样,当接收方最后对信号量的一番改写前,可能在另外一个虚拟机上内存又被分配了出去。

我们观察到的特定字节加一,应该就是信号量的 release 操作(猜测,尚未确认,等节后同事自己推敲修改)。

这也是为何这个 bug 非常容易被 lua gc 干扰,以及只在高并发条件下被触发的缘故了。


终于可以安心陪孩子过节了。

晚上,发出的钉钉消息已读,同事给了个赞 :)