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

news/2024/3/29 2:32:46/文章来源:https://blog.csdn.net/needmorecode/article/details/130145294

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

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

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

减少锁竞争的第一种方法是缩小锁的范围。锁中代码的执行时间会直接影响程序的可伸缩性。假设一个独占锁内部的程序执行需要耗时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工具类通常会带来比加锁更好的性能。另一种优化思路是彻底放弃同步,改用异步的编程模式,不过这会带来额外的代码编写复杂度。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.luyixian.cn/news_show_102509.aspx

如若内容造成侵权/违法违规/事实不符,请联系dt猫网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

宝安日报:联诚发跨界创新“追光”十九载!

世界一流声光电智造一体化服务商、国家级高新技术企业、国家级专精特新“小巨人”企业、博士后创新实践基地、深圳自主创新百强企业……这些熠熠生辉的关键词&#xff0c;是位于宝安区航城街道的深圳市联诚发科技股份有限公司&#xff08;以下简称&#xff1a;联诚发&#xff0…

KingSCADA3.8保姆级安装教程

大家好&#xff0c;我是雷工&#xff01; 最近开始学习KingSCADA&#xff0c;今天这篇详细记录安装KingSCADA3.8的过程。 首先下载需要的安装版本&#xff0c;此处以从官网下载的最新版本KingSCADA3.8为例&#xff0c;双击&#xff1a;Setup.exe ; 一、安装主程序 1、点击“…

AutoSAR内存映射

总目录链接>> AutoSAR入门和实战系列总目录 总目录链接>> AutoSAR BSW高阶配置系列总目录 文章目录 为了防止不必要的内存缺口&#xff08;RAM 中未使用的空间&#xff09;&#xff0c;不同大小&#xff08;8、16 和 32 位&#xff09;的变量根据其大小映射到特…

工业树莓派远程I/O控制套装—更高效、更灵活、更便捷

一、背景 在完整的生产过程中&#xff0c;许多传感器设备和执行设备不完全安装在同一位置&#xff0c;大多分散部署在各个生产环节中。如果采用本地控制的方式&#xff0c;就需要用到多个控制器&#xff0c;但是成本较高&#xff0c;且不利于管理&#xff0c;所以最理想的解决…

Vue表单基本操作-收集表单数据

收集表单数据 使用vue中的v-model收集表单里面的数据&#xff0c;不同的表单元素配合v-model会有不同的写法和技巧 本次的表单元素包括&#xff1a;文本框&#xff0c;单选&#xff0c;多选&#xff0c;下拉框&#xff0c;文本域 编写表单元素 首先编写表单元素&#xff0c;…

ROS学习第三十七节——机器人运动控制以及里程计信息显示

https://download.csdn.net/download/qq_45685327/87719766 https://download.csdn.net/download/qq_45685327/87719873 gazebo 中已经可以正常显示机器人模型了&#xff0c;那么如何像在 rviz 中一样控制机器人运动呢&#xff1f;在此&#xff0c;需要涉及到ros中的组件: ros…

camunda的service task如何使用

在 Camunda 中&#xff0c;使用 Service Task 节点可以执行各种类型的业务逻辑&#xff0c;例如计算、数据转换、数据格式化等。在 Service Task 节点中&#xff0c;可以使用不同的编程语言来实现业务逻辑&#xff0c;例如 Java、JavaScript、Python 等。 下面是使用 Java 实现…

状态压缩DP-蒙德里安的梦想

题意 求把 NM 的棋盘分割成若干个 12 的长方形&#xff0c;有多少种方案。 例如当 N2&#xff0c;M4 时&#xff0c;共有 5 种方案。当 N2&#xff0c;M3 时&#xff0c;共有 3 种方案。 如下图所示&#xff1a; 输入格式 输入包含多组测试用例。 每组测试用例占一行&#xff0…

这份最新阿里、腾讯、华为、字节等大厂的薪资和职级对比,你看过没?

互联网大厂新入职员工各职级薪资对应表(技术线)~ 最新阿里、腾讯、华为、字节跳动等大厂的薪资和职级对比 上面的表格不排除有很极端的收入情况&#xff0c;但至少能囊括一部分同职级的收入。这个表是“技术线”新入职员工的职级和薪资情况&#xff0c;非技术线(如产品、运营、…

【Linux】环境变量与进程优先级知识点

