EnTT: 多线程 Multithreading

整理 EnTT 多线程访问的安全边界、类型索引安全和 const registry 的延迟初始化问题。

多线程 Multithreading

在 EnTT 中, 线程安全并不是默认支持的, 这主要是出于性能方面的考虑, 因为这可以避免 注册表 不必要的同步开销, 从而最大化性能, 如果需要线程安全,开发者需要自己负责管理同步

迭代器视图, 这些工具本身也不是线程安全的, 但可以在某些条件下实现并行操作

安全的并行

  • 如果线程操作的是不同的组件集合, 则是安全的
    例如, 一个线程处理组件 X, 另一个线程处理组件 YZ, 实际的例子可能是渲染系统迭代 可渲染组件 进行渲染, 同时物理系统在另一个线程上更新 物理组件

  • 如果多个线程只对同一组组件进行读取, 且没有添加或删除组件的操作, 则也是安全的 例如, 通过划分实体集合, 让多个线程分别处理包含 速度位置 组件的 不同实体

不安全的操作

同时对 同一个组件集合 进行迭代和修改 (如添加或移除组件) 会导致问题

EnTT 让开发者完全负责在必要时实现同步, 这样设计可以让开发者根据实际需求, 灵活地选择最优的同步方式, 而不会因为默认同步机制导致性能损失

简单来说

  • 不同组件集合 进行并行处理是安全的
  • 同一组件集合 进行多线程读取也是安全的, 但不能同时修改
  • 实体 的并行操作是不安全的

类型索引安全 type index

EnTT 提供了一些编译时定义 (如 ENTT_USE_ATOMIC), 用于在特定情况下启用线程安全, 例如, 如果多个线程使用依赖 type index generator(类型索引生成器) 的对象 (比如 注册表), 可以通过定义 ENTT_USE_ATOMIC 来确保并发操作的安全性

EnTT 的 type_index 是全局静态变量,如果没有定义 ENTT_USE_ATOMIC, 会导致跨线程访问的竞态条件, 即使单独为每个线程创建 registry, 这种竞态条件仍然存在

ENTT_USE_ATOMIC 通过使 type_index 成为原子操作, 保证了跨线程访问的线程安全性, 在多线程环境中, 如果没有定义 ENTT_USE_ATOMIC, EnTT 的许多功能 (如 registrydispatcher 等) 可能会导致线程安全问题, 因为它们依赖于 type_index

关于此的 github 讨论

引入线程冲突检测工具 (例如 ChromiumDFAKE_SCOPED_RECURSIVE_LOCK), 可以在开发阶段检测不安全的多线程使用场景

注意, 组件增, 删, 改 都不依赖于 type_index, 只有组件的 注册 依赖于它

type_index 是 EnTT 用于唯一标识 类型 的机制, 其主要功能是将 组件类型 (如 struct ComponentA) 与对应的 存储 关联起来, 每种组件类型在 EnTT 中都有一个全局唯一的 type_index, 用于区分不同类型的组件

当执行增删改操作时, EnTT 会通过 type_index 找到对应组件类型的存储, 实际操作只发生在存储上, 一旦找到正确的存储对象 (例如 registry.storage<ComponentA>()), 增删改的操作完全发生在存储层, 而与 type_index 无关, 如果存储不存在, EnTT 会动态地通过 type_index 初始化并关联组件类型与存储

所以 type_index 的多线程安全性和 注册表 的多线程安全性并不是一回事

常量注册表 Const registry

在 EnTT 中, const registry 是完全线程安全的, 但它有一些特定的限制和注意事项

关于此的 github 讨论

无法延迟初始化

const registry 是完全线程安全的, 由于线程安全的约束, const registry 无法延迟初始化缺失的 存储, 也就是说, 当生成 视图 时, 它不会像普通的 registry 那样懒加载组件的存储

懒加载组件的存储 是指在需要某个 组件存储 时, 动态地创建和初始化该存储, 而不是在 注册表 初始化时预先创建所有可能的组件存储, 这种设计是一种优化策略, 用于减少内存占用和初始化开销

也就是说, 组件池的初始化被延迟到首次访问时, 比如, 调用 registry.view<Component>() 时, 如果 Component 的存储池还不存在 (首次访问), EnTT 会动态地为该组件类型的存储池分配存储空间

因为从 const registry 中生成的视图总是有效的, 所以如果这种视图被长时间保留并重复使用, 当某些组件的存储不存在时, 就会导致问题: 视图可能会包含指向这些不存在存储的 悬空引用

一般情况下, 建议在需要时动态创建视图, 并在使用后立即丢弃它们, 对于 const registry来说, 这种做法从建议变成了规则, 因为这样可以避免视图因组件存储的缺失而无效

解决方案

如果不确定某些组件是否存在存储, 或者有特殊需求, 可以通过调用 registry.storage<Component>() 来显式声明并初始化特定组件的存储, 这种方法可以在生成视图前, 确保所有可能用到的存储都已经存在, 从而避免潜在问题

也就是说, 我们可以在使用多线程之前, 用单线程运行一次 预热 过程, 在预热期间, 可以确保所有需要的存储和组件被正确地初始化, 当然这种方法并不适用于所有场景