Skip to content

引言

在 BattleSystem-ECS 项目中,为了实现 10K 敌人 + 20+ 系统并行运行、帧率超 3000 FPS,所有系统内部都用 Parallel.For 批处理。但引入并行后第一个踩到的坑就是:多个攻击系统同时写同一个敌人的血量,数据竞争导致伤害丢失。

这篇文章总结一套在 ECS 并行环境下验证过的安全模式——"两阶段模式"(并行收集 + 串行 apply),并附带几个具体踩坑案例。

一、问题的本质:并行写共享状态

先看一个朴素的塔防攻击流程:

PlayerTowerAttack.Update()  → 遍历玩家攻击范围内的敌人 → enemyHealth -= damage → 检查是否需要销毁
TowerAttack.Update()        → 遍历塔攻击范围内的敌人    → enemyHealth -= damage → 检查是否需要销毁
SkillSystem.Update()        → 遍历技能范围内的敌人      → enemyHealth -= damage → 检查是否需要销毁

三个系统在 Parallel.For 里各自并行执行。假设玩家和塔同时打中同一个敌人,代码可能是这样的:

csharp
// 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() 统一销毁实体

关键原则:

  1. damage queue 存 raw value,不存 derived value。存 (enemyId, 20) 而不是 (enemyId, 30)。多个攻击者对同一敌人的伤害可以通过加法累加——enemyHealth -= damage1; enemyHealth -= damage2; 结果正确,而 enemyHealth = clampedValue1; enemyHealth = clampedValue2; 会丢伤害。

  2. 帧末统一做死亡结算。系统只负责说"这个敌人该死了",GameManager 负责说"什么时候销毁、按什么顺序销毁、销毁后触发什么奖励结算"。这避免了 A 系统在 B 系统还在遍历敌人列表时把某个实体销毁,导致 B 系统的迭代器出错。

  3. EnemyAI 的两阶段。行为树评估(BT 解析、action 选优)在并行段,动作执行(EventBus.Publish、写攻击相关数据)在串行段。因为 EventBus 的 Publish 可能触发其他系统的回调,如果并行执行会导致级联的竞争条件。

调用的整体链路:

GameManager.Run()
  → BeginFrame()(重置 queues)
  → 各系统 Update()(只 queue,不 resolve)
  → ResolveEnemiesKilledThisFrame()(统一结算,死亡队列自清空)

三、具体踩坑案例

案例 1:GetAllActiveEnemyIds 返回内部引用

csharp
// 错误版本
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 统一执行"

核心思想都一样:

把"什么时候写"和"谁来写"分开。并行段只读不写只收集,串行段做真正的写。

参考文献