游戏服务器开发指南(四):降低同步的开销

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

这周的文章因为工作繁忙又鸽了几天。本次的主题是降低同步的开销,是并发类别下的第二篇。

在游戏服务器开发中,多线程竞争资源时,为了保证资源状态的正确性,不可避免要使用加锁等同步方式。但加锁是有代价的,不仅会限制多线程以串行的方式运行,还会导致上下文切换,因此为了提升性能和可伸缩性,我们需要减少对锁的竞争。加锁对可伸缩性的影响取决于两个因素:一是单次锁占用的时间,二是请求锁的频率。我们可以从这两个角度对加锁进行优化。

减少锁竞争的第一种方法是缩小锁的范围。锁中代码的执行时间会直接影响程序的可伸缩性。假设一个独占锁内部的程序执行需要耗时20ms,那么每秒最多只能支持50次执行的并发量。因此需要将锁内部与同步无关的代码移到锁外,特别是执行耗时长且可能阻塞的操作,如网络IO。

让我们看这样的一个例子。游戏模式是开房间战斗,玩家可以自由进入或退出战斗房间,当房间人数满5人时自动开启战斗,当房间人数为0时房间自动解散,玩家进入或退出房间都会向房间中的其他人推送进入或退出的消息,玩家有每日可战斗次数,进入房间时消耗1次次数,退出房间时增加1次次数,该次数会计入DB。

为了保证房间状态的正确性,我们需要保证进入房间和退出房间的操作为原子操作,例如进入房间至少应包括:判断是否能进入,将玩家id加入到房间中的玩家集合,当玩家人数满5人时自动开启战斗等,这一系列操作应该组合为一个原子操作不可分割。原始的设计是编写joinRoom和exitRoom两个方法,并将锁加在这两个方法上,Java版代码如下:

synchronized int joinRoom(int playerId) {	 // 将锁加在方法级别上
	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
}

synchronized int exitRoom(playerId) {
	int errorCode = canExit(playerId)
	if (errorCode != 0) {
		return errorCode
	}
	players.remove(playerId);
	if (players.size() <= 0) {
		destroyRoom();
	}
	notifyAll(playerId);
	db.increaseLeftFightNum(playerId);
}

以上代码有可以优化的地方,优化方法是将notifyAll和对leftFightNum的修改移到锁以外。可以这样做的原因是,这两个操作本质是线程竞争完后做的后续跟随操作,无论它们是否放在同步块中,都不影响房间状态的正确性。另外,网络推送和数据库操作都有可能引发网络IO阻塞,因此不宜放在同步块中(尽管这两个操作都可以改成异步的,我们还是可以把它们移出去节省占用锁的时间)。修改后的代码如下:

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
}

int exitRoom(playerId) {
	synchronized(this) {	
		int errorCode = canExit(playerId)
		if (errorCode != 0) {
			return errorCode
		}
		players.remove(playerId);
		if (players.size() <= 0) {
			destroyRoom();
		}
	}
	notifyAll(playerId);
	db.increaseLeftFightNum(playerId);
}

减少锁竞争的第二种思路是降低请求锁的频率,一种常用的可扩展的方法是使用分段锁。分段锁是一组锁的集合,每个锁独立控制资源的一部分。与单个锁相比,分段锁可以将线程之间的竞争分摊到多个锁上面,从而减小对锁的竞争。

下面是一个在活动初始化数据时使用分段锁的例子。游戏中开启某个活动后,需要为参与活动的玩家初始化他的活动数据,为了节省存储空间,我们选择使用延迟初始化,即在他第一次用到这个数据的时候初始化。初始化可能来自多处:玩家线程或者后台线程,有出现并发竞争的可能性,因此需要对初始化方法加锁:

synchronized void checkInitActivityData(int playerId) {
	if (needInit(playerId)) {	// 检查数据是否初始化过,若没有则初始化
		initData(playerId);
	}	
}

我们使用分段锁对其进行优化,创建一组锁共1024个,根据playerId将当前线程映射到其中一个锁上:

private final static Object[] locks;

private final static int LOCK_NUM = 1024;

static {
	locks = new Object[LOCK_NUM];
	for (int i = 0; i < LOCK_NUM; i++) {
		locks[i] = new Object();
	}
}

void checkInitActivityData(int playerId) {
	synchronized (locks[playerId % LOCK_NUM]) {	// 按playerId映射到其中一个锁上
		if (needInit(playerId)) {	// 检查数据是否初始化过,若没有则初始化
			initData(playerId);
		}	
	}
}

进一步,我们可以将初始化逻辑优化成双重检查加锁(Double Check Lock),使得绝大部分情况下无需走到加锁逻辑中:

void checkInitActivityData(int playerId) {
	if (needInit(playerId)) {	// 第一次检查是否已初始化
		synchronized (locks[playerId % LOCK_NUM]) {
			if (needInit(playerId)) {	// 第二次检查
				initData(playerId);
			}	
		}
	}
}

减少锁竞争的另一种方法是使用读写锁替代独占锁。读写锁的规则是:多个读操作可以共享锁,而写操作会独占锁。这样提升了读操作时的并发性,在读多于写的场合对于并发性的提升尤其明显。

例如,在上面提到过的战斗房间中,我们在获取某个时刻获取房间的信息(包括是否已开始战斗、房间中的玩家列表等)。为了保证用于显示的房间状态是一致的,我们对于这个读操作加读锁,而对于加入、退出房间改为加写锁:

private ReadWriteLock lock = new ReentrantReadWriteLock();

private ReadLock readLock = lock.readLock();

private WriteLock writeLock = lock.writeLock();

RoomInfo showRoom() {	// 获取房间信息
	readLock.lock();
	...	// 拼装房间信息
	readLock.unlock();
	return roomInfo;
}

