std::function 堆分配:小对象缓冲阈值怎样用 operator new 计数实测出来
编译优化系列的第七篇。上一篇讲的是多线程间共享所有权的原子计数争用,本篇回到单线程内的类型擦除成本:std::function 能装下函数指针、lambda、仿函数,代价是构造时可能悄悄做一次堆分配。标准库实现普遍内置一块小对象缓冲(Small Buffer Optimization,下称 SBO),小捕获体直接放在 std::function 对象内部,超尺寸才落到堆上。阈值是多少、什么条件会破例、破例之后贵多少,标准一个字都没规定。本篇用重载 operator new 的办法把分配次数直接数出来,再用 O2/O0 两档编译对照,把这个「实现细节」变成可复现的数字。
本文要回答的问题
std::function内部的小对象缓冲有多大,捕获体超过多少字节会触发堆分配?- 怎样在程序里精确统计
std::function构造了几次堆分配,而不是靠猜? - 过对齐的可调用体、移动构造不是
noexcept的可调用体,会不会被缓冲拒之门外? - 堆分配形态与 SBO 形态在构造、调用两个环节各慢多少,优化档位会改变结论吗?
- 工程上写回调与闭包时,怎样把
std::function的隐性分配挡在热路径之外?
一、类型擦除与小对象缓冲的工作方式
std::function<int(int)> 对外是一个固定大小的栈对象,对内要能装下任意签名匹配的可调用体。做法是类型擦除:对象内部留一块存储,再配一张隐藏的函数表,负责对存储里的目标做构造、移动、析构与调用。存储有两种落点:目标足够小且对齐要求不过分,就直接放进对象自带的内嵌缓冲,构造析构都不碰堆;目标装不下,就在构造时 operator new 一块堆内存把目标拷进去,析构时配对归还。
这套机制和字符串的 SSO(短字符串优化)是同一思路,也与上一篇里控制块的独立堆分配同源——堆上的簿记对象越多,分配器压力与缓存失配越重。区别在于 std::function 的缓冲准入条件完全由标准库厂商决定:C++ 标准只规定行为,不规定缓冲尺寸,也不规定是否检查移动构造的异常规格。因此「捕获几个 int 算安全」没有可移植答案,只能在具体工具链上实测。
本平台的被测对象是 Apple clang 21.0.0 配 libc++ 13000。实测前先明确判定方法:重载全局 operator new/operator delete,在测量段内对每次分配计数并累计字节数;为了不把程序启动期的库内部分配混进来,用一个开关只在探针运行时计数。还有一个容易漏的细节——过对齐类型走的是带 std::align_val_t 的对齐版 operator new,普通版重载根本拦不到它,必须把对齐版六个函数一并重载,否则过对齐探针会报出虚假的「零分配」。
二、基准设计:尺寸扫档加两个机制反证
探针模板 Capture<N> 就是一个 char[N] 加一个 int 令牌,函数调用运算符返回入参加令牌,移动构造默认 noexcept。让 N 取 1、8、16、20、24、32、40、48、64,对应可调用体实际尺寸 8、12、20、24、28、36、44、52、68 字节,逐级逼近并跨过缓冲边界。每个档位做四项测量:单次构造的分配次数与字节数、移动构造的分配次数、两百万次反复构造析构的单位耗时、两千万次调用的单位耗时。令牌值来自 volatile 全局变量,防止编译器把常量传播后整段构造循环优化掉。
两个机制反证专门用来试探缓冲准入的边界条件:
| 反证类型 | 尺寸/对齐 | 想验证的问题 |
|---|---|---|
OverAligned | 16 字节载荷,64 字节对齐 | 尺寸很小但对齐超出缓冲承诺,会不会被挤到堆上 |
RiskyMove | 8 字节,移动构造非 noexcept | 库是否要求移动不抛异常才敢放进内嵌缓冲 |
另外放两条基线:裸函数指针与无捕获 lambda 装进 std::function,代表最轻形态的调用开销。整份程序分别以 -O2 与 -O0 编译,各跑 7 次取中位——O2 看部署形态的真实成本,O0 保留完整函数边界,让分配机制本身的差价不被内联抹平。
下图把一次构造的判定路径画成漏斗:所有可调用体从同一入口进来,经过尺寸与对齐两道收敛,最终落到三个不同的存储位置。
读这张漏斗抓住两条岔路:尺寸是硬门槛,对齐是隐性门槛,而异常规格在本平台根本不设卡——这个结论要等第四节的反证数据落地。
三、实测数据:24 字节是分水岭,调用成本与落点无关
7 次中位的核心结果如下表,构造列是单次「构造加析构」的耗时,调用列是热循环里单次 operator() 的耗时。
| 可调用体 | 尺寸(字节) | 每次构造分配 | O0 构造(ns) | O2 构造(ns) | O2 调用(ns/次) |
|---|---|---|---|---|---|
| Capture<1> | 8 | 0 | 98.65 | 2.56 | 1.68 |
| Capture<8> | 12 | 0 | 106.41 | 1.53 | 1.67 |
| Capture<16> | 20 | 0 | 98.41 | 1.53 | 1.67 |
| Capture<20> | 24 | 0 | 99.81 | 1.53 | 1.67 |
| Capture<24> | 28 | 1(40 字节块) | 228.30 | 2.23 | 1.70 |
| Capture<32> | 36 | 1(48 字节块) | 218.86 | 1.39 | 1.68 |
| Capture<40> | 44 | 1(56 字节块) | 230.62 | 1.67 | 1.70 |
| Capture<48> | 52 | 1(64 字节块) | 218.44 | 1.40 | 1.41 |
| Capture<64> | 68 | 1(80 字节块) | 220.36 | 2.23 | 1.70 |
左面板绿底是 SBO 区、红底是堆分配区,橙色的 O0 曲线在 24 到 28 字节之间出现一个干净的阶跃:构造中位从约 100 ns 跳到约 228 ns,约 2.2 倍。这一跳与右侧面板的分配计数阶跃发生在完全相同的位置,因果关系不需要推测——多出来的一百多纳秒就是一次 operator new 加配对 operator delete 的价格。堆块字节数随可调用体线性增长(28 字节目标对应 40 字节块,68 字节对应 80 字节块),分配器还要加上自己的元数据开销与对齐填充。
第二个事实同样重要:调用成本与存储落点无关。O2 下所有探针的调用耗时都在 1.4–1.7 ns,O0 下都在 16.2–17.7 ns,SBO 形态没有因为「对象就在身边」而调用更快。原因是两种形态的调用都经过同一张类型擦除函数表,一次间接跳转的价格由间接分支本身决定,目标在缓冲里还是堆里只影响一次指针解引用的位置,在现代核的乱序执行下看不出差别。所以 SBO 省下的是构造析构与分配器压力,不是每次回调的调用税。
O2 构造列全部收敛到 1.3–2.6 ns,档位差异本身也是结论:部署形态下小规模 SBO 构造近乎免费,但堆形态的分配调用被内联进探针后依然真实存在(分配计数一列不变),只是廉价到与 SBO 噪声同量级。一旦分配器遇到竞争、碎片或线程缓存未命中,这个被平均掉的成本会重新涨出来——机制证据比单次中位更可靠。
完整记录如下,含两档全部探针的 7 次中位、单次分配计数与二进制符号证据。

