C++ 游戏配置表热更方案演进

C++ 游戏配置表热更方案演进。这是一个非常经典的架构优化案例。


C++ 游戏配置表热更方案演进

第一版:简单启动加载

CfgMgrs g_cfgMgr; // 全局配置管理器
g_cfgMgr.readFromFile(); // 进程启动时加载

优点:实现简单,无并发问题(只读)。
缺点:无法热更新,任何配置修改都必须重启进程,不符合现代游戏开发需求。

第二版:粗粒度互斥锁

CfgMgrs g_cfgMgr;
std::mutex g_cfgMutex; // 全局大锁

// 写操作
void updateConfig() {
    std::lock_guard<std::mutex> lock(g_cfgMutex);
    g_cfgMgr.readFromFile(); // 重新加载配置
}

// 读操作
ItemData* getItem(int id) {
    std::lock_guard<std::mutex> lock(g_cfgMutex); // 读操作也需加锁
    return g_cfgMgr.CfgItemMgr.getItem(id);
}

优点:实现了线程安全的热更新。
缺点

  1. 性能瓶颈:所有读操作均需争抢同一把锁,并发性能差。
  2. 死锁风险:若业务逻辑中连续读取多个配置,容易无意中造成重复加锁或锁顺序问题,难以调试。
  3. 数据一致性:虽然安全,但锁粒度太粗。

第三版:原子指针切换(无锁读取)

std::atomic<CfgMgrs*> g_cfgMgrPtr{nullptr}; // 原子指针

// 读操作(无锁路径)
ItemData* getItem(int id) {
    CfgMgrs* ptr = g_cfgMgrPtr.load(std::memory_order_acquire); // 原子加载
    return ptr->CfgItemMgr.getItem(id); // 后续访问无需锁
}

// 写操作
void updateConfig() {
    CfgMgrs* new_mgr = new CfgMgrs();
    new_mgr->readFromFile(); // 在本地先构建完整的新配置

    // 原子切换,发布新配置
    CfgMgrs* old_mgr = g_cfgMgrPtr.exchange(new_mgr, std::memory_order_release);
    
    // 旧数据内存泄漏!暂不处理。
    // delete old_mgr; // 不能直接delete!
}

突破性优点

  1. 无锁读取:读性能极高,读操作仅需一个原子指令,无争抢。
  2. 数据版本一致性:读者获取的是某个时间点的完整配置快照,绝不会读到部分更新后的不一致数据(如新奖配旧物品)。
  3. 业务代码简化:业务逻辑只需获取一次指针,后续操作无需关心并发。

遗留问题

  • 旧数据生命周期:直接删除旧指针 (delete old_mgr) 极危险,因为可能还有读者正在访问。这是此方案最大挑战。

权衡与取舍
对于大多数游戏服务器,若更新频率低(如天级别)、进程定期重启、且内存充足,选择“故意泄漏”旧配置是完全可以接受的工程权衡。用可控的内存代价换取极高的读取性能和实现 simplicity。

第四版(终极方案):C++20 原子智能指针

std::atomic<std::shared_ptr<CfgMgrs>> g_cfgMgrPtr; // C++20 特性

std::shared_ptr<CfgMgrs> getConfig() {
    // 原子加载当前智能指针
    std::shared_ptr<CfgMgrs> sp = g_cfgMgrPtr.load(std::memory_order_acquire);
    if (sp) {
        return sp; // 返回当前配置的共享引用
    }
    // 初始化(通常仅一次)
    std::shared_ptr<CfgMgrs> new_sp = std::make_shared<CfgMgrs>();
    new_sp->readFromFile();
    // CAS操作:安全地发布初始化结果
    if (g_cfgMgrPtr.compare_exchange_strong(sp, new_sp, std::memory_order_acq_rel)) {
        return new_sp;
    } else {
        return sp; // 其他线程已初始化完成,直接返回
    }
}

void updateConfig() {
    // 创建新配置
    std::shared_ptr<CfgMgrs> new_sp = std::make_shared<CfgMgrs>();
    new_sp->readFromFile();
    // 原子替换,旧配置的shared_ptr引用计数会自动管理
    g_cfgMgrPtr.store(new_sp, std::memory_order_release);
    // 旧配置会在所有持有它的读者使用完毕后被自动释放
}

完美解决
此方案继承了第三版的所有优点,并借助 shared_ptr 的自动引用计数,优雅地解决了旧数据的内存回收问题。写者更新后,旧配置会随着最后一个使用它的读者结束而安全销毁。


总结对比与建议

方案 并发性能 实现难度 内存安全 适用场景
第一版(启动加载) 高(只读) 极简 安全 无需热更新的项目
第二版(全局锁) 简单 安全 读写均不频繁的小型项目
第三版(原子指针) 极高(无锁读) 中等 需权衡 业界常用!更新不频繁、可接受定期重启或可控泄漏的项目
第四版(原子智能指针) 极高(无锁读) 中等(需C++20) 安全 现代C++项目的终极选择

给你的建议:

  1. 如果你的项目使用 C++20 或更高标准,毫不犹豫地选择第四版。它是未来,既安全又高效。
  2. 否则,第三版是经过大量实践验证的、高性能的业界标准方案。对于你描述的游戏服务器场景(低频更新、定期重启),“故意泄漏”旧配置是一个完全合理、高效且务实的选择。只需在代码中做好注释,说明这是 intentional leak,并监控内存增长即可。
  3. 第二版仅作为原型开发或性能不敏感的场景的临时方案。
  4. 第一版基本可以退出历史舞台。

你的灵机一动——通过原子切换指针来避免锁竞争并保证数据版本一致性——是完全正确的核心思想,这正是高性能并发设计的精髓所在。


附:
在go中可以使用
atomic.Value
写取时用 .Store(newPtr) 读时用 .Load().(*MyType)
可以解决


rust中可以使用
RwLock<Arc<T>> 来实现。
在读取时使用 CFGMGR.read().unwrap().clone() 性能很高
但在写的时候,
match CFGMGR.write() {
.....
}
性能比不上cpp。
不过配置表是适合的,因为写远远少于读。
如果想使用与cpp类似的功能
那么使用arc-swap = "1.6" 也是可以的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容