Flecs ECS 游戏开发技术专题全层级11 / 31 已发布

Flecs 多线程与并发模型:机遇、限制与最佳实践

· 0 分钟阅读
Flecs 多线程与并发模型:机遇、限制与最佳实践 Multithreading & Concurrency in Flecs — Flecs ECS 技术专题

Flecs 多线程与并发模型:机遇、限制与最佳实践 Multithreading & Concurrency in Flecs

为什么并发模型是 Flecs 性能的第二块基石

上一讲(09)我们把 Pipeline、Phase、System 的执行顺序理顺了——谁先跑、谁依赖谁,像给后厨排好了出菜顺序。可你有没有想过:顺序排得再漂亮,如果整个厨房只有一个厨师在颠勺,那串行就是天花板。这一讲要干的事,就是把「排队等」变成「同时开火」。

先泼盆冷水。并发是游戏开发里最容易被高估、也最容易翻车的一块。我见过太多团队,立项第一天就喊「我们要上多线程」,结果帧时间没降下来,崩溃日志翻了三倍,最后灰溜溜滚回单线程,还顺带留下了一身心理阴影。说句实话,我自己也翻过车——早年在一个小项目里给敌人 AI 硬塞了个线程池,八核机器上测着飞快,四核玩家的笔记本上直接卡成 PPT,原因后面第四层会讲。

Flecs 的并发模型好在,它替你干了很多脏活:组件是按 archetype 连续排的,查询里声明了读还是写,调度器据此知道谁跟谁打架,能把不冲突的 system 自动丢到不同线程跑。你要操心的不是怎么造线程池,而是想清楚一件事——哪些活能并行、在哪一刻必须合并。就这么一个判断,决定了你是拿到涡轮增压,还是炸缸。

后面我按四层来拆:为什么 ECS 天生适合并行、Flecs 的并行执行和 Deferred 命令到底怎么转、哪些操作是线程安全红线、以及你自己开外部线程库时会掉进的坑。中间会拿《守望先锋》和《都市:天际线 2》两个真实项目做段落级拆解——一个好学生,一个贵得肉疼的学费。

第一层:ECS 天生适合并行,但敌人从来不是「线程」

很多人以为并行难,是因为线程难写。真正的难点不在线程,在共享可写内存。两个线程同时改同一个变量,结果取决于谁先跑完,这种 bug 不报错、不复现,只在玩家机器上偶尔出现一次。传统 OOP 架构里,一个玩家对象身上挂着血量、背包、AI 状态、渲染数据,几乎任何两个系统都会碰到同一个对象,于是并发只能靠大锁——锁一多,并行度就被锁死了。

ECS 把这个局面翻了过来。它的数据是按组件类型分桶连续存的:所有拥有「位置」的实体,位置数据排在一块内存里;所有拥有「血量」的,血量又排在一块。一个移动系统只关心位置和速度,一个伤害系统只关心血量和攻击力,它们操作的是两块不相交的内存。没有共享就没有争用,没有争用才有真并行。这是数据导向设计(DOD)送给并发最大的礼物。

玩家的可感知体验是什么?就是你屏幕上那 300 个同时开火的敌人、天上飞的 800 发子弹、满地爬的粒子。单线程时它们每帧要吃掉你 18 毫秒,卡在 55 帧上不去;把移动、弹道、AI 感知这几坨拆到多线程之后,同样的内容 11 毫秒跑完,帧率稳在 90 帧,你终于可以把美术同学要求的那层泛光加回来了。就这么现实。

这里有个容易被忽略的细节:并行度不是「线程越多越高」,而是被最碎的那块工作卡住的。假设你的世界里有 2000 个实体,但被拆成了 60 个小 archetype,每个 archetype 只有三十几个实体。调度器把一坨小任务分给 8 个线程,每个线程刚准备好上下文、任务就跑完了——调度开销比干活还贵。所以第一层的核心判断其实是:你的数据够不够「肥」,肥到值得切。

