现在的游戏服务器设计的时候为了提高吞吐量会考虑充分利用多核的性能,一种思路是偏向多进程并发模型,不同游戏系统可以独立拆分不同进程并行运行,进程之间通过RPC交互,另一种思路就是本文要谈到的多线程模型。

多进程相比多线程的缺点是:进程间RPC交互频繁,性能低数据交换麻烦;进程拆分过多,不方便部署;跨进程的事务实现起来非常困难;因为游戏拆分到不同进程整体提供服务,进程间需要保活及断线重连维持整体可用;

相反在多线程模型中:线程之间共享数据和调用方法简单直观高效(但是有更恼人的问题);部署简单;做为一个单一整体提供服务;

但是多线程也有缺点:线程间共享数据通常加锁同步,导致模块之间互相调用容易死锁,死锁会让服务器完全不工作但是又不会退出,通常要一段时间后才会被发觉;系统线程消耗比较高,意味着想把逻辑拆分到更细粒度的并行单元中代价会很高;

Golang比较适合解决多线程模型的这几个问题(但不是完全解决,后面会谈到)。原生支持Goroutine,可以以极低代价创建成千上万个并行goroutine,runtime的调度性能优越,能充分利用多核;不同于其他线程模型,golang鼓励使用channel在不同goroutine之间发送数据引用来共享数据,亦即Share Memory By Commuicating。

基于golang构建高并发服务器的便利,实践中有一种这样的方式设计游戏逻辑服务器:在服务器上为每个客户端user都分配一个独立goroutine,大部分没有交互的逻辑的可以在user各自的goroutine中并行运行,但是对于多个user共同操作访问的模块比如帮会系统,组队系统等,更直观的方式是用锁来同步访问,细粒度的锁当然能带来更高的并行度,但是连带的是难以应付的复杂度,一个可以接受的平衡是,每个系统独立拥有一把锁,这样至少看起来能保证不同系统之间的并行性。这样的设计一开始还能应付策划的各种需求,但是当需求变得越来越复杂,各种系统也越来越紧密耦合时,不同系统互相调用导致的死锁变成了噩梦。举个例子:副本系统在队伍进入副本的时候要锁定队伍状态,队员不能离队也不能加入其他人,于此同时队伍系统中的招募匹配逻辑则需要访问副本系统中的实时状态,A调用B,B调用A,双方有各自的锁,并行时会导致死锁。

我们直观的解决方案则是给每个模块加上类似优先级的概念,调用流只允许从优先级高的方向往优先级低的方向流动。但是如果这个方案只是个文档化的约束,这样的潜规则,就会成为极容易犯下并且难以发现的错误。或许制造静态检测工具,或者从架构设计层面让错误的调用流不可能发生也是一种不错的思路,但是我们没有往这一条路上尝试。

前面说过golang鼓励使用channel共享数据,但是这里并不适合我们,一个原因是并没有使用锁来同步共享数据那么直观,另外channel也不能解决死锁的问题。channel是基于一种CSP的并发模型,本质是同步的发消息,你需要对方在接收消息,不然也会阻塞,理论上凡是有阻塞的可能,就会有死锁的风险,设想A在往B发消息等待B处理,并行的B也在发消息等待A处理。

另一种经常会和CSP模型被一起提起的模型是Actor模型,他的不同之处在于,并行单元之间发送消息是异步的,通常会有个邮箱的概念(参考Erlang的实现),各自往各自的邮箱发送消息,消息会被异步处理。我们知道游戏中经常出现逻辑运行到某个时机会触发事件回调各个其他系统的监听函数,例如任务系统,成就系统就是靠监听其他系统发生的事件来驱动的,Actor模型在这种情况下比CSP更适合我们。

没有人说golang不可以用Actor模型,我们为每个goroutine维护一个FIFO的事件队列,无处不在的事件回调变成了抛向各个goroutine的异步事件,goroutine则转变成消息事件驱动。就这种简单的方式能解决大部分我们遇到的不同系统之间的回调死锁的问题,但还是有一些情况不能解决。

情况一:我们常有取不同系统数据的需求,比如取玩家帮会id,队伍id,这种需要及时返回数据只能是同步调用。解决方法是,把这种数据同步存到全局的对象上,这个对象可以理解为内存数据库,只提供CRUD的简单接口,他可以有自己的锁保护数据,接口都是线程安全的,但是不会调用任何其他模块接口,因此被其他系统调用是没有死锁风险的。以帮会id为例,这个全局对象可以是User,一旦玩家入帮或者退帮,帮会系统的逻辑会调用User的接口同步帮会id变更,而另一边并行的副本系统可以通过User取到当前的帮会id,相当于User解耦了帮会和副本系统。

情况二:跨系统,跨goroutine的事务需求,这种有些像分布式事务,解决起来又比分布式事务简单很多,但同时也没有一种解决方案可以适用所有需求,通常是具体问题具体分析设计。比如好友系统中两个好友之间的交互,由于好友相关数据是按照玩家为单位各自管理维护,一种设计是,两个玩家按照uid的大小顺序加锁来同步交互操作事务;又比如在以队伍为单位报名参加某个活动时,会调用队伍check and lock的事务接口(检查条件是否满足若是则同时锁定状态的事务),直到活动结束或者取消报名才解除锁定。

到了这里,这个golang多线程游戏逻辑服务器的设计已经能规避大部分的死锁问题。现实中大部分的需求逻辑只在自己独立的goroutine跑逻辑,开发难度小很多,并且这套设计没有潜规则,新手熟悉了也不容易踩坑。

最后,即便线上遇上了死锁问题,我们也得有一套方法及时定位到原因。runtime内置的pprof server,通过接口debug/pprof/goroutine?debug=2可以展示所有当前在运行的goroutine调用栈,效果类似gdb调试c++多线程程序,分析那些block中的goroutine不难找出一个死锁循环链,如果goroutine很多(线上经常遇到有成千上万个),接口debug/pprof/goroutine?debug=1则可以把相同调用栈goroutine合并展示,这些接口的调用对线上进程运行没有太大影响,获取也异常方便,只要在浏览器中打开。还有个在实践中很有用的经验是,启动时设置环境变量GOTRACEBACK=crash并把进程的错误输出重定向到一个日志文件,这样做可以让runtime crash时把错误和各个goroutine调用栈输出到文件,还会生成core文件,方便后续调试定位bug。因为多线程除了死锁问题,就是忘记加锁同步访问共享数据,golang的map如果没有加锁访问,runtime能够检测到并crash掉进程,如果能获取到crash时的调用栈就能定位到是哪里误用了map。https://golang.org/pkg/runtime中还描述了很多其他有用的环境变量,可以监控gc运行,调整gc频率等。