图中 O0 表的三段式结构很直观:SBO 组趴在 99–110 ns,普通堆组抬升到 218–231 ns,最上方 390.67 ns 的孤峰就是下一节的过对齐反证。
四、两个反证:对齐是隐性门槛,异常规格在本平台不设卡
先看过对齐。OverAligned 的载荷只有 16 字节,论尺寸完全进得下 24 字节缓冲,但它要求 64 字节对齐。实测每次构造触发一次分配,块大小 128 字节,走的是对齐版 operator new(size_t, align_val_t),O0 构造中位 390.67 ns,约为 SBO 组的 3.9 倍。内嵌缓冲随 std::function 对象本身对齐,无法承诺 64 字节边界,库只能把目标请到堆上专门对齐的内存里。这个反证说明「捕获体小就安全」是不完整的心智模型,带 SIMD 扩展对齐或缓存行对齐的仿函数是真实代码里会踩到的坑。
再看非 noexcept 移动。RiskyMove 只有 8 字节、移动构造显式声明为可能抛异常,特征检测 is_nothrow_move_constructible 明确为 false,但实测分配次数为零,构造中位 110.25 ns,与同尺寸 SBO 探针无异。也就是说 libc++ 的缓冲准入不检查移动的异常规格;这与某些把「可移动且不抛」作为 SBO 前提的标准库实现不同。这条阴性结果值得专门写下来:它提醒读者 SBO 规则是平台相关的,换一套标准库(甚至同一标准库的大版本升级)阈值与准入条件都可能变化,跨平台代码不能把 24 字节当契约,只能当本机调优依据。
移动构造本身则在所有档位都保持零新增分配:SBO 形态把缓冲内容搬到新对象,堆形态直接偷走堆指针、源对象置空,与上一篇里移动 shared_ptr 不触碰引用计数是同一种「偷资源」手法。需要频繁转移 std::function 所有权的场景(任务队列入队出队)可以放心移动,真正花钱的是从左值反复拷贝构造。
二进制层面再补一条物证。重载后的普通版与对齐版 operator new 在符号表里都是本二进制定义的 T 符号,O2 下函数体被内联,反汇编里堆路径直接表现为 19 处 callq _malloc 与 6 处 callq _aligned_alloc,与分配计数的两个来源一一对应。
===== 二进制证据:operator new 被本程序重载(T=本二进制定义) =====
0000000100000870 T __ZdaPvSt11align_val_t # operator delete[](align_val_t)
00000001000007f0 T __ZnamSt11align_val_t # operator new[](align_val_t)
00000001000006b0 T __Znam # operator new[]
0000000100000680 T __Znwm # operator new
===== 反汇编证据:operator new 在 -O2 被内联,堆路径直接调用 malloc =====
6 _aligned_alloc
19 _malloc五、工程纪律:把隐性分配挡在热路径之外
把实测结论翻译成四条可执行的写法纪律。
第一,保持捕获体轻小。本机 24 字节是经验红线,lambda 按值捕获两三个指针或标量稳在 SBO 区;需要大块上下文时按引用捕获或捕获 this,让 lambda 只持有一个指针,把大对象的生命周期放在外层管理。要注意按引用捕获把生命周期责任转嫁给了调用方,异步回调里引用的对象必须保证活到回调执行完毕,否则就是悬空引用——省分配不能以牺牲生命周期安全为代价。
第二,不要在热循环里构造 std::function。它的成本集中在构造析构而非调用,正确形态是在外层构造一次、循环里只调用;把 std::function 的创建挪进每帧/每请求/每消息的高频路径,等于把 SBO 省下来的分配又亲手放回去。注册型回调(构造次数少、调用次数多)是 std::function 的舒适区。
第三,识别过对齐与重捕获陷阱。仿函数带超默认对齐要求、或按值捕获了大对象/容器时,先在本机用本篇的重载计数法量一次,再决定要不要改成引用语义或自定义分配器。排查手段很轻:临时重载全局 operator new 打印调用栈,或在构造点前后读分配计数器,几十行代码就能定位隐性分配源。
第四,只需要「可调用视图」时不必取得所有权。非持有型的函数引用(标准演进中的 std::function_ref、业界长期使用的同名库组件)只保存目标的引用、不做任何分配,适合同步回调参数;它不延长目标生命周期,语义上要求调用期间目标存活。选型边界是:需要持有、存储、转移回调所有权用 std::function,只在一次同步调用里多态地调一下,函数引用更省。
实验环境
- 机器/系统:Mac mini(6 性能核 + 6 能效核)/ macOS 26
- 工具版本:Apple clang 21.0.0(clang-2100.1.1.101),libc++ 13000;编译参数
-std=c++17 -Wall -Wextra,O2/O0 双档 - 日期:2026-11-11
- 完整命令:
make产出bin/bench-O2与bin/bench-O0→bash run.sh两档各跑 7 次取构造/调用耗时中位 → 同一脚本用nm/otool -tvV固定 operator new 重载符号与 malloc 调用点证据
代码获取
完整可复现工程(尺寸扫档探针、两个机制反证、普通版与对齐版 operator new/delete 全套重载、O2/O0 双目标 Makefile 与七轮中位编排)已公开:
- 仓库地址:https://github.com/derekzhuo/geek-doc.cn/tree/main/experiments/cpp-std-function-heap-alloc-threshold
- 复现方式:克隆后进入该目录执行
bash run.sh,约一分钟输出两档中位表、逐档分配计数与二进制证据;在其他标准库平台复跑可直接得到该平台自己的 SBO 阈值,不要沿用本篇的 24 字节结论。
这一篇在知识体系里的位置
本篇属于 C++ 性能实测系列的值语义与内存管理分组。shared_ptr 引用计数争用实测解决多线程所有权怎样传递不引入串行点,RVO 与移动语义实测解决单线程对象怎样少拷贝,本篇补上类型擦除容器的存储落点成本;三篇共同覆盖现代 C++ 里三类最常见的隐性资源动作——原子计数、复制消除与静默堆分配。下一篇进入字符串的短字符串优化与 string_view 的去拷贝边界。
总结
在重载 operator new 的分配计数基准上,Apple libc++ 的 std::function 小对象缓冲边界被精确定位为 24 字节:可调用体不超过 24 字节时构造零堆分配,28 字节起每次构造配对一次 new/delete,O0 下构造中位从约 100 ns 阶跃到约 228 ns(2.2 倍);过对齐类型即使体积小也走 128 字节的对齐版分配、构造贵约 3.9 倍,而非 noexcept 移动在本平台不影响缓冲准入,是标准库实现差异而非通用规则。调用成本与落点无关,O2 下所有探针稳定在 1.4–1.7 ns;移动构造全程零新增分配。工程上守住捕获体轻小、构造移出热循环、警惕过对齐、同步回调优先非持有视图四条纪律,即可把这类静默堆分配挡在性能关键路径之外。