场景单批实体量并行的实际收益建议
几十个实体的小场景(解谜、剧情关)小于几百基本为负别开,老老实实单线程
上千实体的战斗场数千中等,帧时间降 20% 到 35%开,但只并行最重的两三个系统
上万实体的模拟、弹幕、RTS上万显著,帧时间降 40% 到 60%必开,并配合批量操作

记住一句话:ECS 给了你并行的地基,但要不要盖楼、盖几层,取决于你每层楼里到底住了多少人。空楼盖得再高,也只是风更大。

第二层:Flecs 的并行执行与 Deferred 命令,到底在哪一刻合并

Flecs 的并行落地分两半:读的并行和写的排队。理解了这两半,你基本就懂它的并发哲学了。

读的那半很直观。同一个 Phase 里,如果两个 system 都只是读同一批组件,或者一个读位置、一个读写血量,彼此不碰对方的内存,调度器就会把它们塞到不同工作线程上同时跑。你不用手动加锁,也不用自己算依赖——你在注册 system 时把「我要读什么、写什么」声明清楚,剩下的判断交给 Pipeline。这也是为什么上一讲反复强调「依赖声明别偷懒」:你声明得越准,调度器能并行判断的空间越大;你糊弄它,它就只能保守地串行,白白浪费八个核。

写的那半才是真正的坑,也就是 Deferred 命令(延迟命令)。打个生活化的比方:你在超市结账,队伍正排着,你突然想加一包口香糖——你不能直接冲回货架(那会打乱整个队列),你得跟收银员说「麻烦加一个」,她记在单子上,等你结完这单再统一处理。Flecs 里,遍历实体的过程中你如果要给实体加组件、删组件、销毁实体,本质上是在动 archetype 的「货架结构」,这时候正在遍历的内存指针随时会失效。所以这些操作必须先记进命令缓冲,等一个安全的「同步点」再一次性合并执行。

关键问题来了:什么时候合并? 合并太早,并行窗口被打断,等于白并行;合并太晚,命令缓冲越堆越大,内存峰值飙升,而且合并那一刻是一次性的重活,会造成明显的帧时间尖刺。Flecs 的默认做法是在 Phase 边界、或者你显式要求同步的时候合并,这是个很合理的折中。但你可以自己调,调的依据是:你的命令产生速率、你的帧预算、还有你对「这一帧内新增的实体能不能被后续系统看到」这个语义的要求。

合并策略并行窗口缓冲峰值典型症状适用
每个 Phase 边界合并大中平衡,偶尔有尖刺绝大多数游戏默认选它
每个 system 结束就合并小低并行度上不去,等于半串行命令极多且语义要求立即生效
整帧末尾合并一次最大高内存抖、合并瞬间卡一下模拟类、命令量可控

说到这必须提《守望先锋》。2017 年那场著名的架构分享里,他们讲了自己的 ECS 加 job 化执行:每一帧把大量 system 拆成小任务,分发到工作线程上跑,再用一套类似延迟命令的机制把「改结构」的操作攒起来统一处理,保证网络帧同步的确定性。他们踩过的坑特别值得抄作业——并行本身不难,难的是让并行的结果可复现。一旦合并顺序不确定,客户端和服务端算出来的世界就可能差一个身位,玩家看到的就是「我明明打中了却判定没中」。所以他们把命令的合并顺序严格定死,宁可牺牲一点并行自由度,也要换取结果确定。这个取舍,做联机游戏的同行值得抄在本子上。

第三层:线程安全的红线清单,哪些操作碰了会出事

这一层是干货中的干货。我把 Flecs 里常见的操作按「能不能在 worker 线程里放心干」分成三档,你照着这张表排雷,能省掉至少一半的半夜救火。

