ECS架构之并行处理的思考

在思考ECS架构并行处理之前,我先思考了一下ECS架构对游戏业务逻辑的影响,游戏能否使用纯ECS架构?我参考了最为普通但是最重要的业务逻辑角色控制。

角色控制包括角色的创建,角色的更新,角色的销毁,这三个主要业务逻辑。

通常我们会从服务器获得创建角色的消息,传统游戏架构会在处理这个消息的时候创建一个角色。基本上会有一个PlayerManager的单例来创建这个角色。创建角色的同时会创建角色需要的组件,比如AI组件,移动组件,动画组件,声音组件以及一系列角色数据。

如果用ECS思想去实现角色创建,我们不会立即创建一个Player,而是先创建一个Entity,然后挂上一个系统组件比如叫CreatePlayerInfoComponent,这个组件中包含创建角色所需的数据。这里可能会有很多个待创建的Player。接下来我们会有一个叫CreatePlayerSystem来搜索包含CreatePlayerInfoComponent组件的Entity,然后对其进行二次创建(创建出所有组件)。这里对比一下两种实现其实影响并不大。ECS方式每个被创建的Entity还会多一次内存拷贝,因为被创建的Entity的组件发生了变化。

角色的更新这里差别就大了,这种差别就像AOS和SOA的差距。传统的Player更新是以Player为单位,比如更新PlayerA的AI组件,然后更新移动组,等PlayerA更新完了再更新PlayerB。而ECS则是以组件为更新单位,比如先更新所有Player的AI组件,然后再更新所有Player的move组件。这里ECS架构会明显优于传统架构,好处我就不再多说了。我想说的是并非所有组件都能用ECS来表示,比如AI组件。通常我们的AI组件会使用行为树或状态机来实现,而ECS架构很难去实现这两种设计。比如行为树,对于行为树我们该怎么抽象出纯数据的Component,然后连续存放到内存中。我就脑洞大开的想一下,我们对使用相同行为树的Player进行归类,然后连续存储行为树的每一个节点。这样可以保证节点数据的连续性(root A, root B, root C, child1A, child1B,child1C。。。)这种结构是可以存在的(很别扭),但是update的时候每一个player的逻辑并不一致,这就无法保证连续读取数据节点了。行为树这种设计本身就是数据不连续,指令不连续,我们没必要非拧巴出一个ECS的行为树。但是行为树的设计还必须使用,这时候该怎么办?答案就是纯ECS架构实现游戏是不现实的。对于行为树我们可以使用传统的oop来实现一个AIComponent,然后设计一个AIManger单例负责创建和销毁AI组件。但是AI组件的更新还是希望放到System中,这样做的好处有两点,第一可以复用system的排序规则,第二代码很干净,在一堆system中间调用一个AIManager的Update很别扭吧。为了让AI组件看上去ECS,我会创建一个空的AIComponent,然后创建一个AISystem去遍历所有包含AIComponent的Entity。

角色销毁和角色创建差距不大这里就不多说了。

说这么多只想对自己说一句ECS架构不是万能钥匙,它可以做为首选的设计思想,但是一旦遇到ECS架构不友好的情况,还是需要使用其他架构的,但是尽量能优雅的和ECS架构融在一起。

接下来说一下ECS架构的并行处理。

首先多线程的使用不是越多越好,线程之间的切换是有消耗的。之前写游戏逻辑基本上只有一个主线程在更新逻辑,这在多核环境下属于浪费资源。既然使用多线程,我们应该采用一个什么样的策略来分配线程呢?ECS架构非常反感回调函数,为什么?因为回调函数会跳转逻辑,从而导致数据和指令缓存的失效。如果我们将多线程设计在system级别上就非常不ECS了,想一下线程运行的顺序systemA,systemB,systemA,systemC,这和回调函数一样破坏逻辑连贯。而且跨system的多线程会引入更复杂的资源竞争。最终我的想法是多线程只使用在system内部。比如一个system需要处理100个entity,我会根据组件的大小决策分配多少个job来处理这100个entity。这样做的好处是即利用了cpu多核提高效率,又不至于跳转逻辑导致性能下降。而且资源的竞争只需要保证system内部使用的数据不冲突即可。每一个system的update还是在主线程中单独调用。如果cacheline是64个字节,所需组件中最大的组件是16个字节,那么就申请100/(64/16)个job来处理这些entity。这样做是尽量保证每一个核的cachline不会被多次读取。当然这只是默认的策略,接口层面会让system能够自己去根据性能测试结果分配job。最后job不等于多线程。具体是在主线程中执行还是在worker线程执行,会由底层线程管理模块决定。每一个System我会等待所有线程结束后调用下一个system更新。等待所有线程执行结束,会不会造成性能的浪费?我的想法是不会,最次是单线程执行,就和没有用多线程一样。如果能分配出worker线程,那么效率会比单线程高很多。

另外我想多说一下cpu和gpu之间的并行处理。cpu不管是单线程还是多线程,只要cpu处理的太快,cpu都会等待gpu完成一帧的所有工作。为了防止cpu被gpu阻塞,我们可以使用FrameResoure来解决,也就是cpu比gpu多跑几帧。渲染模块会缓存多帧的指令供gpu使用。如果gpu跑的太快呢?那就把cpu的工作交给gpu做吧。

---------------------------------------以下内容很重要---------------------------------------------

经过测试当任务消耗时间在毫秒级以上的时候多线程才能跑过单线程,我的测试用例很简单就是读取连续的内存然后再赋值。更准确的测试结果是多线程未必会跑过单线程。