目录 环境变量1.基本概念2.常见环境变量3.我们写的程序和命令行指令有什么区别&#xff1f;4.自己的程序为什么要用 ./ 执行&#xff0c;而命令行指令可以直接执行&#xff1f;5.如何追加环境变量&#xff1f;6.Linux如何查看环境变量7.如何在代码层面获取环境变量main函数的参…

ubuntu 3060显卡驱动+cuda+cudnn+pytorch+pycharm+vscode

文章目录 运行环境&#xff1a;适用&#xff1a;思路&#xff1a;1.1 3060显卡驱动自动安装2.1 CUDA11.1.11)下载CUDA Toolkit 11.1 Update 1 Downloads2)contunue , 然后accept3)回车取消Driver安装&#xff0c;然后install4)添加环境变量5)确认是否安装成功 3.1 cudnn 8.1.11…

【Cartopy基础入门】如何更好的确定边界显示

原文作者&#xff1a;我辈理想 版权声明&#xff1a;文章原创&#xff0c;转载时请务必加上原文超链接、作者信息和本声明。 Cartopy基础入门 【Cartopy基础入门】Cartopy的安装 【Cartopy基础入门】Geojson数据的加载 【Cartopy基础入门】如何更好的确定边界显示 文章目录 Ca…

【边缘计算】登临(Goldwasser-UL64)BW-BR2边缘设备配置指南

目录 开箱配置激活SDK环境测试cuda兼容性 开箱配置 更改盒子root用户密码&#xff1a; sudo passwd root(密码同为root) 切换到root用户身份&#xff1a; su root查看ssh的状态&#xff0c;没有返回说明没有启动 sudo ps -e|grep ssh此时说明ssh服务已启动。 更改ssh配置文…

java定位系统源码,通过独特的射频处理,配合先进的位置算法,可以有效计算出复杂环境下的人员与物品的活动信息

智慧工厂人员定位系统源码&#xff0c;区域电子围栏管控源码 文末获取联系&#xff01; 在工厂日常生产活动中&#xff0c;企业很难精准地掌握访客和承包商等各类人员的实际位置&#xff0c;且无法实时监控巡检人员的巡检路线&#xff0c;当厂区发生灾情或其他异常状况时&#…

postman安装

目录 下载、安装 Postman是一款功能强大的网页调试与发送网页HTTP请求的Chrome插件。 Postman原是Chrome浏览器的插件&#xff0c;可以模拟浏览器向后端服务器发起任何形式(如:get、post)的HTTP请求 使用Postman还可以在发起请求时&#xff0c;携带一些请求参数、请求头等信息…

WebSocket+Vue+SpringBoot实现语音通话

参考文章 整体思路 前端点击开始对话按钮后&#xff0c;将监听麦克风&#xff0c;获取到当前的音频&#xff0c;将其装化为二进制数据&#xff0c;通过websocket发送到webscoket服务端&#xff0c;服务端在接收后&#xff0c;将消息写入给指定客户端&#xff0c;客户端拿到发送…

日本PSE认证日本的電気用品安全法METI备案

日本的電気用品安全法&#xff08;PSE认证&#xff09;法规要求日本的采购商在购进商品后一个月内必须向日本METI注册申报&#xff0c;并必须将采购商名称或ID标在产品上&#xff0c;以便在今后产品销售过程中进行监督管理&#xff0c;完成后将获得電気用品製造事業届出書&…

Java基础学习(10)

Java基础学习 一、JDK8时间类1.1 Zoneld时区1.2 Instant时间戳1.3 ZonedDateTime1.4 DateTimeFormatter1.5 日历类时间表示1.6 工具类1.7 包装类JDK5提出的新特性Integer成员方法 二、集合进阶2.1 集合的体系结构2.1.1 Collection 2.2collection的遍历方式2.2.1 迭代器遍历2.2.…

元宇宙场景下的实时互动RTI技术能力构建

元宇宙可谓是处在风口浪尖&#xff0c;无数的厂商都对元宇宙未来抱有非常美好的憧憬。正因如此&#xff0c;许许多多厂商都在用他们自己的方案&#xff0c;为元宇宙更快、更好的实现&#xff0c;在自己的领域贡献力量。LiveVideoStack 2022北京站邀请到了 ZEGO 即构科技的解决方…

17.集合

集合 集合类是Java数据结构的实现。Java的集合类是java.util包中的重要内容&#xff0c;它允许以各种方式将元素分组&#xff0c;并定义了各种使这些元素更容易操作的方法。Java集合类是Java将一些基本的和使用频率极高的基础类进行封装和增强后再以一个类的形式提供。集合类是…