EnTT: 跨越边界 across boundaries

整理 EnTT 跨动态库边界使用时的类型识别、meta 上下文共享和内存管理问题。

在使用 EnTT 进行跨库或插件开发时 (尤其是 Windows 或 GNU/Linux 默认隐藏符号的情况下), 了解其符号可见性和类型擦除的处理方式是非常重要的

实现

历史问题

EnTT 在以下场景中曾遇到过限制:

  • Windows: 跨动态库或应用程序边界使用时
  • Linux: 当设置默认符号可见性为 hidden

问题的根源在于 EnTT 使用的内部工具来为不同类型分配唯一且连续的标识符

幸运的是, 这些问题已经得到解决, 只要正确配置编译选项, EnTT 现在可以顺畅地跨边界运行

类型擦除与类型识别

类型擦除 是 EnTT 用于管理 实体组件 的一项重要技术, 由于类型被擦除, 必须通过某种机制来识别它们, EnTT 提供了 type_hash 类模板来处理, 为被擦除的类型生成唯一标识符, 标识符冲突虽然极少发生, 但确实可能出现, 如果默认方案不适合需求, EnTT 提供了自定义的解决方案

符号导出与导入

在使用 动态链接库 时, 使用 ENTT_API_EXPORTENTT_API_IMPORT 宏来控制符号 导出/导入 以保证一切顺利跨边界运行

  • ENTT_API_EXPORT: 用于从共享库导出符号
  • ENTT_API_IMPORT: 用于在应用程序或其他库中导入符号

如果是 不导出符号的共享库 (如插件), 则通常无需额外配置, 因为不涉及符号 导出/导入 的问题

测试用例

EnTT 的 测试用例 中包含了许多关于跨边界使用的示例, 在源码的 lib 目录下, 可以找到以下内容

  • 使用 ENTT_API_EXPORTENTT_API_IMPORT 处理动态链接库的示例
  • 插件形式使用 (不导出符号) 的示例
  • 解决类型哈希冲突或自定义标识符的用例

这些测试用例覆盖了大部分常见场景, 可以作为开发的基础参考

跨边界的 Meta 上下文共享

在使用 EnTT 的运行时反射系统时, 跨边界使用场景需要特别注意上下文 context 的共享问题

EnTT 的反射系统依赖于一个静态上下文, 所有的反射元素都会附加到这个上下文上

  • 不同的上下文是独立的, 互不相关
  • 如果不共享上下文, 则跨边界无法识别对方的反射类型

为了解决这个问题, 必须在多个边界间共享同一个上下文, 以确保可以使用相同的 meta 类型

共享上下文的方法非常简单, 分两步完成

  1. 获取当前上下文句柄
    发送端 获取本地上下文的句柄
auto handle = entt::locator<entt::meta_ctx>::handle();

此时, handle 保存了当前上下文的引用

  1. 接收端 设置上下文 将获取到的上下文句柄传递到另一端, 并将其设置为默认上下文
entt::locator<entt::meta_ctx>::reset(handle);

这一步会将接收端的本地上下文替换为共享的上下文, 如果有必要, 可以将本地上下文存储起来以备后用

从这一刻起, 所有新的 meta 类型都会附加到共享上下文, 无论它们是在何处创建的

注意事项

  • 更换上下文不会自动同步更改
    如果一端替换了上下文, 这种更改不会自动传播到另一端, 上下文的替换会导致两个端之间的 解耦, 从而引发内容的分歧

  • 上下文共享是单向的
    共享上下文仅在初始设置时有效, 之后的所有更改需要确保对共享的上下文直接操作,而不是替换

示例代码

发送端

// 获取当前上下文句柄
auto handle = entt::locator<entt::meta_ctx>::handle();
 
// 传递 handle 给接收端(假设通过某种 IPC 或者直接调用)
sendContextToReceiver(handle);

接收端

// 接收到句柄并设置为默认上下文
entt::locator<entt::meta_ctx>::reset(receivedHandle);

此后, 发送端和接收端都使用相同的上下文, 所有 meta 类型的注册和查询都在共享的上下文上完成

