引言
在 BattleSystem-ECS 项目中,为了实现 10K 敌人 + 20+ 系统并行运行、帧率超 3000 FPS,所有系统内部都用 Parallel.For 批处理。但引入并行后第一个踩到的坑就是:多个攻击系统同时写同一个敌人的血量,数据竞争导致伤害丢失。
这篇文章总结一套在 ECS 并行环境下验证过的安全模式——"两阶段模式"(并行收集 + 串行 apply),并附带几个具体踩坑案例。
一、问题的本质:并行写共享状态
先看一个朴素的塔防攻击流程:
PlayerTowerAttack.Update() → 遍历玩家攻击范围内的敌人 → enemyHealth -= damage → 检查是否需要销毁
TowerAttack.Update() → 遍历塔攻击范围内的敌人 → enemyHealth -= damage → 检查是否需要销毁
SkillSystem.Update() → 遍历技能范围内的敌人 → enemyHealth -= damage → 检查是否需要销毁三个系统在 Parallel.For 里各自并行执行。假设玩家和塔同时打中同一个敌人,代码可能是这样的:
// PlayerTowerAttack 线程 A
float newHealth = enemyHealth[enemyId] - damage1; // 读到 50
enemyHealth[enemyId] = newHealth; // 准备写 30
// TowerAttack 线程 B 恰好插在中间
enemyHealth[enemyId] = enemyHealth[enemyId] - damage2; // 读到 50 → 写 30
// 线程 A 最后写入 30,覆盖了线程 B 的结果这就是典型的 last-write-wins ——线程 A 看到 50、算出差 20 要写 30;线程 B 同步地从 50 开始算、算出 30、写入 30。两个攻击者只造成了一次伤害的效果。
二、两阶段模式
解决方案是把每个系统的执行分为两个阶段:
并行段(Parallel.For)
→ 只读组件数据(position/range/stats...)
→ 计算结果收集到 ConcurrentBag<(enemyId, damage)>
→ 禁止写 EnemyHealth / PlayerHealth / ActiveEnemyIds / EventBus
串行段(帧末统一结算)
→ 从 ConcurrentBag 取出所有事件,串行执行 enemyHealth -= damage
→ QueueEnemyDeath → ResolveEnemiesKilledThisFrame() 统一销毁实体关键原则:
damage queue 存 raw value,不存 derived value。存
(enemyId, 20)而不是(enemyId, 30)。多个攻击者对同一敌人的伤害可以通过加法累加——enemyHealth -= damage1; enemyHealth -= damage2;结果正确,而enemyHealth = clampedValue1; enemyHealth = clampedValue2;会丢伤害。帧末统一做死亡结算。系统只负责说"这个敌人该死了",GameManager 负责说"什么时候销毁、按什么顺序销毁、销毁后触发什么奖励结算"。这避免了 A 系统在 B 系统还在遍历敌人列表时把某个实体销毁,导致 B 系统的迭代器出错。
EnemyAI 的两阶段。行为树评估(BT 解析、action 选优)在并行段,动作执行(
EventBus.Publish、写攻击相关数据)在串行段。因为 EventBus 的 Publish 可能触发其他系统的回调,如果并行执行会导致级联的竞争条件。
调用的整体链路:
GameManager.Run()
→ BeginFrame()(重置 queues)
→ 各系统 Update()(只 queue,不 resolve)
→ ResolveEnemiesKilledThisFrame()(统一结算,死亡队列自清空)三、具体踩坑案例
案例 1:GetAllActiveEnemyIds 返回内部引用
// 错误版本
public List<int> GetAllActiveEnemyIds() => ActiveEnemyIds;
// 修复版本
public IReadOnlyList<int> GetAllActiveEnemyIds()
=> new List<int>(ActiveEnemyIds);返回内部 List<int> 引用意味着调用方拿到的是一个可变对象。如果某个系统在遍历这个列表的过程中,另一个系统(比如 WaveSpawning)在 BeginFrame 时添加了新敌人,ActiveEnemyIds 扩容,持有旧引用的系统可能访问到已被 GC 回收的内存区域。
改成返回副本解决了问题,代价是每帧多分配一个 List。对于最多 10K 实体的场景这个开销可接受。如果是更极端的性能要求场景,可以考虑双缓冲——写入 _pending、交换指针、读取 _current。
案例 2:float[] 和 bool[] 的并行写入没有任何保护
C# 的 float[] 读写不是原子操作。在 x86 上,对齐的 float(4 字节)读写通常不会出现 tearing,但这个假设不是语言保证的。正确的做法是永远不要在并行段写共享 float[],即使是看起来"无害"的 enemyHealth[i] -= damage。
本项目在把所有 damage 写入改为 ConcurrentBag 收集、串行 apply 之后,再也没有出现过伤害丢失的 bug。
案例 3:ConcurrentBag 和 ConcurrentDictionary 的选择
最开始用了 ConcurrentDictionary<int, float>(enemyId → 累加伤害),后来发现:
- Dictionary 的 key 可能是热的(同一 enemy 被多次命中),内部锁竞争比 Bag 严重
- Bag 只是追加,不需要 key 查找,性能好得多
改 Bag 后串行段多了一步——需要按 enemyId 把 damage 累加,但因为串行段的 List<(enemyId, damage)> 是单线程处理的,按 enemyId 分组累加是 O(n) 操作,比并行 Dictionary 的锁开销小。
四、这个模式不只适用于 ECS
两阶段模式不限于 ECS,任何需要在多线程/多系统环境下操作共享状态的场景都可以用:
- MVC 框架:多个 Controller 对同一个 Model 的修改,可以收集事件、帧末统一同步到 Model
- 服务端战斗计算:多个玩家同时攻击一个 Boss,集中收集操作、在帧末统一计算并广播结果
- MonoBehaviour Update 链:如果有多个 Update 需要修改同一个数据,可以约定"Update 只收集意图,LateUpdate 统一执行"
核心思想都一样:
把"什么时候写"和"谁来写"分开。并行段只读不写只收集,串行段做真正的写。
参考文献
- GitHub — BattleSystem-ECS(个人项目,本文相关内容来自此仓库)
- 本项目文档 [docs/philosophy.md]
- C# 中 ConcurrentBag 的内部实现