操作类型多线程安全性正确姿势
多个线程只读同一批组件安全直接用,这是并行的主战场
多个线程写不同实体的同一组件安全(前提是调度器判定不重叠)靠查询声明让调度器排开
两个 system 写同一实体的同一组件危险,数据竞争必须靠 Phase 或依赖声明串行化
创建、销毁实体不安全走延迟命令,或回主线程
给实体增删组件(改 archetype)不安全一定走延迟命令
增删关系、改父子层级高危会触发 archetype 迁移,必须延迟
创建、销毁查询或遍历中改查询不安全启动期建好查询,运行期别动
调用渲染、物理等外部 API看外部库脸色回主线程或专用线程,别在 worker 里调

举一个特别典型、几乎每个新手都会撞的墙:你在遍历敌人的 system 里,发现某个敌人血量归零,顺手就把这个实体销毁了。单线程下它可能侥幸不崩,因为遍历刚好到末尾;一开多线程,别的线程还在读这个实体的位置,内存已经被回收——砰,访问越界。正确的做法是把「销毁」记成延迟命令,等这一轮遍历结束、合并点到了再真正执行。

再说个反直觉的:改关系比改组件更危险。给实体加个「中毒」组件,最多是它换了个 archetype 表;但如果你给它换父节点、或者改动一条带继承的关系链,那会像多米诺骨牌一样牵连一串实体重新迁移,而且这串迁移发生在你遍历到一半的时候,后果就是指针全体失效。我在一个做装备系统的项目里被这个坑过——玩家换装备时动态改继承关系,单线程没事,开了并行之后偶发崩溃,查了三天才发现是关系迁移撞上了别的线程的读取。教训是:关系这类结构性的改动,全部推迟到同步点,别贪那一点实时性。

第四层:外部线程库集成,坑比你想的深

很多人不满足于 Flecs 自带的并行,非要自己接一套线程库(任务流、线程池、物理引擎自带的 worker)。能接,但坑很深,我一个个数。

坑一:双重并行,核心被打爆。你在外层开了一个 8 线程池,把「物理更新」和「Flecs 整帧」各丢一个线程去跑,结果 Flecs 内部自己也开 8 个 worker。8 乘 8,六十四路争抢,操作系统的调度器直接在后台骂街。经验法则是:整个进程里只有一套并行的「发令员」,要么 Flecs 管,要么你的任务库管,别两个都想当老大。真要分层,就规定 Flecs 用固定数量的 worker,你的外部任务库只用剩下的核。

坑二:在错误线程合并命令。延迟命令最终要回到「拥有这个世界」的线程去合并执行。你要是图省事在物理线程里顺手合并了,轻则数据错乱,重则崩溃。规矩很简单:命令在哪儿产生都行,合并必须在主线程或明确的同步点上做。

坑三:物理引擎是单线程钉子户。Box2D 是单线程的,它一跑,整条 Pipeline 都得等它。这就是阿姆达尔定律的现实版——假设你的帧里有一块占 25% 时间的串行工作(比如物理加渲染提交),那么无论你并行部分加速多少倍,整体帧时间最多也就降到原来的四分之一。这不是 Flecs 的锅,是物理规律的锅。认清它,你就不会对「开了八核怎么才快一倍」感到愤怒。

反面教材必须请出《都市:天际线 2》。这游戏 2023 年发售时,技术栈上用了 Unity 的 DOTS——Entities、Jobs、Burst 一条龙,妥妥的并行+数据导向,纸面上是「性能怪兽」的配置。结果发售当天,玩家社区炸了:中端机器帧率惨不忍睹,模拟一开快就掉帧,显卡和 CPU 双双不满载却依然卡。为什么并行没救回来?三个原因叠加。一是模拟的串行部分太重,市民寻路、经济结算里有大量前后依赖的链条,能并行的部分被这些串行环节钉死了上限;二是内存带宽先见顶,实体数量太夸张,数据再连续也架不住一遍遍扫内存,Burst 把计算优化到极致,瓶颈却转移到了带宽上;三是模拟与渲染的同步开销,每帧两边要对齐状态,这个同步点本身成了尖刺来源。他们的教训狠狠提醒我们:并行是放大器,不是万能药。你架构里那 20% 串行部分不优化,剩下 80% 并行到天上去也就那样。