什么时候多线程跑不过单线程?简单概括就是当线程管理(包括线程创建,切换,销毁等等)带来的消耗抵不过多核带来的性能提升时,多线程会跑不过单线程。

比如一个任务是简单的数学运算,那么开多线程很有可能跑不过单线程,当我把任务的算力消耗提升到毫秒以上时多线程才逐渐超越单线程,而且随着计算量的增加以及线程数量的增加,多线程的优势会越来越明显。

多线程之所以高效是因为可以并行,如果一个任务需要等待很长时间如IO操作,那么因为并行多线程会把等待的时间缩短几倍。 也是这个原因通常我们游戏启动多线程的地方多是资源加载或是网络数据读取上面。但是对于游戏逻辑是否需要使用多线程来加速,我的答案是肯定的。在google上看到一篇帖子就是讨论ECS中的job到底有没有用。unity官方写引擎的人觉得很有必要。他们认为当前的硬件环境决定了以后多核是一种趋势,多线程编程一定会对游戏性能带来巨大提升,而且他们觉得之所以这么多年没有人用多线程开发游戏是因为多线程开发太复杂,因此他们希望写一个简单易用的ECS架构可以让上层调用者很简单的使用多线程开发游戏。首先为他们的想法点个赞,无论能否做到,至少是有梦想的一群开发者。当年unity引擎的创始人也是因为厌恶现有引擎设计太混乱才创造了gameobject这种组件模式的引擎。持否定态度的人,往往是上层调用的游戏开发者,他们对游戏开发很有经验,觉得游戏中没有很多逻辑可以并行计算,而且它们的游戏很有可能没有那么多entity,也就无法体现ecs批处理的优势了。他们还认为ECS架构对底层或者是驱动开发是一个很高效的抽象形式,但是对于上层游戏开发并不是一个很好的抽象模型。其实站在不同的角度,每个人都是对的。没必要因为这个进行争论,每个人只需要坚持自己想要的即可。我会拥抱ECS,原因很简单,OOP的项目我做了很多,ECS还未尝试,没吃过怎能判断是否好吃呢。还有我不觉得ECS的抽象模型很底层,有些时候人只是不喜欢跳出自己的舒适圈而已。言归正传,之前对ECS并行处理的想法过于简单,之前只是想把并行做到System内部,但是这种设计有一个明显的弊端就是多线程很有可能跑不过单线程,尤其是当entity很少的时候。为此我必须妥协,将并行处理提升到System层。unity的并行处理即在system层,也在system内部,也可以单线程,它给上层调用者很大的灵活性,让你去选择如何使用。我准备实现一个类似untiy的并行处理,但是限制肯能要比unity严格,毕竟我不是写通用引擎。我只是想写一个高效的框架去实现各种先进的demo。

如果将多线程处理放到system层,那就必须要处理system之间的资源竞争。因为同一个组件很有可能被多个线程同时进行读写操作。还有一个问题,如果两个系统资源没有竞争,我们还需要保证系统之间的执行顺序吗?

为了处理资源竞争,通常会引入某种同步机制,但是同步机制往往是低效的而且有可能会产生死锁。unity为了避免这个问题,引入了job依赖,也就是如果A依赖B,那么A一定会等B执行结束后再执行A。这种依赖关系的推导主要是依靠System中处理的组件决定的,对于只读组件,多个system完全可以并行读,但是对于写组件,system就需要根据依赖关系对system的执行顺序进行排序了。做为ECS架构的上层使用者,应当尽量减少系统之间的依赖性,因为如果所有系统都有依赖,那么这就和串行执行没什么区别了,甚至还不如直接单线程update了,因为管理依赖也需要成本。关于依赖的设计,我的想法是在每一个chunk上维护一个系统执行列表,Chunk会按照这个列表顺序执行每一个system。因为一个Chunk代表所需处理的部分entitys,这个chunk应该是system独占的(如果有写需求)。保证chunk的独占就可以保证组件数据不会被多个system竞争。这样处理的结果就是如果A依赖B,那么A和B也有可能并行处理,但是处理的数据一定是B先处理后再由A处理。为了保证chunk数据是干净的,不允许在work线程中隐式修改chunk数据,比如创建删除entity或者是创建消耗某个组件,这些行为都会导致chunk数据变脏,而这些操作是隐藏在system内部执行的,无法从system检索的组件中明确标识,因此我设计的系统只有主线程才被允许创建销毁组件和entity。

我很担心chunk中因为entity太少,导致chunk并行会跑不过单线程。如果我们一旦确认entity的量很少,我们就不妨大方的在主线程中阻塞处理这个system。如果system依赖了某个并行的system,那么主线程也需要等待依赖system。

另外一个问题如果系统没有资源竞争,那么它们就可以不考虑运行顺序了么?我的理解是如果系统没有资源竞争,完全可以不考虑运行顺序。关键问题是我们必须要保证ECS系统能够判断出system的真实依赖关系。总结一下新的ECS并行思路:

1.并行以chunk为单位,如果已经有一个job开始写chunk,那么其他job就需要等待现有job处理完后再执行。chunk会维护一个使用列表,按照列表的顺序执行system。如果一个system处理多个chunk,那么只保证chunk之前的依赖顺序,system有可能会并行执行。

2.如果chunk中只有很少的entity,请使用主线程阻塞处理,主线程运行的system也需要等待依赖系统执行完毕后再执行。

3.每一个system必须明确指出需要处理的读,写组件,框架会根据这些组件调度system。

4.只能在主线程中创建和销毁组件或entity。

5.目前任务的分配最小单位为chunk,暂不再继续细分任务。

先按这个规则制作ECS的并行处理,如果遇到不合理的地方,会继续更新这篇文章。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值