回顾这段工作
这段旅程到此结束。回顾一下我在这个项目里做了哪些事吧。
我刚来这个项目的时候,项目还没有在外网部署。在部署的时候发现了很多问题,首先登录都有死锁(其实目前还是有一个隐藏的死锁没处理)。其次服务器的承载量没有压测过,谁心里都没底上线后会是什么样。
我们这个项目是2d的推关游戏,客户端unity,服务器是c#的。玩家在通关关卡后,会由服务器去验算一遍战斗。我用dottrace去分析这个战斗验算代码,发现简单的战斗一局10s,验算耗时20ms左右,复杂的战斗一局可能120s,验算100ms左右。
当时我们的战斗验算是放在逻辑服的,逻辑服是由两个线程组成,一个网络线程,一个逻辑线程。我在同时压测登录与战斗时发现,在登录不到500人的时候,机器人就频繁掉线了。并且请求的响应时间超过了30s。
A玩家的战斗验算能卡住B玩家的登录,这是极其不合理的。战斗验算是个 CPU 密集型任务,并不适合在逻辑服线程处理。之后我将战斗验算放到了单独的进程去处理,玩家的战斗数据由逻辑服向战斗服发送请求,战斗服处理完毕后再返回给逻辑服。
后来压测过程中发现,在200人左右同时验证的时候,战斗验证请求的响应时间就有超过10s的。因为战斗验证只部署了一个进程,所以这个解决方法还是没有解决问题。
无非是两个方案,一是多部署几个战斗进程。二是将战斗进程改成多线程。目的是利用CPU多核。
多部署战斗进程,耗费的内存会比较高,但是实现简单。
后来还是选了改成多线程的方案。
在看战斗验算的代码的时候,发现代码量比较大,而且从最开始的设计上,就没有考虑会有多个战斗同时验算,处处都没有考虑线程安全问题,尤其是对象池。
World里有个对象池,这个对象池竟然是个static的单例!很多代码都是直接从BattleWorld中获取对象池。
public class BattleWorld
{
public static readonly GameObjectPool GameObjectPool = new();
}
在实现的时候发现,代码改动太大了,而且我们这个是客户端与服务器通用的代码,相当于重写,并且还避免不了出现其他的问题。在折腾了许久后,竟然实现了一个改动极少的方案。
大致思路:
- 在启动服务器时,创建一个线程池,每个线程都创建一个GameObjectPool,每个线程去遍历阻塞队列。
- 当有战斗验算请求时,新建BattleValidateItem加入阻塞队列,异步等待结果。
- 验算结束后,设置tcs,将验算结果返回。
public class BattleValidateItem
{
public BattleValidateReq Request; // 战斗验算请求
public TaskCompletionSource<BattleInfoValidateInfoItem> TaskCompletionSource; // tcs
}
public class BattleWorld
{
public static GameObjectPool GameObjectPool => ConfigDict[Environment.CurrentManagedThreadId];
public static readonly ConcurrentDictionary<int, GameObjectPool> ConfigDict = new();
}
// 此方法在线程池中运行。
private static void ValidationLoop()
{
... 线程初始化
var gameObjectPool = new GameObjectPool();
foreach (var item in BlockingCollection) // 当阻塞队列中没有抢到数据时,代码是阻塞的。
{
int threadId = Environment.CurrentManagedThreadId;
BattleWorld.ConfigDict[threadId] = gameObjectPool;
var result = BattleRunHelper.Run(item.Request); // 验算战斗
item.TaskCompletionSource.SetResult(result); // tcs设置验算结果,await后的代码继续执行。
BattleWorld.ConfigDict.Remove(threadId);
...
}
}
战斗验算是CPU 密集型任务,验证过程中不会有线程切换,由此在代码改动极少的情况下实现了多线程战斗验算。当然,如果在战斗验算里加入写文件等异步操作,导致线程id改变,代码就完全不适用了。