ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++享元模式实战:从子弹内存爆炸到共享只读数据

C++享元模式实战:从子弹内存爆炸到共享只读数据 上周帮同事review一段战斗系统的代码场景是一个射击游戏里同时有几千发子弹在飞行每颗子弹不只记录了位置和速度还完整拷贝了一份伤害配置、特效ID、命中音效。这个文件里光是子弹对象的构造函数就写了四百多行测试时场景一热闹内存直接奔着2GB去了。同事的第一反应是给子弹做一个对象池我说你先别急——你真正的问题不是创建销毁太频繁而是每一颗子弹都在重复存储同一份永远不变的只读数据。这种场景学名叫享元模式。C里聊享元模式很多人第一反应是《设计模式》书上那个字体和字符的例子教科书味太浓真到项目里反而不一定用得上。这篇我打算换一种讲法不讲UML图只讲我在真实项目里怎么判断该不该用享元、内部状态和外部状态怎么切、生命周期和线程安全怎么收尾以及哪些情况根本不该硬套享元。内容包括经典实现、现代C的空间优化思路、线程安全案例和反模式排查想搞懂设计模式在C里怎么落地的或者准备C八股时想知道“享元、单例、对象池有什么区别”的可以放心看下去。1. 三个触发信号什么时候应该想到享元1.1 先算一笔内存账享元模式解决的问题核心不是“对象太多”而是“每个对象都背着一大块相同的只读数据”。我拿子弹举例假设一颗子弹的位置、速度、旋转这些运动学字段一共占48字节这个没法省是每个实例独有的但在它身上挂的伤害配置、弹道类型、技能效果描述、特效路径加起来可能占2KB而这2KB在同一个弹道技能下全是不一样的吗实战中大多数子弹的伤害配置完全一致。你把10万个实例算一遍运动学字段才480万字节不到5MB但10万份2KB只读配置就是200MB。对象池只能降低new/delete的频率解决不了这份200MB的重复存储。享元关心的就是这2KB把这些不变的数据从实例里抽出来放到一个共享表里每个子弹只持有指向共享数据的一个句柄或者指针内存立刻从200MB降到几MB这还不加缓存命中率带来的速度收益。我在重构这个模块之前先写了个小脚本统计了对内存的占用确认最大的头确实就是那2KB重复配置才决定引入享元。如果头不在只读共享数据上这个模式基本帮不上忙。1.2 内部状态与外部状态设计模式书上的术语把“共享数据”叫内部状态“每次调用传进去的数据”叫外部状态。用代码来理解更直接一颗子弹的纹理路径、伤害数值、音效ID这些是内部状态因为它们在一颗子弹的整个生命周期里不会变而当前坐标、当前速度、还剩下多少存活时间这些是外部状态每个实例都不同并且每帧都在变。两个绘画界面的人共用一套字体样式定义字体大小、颜色、字重是内部状态写在哪一页、哪一行、哪个位置是外部状态——这是最经典的环境。在游戏里更常见的例子是多个敌人共用同一套寻路配置、多个实例共用同一个技能数据块。划分原则就一条要共享的数据必须满足“全生命周期只读”和“全局恒定”这两个条件。只要你的共享字段会被某个实例修改就得把它踢回外部状态否则并发环境里会出现那种最难查的“偶尔数据错乱”问题。1.3 享元不是单例也不是对象池很多C面试题喜欢把这三个概念放一起问因为真的容易混。单例模式保证整个进程只有一个实例而这个实例本身通常是某个全局服务对象池减少重复分配但池子里的对象各自有独立的数据享元模式则允许共享对象有多个逻辑实例但共享数据只有一份物理存储。模式解决的核心问题实例数量数据存储单例全局唯一访问点1个固定一份对象池降低创建销毁成本N个可复用每个对象独立享元消除重复存储逻辑上N个物理共享M份M通常远小于N实际项目里这三个经常组合使用享元负责把只读大块抽出来共享对象池负责管理频繁创建销毁的外部状态对象两者并不冲突。你先判断自己在哪一层遇到了瓶颈再去选工具。2. 经典实现工厂、注册表与 shared_ptr2.1 从“来一个建一个”到“查表共享”最常规的做法是加一个工厂类负责维护共享资源注册表。调用方不再直接new而是带着“共享数据的唯一标识”来工厂里查询查到直接返回查不到就创建一个再塞进表里。拿粒子系统举例可以这样设计共享数据struct ParticleSharedData { TextureId texture; // 纹理ID float baseSize; // 基础尺寸 std::arrayfloat, 8 alphaCurve; // 透明度变化曲线 }; class ParticleSharedRegistry { public: std::shared_ptrconst ParticleSharedData GetSharedData( TextureId tex, float baseSize, const std::arrayfloat, 8 alphaCurve); };传入的三个字段组成了这条共享数据的唯一标识。同一个技能生成的粒子三个字段完全一致工厂返回的是同一个shared_ptrconst ParticleSharedData粒子对象里只需要存一个指针或者句柄。2.2 用 weak_ptr 当缓存而不强持有注册表有两个选择直接存shared_ptr或存weak_ptr。区别很关键。如果工厂用shared_ptr强持有所有创建过的共享数据那么这些数据即使没有任何粒子在引用也会一直活到程序退出这在长时间运行的服务器或者关卡切换频繁的游戏里就是隐性内存泄漏。我会让注册表存weak_ptr这样等到最后一个粒子销毁时共享数据自动跟着销毁。class ParticleSharedRegistry { public: std::shared_ptrconst ParticleSharedData GetSharedData( TextureId tex, float baseSize, const std::arrayfloat, 8 curve) { Key key{ tex, baseSize, curve }; { std::shared_lock readLock(mutex_); auto it cache_.find(key); if (it ! cache_.end()) { if (auto shared it-second.lock()) { return shared; // 命中缓存直接返回强引用 } } } std::unique_lock writeLock(mutex_); auto [it, inserted] cache_.try_emplace( key, std::weak_ptrconst ParticleSharedData{}); if (inserted) { auto shared std::make_sharedconst ParticleSharedData(tex, baseSize, curve); it-second shared; return shared; } return it-second.lock(); } private: struct Key { TextureId texture; float baseSize; std::arrayfloat, 8 alphaCurve; bool operator(const Key other) const default; }; struct KeyHash { std::size_t operator()(const Key key) const { std::size_t h std::hashTextureId{}(key.texture); h ^ std::hashfloat{}(key.baseSize) 0x9e3779b9 (h 6) (h 2); for (float v : key.alphaCurve) { h ^ std::hashfloat{}(v) 0x9e3779b9 (h 6) (h 2); } return h; } }; mutable std::shared_mutex mutex_; std::unordered_mapKey, std::weak_ptrconst ParticleSharedData, KeyHash cache_; };注意上面开头先加读锁查一次没有才加写锁创建锁粒度尽量小。中间那个短窗口里可能出现两个线程同时进入创建分支但try_emplace保证只有第一个成功插入另一个拿到已有结果不会重复占用共享资源。weak_ptr的做法也有个前提共享数据本身必须是真正只读的。一旦有代码试图通过shared_ptrconst T之外的路径修改数据问题就大条了。所以我在整个模块里坚持所有共享数据都以const对外暴露内部想改只能通过管理器重建避免接口层面泄露出写入口。2.3 为什么我推荐整数句柄而不是裸指针很多第一次写享元的人喜欢直接返回裸指针好看也好写但生产环境里裸指针在做共享资源管理和回收时容易出悬垂问题。某个子弹还握着老指针另一个线程已经把共享数据析构了下一次访问就是未定义行为。我的习惯是丢出一个整数句柄在管理器的内部数组上做映射struct SharedHandle { uint32_t index; // 对象池或数组里的下标 uint32_t epoch; // 代际计数防止悬垂复用 };调用方持有的是SharedHandle真正解引用的时候统一通过管理器去取管理器能检查epoch是否匹配、越界是否发生。调试期顺手加一句断言就能抓住绝大多数非法访问比裸指针对着堆栈猜半天舒服得多。句柄方案裸指针方案可以检查越界和代际越界就是UB序列化、网络传输友好指针跨进程无意义调试期能打印index/epoch只能打印地址多一层间接调用少一次查表这个取舍不是绝对的如果你写的是一个轻量工具类生命周期非常可控裸指针也完全可以。但如果是游戏引擎、网络服务这种长期跑、多线程访问的重型系统我建议你一开始就用句柄。3. 进阶思路空间优化与缓存局部性3.1 另一种“享元”不一定需要共享现代C社区里还有一种思路常被称为Flyweight空间优化它和经典享元不完全一样。经典享元强调N个实例共享同一份数据空间优化强调的是“不要在一个对象里复制一份大体积的引用对象”而是把大对象统一放在某个注册表或者容器里业务对象只保留一个小的指针、索引或ID。class AnimationClipRegistry { public: const AnimationData* GetClip(uint32_t clipIndex) const; private: std::vectorAnimationData clips_; // 动画数据统一在这份集合里 }; struct Enemy { uint32_t clipIndex; // 不再持有一份 AnimationData 拷贝 const AnimationData* clip() const; // 按需从注册表取 };看似简单但能带来两个稳定的收益第一实体对象体积变小批量创建时内存带宽和cache压力都降第二大对象生命周期收拢到注册表不容易被外部乱改。在只有几百个实体的小系统里效果不明显但到了万人同屏、实体频繁增删的网络游戏里这个习惯非常值钱。3.2 收益不只是省内存我见过不少团队为了“省内存”上享元结果压测后最大的收益其实是降低了分配次数和提升了缓存命中率。深拷贝版本每创建一个粒子要拷贝一次2KB配置分配器压力大缓存行被无意义的数据填满享元版本省的是一次分配构造和析构次数也少再加上多个粒子同时访问同一份共享数据那几KB数据很容易一直留在L1/L2 cache里实际访问速度远快于到处拷贝。指标深拷贝方案享元方案内存占用200MB约5MB构造时分配次数10万次600次每帧缓存命中率不稳定稳定高修改共享配置每个副本都要改只改一处所以别把享元当成纯内存优化它同时是一个带宽和分配优化方案。3.3 数据布局细节内部状态越频繁被读取越应该保证这些共享数据密集排列。注册表底层优先用vectorT而不是listT保证同一批共享数据的内存尽量连续。如果共享数据和外部状态经常一起被访问还可以考虑把外部状态改成结构体数组SoA布局把连续多个粒子的坐标单独排一块连续内存物理引擎和渲染遍历起来会有额外收益。一句话总结共享数据不共享用一个管理器统一持有外部数据密集排列用连续容器管理。这两条做好了内存和带宽上基本就没有明显浪费了。4. 生命周期C没有GC时的三个答案4.1 shared_ptr weak_ptr 组合第2节代码里的方案已经体现了最常用的生命周期策略管理器的缓存用weak_ptr保存逻辑消费者用shared_ptr持有。优点是代码直观、线程安全有标准库保证适合大部分多线程模块。注意两个风险一个是循环引用共享数据内部不要反向持有业务对象另一个是引用计数在高频场景下可能成为瓶颈但大多数游戏模块的开销还能接受。4.2 句柄令牌方案如果你不需要每个消费者独立影响销毁时机而想对资源生命周期做集中控制可以走句柄令牌路线。资源躺在管理器的一个vector里每个表项带一个epoch句柄就是indexepoch的复合体。销毁由管理器统一执行拿到旧句柄去访问时因为epoch不匹配会直接断言或返回空。class AnimationHandle { public: AnimationHandle() : index_(kInvalidIndex), epoch_(0) {} AnimationHandle(uint32_t index, uint32_t epoch) : index_(index), epoch_(epoch) {} private: uint32_t index_; uint32_t epoch_; };这套方案相比shared_ptr的好处是跨进程、跨语言边界时可以直接序列化也便于做调试诊断。Unity引擎里的对象、不少开源游戏引擎里的资源ID都接近这种设计。缺点是要自己维护一套句柄分配表实现量比直接make_shared大一些。4.3 版本纪元成批失效还有一类特殊场景共享数据的生命周期和关卡、副本强绑定一波波成批创建、成批销毁。比如一场Boss战里所有子弹共享同一套技能配置打完Boss整套配置都要失效。这种场景不必对每个共享对象做精细化引用计数直接维护一个全局或者按关卡的epoch每次进入新关卡之前把epoch加1所有句柄携带的旧epoch自然失效新逻辑用新epoch创建新资源。策略控制粒度实现成本适用场景shared_ptr weak_ptr精确到单个资源低通用多线程模块句柄令牌精确到单个资源中资源表集中管理版本纪元批次级低关卡、副本、一次性结算别小看这个选择生命周期策略没想清楚就开始写享元后面一定会出现“共享数据被提前回收”或“内存迟迟不释放”这种问题机制本身再漂亮也白搭。5. 线程安全实战一个纹理缓存组件的崩溃日记5.1 从单线程到多线程的第一步单线程里写享元很轻松加个map就行但项目一旦切到多线程问题立刻冒出来两个战斗线程可能同时加载同一个技能纹理。最简单的方案是给注册表加一个std::shared_mutex读多写少时用共享锁第一次创建时用独占锁。第2节代码示例里已经演示了“读锁先查查不到再切写锁建”这个流程在多线程下还要注意一点不要在锁内做耗时很长的磁盘加载或编译否则所有线程都会卡在锁上。我的习惯是先把稀有资源准备好再进锁内做插入。5.2 一个让我印象深刻的实际崩溃之前有个项目就是踩了“锁没覆盖到位”的坑。加载纹理时两个线程同时检查缓存都发现没有于是都开始读同一个文件最后返回了两个内容相同但地址不同的纹理对象。后面系统里有个地方直接用纹理地址做唯一标识导致两个地址都出现逻辑断言炸了而且只在特定机型上概率性触发排查了整整两天。后来修法很简单在锁内完成一次统一的“检查-创建-插入”并且把创建前的重复请求改成等待同一个加载任务而不是各自加载。教训就是享元的注册表操作必须是原子的绝不能把“确认不存在”和“创建并插入”拆成两个线程各自进行的独立动作。5.3 thread_local 局部缓存解决竞争如果你的热点线程每个帧循环都要从注册表读取几十个共享资源每次都去查全局unordered_map还是会引入不必要的锁竞争。可以在关键线程上做一个thread_local小缓存保存最近高频命中的几个shared_ptr下次直接命中完全不用抢锁。thread_local std::arraystd::shared_ptrconst ParticleSharedData, 8 tlsCache; thread_local std::arrayuint64_t, 8 tlsKeys; std::shared_ptrconst ParticleSharedData GetFast(TextureId tex, float baseSize) { uint64_t key (uint64_t(tex.id()) 32) ^ std::bit_castuint32_t(baseSize); for (size_t i 0; i tlsCache.size(); i) { if (tlsKeys[i] key tlsCache[i]) { return tlsCache[i]; } } auto shared GetSharedData(tex, baseSize, /* curve */); tlsKeys[tlsHead] key; tlsCache[tlsHead] std::move(shared); tlsHead (tlsHead 1) % tlsCache.size(); return tlsCache[(tlsHead tlsCache.size() - 1) % tlsCache.size()]; }这个设计里线程内已经持有强引用外部注册表即使临时清理weak_ptr也不影响当前线程正在使用的数据。需要注意thread_local缓存持有的强引用会让资源生命周期比预期稍长一点高频刷新的场景下可以接受。6. 反向指南什么情况不要用享元6.1 三个危险的信号享元不是万能钥匙我见过硬套享元导致项目复杂度爆炸的案例主要原因有三个共享数据会被修改一旦有一个实例修改了共享字段全部实例一起变排查起来非常痛苦最后只能给修改加锁白白牺牲性能单个对象本身很小如果共享数据只有一个int或者一个float指针的开销和间接访问可能比直接拷贝更贵生命周期不统一有的调用方想长期持有共享资源有的短期用完就走强制统一管理反而把引用计数的复杂度塞满了业务代码。6.2 替代方案发现不符合享元的条件时通常有更合适的工具需求更合适的方案频繁创建销毁、对象不大对象池大量实体读取同一块配置共享指针或只读全局表遍历大量结构体且关注连续内存结构体数组SoA避免不必要的深拷贝值语义 移动语义需要多个变量同时指向一份可变数据观察者模式或显式状态同步6.3 我的取舍流程现在我在项目里做享元取舍的顺序基本固定先写profile统计内存里大头是重复数据还是低频数据确认共享数据满足只读条件再决定用shared_ptr缓存还是句柄令牌。如果发现改的时候牵动太多模块说明耦合已经大了宁可保留复制也不强行共享。真正稳定可扩展的系统都是先跑数据、再做结论不是一个模式套到底。我在实战里踩过的坑几乎都出在“只考虑了内存节省没考虑生命周期和线程安全”这三件事上。如果你也要在自己的项目里引入享元建议按这个顺序一步步来先把共享数据抽出来用一个管理器维护第二步把调用方手里的裸指针全部换成句柄或shared_ptr第三步再加锁。三步走完你会发现原来动不动几百MB的运行时内存可能压缩到一个零头而且并发访问依然稳定。把这个过程完整走一遍比背一百条设计模式八股有用得多。
返回列表