战神引擎获取版本信息失败_游戏引擎中的线程同步

前言

在多线程的游戏引擎中,线程同步一直是一个比较纠结的问题。游戏引擎中的多线程往往和其他的常用到多线程的地方有不少差距。与传统高并发相比,游戏引擎的数据吞吐量显然非常微小,但是对反应速度的要求又非常高。相比同样对低延迟有极高要求的高频交易系统,游戏引擎又有CPU核心少,帧流程相对固定的特点,用通俗的话说,硬件资源稀缺(往往对单位硬件的性能的要求比上述用途都要高很多)但工作流程可预测性高(优化空间十分明朗)。

原子锁与互斥锁

首先,只要有数据竞争的代码,就要加锁,所谓“无锁”,从严格意义上来讲是不存在的,因为这并不符合逻辑。即使是原子操作,也应该被认为是最小单位的互斥锁,因此,争辩是否是无锁对于提高性能而言毫无意义,此为前提。

当需要同步的指令不能够被一个原子操作函数完成时,锁就必须覆盖从开始的指令到结束的指令。一般常用的锁有原子锁和互斥锁。原子锁指的是类似CompareAndSwap,Atomic Exchange等操作,当原子锁尝试获取执行权限失败时,就会进入自旋循环,一直到上一个线程的锁被释放,自己才会继续执行,而原子操作的基础消耗往往要远低于互斥锁,但是其等待时间与额外开销的复杂度关系最少是O(n),也可能由于过度占用CPU总线资源导致复杂度关系达到O(nLogn)。相比原子锁来说,互斥锁的获取锁操作消耗更大,但是在获取之后线程马上进入休眠状态,并在上一个锁释放时被主动触发,这个过程没有额外消耗,是一个O(1)的操作。

本人进行测试时发现,当完全没有竞争时,原子锁性能十分突出,约为互斥锁的3.5 - 4倍性能,而参与竞争的线程越多,竞争密度越高时,原子锁消耗在自旋上的时间也直线上升,一直到最后16线程都参与竞争时,原子锁已经比互斥锁慢2.5倍左右了。此时不停的循环测试两种锁的耗时与锁数量的关系(锁数量越少时,可以认为发生碰撞概率越高,而锁数量越多时,可以认为锁的碰撞概率越低),当锁增加到3个时原子锁性能已经打平互斥锁了,而当锁数量继续增加时,明显原子操作的性能越发高涨,这个测试与理论是完全契合的。而实际在项目中,我们并不可能开一个死循环拼命的执行入列出列操作,而是会在每次写入和读取前后都有数百倍甚至千倍逻辑复杂度的代码块,因此可以认为,只要使用方法得当,无锁队列在工程中具有很宽广的应用前景。

上下文问题

当逻辑线程高于硬件核心数时,让出时间片的线程就需要把执行权交给其他排队的线程,这个上下文切换会带来额外开销,而且这个开销是不可避免的。上文提到,如果自旋锁保持忙等待,其必然带来高昂的持续开销,所以这个时间片是交也得交,不交也得交,交呢,有上下文消耗,不交呢,一直占用资源,消耗可能是上下文的上百倍甚至上千倍(这完全不是夸张,是真实的测试数据)。所以在设计层面上解决上下文才是真正要解决的问题。Job System就是解决这个问题的存在。通过Job System的设计,让逻辑线程数始终绑死,这样可以最大程度的避免在调用yield或mutex让出时间片时带来的上下文切换,而不是通过诸如忙等待一类方案防止上下文。

让无互斥锁设计拥有性能优势:

