大家好!我是长三月,一位在游戏行业工作多年的老程序员,专注于分享服务器开发相关的文章。
一周一次的系列分享又与大家见面了。这次的主题是设计高效的线程模型,是并发类别下的第一篇。
在游戏服务器开发中,一个高效的线程模型可以充分利用多核能力,将cpu的利用率发挥到极致,并且降低请求响应时间,带给玩家尽量低的延时体验。
在许多场合需要评估是使用一个线程还是多个线程。适合分拆成多线程执行的场合是:多个任务彼此独立性高,而且单线程串行执行可能达到单核能力上限的瓶颈。下面是一个关于开房间战斗的例子,每个房间都有若干玩家一起战斗,房间之间彼此独立。原始的设计是使用单线程执行所有战斗房间的计算任务:
这样设计的问题是,由于战斗计算是CPU密集型的计算任务,当房间数量增多到一定程度时会达到单核性能瓶颈,无法在原定的一帧时间内执行完所有的房间计算,导致掉帧的情况出现。解决办法是开启多个线程分担计算压力,每次有新房间创建就投放到一个最空闲的线程中管理。因为房间之间彼此独立,所以也不需要考虑任何同步相关的问题:
同样典型的场景还有MMO中的大地图,由于整个地图运算量巨大,通常会考虑使用多线程分担计算量,拆分角度通常是按地图区域,每个区域分配一个线程,或者是按业务类型,每种独立的业务逻辑分配一个线程。
不过,并不是所有的任务都适合分拆到多线程。使用多线程相比单线程,至少有以下缺点:
以下是一个反例,说明关联性太强的多个任务使用多线程会遇到的同步问题,及其对性能的影响。
在Java游戏服务器中,从网络获取的玩家请求经过解析,投放到业务线程中进行处理。我们为来自同一个玩家的多个请求添加玩家锁,目的是希望它们串行执行,避免多线程造成的竞争问题。这样做的结果是,来自同一个玩家的多个请求虽然投放到线程池中多线程执行,但是每个请求都要等待前一个请求执行完并释放玩家锁,在此之前都是阻塞的。考虑一个更极端的情况,玩家登陆时通常会同时调用一系列接口,当调用接口数大于线程池中线程总数,而且第一个请求因异常情况阻塞时,此时线程池中所有线程都被阻塞,无法处理其他玩家的请求。
解决这个问题的办法是避免同步,改为将每个玩家绑定到一个线程上,玩家请求放入线程专属的任务队列依次执行。绑定的策略可以是对玩家id取模:
分配到的线程id = 玩家id % 线程总数
以上模型虽然能避免同步带来阻塞的问题,但是可能在部分线程上存在性能热点。例如,恰好玩家分配到线程1上的数量较多,而且来自这部分玩家的请求数也较多,就会造成线程1繁忙,而其他线程空闲的情况。
我们对上述模型进一步优化。上述模型的问题是没有利用到线程池自动分配任务到空闲线程的功能。因此我们改成为每个玩家维护一个请求队列,该队列每次将一个新任务投放到线程池中,具体投放到哪个线程由线程池来决定,等这个任务执行完后,再通知该玩家的请求队列投放下一个请求。
这样既避免了多线程同步的问题,也避免了部分线程上的性能热点。
设计线程模型时,另一件要注意的事是:尽量按业务类型划分不同的线程池,不要共用一个。共用一个线程池的坏处是,当一个业务遇到异常情况(例如网络或者数据库IO阻塞),导致线程耗尽时,可能会影响到别的业务。而且多个业务放在一个线程池中也不利于排查问题。
一个典型的游戏服务器通常有以下类型的线程池:
最后,不同的并发模型对线程模型的设计也会有影响。以下是三种常见的并发模型:
总之,一个高效的线程模型可以充分榨干多核的性能。对于独立性高的多个计算密集型任务,使用多线程可以避免单核的性能瓶颈。对于互相依赖的多个任务,我们应该仔细设计线程模型,避免同步和单点过热对性能的影响。除了共享内存,还有Actor和CSP两种并发模型也很常见,它们各有特点,为编写高效的并发程序带来了便利。即使没有实际用到它们,熟悉和借鉴不同并发模型的思想也是有益的。