写 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 是否生效,再决定哪些地方值得拆成普通函数、哪些地方值得手写状态机。这些都比“协程很轻”这个笼统印象更有指导意义。