游戏服务器开发指南(三):设计高效的线程模型

大家好!我是长三月,一位在游戏行业工作多年的老程序员,专注于分享服务器开发相关的文章。

一周一次的系列分享又与大家见面了。这次的主题是设计高效的线程模型,是并发类别下的第一篇。

在游戏服务器开发中,一个高效的线程模型可以充分利用多核能力,将cpu的利用率发挥到极致,并且降低请求响应时间,带给玩家尽量低的延时体验。

在许多场合需要评估是使用一个线程还是多个线程。适合分拆成多线程执行的场合是:多个任务彼此独立性高,而且单线程串行执行可能达到单核能力上限的瓶颈。下面是一个关于开房间战斗的例子,每个房间都有若干玩家一起战斗,房间之间彼此独立。原始的设计是使用单线程执行所有战斗房间的计算任务:

游戏服务器开发指南(三):设计高效的线程模型_第1张图片这样设计的问题是,由于战斗计算是CPU密集型的计算任务,当房间数量增多到一定程度时会达到单核性能瓶颈,无法在原定的一帧时间内执行完所有的房间计算,导致掉帧的情况出现。解决办法是开启多个线程分担计算压力,每次有新房间创建就投放到一个最空闲的线程中管理。因为房间之间彼此独立,所以也不需要考虑任何同步相关的问题:
游戏服务器开发指南(三):设计高效的线程模型_第2张图片同样典型的场景还有MMO中的大地图,由于整个地图运算量巨大,通常会考虑使用多线程分担计算量,拆分角度通常是按地图区域,每个区域分配一个线程,或者是按业务类型,每种独立的业务逻辑分配一个线程。

不过,并不是所有的任务都适合分拆到多线程。使用多线程相比单线程,至少有以下缺点:

  1. 需要更多地考虑线程之间同步的问题。这对于程序员带来了额外的开发负担,处理得不好可能导致死锁或状态错误等严重问题。而且如果同步粒度太大,会使多线程对性能的提升变得很有限。
  2. 多线程线程切换会带来额外性能开销,另外每个线程都要分配独立的堆栈空间。

以下是一个反例,说明关联性太强的多个任务使用多线程会遇到的同步问题,及其对性能的影响。

在Java游戏服务器中,从网络获取的玩家请求经过解析,投放到业务线程中进行处理。我们为来自同一个玩家的多个请求添加玩家锁,目的是希望它们串行执行,避免多线程造成的竞争问题。这样做的结果是,来自同一个玩家的多个请求虽然投放到线程池中多线程执行,但是每个请求都要等待前一个请求执行完并释放玩家锁,在此之前都是阻塞的。考虑一个更极端的情况,玩家登陆时通常会同时调用一系列接口,当调用接口数大于线程池中线程总数,而且第一个请求因异常情况阻塞时,此时线程池中所有线程都被阻塞,无法处理其他玩家的请求。
游戏服务器开发指南(三):设计高效的线程模型_第3张图片解决这个问题的办法是避免同步,改为将每个玩家绑定到一个线程上,玩家请求放入线程专属的任务队列依次执行。绑定的策略可以是对玩家id取模:

分配到的线程id = 玩家id % 线程总数

游戏服务器开发指南(三):设计高效的线程模型_第4张图片以上模型虽然能避免同步带来阻塞的问题,但是可能在部分线程上存在性能热点。例如,恰好玩家分配到线程1上的数量较多,而且来自这部分玩家的请求数也较多,就会造成线程1繁忙,而其他线程空闲的情况。

我们对上述模型进一步优化。上述模型的问题是没有利用到线程池自动分配任务到空闲线程的功能。因此我们改成为每个玩家维护一个请求队列,该队列每次将一个新任务投放到线程池中,具体投放到哪个线程由线程池来决定,等这个任务执行完后,再通知该玩家的请求队列投放下一个请求。

这样既避免了多线程同步的问题,也避免了部分线程上的性能热点。
游戏服务器开发指南(三):设计高效的线程模型_第5张图片
设计线程模型时,另一件要注意的事是:尽量按业务类型划分不同的线程池,不要共用一个。共用一个线程池的坏处是,当一个业务遇到异常情况(例如网络或者数据库IO阻塞),导致线程耗尽时,可能会影响到别的业务。而且多个业务放在一个线程池中也不利于排查问题。

一个典型的游戏服务器通常有以下类型的线程池:

  1. 网络IO:负责读写网络消息,并处理网络数据与用户请求之间的转换。
  2. 用户请求处理:也叫业务线程池,处理类型1中解析出的用户请求。
  3. 定时任务:分为定时任务的管理线程和执行线程,前者负责定时任务的添加、删除和触发,通常一个线程就够了,后者一般使用线程池。
  4. 异步SQL:在异步存储SQL的场合使用。
  5. 对外通信:用于与第三方服务器异步通信,避免阻塞业务线程。

最后,不同的并发模型对线程模型的设计也会有影响。以下是三种常见的并发模型:

  1. 同步和共享内存:代表有Java。这是最常见的并发模型,通过同步来控制并发,直接使用线程池。对于程序员处理线程同步和设计线程模型有较高的要求。
  2. Actor:代表有Skynet、Erlang、Akka。基本思想是:不同的Actor维护自己独立的数据,Actor之间通过异步消息通信,避免直接调用;Actor维护和处理自己的消息列表;只有每个Actor才允许写自己的数据,保证写唯一性。Actor的好处是从模型上避免了考虑同步的负担,缺点是模型更复杂,需要一定的经验才能掌握。
  3. CSP:代表有Golang。核心要素是协程和通道。CSP与Actor类似,区别在于Actor之间是直接通信的,而CSP的通信是面向通道的;Actor之间通信是异步,而CSP从通道读写数据是同步的;在Actor模型中,消息队列存在于每个Actor实例中,而CSP中消息队列存在于通道中(通道带缓存的前提下)。与Actor模型类似,通道让程序员省去了考虑同步的负担,而协程让编写高效的并发代码更加容易。首先,协程是比线程更轻量级的调度单位,一个进程中可以开启成千上万个协程;其次,协程遇到阻塞时会从逻辑处理器中卸载直至从阻塞恢复,在此期间逻辑处理器又会新建线程处理别的协程任务,不会出现直接使用线程池时遇到的线程全阻塞的问题。

总之,一个高效的线程模型可以充分榨干多核的性能。对于独立性高的多个计算密集型任务,使用多线程可以避免单核的性能瓶颈。对于互相依赖的多个任务,我们应该仔细设计线程模型,避免同步和单点过热对性能的影响。除了共享内存,还有Actor和CSP两种并发模型也很常见,它们各有特点,为编写高效的并发程序带来了便利。即使没有实际用到它们,熟悉和借鉴不同并发模型的思想也是有益的。

你可能感兴趣的:(游戏服务器开发指南,服务器,游戏,java)