int joinRoom(int playerId) {	 
	writeLock.lock();
	...	// 加入房间的逻辑
	writeLock.unlock();
	...	// 加入房间后的跟随逻辑
}

int exitRoom(playerId) {
	writeLock.lock();
	...	
	writeLock.unlock();
	...
}

对于某些场合,使用读锁不一定是必须的,需要代码编写者根据实际情况自行做出判断。例如,在上面的例子中,如果showRoom方法不加读锁,那么可能会出现短时间的状态不一致,例如房间中已经满5个人了,但是战斗仍然未开启。如果代码编写者觉得这种暂时的不一致是可以接受的(重复拉取接口数据就能恢复正常),而且用于存储玩家id的集合等容器本身是线程安全的,那么完全可以不加读锁;反之,如果前端某些逻辑同时依赖房间人数和战斗状态,那么状态不一致可能导致前端逻辑错误,则应该加锁。

线程同步主要的开销在于线程之间的互相等待,在等待过程中线程无法继续执行。而如果要彻底解决线程之间等待的问题,让线程不再阻塞,可以将同步操作改为异步。异步编程的思想在服务器领域有很多实践的例子。在上一篇(设计高效的线程模型)中,我们讲到Skynet的actor模型就是一个异步编程的典型例子,Skynet中actor之间发送消息、sleep休眠、网络IO都是异步的。设计成异步的好处是不会阻塞当前线程,actor可以在无阻塞的情况下持续运转,不过异步的编写模式会将原来写在一起的代码拆分到多个回调中处理,对于程序员处理会比同步更加麻烦。

下面是一个游戏中使用异步编程的典型场景。在一个由服务器驱动的开房间战斗中,玩家可以操作本方的单位,例如移动、放技能等。如果我们把这些操作设计成同步的,那么如何加锁会是一件非常麻烦的事情,每种操作竞争的资源数量难以确定,而且加得不好还容易产生死锁;若是对房间整体加锁,加锁粒度又太大。所以我们设计成异步的形式,将所有的玩家操作放入事件队列中异步处理,代码如下:

private Queue<Event> eventQueue = new MpscUnboundedArrayQueue<>(10);	// 事件队列,使用Mpsc队列

public void move(int x, int y) {	// 移动操作
	eventQueue.offer(new MoveEvent(x, y));	// 给事件队列添加移动事件
}

public void update(int dt) {	// 每帧执行一次房间运算
	while (true) {
		Event event = taskQueue.poll();	// 从事件队列不停取事件,直至队列为空
		if (event == null) {
			break;
		}
		event.handle();	// 事件处理
	}
}

注意,这里存储事件队列使用了线程安全的Mpsc队列。它是一种非阻塞队列,适合多生产者单消费者(Multiple producer, single consumer)的场合,底层使用了CAS和数组,性能优于JDK自带的使用CAS和链表的ConcurrentLinkedQueue。

既然说到CAS(Compare And Swap),再比较下它和加锁各自的性能优劣和适用场景。CAS是硬件直接支持的非阻塞同步原语,通过原子性的比较并交换操作,实现比加锁更轻量级的同步。CAS的优点是不会阻塞线程,也不会造成线程切换,缺点是如果多次自旋测试不成功,会造成较大的CPU开销,同时CAS代码编写比加锁复杂得多,还有可能造成ABA等问题。CAS自旋测试的次数取决于测试失败的概率,这个概率与两个因素有关:一是并发量,二是CAS所保护代码块的执行时间。当并发量越大、CAS保护的代码块执行时间越长时,更容易出现多线程同时进入受保护代码块中,导致这部分代码以非原子的方式执行,从而CAS测试失败。因此,CAS更适合较轻量级的同步场合,即并发量不大或者受保护的代码块执行时间较短

以Java中的AtomicInteger为例,以下来自getAndIncrement方法的源码:

    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }

    //Unsafe中的方法
    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

可以看出,原子性的获取当前值并自增操作,底层实际是用CAS不断比较自增后的值是否与预期值相等,若失败则重试,直至成功。getAndIncrement本质上是对++i操作的模拟,并保证这个操作的原子性。++i在底层执行时实际上分两步:先获取i的值,再执行i = i + 1,所以如果没有同步保护,有可能出现两个线程同时执行++i,结果i只被加了1次的情况。显然,用CAS对这两步操作做同步保护,即使在并发量很大的情况下,CAS测试失败的概率也是很低的,因为被同步保护的代码非常简单,执行起来很快。所以,基于CAS实现的原子变量性能在通常情况下性能强于基于锁的实现,无论在并发量大还是小的情况下都很实用。

在业务代码中,极少需要直接用到CAS,更多地是使用已有的基于CAS的工具类,如线程安全的非阻塞队列、原子变量等。使用CAS比加锁实现起来更复杂,更容易出错,而且性能未必比加锁更好,或者优化带来提升可能很小,性价比不高。因此,除非通过profile证明CAS确实能在某些关键热点上带来显著提升,否则不用考虑CAS。正如那句名言所说:“过早的优化是万恶之源”。

总之,线程同步虽然能保证资源状态的正确性,但是会带来性能损失。优化方法对于同步是阻塞式还是非阻塞式有所不同。对于阻塞式的加锁同步,我们可以通过缩小加锁范围和降低加锁频率,减小线程之间对锁的竞争;通过引入读写锁,可以避免读操作对锁的独占;对于非阻塞式的CAS同步,我们极少会在业务中直接用到,但是善用已有的CAS工具类通常会带来比加锁更好的性能。另一种优化思路是彻底放弃同步,改用异步的编程模式,不过这会带来额外的代码编写复杂度。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值