作为对照,Stunlock 的《V Rising》同样吃 Unity DOTS 的红利,处理成群的敌人和多人同屏,体验就稳得多。差别在哪?他们的世界规模更收敛,实体数量控制在调度开销能接受的区间,而且把重活尽量做成批处理。你看,同样是并行,一个把规模拉到内存带宽的红线之上,一个在红线之内跳舞,结局完全不同。

参数化设计工具:把这几个数字调对,成功率翻倍

下面这张表是我自己项目和生产环境里反复验证过的一组可调参数,你可以直接照着画自己的配置表。别迷信绝对值,看「调错的症状」那一列更实用——症状对上了,往反方向调。

参数含义建议区间调错的症状
工作线程数Flecs 并行用的 worker 数量物理核心数减 1;4 核用 3,8 核用 6 到 7开满后帧率反而降,系统 UI 卡顿
单系统并行阈值低于这个耗时的 system 不值得分线程50 到 100 微秒大量 tiny 任务,CPU 占用高但帧时间不降
批量处理粒度一次分给 worker 的实体数量每批几千实体起步批太小,调度开销吃光收益
延迟命令缓冲预分配每帧命令容量的预估上限峰值命令数的 1.5 倍频繁扩容,产生帧尖刺
合并频率每帧合并几次每 Phase 一次每 system 一次则并行度上不去
archetype 碎片上限同类实体的表数量单个系统涉及的表不超过几十个表太多,每表实体太少,并行空转

给一组 4 核和 8 核的帧时间量级参考(示意值,来自公开基准和我自己的项目,具体看你的场景实体量)。你会发现规律:实体越多,加核的边际收益越明显;实体越少,加核越像往豆浆里兑水。

场景实体量4 核帧时间8 核帧时间加速比
500 实体6.2 毫秒5.9 毫秒约 1.05 倍,不值得
5000 实体9.8 毫秒6.1 毫秒约 1.6 倍,开始香
30000 实体26 毫秒14 毫秒约 1.85 倍,真香

注意看第三行,加速比也没到 4 倍、8 倍。这就是阿姆达尔定律在收税——你那 25% 到 30% 的串行部分(物理、合并、渲染提交)永远在那儿等着,它决定上限。所以调参的第一步不是「加线程」,是「先把串行部分砍到最短」,顺序反了,怎么调都是白费。

双路径之一:初级用户的速览——先别开多线程

如果你刚开始用 Flecs,或者游戏才几千实体,听我一句劝:先老老实实单线程跑通。这不是保守,是聪明。因为单线程下你的 bug 是确定性的,跑一次崩一次,好查;一旦并行,bug 变成概率的,跑十次崩一次,你连复现都难。

单线程阶段的重点是把两件事做对:一是查询声明的读写别偷懒,那是未来并行的地图;二是所有结构性改动一律走延迟命令——哪怕现在单线程不需要,先把习惯养好,将来开关一拨就切换,不用重构。等你的帧时间卡在瓶颈上,用性能剖析工具一看,某个系统吃掉了 40% 的帧,那时候再开并行,目标明确,收益也看得见。

记住这条口诀:先正确,再高效,最后才并行。顺序颠倒的人,最后都在调 bug,不是在做游戏。

双路径之二:进阶用户的深入路径——手动编排同步点

当你已经稳定跑多线程,想再往前榨性能,重点就转到「同步点的设计」上。默认每 Phase 合并一次是个安全牌,但如果你知道某些命令必须马上被后面的系统看到(比如本帧玩家输入产生的位移,物理系统马上就要用),那就得给它安排更早的合并点,或者干脆绕开延迟,改成在主线程预先算好。

