游戏服务器开发指南(五):避免死锁

文章讲述了在游戏服务器开发中如何避免死锁问题,提出了保证加锁顺序、使用开放调用、定时锁和轮询锁等方法,并提到异步编程和CAS作为替代方案来减少死锁风险。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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

这次的文章由于笔者五一节前工作任务堆积,直到放假才有时间写完。本次的主题是避免死锁,是并发类别下的第三篇。

死锁的原因是多线程对资源的竞争形成了循环等待的情形,导致彼此都阻塞而无法执行下去。形成死锁后,不仅线程无法再被使用,而且线程持有的资源(锁)也无法再被其他线程获取,这会导致程序的全部或部分功能无法正常运行,对于游戏服务器来说需要重启才能恢复。避免死锁也要从避免出现资源的循环等待着手。

第一种方法是保证嵌套锁的加锁顺序。当有多个锁嵌套时,如果能保证加锁顺序是固定的,如永远是A -> B -> C,那么就不会发生死锁。例如,想象一个两人组队的游戏场景,A玩家向B玩家发送组队邀请,同时B玩家也向A玩家发送组队邀请,为了避免两个玩家之间同时创建两份组队邀请,我们为组队邀请的操作加上A、B两个玩家的锁,只允许第一个邀请发送成功,对应的Java代码如下:

public void invitate(Player fromPlayer, Player toPlayer) {
	synchronized (fromPlayer) {
		synchronized (toPlayer) {
			createInvitatiion(fromPlayer, toPlayer);
		}
	}
}

invitate(playerA, playerB);	// A玩家发送邀请给B

invitate(playerB, playerA);	// B玩家发送邀请给A

这样的代码可能导致死锁,死锁发生在A、B玩家的线程分别持有自己玩家的锁并请求对方玩家的锁。解决办法就是对这两个玩家按id加以排序,修改后的代码如下:

public void invitate(Player fromPlayer, Player toPlayer) {
	Player smallIdPlayer = fromPlayer.getId() < toPlayer.getId() ? fromPlayer.getId() : toPlayer.getId();
	Player bigIdPlayer = fromPlayer.getId() < toPlayer.getId() ? toPlayer.getId() : fromPlayer.getId();
	synchronized (smallIdPlayer) {	// id较小的玩家锁先获取
		synchronized (bigIdPlayer) {	// id较大的玩家锁后获取
			createInvitatiion(fromPlayer, toPlayer);
		}
	}
}

invitate(playerA, playerB);	// A玩家发送邀请给B

invitate(playerB, playerA);	// B玩家发送邀请给A

在某些场合锁嵌套不是必需的,这时可以使用开放调用(open call)。开放调用意思是不在锁中调用外部方法,而是改为在锁外调用,这样能在外部方法加锁情况未知的情况下,避免出现锁嵌套。使用开放调用还可以缩小加锁范围,提升程序的可伸缩性。例如,在上一篇文章《降低同步开销》中讲过的代码优化:

int joinRoom(int playerId) {
	synchronized(this) {	// 房间锁从方法级别移到这里
		int errorCode = canJoin(playerId);	// 先判断能否进入
		if (errorCode != 0) {
			return errorCode;	// 不能进入则返回错误码
		}
		players.add(playerId);	// 加入房间中玩家集合
		if (players.size() >= 5) {	// 当玩家数量满5人时开启战斗
			startFight();
		}
	}	
	notifyAll(playerId);	// 将进入消息推送给房间中其他玩家
	db.decreaseLeftFightNum(playerId);	// 今日剩余可战斗次数减1,计入db
}

db.decreaseLeftFightNum(playerId)移出同步块就是改为开放调用。

另一种避免死锁的方法是使用定时锁和轮询锁。定时锁可以设置一个超时时间,当超过这个时间还未获得锁时不会继续阻塞等待,而是返回失败状态,程序员可以根据返回状态的不同,决定是继续执行同步块内部的代码,还是回滚当前操作。一种特殊的情况是是不带超时时间的定时锁,在这种情况下,尝试获得锁会立即返回成功或失败,失败后可以间隔一小段时间重试获取锁,直至超时操作失败,这种情况下的锁称为轮询锁。由于定时锁和轮询锁都避免了无限阻塞等待锁,所以它们不会造成死锁。以下代码就是使用轮询锁修改上面的invitate方法,避免死锁:

	public boolean invitate(Player fromPlayer, Player toPlayer) {
		long timeout = 2000L;
		long stopTime = System.nanoTime() + timeout;
		whiletrue{
			if(fromPlayer.lock.tryLock()){
				try{
					if(toPlayer.lock.tryLock()){
						try{
							return createInvitation(fromPlayer, toPlayer);
						} finally {
							toPlayer.lock.unlock();
						}
					}
				} finally {
						fromPlayer.lock.unlock();
					}
				}
				ifSystem.nanoTime()>= stopTime)// 若超过超时时间,则操作失败
					return falsesleepRandomInterval();	// 休眠一小段随机的时间
			}
	}

如果不用加锁的方式处理线程同步,而是改成异步或者CAS,那么都不会产生死锁。 异步不会造成线程阻塞,不过会引入处理回调额外的代码复杂度(callback hell)。CAS是硬件支持的非阻塞原语,行为模式上有点类似于前面提到的轮询锁,也是需要不断轮询才能让代码进行下去,不过CAS相对加锁来说也会显著提升代码复杂度。

总之,对于游戏服务器来说,程序死锁通常会造成功能失效的严重问题。如果一定要用锁,那么最优的解决办法是保证嵌套锁的顺序永远一致,另外避免非必要的锁嵌套。另一种思路是彻底摒弃锁,改用异步的编程模式(例如Skynet、Actor),这样就不用考虑死锁,不过会带来额外的代码复杂度。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值