大家好!我是长三月,一位在游戏行业工作多年的老程序员,专注于分享服务器开发相关的文章。
这次的文章由于笔者五一节前工作任务堆积,直到放假才有时间写完。本次的主题是避免死锁,是并发类别下的第三篇。
死锁的原因是多线程对资源的竞争形成了循环等待的情形,导致彼此都阻塞而无法执行下去。形成死锁后,不仅线程无法再被使用,而且线程持有的资源(锁)也无法再被其他线程获取,这会导致程序的全部或部分功能无法正常运行,对于游戏服务器来说需要重启才能恢复。避免死锁也要从避免出现资源的循环等待着手。
第一种方法是保证嵌套锁的加锁顺序。当有多个锁嵌套时,如果能保证加锁顺序是固定的,如永远是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;
while(true){
if(fromPlayer.lock.tryLock()){
try{
if(toPlayer.lock.tryLock()){
try{
return createInvitation(fromPlayer, toPlayer);
} finally {
toPlayer.lock.unlock();
}
}
} finally {
fromPlayer.lock.unlock();
}
}
if(System.nanoTime()>= stopTime)// 若超过超时时间,则操作失败
return false;
sleepRandomInterval(); // 休眠一小段随机的时间
}
}
如果不用加锁的方式处理线程同步,而是改成异步或者CAS,那么都不会产生死锁。 异步不会造成线程阻塞,不过会引入处理回调额外的代码复杂度(callback hell)。CAS是硬件支持的非阻塞原语,行为模式上有点类似于前面提到的轮询锁,也是需要不断轮询才能让代码进行下去,不过CAS相对加锁来说也会显著提升代码复杂度。
总之,对于游戏服务器来说,程序死锁通常会造成功能失效的严重问题。如果一定要用锁,那么最优的解决办法是保证嵌套锁的顺序永远一致,另外避免非必要的锁嵌套。另一种思路是彻底摒弃锁,改用异步的编程模式(例如Skynet、Actor),这样就不用考虑死锁,不过会带来额外的代码复杂度。