上面的测试已经证明了,在可能发生竞争的代码区域,只有尽可能减少竞争发生率和竞争时间,才能够真正的让原子锁发挥性能优势,否则很大的概率会是劣势。在所有线程同步操作中最普遍也是最容易出现问题的就是堆内存的分配,在这一领域,微软大爹的mimalloc内存分配库的思想十分先进。mimalloc是专门为高频交易和游戏引擎这种对延迟要求极为苛刻的场景研发的,现代操作系统会提供虚拟内存的map和物理内存到虚拟内存的分页操作,得益于64位操作系统,虚拟内存是几乎不可能不够用的,mimalloc会给每个线程都提供一块足够大的虚拟内存,在每次需要分配实际能够使用的内存时,会在虚拟内存中进行分页操作,通常一个逻辑块为64k,而页内使用了固定大小块的Free List。也就是说,只有当两个不同的线程同时释放两个位于同一个页内的大小相近的堆内存时,线程竞争才会发生,而这个几率很显然是非常小的,通过这种操作,就可以让内存分配这个高频率的操作变得对多线程友好起来。

当然,这里只是用mimalloc的实现思路做了一个例子,在非底层的逻辑层开发中,也可以把这种思维应用上,把锁的范围细化并把竞争的概率降低。如某个加载过程中,先前的一些使用mutex + double buffer进行数据传导的操作,可以改制成Ring Queue,Ring Queue的入列和出列工作十分短暂,可以直接用原子操作保护。

除此之外,有些可以并行与异步的工作,也具有相当大的优化空间。譬如最常规的加载,我本人绝非ECS的粉丝,但是ECS在众多奇妙的概念中有一条着实戳中了我:将数据打散成小块的组件。这一点即使不用完整的ECS架构,也可以予以参考。拿Unity这种GameObject + Component的设计举例,GameObject起到一个容器的作用,而GameObject本身并没有实际的功能,甚至基本的位置信息也要通过Transform组件。那么(假如)我们正在仿照Unity设计自己的引擎逻辑架构,对于GameObject的加载设计就可以进行一些改进。譬如一个GameObject和其身上绑定的Component都可以在加载线程完成实例化,并依次(不一定是同帧内)提交到主线程,这样,锁的粒度就被细化到了“同GameObject的相同Component”上,当GameObject本体实例化完成时,每个Component的占位符都应该归位,但这并不代表Component也已经完成了加载。用一段伪代码表示一个GetComponent:

component->componentLock.lock();
if(component->IsAvaliable()) result = component;
else result = nullptr;
component->componentLock.unlock();

我们在每次调用GetComponent时,都执行这么一个操作,甚至可以许多个线程互相交互操作,而原子锁成本确实足够低到其在没有发生竞争关系时性能仅仅相当于几个加减法一样的消耗,而实际产生竞争的概率又十分的低(没有人会没事调GetComponent玩的吧?),换来的却是可以让整个逻辑架构对异步执行变得十分友好。

当然,逻辑相关的依赖在之前讲解Job System的文章中已经着重提到过,这里就仅仅针对上述这种无先后依赖关系但有数据竞争可能性的应用场景。在实际开发中,会有相当多的把这种思想贯彻的情况,我们一定要对此保持很高的敏锐,并在该用的地方用上。当然,这可能会被认为是“过早的优化”,然而这类操作我并不愿意将其归结为优化,而是更倾向于归结为设计,优化是有针对性的措施,而设计是无针对性的范式,除非发生极大的变动(比如游戏项目要改硬件平台,改游戏类型),否则设计是一劳永逸的,持续带来优势的。

不要害怕锁:

全文一直在贯通一个事实:目前的高端平台上,线程锁并不是很可怕,不要去妖魔化它。对于近几年某些一直尬吹一些用一些基于深复制的,甚至于避免使用变量而是用常量的方法去“解决”并行竞争问题,这绝对不是什么优化,反而在现代计算机上内存IO带来的问题很有可能比计算密集型任务带来的要严重许多,上述的一些无锁架构设计仅仅是没什么副作用的一次性的工作,而在必须要用到时,一定要大刀阔斧的用,而不是钻入“过早优化”的思维怪圈拼命的尝试干掉它。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值