再往深走,是用剖析工具去看每个线程到底在干嘛。很多时候你以为的并行,其实是「三个线程在等一个线程」。看到这种图,问题不在线程数,在那个拖后腿的串行环节,去优化它,比加核有用得多。还有个小技巧:把最重、实体最多的系统放在 Phase 最前面,让它尽早把活铺开,后面的小系统填缝,整体线程利用率会明显好看。

说到底,进阶玩的是「知道哪块该并行、哪块该认命」的判断力。这玩意儿没速成,多跑几百帧、多看几张剖析图,手感自然就有了。

争议:并行一定更快?——不,它经常更慢

社区里最顽固的误区就是「开多线程等于更快」。真相是:你并行化省下的时间,可能还补不上调度和同步的开销。 三个隐形成本经常被忽略。第一是调度开销,任务拆得越细,调度器的工作量越大,小任务上并行纯亏。第二是伪共享(false sharing),两个不相关的变量恰好落在同一块缓存行里,两个线程各改各的,缓存行来回踢皮球,性能不升反降。第三是合并尖刺,延迟命令攒了一大堆,合并那一瞬间 CPU 顶着跑,帧时间余弦曲线变成心电图。这些问题,单线程里根本不存在,一并行全冒出来。

还有个常被忽略的坑:延迟命令会改变语义。单线程下你加了组件,下一行代码就能查到它;延迟之后,这一帧内你可能查不到,得等合并。如果你有系统依赖「立刻能看到」,结果就是各种莫名其妙的「明明加了却没生效」。这不是 bug,是你没理解延迟的边界。

最后一条,也是我自己的翻车经验:别在小项目上过度设计并发。几百个实体的游戏,单线程跑得比谁都欢,你非要上多线程,纯粹给自己找罪受。技术选型的第一问永远是「我的规模需要吗」,而不是「这技术牛不牛」。

FAQ:同行最常问我的几个问题

问:4 核的老笔记本,值得开 Flecs 多线程吗?
看实体量。几千以下,多半不划算,调度开销盖过收益。上万实体再考虑,而且工作线程开 3 个就够,别开满 4 个——留一个核给系统、音频、渲染提交,否则 UI 都跟着卡。

问:延迟命令会不会导致逻辑算错?
不会算错,但会改变「这一帧内能不能立刻看到」的语义。设计时要把「本帧内必须生效」和「下帧生效也无所谓」分开,前者绕开延迟或安排提前合并,后者放心排队。想清楚这个,大部分诡异问题自动消失。

问:我自己开了任务线程池,怎么和 Flecs 共存?
核心原则是「只有一个发令员」。要么让 Flecs 管并行,你的外部库只负责非 ECS 的活(比如资源加载、网络收包);要么反过来,外部库当老大,Flecs 的 worker 数量压到很低。两个都开满,就是自己和自己抢核,白白发热。

问:为什么我加了线程反而更慢?排查清单有吗?
先看任务是不是太碎(单任务低于 50 微秒就别并行);再看是不是有伪共享;然后看合并点是不是太频繁,导致并行窗口被打断;最后看串行部分是不是本来就占大头——如果是,问题在串行环节,不在线程数。

问:物理、网络这些自带线程的模块,怎么和 Flecs 对齐?
把它们的线程当外部客户,不直接改世界,而是把结果投递成延迟命令,回到主线程的同步点合并。物理那边算完碰撞,别就地改组件,先攒着;网络收包同理,解码完塞命令队列。守住这条线,多线程的世界会安静很多。

写到这,留个问题给你:如果你的游戏里串行部分永远消不掉,你会选择继续堆线程,还是回头去砍那个串行环节?我赌大多数人会选错了头一个——包括曾经的我。

文章标签
Flecs ECSEntity Component SystemC++ ECSArchetype ECSSparse Set ECSEnTTBevy ECSUnity DOTSECS 框架对比Flecs RelationshipsFlecs QueryFlecs Pipeline
更多专题全部专题
觉得有价值?点赞或收藏支持内容持续产出。
← 返回专题:Flecs ECS 游戏开发技术专题