885 字
4 分钟
C++23 协程的帧到底分配在哪里

写 asio::awaitable 的人大多知道协程会分配一块“帧”,但很少人量过它有多大、什么时候被省掉。一次压测里,一个看起来只有几行的协程,每次调用固定多出一次 200 字节左右的分配,最后就是它在 p99 上留下了痕迹。

帧里装了什么#

编译器把协程的这些东西搬进帧:

  • 形参的副本(按值传递的那些);
  • 跨 co_await 仍然存活的局部变量;
  • 当前挂起点、异常状态;
  • promise 对象;
  • 编译器生成的簿记字段。

注意“跨挂起点仍然存活”这个限定。一个只在第一个 co_await 之前使用的局部变量,不一定会进帧;但如果它的析构函数有副作用,编译器通常仍然要保证它在正确的时间被调用,处理方式依实现而定。所以帧的大小不能只靠数局部变量估算。

task<void> handle(connection& c) {
auto first = c.read_head(); // 跨挂起点,进帧
auto body = co_await c.read_body(); // body 也跨挂起点
co_await c.write(process(first, body));
}

first 和 body 的尺寸、以及 promise 的尺寸,一起决定帧的基线。

operator new 是入口#

协程帧的分配走的是 promise 类型的 operator new。没有自定义时,编译器生成一个签名为 operator new(size_t) 的版本,内部就是一次普通的堆分配。想看帧多大,把它替换掉即可:

struct task {
struct promise_type {
static void* operator new(std::size_t n) {
std::fprintf(stderr, "frame: %zu bytes\n", n);
return ::operator new(n);
}
static void operator delete(void* p, std::size_t n) noexcept {
::operator delete(p);
}
// ...
};
};

在 release 构建里跑一遍,就能拿到每个协程的实际尺寸。不同编译器、不同优化级别给出的数字可能不同,这也是为什么要在目标工具链上量,而不是引用别人的博客。

HALO 什么时候生效#

HALO(heap allocation elision optimization)指的是编译器证明帧的生命周期完全被调用者包住,于是把帧挪到调用者的栈上,省掉 operator new。它不是一个标准保证,而是优化:

  • 调用点必须可见,通常是内联之后;
  • 协程帧不能被逃逸到调用者看不到的地方;
  • 需要能看到协程的完整生命周期。

因此“加个 inline 就好了”并不成立。跨编译单元、经由函数指针调用、或者把 handle 存进容器,都可能让 HALO 失效。想确认的话,看汇编里有没有对 operator new 的调用,比看源码可靠。

和 awaitable_operators 的关系#

组合多个 awaitable 时,组合器本身也是协程,会再分配一层帧。co_await (a() || b()) 这种写法在超时控制里很常见,但每个操作数都是一次帧分配,组合器又是一次。嵌套深了以后,一次请求的分配次数会比想象中多。

一个实用的做法是把“小操作”尽量写成非协程的普通函数,只在真正需要挂起的地方引入 co_await。组合器内部如果能复用同一帧(比如手写一个状态机而不是嵌套协程),就省掉了中间的分配。

结论#

协程帧的分配不是零成本的语法糖。用自定义 operator new 量尺寸,用汇编确认 HALO 是否生效,再决定哪些地方值得拆成普通函数、哪些地方值得手写状态机。这些都比“协程很轻”这个笼统印象更有指导意义。

C++23 协程的帧到底分配在哪里
/posts/cpp-coroutine-frame/
作者
fantasy-peak
发布于
2026-10-05
许可协议
CC BY-NC-SA 4.0