并发问题 bug 小记
今天解决了一个遗留多时的 bug , 想明白以前出现过的许多诡异的问题的本质原因。心情非常好。
简单来说,今天的一个新需求导致了我重读过去的代码,发现了一个设计缺陷。这个设计缺陷会在特定条件下给系统的一个模块带来极大的压力。不经意间给它做了一次高强度的压力测试。通过这个压力测试,我完善了之前并发代码中写的不太合理的部分。
一两句话说不清楚,所以写篇 blog 记录一下。
最开始是在上周,阿楠同学发现:在用机器人对我们的服务器做压力测试时的一个异常状况:机器人都在线的时候,CPU 占用率不算特别高。但是一旦所以机器人都被关闭,系统空跑时,CPU 占用率反而飚升上去。但是,经过我们一整天的调试分析,却没有发现有任何线程死锁的情况。换句话说,不太像 bug 导致的。
但是,一旦出现这种情况,新的玩家反而登陆不进去。所以这个问题是不可以放任不管的。
后来,我们发现,只要把 skynet 的工作线程数降低到 CPU 的总数左右,问题就似乎被解决了。不会有 CPU 飚升的情况,后续用户也可以登陆系统。
虽然问题得到了解决,但我一直没想明白原因何在,心里一直有点不爽。
待到开发过了一段落,我又想回头来分析这个问题。发现,在 CPU 占用率很高的时候,大量 CPU 都消耗在对全局消息队列进出的操作上了。看起来是工作线程空转造成的。这个时候,如果设置了上百个工作线程,消息队列中有没有太多消息的话,处理消息队列的 spin lock 的碰撞就恶化了。那些真正有工作的线程有很大几率拿不到锁。
我的全局消息队列处理的业务很简单,就是存放着系统所有服务的二级消息队列。每个工作线程都是平等的,从中取得一个二级消息队列,处理完其中的一个消息,然后将其压回去。
这时我发现,其实,这个全局队列完全可以实现成无锁的结构,这样就不会再有锁碰撞的问题了。
原来的锁最重要的用途是在全局消息队列容量不够时,保护重新分配内存的过程不被干扰。但实际上,全局消息队列的预容量大于系统中服务体的数量的话,是永远够用的。我设置了单台机器支撑的服务体数量上限为 64K 个,那么消息队列预分配 64K 个单元,就无需动态调整。
昨天我着手实现无锁版的循环队列,原本以为也就是几行代码的事情,但事实上隐藏的坑挺深。
当线程特别多时,任何一个线程都可能暂时被饿死。那么,即使我们原子的移动了循环队列的指针,也无法保证立刻就从队列头部弹出数据。这时,纵然队列容量大到了 64K ,而队列中的数据只有几个,也无法完全避免队列的回绕,头尾指针碰撞。
为了保证在进队列操作的时序。我们在原子递增队列尾指针后,还需要额外一个标记位指示数据已经被完整写入队列,这样才能让出队列线程取得正确的数据。
一开始,我让读队列线程忙等写队列线程完成写入标记。我原本觉得写队列线程递增队列尾指针和写入完成标记间只有一两条机器指令,所以忙等是完全没有问题的。但是我错了,再极端情况下(队列中数据很少,并发线程非常多),也会造成问题。
后来的解决方法是,修改了出队列 api 的语义。原来的语义是,当队列为空的时候,返回一个 NULL ,否则就一定从队列头部取出一个数据来。修改后的语义是:返回 NULL 表示取队列失败,这个失败可能是因为队列空,也可能是遇到了竞争。
我们这个队列仅在一处使用,在使用环境上看,修改这个 api 语义是完全成立的,修改后完全解决了我前面的问题,并极大的简化了并发队列处理的代码。
有兴趣的同学可以看看代码 。最前面 30 行操作 globalmq 的代码既是。
由于简化了代码,使得我上周的问题更清晰的展现出来。但问题的解决并非这么直接。
今天, mike 同学要求在底层增加一个接口,可以把一个消息同时发给许多目标。虽然我之前实现了组播服务,但 make 同学不希望额外维护一个组对象,而是希望每次主动提交一个目标地址列表。
我开始为组播服务增加新接口。
这时,我发现原来的代码写的有些过于复杂了。这个复杂性来至于优化。
在 skynet 系统中,大都不直接引用服务对象,而是记录一个数字 handle 。等到要发送消息时,再从 handle 转为一个 C 对象。这个查询转换有一定的代价(hash 查询),但可以保证 C 对象的生命期管理容易实现。
事实上,这个简化方案,在复杂的并发环境中也坑过我一次。可见前面一篇 blog 。
没想到这次的问题还是和这里有关。
我为了优化组播过程,缓存了 C 对象,相对应的,对这些保持的 C 对象指针做了引用计数加一。等到它们离组的时候再减少。
另外,分组并不是用 hash set 实现的,离组操作代价比较高。所以又是在组播过程中成批处理的。也就是说,当一个对象退出,并不能保证它即使的从分组中拿掉,那么其引用记数就无法正确的减少到 0 。
而之前我在删除这些对象附着的消息队列时,采用的策略是,先删除对象,再将其消息队列标记成可删除,并把消息队列压入全局队列,让全局队列分发函数去检查标记并真正删除。
到现在,我就完全明白了上周问题的成因:当用户退出,系统删除了它对应的 handle ,并试图删除对应的消息队列。而对象被某个组播分组引用,阻止了退出流程,迟迟未能标记消息队列为可删。然后工作线程就反复的做退出队列,重回队列的操作,浪费了大量的 CPU 。同时,在这种情况下,全局消息队列的冲突变得非常严重,早先的 spinlock 未能合理的处理这种不健康状态,导致了整个系统性能的下降。
Comments
Posted by: AUHS | (15) June 15, 2014 12:51 PM
Posted by: AUHS | (14) June 15, 2014 12:50 PM
Posted by: dndxsys | (13) April 21, 2014 01:32 PM
Posted by: wandefou | (12) December 25, 2012 03:26 PM
Posted by: dndxsys | (11) November 12, 2012 11:35 AM
Posted by: David Xu | (10) October 16, 2012 01:49 PM
Posted by: Cloud | (9) October 16, 2012 10:35 AM
Posted by: David Xu | (8) October 16, 2012 09:54 AM
Posted by: cx | (7) October 14, 2012 10:54 PM
Posted by: Anonymous | (6) October 14, 2012 07:41 AM
Posted by: lite3 | (5) October 13, 2012 09:56 AM
Posted by: cloud | (4) October 13, 2012 09:42 AM
Posted by: ccc | (3) October 13, 2012 08:08 AM
Posted by: nori/twinkling | (2) October 12, 2012 06:01 PM
Posted by: lo | (1) October 12, 2012 05:02 PM