ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

std::hive:C++26新容器的性能与适用边界

std::hive:C++26新容器的性能与适用边界 std::hive 是 C26 标准库里新加入的容器核心卖点一句话就能说完插入和删除是常数时间同时迭代器、引用和指针不会因为其他元素的增删而失效。这两个能力在std::vector上很难同时成立在std::list上虽然成立但迭代又慢。所以社区里最常见的问题就是std::hive 到底有多快值不值得在实体管理、ECS、观察者列表、事件订阅这类场景里替换现有容器这篇就从实际验证的角度来拆先讲清楚它解决什么问题再讲怎么搭一个公平的基准测试然后聊决定性能的参数和实现细节最后给一份使用边界和踩坑清单。我的建议很简单不要只看一遍 API 就动手替换先按下面的流程跑一轮小样本测试再决定要不要落地。1. 先看它解决的是哪一个“不可能三角”1.1 为什么 vector 和 list 都不能同时满足需求如果你维护一个对象集合绝大多数业务场景其实只有三种需求频繁遍历全部元素、随机或按条件删除元素、在遍历过程中新增元素。这三个需求单独拿出来都很简单但组合起来会暴露容器的短板。std::vector的优势是内存连续、缓存命中率高、支持随机访问遍历速度几乎是标准容器里最快的。可它的问题也很明显在中间erase或insert需要移动后续所有元素复杂度是 O(n)。更难受的是vector 扩容时会重新分配整块内存所有迭代器、引用、指针全部失效。哪怕只是删除中间一个元素后面的迭代器也会受到扰动。std::list采用节点分配插入和删除单节点都是 O(1)迭代器也稳定。但它的每个节点单独分配在小块内存上遍历时需要沿着指针一个个跳缓存命中率差速度通常明显慢于 vector。节点本身也有额外的指针开销内存占用并不低。所以当一个场景同时需要“频繁扫描所有对象”和“在扫描过程中删除对象”时vector 删除代价太大list 遍历代价太大。这正是 std::hive 想填的空档。1.2 hive 的存储方式分组加跳过std::hive 的基本思路是把元素放进一系列“块”里。每个块内部是一段连续的元素存储区同时维护用于标记“哪些槽位还活着”的跳过信息。删除元素时对象并不会被逐个移动只把对应槽位标记成空闲遍历时迭代器利用跳过信息快速越过死槽位而不是一个一个检查。后续插入时新元素可以复用这些空闲槽位。这个设计和 list 最大的区别是块内部是连续内存遍历时不会完全退化成指针跳跃。和 vector 最大的区别是元素一旦被创建地址就不会变新增或删除其他元素都不会让已有对象的位置发生移动。这样带来的结果是插入和删除近似常数时间迭代器、引用和指针稳定遍历速度虽然不一定追得上 vector 的纯连续扫描但通常明显好于 list。1.3 和常见容器的定位差异容器中间插入/删除增删其他元素后迭代器稳定性遍历速度随机访问典型取舍std::vector中间操作 O(n)扩容后失效中间操作后后续迭代器受影响最快支持删除贵缓存好std::list单节点操作 O(1)稳定较慢缓存不友好不支持插入删除便宜遍历贵std::deque中间操作 O(n)视实现而定不稳定较快支持两端操作便宜中间贵std::hive摊还 O(1)稳定快跳过空洞的块内扫描不支持换取了稳定引用和删除效率我一般建议用一句话判断如果你的代码里“每帧或每个循环都要扫描全部对象同时不断有对象被删除和新增”那 hive 就是从容器设计上更贴合这个负载的方案。2. 实测前先确认环境和测试口径2.1 确认编译器是否支持std::hive 虽然已经进入 C26 工作草案但不同编译器的标准库实现进度差异很大。有些编译器需要开启 C26 或 latest 模式有些目前还没有实现。你没必要卡在“必须等编译器支持”上因为 std::hive 的前身 plf::hive 是单头文件库把 plf 的头文件放进工程命名空间换成 plfAPI 基本一致。这里的思路是先用 plf::hive 验证性能趋势和业务逻辑等本机标准库支持成熟后再切到 std::hive。两种实现的容器结构和复杂度性质一致得出的结论基本能迁移。# GCC/Clang 示例具体参数以编译器版本为准 g -stdc26 -O2 bench.cpp -o bench # MSVC 示例 cl /std:clatest /O2 bench.cpp如果编译器没有 就下载 plf::hive 的单头文件包含进工程把std::hive替换成plf::hive。2.2 为什么测试口径比测试本身更重要容器基准测试最容易犯的错误不是代码写错而是负载设计失真。比如只测“纯遍历”那 vector 几乎一定能赢只测“往 begin 位置插入”那 vector 会被 hive 甩开很多。这两种都不是真实业务。更稳的做法是先把业务负载拆成三个维度容器最大规模1000、10 万、100 万个元素结论可能不同。删除比例每轮删除 1%、10%、50%删除越多 vector 移动成本越高。遍历次数每轮遍历一次还是一百次决定缓存命中率的影响权重。我会先按这三个维度各跑一组而不是只跑一个固定规模。只有这样你才能说清楚“在什么条件下 hive 更快在什么条件下不如 vector”。2.3 注意版本和优化开关基准测试一定要在 Release、开启优化的情况下跑。Debug 模式下 MSVC 的迭代器检查会大量增加容器操作开销hive 这种需要精细指针操作的容器受影响尤其明显。另外关闭其他占用 CPU 的应用多轮取中位数避免只取一轮时间。3. 手写一个最小基准测试3.1 先测纯遍历最小测试能回答一个问题在完全没有删除的情况下hive 比 vector 慢多少。真实场景里这不是最终结论但它能帮你评估 hive 的遍历底噪。#include hive // 编译器支持时否则用 plf::hive #include vector #include chrono const int N 1000000; const int ROUNDS 20; volatile long long sink 0; template typename F long long measure_ms(F f) { auto t0 std::chrono::steady_clock::now(); f(); auto t1 std::chrono::steady_clock::now(); return std::chrono::duration_caststd::chrono::milliseconds(t1 - t0).count(); }填充数据时对 vector 可以用push_back对 hive 用insert或emplacestd::vectorint v; for (int i 0; i N; i) v.push_back(i); std::hiveint h; for (int i 0; i N; i) h.insert(i);遍历部分要防止编译器把空循环优化掉所以加一个累加变量long long sum 0; for (int r 0; r ROUNDS; r) { for (auto x : h) sum x; } sink sum;这个测试只说明一件事hive 遍历所有存活元素的开销大约在什么水平。通常结果会是 vector 最快hive 次之list 明显最慢。如果 hive 比 list 还慢那首先要怀疑是不是实现或编译模式有问题。3.2 再测“扫描过程中删除”这是 hive 最值得测的场景也是它设计之初就瞄准的场景遍历容器删除满足条件的元素过程中还有新增。auto it h.begin(); while (it ! h.end()) { if (*it % 3 0) { it h.erase(it); // hive 返回下一个有效迭代器 } else { it; } }对 vector 要做同样语义的删除但要用更合适的写法v.erase( std::remove_if(v.begin(), v.end(), [](int x) { return x % 3 0; }), v.end());这里有个很容易踩的误区如果给 vector 也写一个循环里erase的版本性能会非常差但这种差不是 vector 的公平水平。要比较就必须让每个容器使用自己的最佳实践。vector 的最佳实践是 erase-removehive 的最佳实践就是直接遍历删除。如果删除比例高比如每轮删掉一半元素vector 需要不断移动大量元素hive 则只标记槽位差距会很明显。如果删除比例只有 1%vector 的移动成本分摊下来不高hive 的块元数据开销反而可能让优势变弱。3.3 再测中间插入和反复重建第三个测试是反复插入删除。比如每轮随机在中间位置插入一批元素再删掉一部分。vector 在中间插入会移动后续元素O(n) 成本跑不掉hive 插入时只要找到空闲槽位成本近似常数。这里要注意的是hive 没有随机访问随机“中间位置”要依赖迭代器前进这个前进动作本身是 O(n)但通常遍历插入的场景总是从 begin 到 end 扫描此时插入自身的常数时间优势能发挥出来。如果你的业务是“每次定位到某个头部或中部索引再插入”那这份随机定位成本已经抵消了 hive 的插入优势这种情况下 vector 可能更合适。3.4 结果怎么判断我的判断经验是这样纯遍历且无删除vector 赢hive 在可接受范围list 输。遍历中删除删除比例不低hive 赢删除比例越高优势越明显。频繁中间插入且插入点通过扫描到达hive 赢。需要按索引随机访问hive 根本不参与比较硬件上不是你该用的容器。容器规模很小比如只有几十个元素直接用 vectorhive 的块管理开销没有意义。不要拿一次测试结果当唯一结论。不同元素大小、不同删除比例、不同初始化方式结果会变。关键是理解趋势而不是记住某一个环境里的具体倍数。4. 影响 std::hive 性能的几个关键点4.1 块的大小影响缓存和空洞std::hive 的实现会把元素放进块里块内部是连续存储。标准并没有硬性规定块的大小和调整策略plf::hive 的构造参数里可以通过用户传入 block size 来调节。块越大遍历时连续读入的内存越多缓存友好度越高但代价是块内可能留下更多空闲槽位删除比例高时内存浪费更多。块越小插入和空间复用更灵活但块与块之间的跳转更频繁遍历时连续感下降。一般默认值适合绝大多数普通对象。如果你的元素特别小比如 int 这种可以试试调大块如果元素特别大比如几百字节的结构体块内能放的元素数量本来就不多调小一点反而更灵活。建议在测试时单独跑一组不同 block size 的对比。4.2 迭代器类别决定了不能做什么std::hive 的迭代器不是随机访问不支持operator[]和按索引跳到中间。如果你调用std::distance计算两个迭代器之间的距离因为迭代器是双向的这个操作复杂度是 O(n)不是 O(1)。这个是设计取舍为了稳定引用和跳过空洞它放弃了随机访问能力。所以如果算法里大量依赖“根据编号找到第 k 个对象”hive 不合适如果只是按顺序扫描全部对象hive 没有任何问题。4.3 稳定引用的代价是空间和管理hive 保证不移动已有元素所以插入时不需要把旧元素搬到新内存。但从另一个角度看这种稳定性是靠块内槽位复用和跳过信息换来的。也就是说只要元素插入过并被删除那块内存区域就可能保留空洞直到新的元素填进来。如果容器的生命周期是“先填满再全部清空”vector 的紧凑存储优势明显。如果容器长期处于“不断新增、不断删除、保持在某个规模附近”的状态hive 的空间复用能发挥价值。4.4 内存开销怎么看从我实际观察来看hive 的内存开销通常低于 list因为 list 每个节点都有额外的指针和分配头但高于 vector因为每个块有元数据块内也可能有空闲槽位。真要评估内存不要只看 sizeof要看运行时的整体占用。建议在测试程序里打印容器的容量或记录系统级峰值内存。如果内存接近上限先用小规模数据确认 hive 的空洞率再决定是否替换。4.5 和其他哈希容器组合使用有时候最优解不是“整个项目只用一个容器”。常驻的、几乎不删除的核心数据可以继续放 vector短期存活的动态对象比如子弹、特效、临时订阅者可以放进 hive。两者配合比强行用 hive 保存所有数据更稳妥。5. 什么时候用 std::hive什么时候不要用5.1 适合的场景我在实战里看到 hive 最典型的用武之地是三块一是游戏实体管理。每帧遍历所有实体做更新战斗过程中频繁出生子弹、怪物删除死亡对象。vector 在高压删除时性能不稳list 遍历又太慢hive 正好匹配“遍历 增删”并存的负载。二是 ECS 或组件对象管理。组件对象经常被外部持有引用如果容器扩容导致对象移动引用就失效了。hive 能保证引用稳定插入删除也不贵。三是观察者列表、事件订阅表、回调注册表。这类容器经常在遍历过程中出现“回调内部把自己从列表移除”的情况hive 遍历时删除当前元素是安全且便宜的。5.2 不适合的场景也有几类场景建议不要盲目替换。需要按索引访问的场景比如“根据 id 取第 k 个元素”vector 或 unordered_map 更合适。需要严格保持插入顺序并且经常按顺序输出的场景hive 的迭代顺序不做严格保证删除空洞后顺序会变化。容器规模很小几十个元素直接 vector连讨论性能的必要都没有。另外如果对象生命周期极长、从不删除hive 的块管理开销就是纯浪费。vector 的紧凑数组一定更省更高效。5.3 渐进式替换路径不要一次性把所有容器都换掉。我建议按四步走先在一个小模块里引入 hive用真实数据结构代替原来的 vector 或 list。保留旧实现在代码里通过别名或宏切换方便随时回退。跑原有功能测试和压力测试确认行为差异特别是迭代顺序是否有影响。在真实负载下记录遍历时间、删除时间、内存峰值再和旧实现对比。最快的验证方式是把容器类型做成一个别名比如using EntityContainer std::hiveEntity;后续切换回 vector 只需要改一行。6. 踩坑记录哪些问题最容易出现6.1 迭代顺序不是插入顺序hive 遍历时只会跳过死槽位并访问存活元素但不会保证元素按插入顺序输出。删除元素后新插入的元素可能会复用被删除的位置导致遍历顺序变化。如果业务逻辑依赖“先进先出”或“按创建时间遍历”就要先评估这个影响。可能需要额外维护时间戳或顺序编号这部分成本有时会抵消 hive 的收益。6.2 erase 之后不要继续使用旧迭代器在循环里删除元素正确写法是接收erase的返回值比如it h.erase(it);。删除之后再对旧迭代器解引用是未定义行为这点和 list 类似。注意不能把erase返回的迭代器再往下跳一步否则会跳过元素。使用范围 for 循环时不要在循环体内删除当前元素因为范围 for 隐藏了迭代器推进逻辑清理起来容易出错。需要边遍历边删除就老老实实写 while 循环。6.3 找不到头文件这个问题最常见也最好解决。先确认编译器是否真的支持 C26 标准库里的 hive。如果支持需要开启对应的语言标准选项。如果不支持直接用 plf::hive 的单头文件版本。有一点要注意std::hive和plf::hive是两套名字不要混着包含。你可以在代码里做一个小封装统一对外只暴露using别名避免到处散落命名空间判断。6.4 性能数据波动大如果测试结果来回差好几倍优先检查几件事是不是没开优化尤其 MSVC Debug 下的迭代器检查。是不是测试里用了未初始化的数据导致每次内存分配行为不同。是不是有其他进程抢占 CPU或者笔记本在省电模式。是不是编译优化把空的累加循环直接消除了。建议每一轮测试执行多次取中位数而不是最小值。最小值容易受到系统噪声干扰中位数更稳。6.5 别拿 hive 和 vector 在“只插不删”场景比较如果你把 hive 用在只 push 不删除的场景hive 是吃亏的。它要维护块结构、跳过信息、空闲槽位这些都需要一点成本。这种场景 vector 就是最合适的。hive 的优势必须放在“有删除、有插入、有遍历”的组合负载里才能体现。真正判断一个容器选得对不对不是看单次操作有多快而是看一个月后代码里新增需求时这个容器还撑不撑得住。我个人更建议先在小模块里用 plf::hive 跑通逻辑再把负载测试补上不要一上来就把核心容器替换掉。std::hive 的真正价值在于它能长期保持稳定引用同时把扫描和删除两种操作都维持在可接受水平。至于它能不能在你的工程里跑出优势还是要靠你自己的数据和环境来回答。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进