内存管理问题

在使用 EnTT 时, 内存管理可能会引发一些微妙的问题, 尤其是在组件或事件等对象通过动态方式按需创建的情况下, 这些问题通常出现在以下场景:

  1. 插件使用场景
  2. 静态链接的运行时 statically linked runtime

当 EnTT 的 注册表 在不同的边界上共享时, 内存管理就可能变得复杂

问题来源

以一个典型场景为例:

  • 主程序 创建了一个 registry 实例, 并将其共享给 插件
  • 插件处理一个主程序未知的组件, 注册表为此组件按需动态创建了一个

问题: 这个池是在插件的一侧创建的, 但却附加到了主程序的注册表中

由于不同边界使用不同的内存分配机制 (如不同的运行时库或分配器), 这种情况可能导致内存管理冲突, 例如

  • 插件释放了主程序分配的内存 (或反之),导致 崩溃
  • 隐藏的内存泄漏或数据不一致

解决方案

使用明确的接口
为了避免跨边界的内存管理问题, 需要通过接口传递基本类型, 而非直接共享 EnTT 的内部实例, 也就说 将 EnTT 的实例 (如注册表或池) 隔离在特定边界内, 并通过接口暴露其功能

class IRegistryInterface {
public:
    virtual void createEntity(int entityId) = 0;
    virtual void assignComponent(int entityId, const std::string &componentData) = 0;
    virtual ~IRegistryInterface() = default;
};

插件和主程序都实现接口, 而不是直接共享 registry 实例

使用统一的内存分配策略
确保插件和主程序使用相同的动态运行时库 (如 MSVC 的动态运行时 /MD/MDd), 如果可能, 使用 共享内存分配器 custom memory allocators, 让内存管理行为一致

隔离注册表实例
在跨边界时, 避免直接共享 registry, 相反, 可以

  • 在每个边界维护独立的 registry 实例
  • 通过接口或序列化机制传递数据

实例

以下示例展示如何通过接口隔离注册表

主程序实现接口

在这种情况下, 接口的实现完全在主程序中, 插件只负责调用主程序暴露的接口, 适用于插件只需要调用功能, 而不需要提供自己的实现

接口定义

class IRegistryInterface {
public:
    virtual void createEntity(int entityId) = 0;
    virtual void assignComponent(int entityId, const std::string &componentData) = 0;
    virtual ~IRegistryInterface() = default;
};

主程序

#include "IRegistryInterface.h"
 
class MainRegistry : public IRegistryInterface {
public:
    void createEntity(int entityId) override {
        registry.create(entityId);
    }
 
    void assignComponent(int entityId, const std::string &componentData) override {
        // 序列化或解析组件数据
        registry.emplace<ComponentType>(entityId, componentData);
    }
 
private:
    entt::registry registry;
};

插件

#include "IRegistryInterface.h"
 
void pluginFunction(IRegistryInterface &registryInterface) {
    registryInterface.createEntity(1);
    registryInterface.assignComponent(1, "some_component_data");
}

通过接口交互避免了直接共享 EnTT 实例, 从而隔离了内存管理

插件实现接口

适用于插件需要扩展功能, 或者插件需要提供不同的行为, 由插件实现接口, 并在运行时由主程序动态加载, 主程序只依赖接口, 不直接与插件交互, 而是通过接口调用插件功能

接口定义 (所有二进制都依赖)

class IRegistryInterface {
public:
    virtual void registerPluginComponent() = 0;
    virtual ~IRegistryInterface() = default;
};

插件实现接口

class PluginRegistry : public IRegistryInterface {
public:
    void registerPluginComponent() override {
        // 注册插件特有的组件
        registry.emplace<PluginComponent>(1);
    }
 
private:
    entt::registry registry;
};

主程序加载插件

#include <memory>
#include "IRegistryInterface.h"
 
void loadPlugin(std::shared_ptr<IRegistryInterface> plugin) {
    plugin->registerPluginComponent();
}

主程序通过接口加载插件的功能, 而不关心具体实现, 插件完全控制自己的 registry, 避免直接依赖主程序的内存管理