多线程 Multithreading
在 EnTT 中, 线程安全并不是默认支持的, 这主要是出于性能方面的考虑, 因为这可以避免 注册表 不必要的同步开销, 从而最大化性能, 如果需要线程安全,开发者需要自己负责管理同步
迭代器、视图 和 组, 这些工具本身也不是线程安全的, 但可以在某些条件下实现并行操作
安全的并行
-
如果线程操作的是不同的组件集合, 则是安全的
例如, 一个线程处理组件X, 另一个线程处理组件Y和Z, 实际的例子可能是渲染系统迭代 可渲染组件 进行渲染, 同时物理系统在另一个线程上更新 物理组件 -
如果多个线程只对同一组组件进行读取, 且没有添加或删除组件的操作, 则也是安全的 例如, 通过划分实体集合, 让多个线程分别处理包含 速度 和 位置 组件的 不同实体
不安全的操作
同时对 同一个组件集合 进行迭代和修改 (如添加或移除组件) 会导致问题
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 的许多功能 (如 registry、dispatcher 等) 可能会导致线程安全问题, 因为它们依赖于 type_index
引入线程冲突检测工具 (例如 Chromium 的
DFAKE_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 是完全线程安全的, 但它有一些特定的限制和注意事项
无法延迟初始化
const registry 是完全线程安全的, 由于线程安全的约束, const registry 无法延迟初始化缺失的 存储, 也就是说, 当生成 视图 时, 它不会像普通的 registry 那样懒加载组件的存储
懒加载组件的存储 是指在需要某个 组件 的 存储 时, 动态地创建和初始化该存储, 而不是在 注册表 初始化时预先创建所有可能的组件存储, 这种设计是一种优化策略, 用于减少内存占用和初始化开销
也就是说, 组件池的初始化被延迟到首次访问时, 比如, 调用 registry.view<Component>() 时, 如果 Component 的存储池还不存在 (首次访问), EnTT 会动态地为该组件类型的存储池分配存储空间
因为从 const registry 中生成的视图总是有效的, 所以如果这种视图被长时间保留并重复使用, 当某些组件的存储不存在时, 就会导致问题: 视图可能会包含指向这些不存在存储的 悬空引用
一般情况下, 建议在需要时动态创建视图, 并在使用后立即丢弃它们, 对于 const registry来说, 这种做法从建议变成了规则, 因为这样可以避免视图因组件存储的缺失而无效
解决方案
如果不确定某些组件是否存在存储, 或者有特殊需求, 可以通过调用 registry.storage<Component>() 来显式声明并初始化特定组件的存储, 这种方法可以在生成视图前, 确保所有可能用到的存储都已经存在, 从而避免潜在问题
也就是说, 我们可以在使用多线程之前, 用单线程运行一次 预热 过程, 在预热期间, 可以确保所有需要的存储和组件被正确地初始化, 当然这种方法并不适用于所有场景