笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
  • 接入与通信

    • 游戏基础架构与工具
    • 接入网关(长连接接入层)
    • 消息总线(共享内存 IPC)
    • 帧同步(Lockstep)
    • 全栈极限低延迟(物理层→应用层)
    • KCP 与 QUIC
  • 网络与服务网格

    • CNI 与 K8s 网络插件
    • 容器运行时:Docker 与 containerd
    • Istio 与 Cilium 服务网格
    • 服务网格:中心化 vs 去中心化
    • eBPF 原理与在网络/可观测/安全的落地
    • 自研 Mesh 服务网格 × K8s 部署
    • 自研 Mesh × K8s · 数据面深水区
    • Tco 协程框架 · 运行时深水区
    • K8s 异构 & 网络插件
  • 有状态服务

    • 分布式游戏存储(分布式 KV)
    • 有状态服务的数据迁移
    • 有状态服务的数据恢复与容灾
    • 有状态服务的 K8s 编排层治理(防漂移 + 节点崩溃恢复)
    • 内存配置热刷新与更新机制
  • 算法与协程

    • 一致性哈希四算法实现(Ring Hash / Ketama / Maglev / Jump Hash)
    • 蓄水池抽样(Reservoir Sampling)与加权扩展
    • C++ 协程:有栈 vs 无栈 vs C++20 原生(函数染色与 libco)
    • Raft 与 Gossip:CP 强一致共识 vs AP 最终一致传播
  • 流量与承载

    • 游戏秒杀场景承载
    • 令牌桶与漏桶
    • 限流与熔断
    • 限流:互联网 vs 游戏
    • 灰度发布 · Canary / BlueGreen / A-B / Shadow
    • 服务器日常系统开发与维护 SOP
    • 极限发压工具设计(多长连接 + 发压任务)
    • 性能分析与优化方法论(六条排查线)
  • 编译与排查

    • 编译优化与 LLVM/Clang/GCC
    • Sanitizer 工具链与内存泄漏定位

C++ 协程:有栈 vs 无栈 vs C++20 原生(函数染色与 libco)

协程的所有分歧都归结到一个问题:挂起时,那个"执行现场"存在哪里。 有栈协程给每个协程一整条独立栈,任意深处都能挂起、老代码一行不改;无栈协程把现场编译成一个定长帧,省内存、切换廉价、能撑百万并发,代价是 co_await 病毒式传染函数签名——这就是"函数染色(function coloring)"。无栈并非 C++20 独有:C++11 就能用 Duff's device 宏手写状态机(Protothreads / Boost.Asio),C++20 只是把这套状态机交给编译器自动、安全地生成。

一句话结论

协程分歧在挂起现场存哪:有栈存独立栈可零改造,无栈存定长帧省内存但函数染色。

场景问题

游戏后台是海量长连接 + 大量 I/O 等待的典型:一台接入服要维持几万到几十万条连接,每条连接的逻辑(读包、查 DB、调后端、回包)里塞满了阻塞等待。两条路都走不通:

  • 一连接一线程:几万线程,光栈内存(默认 8MB/线程)和调度切换就压垮机器。
  • 纯回调/事件驱动:epoll + 回调把一段顺序逻辑撕成几十个回调函数,"回调地狱",业务代码没法读、没法维护,状态全靠手动在堆上存。

协程的价值就是:用同步的写法(顺序代码)跑出异步的性能(遇到 I/O 就让出,不占线程)。但 C++ 在 C++20 之前没有语言级协程,各家(尤其游戏公司)自研;C++20 才有了原生协程。这两条路的取舍,以及历史上为什么这么选,是本文的核心。而横在中间的关键概念是函数染色——一个函数一旦"异步化",是否会强迫调用它的所有函数也跟着异步化。

打个比方(有栈 vs 无栈):有栈协程像给每个协程配一整间独立办公室(一条完整的栈)——你随时能起身离开去干别的,桌上摊开的文件原封不动,回来接着干(任意深处都能挂起、老代码一行不改)。缺点是办公室太占地,几十万个协程就是几十万间房,内存吃不消。无栈协程则不给固定办公室,只发你一个"折叠工位包":要挂起时,把桌面上必需的那几样塞进一个定长小包带走(现场被编译成定长帧),省地方、百万并发也塞得下。但代价是——你的活必须提前登记成"可打包"的,也就是每个挂起点都得显式写 co_await。类比失效边界:这一"登记"会像感冒一样传染——某个函数染上了"异步"这个颜色(变成 co_await 的),调用它的函数也被迫跟着染成同色,一路蔓延整条调用链,这就是函数染色。反过来有栈协程没有染色烦恼(零改造),却要为每个协程预留栈、吃内存还可能栈溢出。所以这是"省内存但染色(无栈)vs 省心但费内存(有栈)"的对立,天下没有免费的协程。

实现方案

本质区别:现场存哪里

  • 有栈协程(Stackful):每个协程分配一条独立完整的调用栈(如 libco 默认 128KB)。挂起 = 保存当前 CPU 寄存器(含栈指针 SP、指令指针)到协程结构,切到另一条栈。因为有自己的栈,可以在任意嵌套函数的深处挂起(f()→g()→h() 里 h 想 yield 直接 yield)。代表:libco、boost.context、ucontext、fcontext。
  • 无栈协程(Stackless):把协程函数改写成一个状态机,局部变量和"当前执行到哪一步"打包成一个定长帧(coroutine frame),大小固定。挂起 = 记住状态机走到第几步、保存几个字段,没有独立栈。因此只能在显式挂起点挂起,深调用栈里的挂起必须靠层层传递上来。这个"状态机 + 无独立栈"的思路并非 C++20 才有——早在 C++11/C++03 就能用 Duff's device 宏技巧手写(Protothreads、Boost.Asio 的 asio::coroutine);C++20 只是让编译器自动、类型安全地生成这套状态机。代表:C++11 宏状态机(手写)、C++20 原生协程(编译器生成)。

认知纠偏:协程不是"轻量级线程",而是取代异步状态机的数据结构

把协程类比成线程,是最常见也最误导的理解。线程回答的是"这段代码在哪个执行单元、哪个核上跑"——它是并发/并行模型,由操作系统调度、可真并行。协程(尤其无栈协程)本身不带调度器、不带并行:一个协程能不能并发跑,取决于把它挂到什么 executor/runtime 上。协程自己只是"能暂停和恢复的函数"。用线程类比,会把"调度、并行"这些运行时才提供的东西误当成协程的语言特性。

协程真正取代的,是手写的异步状态机。看一段典型异步流程的回调地狱:

read_async(sock, [=](auto data) {
    parse_async(data, [=](auto req) {
        query_db_async(req, [=](auto row) {
            write_async(sock, row, [=]{ /* done */ });
        });
    });
});

不用回调、自己手写,就会得到一个显式状态机——注意它的三个组成部分,恰好就是编译器为协程生成的东西:

struct Handler {
    enum State { Reading, Parsing, Querying, Writing } state; // ← 挂起点 / 恢复跳转
    Buffer buf; Request req; Row row;   // ← 跨挂起点存活的局部变量(协程帧的内容)
    void on_event() {                   // ← coroutine_handle::resume()
        switch (state) {
            case Reading:  /* 存 buf; state=Parsing;  发起 parse */  break;
            case Parsing:  /* 存 req; state=Querying; 发起 query */  break;
            case Querying: /* 存 row; state=Writing;  发起 write */  break;
            case Writing:  /* 完成 */                                break;
        }
    }
};
手写异步状态机编译器为协程生成的
enum State + switch挂起点(suspend point)+ 恢复时的跳转
跨状态保存的成员 buf/req/row协程帧(coroutine frame) 里保存的局部变量
on_event() 被事件循环重新调用coroutine_handle::resume()

协程干的事,就是让编译器把线性写下来的代码自动切成这台状态机——上面的 Handler 等价于:

task<void> handle(Socket sock) {
    auto data = co_await read_async(sock);   // 挂起点:局部变量 data 搬进协程帧
    auto req  = co_await parse_async(data);
    auto row  = co_await query_db_async(req);
    co_await write_async(sock, row);
}

代码是顺序、可读的,但编译后与手写 switch 状态机等价。这正呼应了前面"无栈 = 定长帧 = 状态机"的本质。

一句话拨正

  • 线程:这段代码在哪个执行单元/核上跑?——是并发模型(谁来跑)。
  • 协程:被 co_await 打断的逻辑,中间状态存在哪、怎么恢复?——是代码变换 / 数据结构(怎么存、怎么续)。

一个挂起的协程 = 堆上一块内存(帧),存着"执行到哪、局部变量是什么";resume() 就是把这块数据结构再驱动一步。谁调用 resume()、何时、在哪个线程——协程一概不管,那是 runtime 的事。 有栈协程(goroutine/libco)自带栈、看着像线程,容易强化错误类比;无栈协程把"它只是个状态机"暴露得最清楚:没有 runtime,协程连自己都跑不起来。

有栈协程:libco 风格(C++11 时代自研)

libco 的杀手锏是 hook 系统调用:把 read/write/connect/sleep 等阻塞系统调用替换成协程感知的版本——调用时若会阻塞,就自动把 fd 注册到 epoll 并让出协程,数据就绪后再切回来。这样老的同步阻塞代码一行不改,链接进 libco 就自动变成协程化的非阻塞代码。

// libco 风格用法:业务代码看起来完全是同步阻塞的
#include "co_routine.h"
#include <unistd.h>

void* worker(void* arg) {
    co_enable_hook_sys(); // 开启系统调用 hook:下面的 read/write 会自动 yield
    int fd = *(int*)arg;
    char buf[1024];
    // 看起来是阻塞 read,实则:无数据时自动注册 epoll + 让出协程,
    // 数据就绪后调度器再切回来继续。业务代码毫无异步痕迹。
    ssize_t n = read(fd, buf, sizeof(buf));
    write(fd, buf, n); // 同理,写不下时自动让出
    return nullptr;
}

int main() {
    int fd = /* accept 得到的连接 */ 0;
    stCoRoutine_t* co;
    co_create(&co, nullptr, worker, &fd); // 创建协程(分配独立栈)
    co_resume(co);                        // 启动
    co_eventloop(co_get_epoll_ct(), nullptr, nullptr); // 事件循环驱动
    return 0;
}

提示

libco 的价值不在"协程"本身,而在 hook + 独立栈 = 存量同步代码零改造异步化。微信当年有海量用 read/write 写的同步阻塞服务,无法逐个重写成回调;libco 让它们直接跑成高并发协程服务。这是"规避函数染色"的经典工程手段。

无栈协程的前身:C++11 宏 + Duff's device

无栈协程不是 C++20 的发明。早在没有语言支持的 C++11/C++03 时代,就能用 Duff's device(switch/case 穿透)手写无栈协程:用一个整数记住"上次执行到第几行",switch(state) 跳回该处继续。挂起就是"记住行号 + return",没有独立栈——这正是无栈的本质。Simon Tatham 的 Coroutines in C、嵌入式界的 Protothreads(Adam Dunkels)、以及 Boost.Asio 的 asio::coroutine(reenter/yield/fork 宏)都是这一套,在 C++20 之前的异步网络代码里大量使用。

// C++11 无栈协程:一个 int 记住"执行到第几行",靠 switch 跳回该点继续
#define CO_BEGIN(s)  switch (s) { case 0:
#define CO_YIELD(s)  do { (s) = __LINE__; return; case __LINE__:; } while (0)
#define CO_END       }

struct Reader {                 // 跨挂起点存活的局部变量必须手动提成成员——无栈的代价
    int state = 0;              // 状态:记录上次挂起的行号
    int i = 0;
    void resume() {
        CO_BEGIN(state);        // 展开成 switch(state){ case 0:
        for (i = 0; i < 3; ++i) {
            do_read(i);
            CO_YIELD(state);    // 挂起:state=__LINE__; return; 下次 switch 跳回 case __LINE__
        }
        CO_END;                 // 展开成 }
    }
};
// 外层循环:while 反复 r.resume(),每次从上次 yield 处继续,直到跑完

CO_YIELD 展开后是 state = __LINE__; return; case __LINE__:;——return 让出,下次进来 switch(state) 直接跳到那个 case __LINE__ 标号,接着往下执行。整个"现场"就是 state 这一个 int,没有栈拷贝、没有寄存器保存,切换极廉价。

Duff's device 无栈协程的三条硬约束

这套手写方案暴露了无栈协程的全部代价,也解释了 C++20 为什么要交给编译器:

  • 局部变量不跨挂起点存活:宏不保存局部变量,任何要跨 CO_YIELD 存活的变量都得手动提成成员(如上面的 i)。C++20 编译器会自动把这些局部搬进协程帧。
  • 不能在嵌套函数里挂起:CO_YIELD 只能出现在 resume() 本体,调用的普通函数内部无法让出——和 C++20 无栈一样,这是无栈的通病,也是函数染色的源头。
  • 不能把 yield 放进自己的 switch:技巧本身占用了 switch,再嵌一层 switch(含 case)会和 Duff's device 的 case __LINE__ 冲突。C++20 原生协程没有这个限制。

C++20 相对这套手写方案的进步:编译器自动生成状态机(局部变量自动入帧、类型安全、可放进任意控制流),把宏的三条约束基本消除,只留下"深调用需层层 co_await"这一无栈固有特性。所以 C++11 宏协程和 C++20 原生协程是同一种东西的手写版与编译器版,不是两类协程。

无栈协程:C++20 原生

C++20 引入 co_await/co_yield/co_return 三个关键字。带任一关键字的函数即为协程,编译器为它生成 promise_type 驱动的状态机,帧在堆上分配(可被优化消除)。

#include <coroutine>
#include <optional>

// 一个最小的惰性生成器:无栈协程,帧在堆上,只在 co_yield 处挂起
template <typename T>
struct Generator {
    struct promise_type {
        T current;
        Generator get_return_object() {
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(T v) { current = v; return {}; } // co_yield 落到这
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    std::coroutine_handle<promise_type> h;
    explicit Generator(std::coroutine_handle<promise_type> handle) : h(handle) {}
    ~Generator() { if (h) h.destroy(); }

    // 恢复协程执行到下一个 co_yield,取出值
    std::optional<T> next() {
        if (!h || h.done()) return std::nullopt;
        h.resume();                     // 恢复:状态机跳到上次挂起点继续
        if (h.done()) return std::nullopt;
        return h.promise().current;
    }
};

Generator<int> fib() {
    int a = 0, b = 1;
    while (true) {
        co_yield a;         // 挂起点:只有这里能挂起,无独立栈
        int t = a; a = b; b = t + b;
    }
}

// 用法:for (auto g = fib(); auto v = g.next(); ) 打印斐波那契

注意

无栈协程只能在 co_await/co_yield 处挂起。如果 fib() 里调用了一个普通函数 helper(),helper 内部想挂起是做不到的——它没有 co_await,也不在协程帧里。要让深处能挂起,整条调用链都得是协程、层层 co_await。这就是函数染色的根源。

函数染色(Function Coloring)

一旦某函数用了 co_await("异步色"),调用它并想拿到其结果的函数也必须 co_await 它,于是自己也变成协程(染上异步色)。这种传染沿调用链一路向上蔓延:

同步函数("同步色")和异步函数("异步色")不能自由互调:异步函数里可以调同步函数,但同步函数无法 co_await 一个异步函数(拿不到结果、又不能挂起自己)。深调用栈里某处要异步,就得把整条链染成异步;碰到无法改的第三方同步库,就撞墙。

为什么这么做

为什么 C++11 时代要自研有栈协程?

  • 语言没有协程支持,只能自己用 ucontext/汇编(boost.context)保存恢复寄存器与栈。
  • 存量是海量同步阻塞老代码,重写成回调不现实。有栈协程 + hook 系统调用能让这些代码签名不变、逻辑不动就跑成协程——规避了函数染色(因为挂起发生在被 hook 的系统调用里,业务函数看不见 co_await,不需要改签名)。
  • 有栈的"任意深处可挂起"正好匹配"老代码在深调用栈里阻塞"的现实。

为什么 C++20 原生协程选无栈? 三个词:省内存、切换廉价、百万并发。

维度有栈协程(libco 等)无栈协程(C++20)
每协程内存一整条栈(如 128KB,需预留深调用栈空间)一个定长帧(几十~几百字节,编译期算)
切换成本保存/恢复全部寄存器 + 换栈指针存/恢复几个字段,无栈拷贝,更廉价
挂起位置任意嵌套深处仅 co_await/co_yield 点
并发规模受栈内存限制(几万~几十万)百万级(帧小)
存量代码改造零改造(hook + 不改签名)需引入 co_await,函数染色传染
语言支持需库/汇编语言原生

无栈帧小到能开百万协程,切换只动几个字段——这是撑起 C10M 的关键。代价是把"现场存哪"从运行时(独立栈)搬到了编译期(定长帧),于是只能在编译器能生成状态机的显式点挂起,从而带来函数染色。

为什么别的选择不行

  • 一连接一线程:栈内存 + 内核调度成本,几万连接就崩。协程是用户态调度,栈/帧远小于线程栈。
  • 纯回调事件驱动:性能够,但业务逻辑被撕成回调地狱,不可维护;状态要手动堆上管理。协程用同步写法解决可读性。
  • 无栈协程去改造存量同步阻塞代码:函数染色会逼你把整条调用链改成 co_await,碰到第三方同步库(无法加 co_await)就撞墙,改造成本极高。这正是 C++11 时代宁可自研有栈协程的原因。
  • 有栈协程追求百万并发:每协程一整条栈,内存吃不消;且要为深调用栈预留栈空间,浪费大。极致高并发场景无栈更省。
  • 两者混用不加设计:有栈协程里调无栈协程、或反之,调度器/挂起语义不兼容,容易踩坑。选一套体系贯彻到底。

沉淀结论

  • 协程的本质分歧是挂起现场存哪:有栈 = 独立完整栈(运行时);无栈 = 定长帧(状态机)。
  • 有栈(libco/boost.context/ucontext):任意深处可挂起、老代码零改造(hook + 不改签名,规避函数染色),代价是栈内存大、并发受限。
  • 无栈不是 C++20 才有:C++11/C++03 就能用 Duff's device 宏(Protothreads、Boost.Asio asio::coroutine)手写状态机;C++20 只是让编译器自动、类型安全地生成这套状态机。二者是同一种东西的手写版与编译器版。
  • 无栈(C++11 宏 / C++20 原生):省内存、切换廉价、百万并发,代价是只能在挂起点挂起、跨挂起点的局部变量要进帧(手写版需手动提成成员),且 co_await 病毒式传染函数签名 = 函数染色,深调用栈/第三方同步代码难改造。
  • 函数染色:异步色沿调用链向上传染,同步函数无法 co_await 异步函数——这是无栈协程改造存量代码的最大阻力。
  • 选型口诀:存量同步阻塞代码要平滑异步化 → 有栈(libco);新写、追求百万并发与低内存 → C++20 无栈,但要接受函数染色。

记忆口诀

有栈:独立栈 / 任意深处挂起 / 老代码零改造 / 栈大并发受限
无栈:定长帧 / 仅挂起点挂起 / 省内存百万并发 / 函数染色
函数染色:异步色沿调用链上传 / 同步函数不能 co_await 异步 / 撞第三方同步库
选型:改存量→有栈libco / 新写高并发→C++20无栈

内容来源

综合整理。参考资料:微信 libco 开源项目及其 hook 系统调用设计文档、boost.context / boost.coroutine2 文档、Linux ucontext(3) 手册、Simon Tatham Coroutines in C(Duff's device 无栈协程)、Adam Dunkels 的 Protothreads、Boost.Asio coroutine(reenter/yield/fork 宏)、C++20 标准 [coroutine] 章节与 cppreference std::coroutine_handle/promise_type、Lewis Baker 的 C++ Coroutines 系列文章,以及 "What Color is Your Function?"(Bob Nystrom)关于函数染色的经典论述。

自测:合上资料能说清楚吗?

为什么说"不该把协程和多线程做类比"?协程真正取代的是什么?

参考答案

因为线程是并发/并行模型(回答"代码在哪个执行单元、哪个核上跑",由 OS 调度、可真并行),而协程本身不带调度器、不带并行——它只是"能暂停/恢复的函数",能否并发取决于挂到什么 runtime 上。协程真正取代的是手写的异步状态机:回调地狱手写出来就是 enum State + switch + 跨状态保存的成员变量,这三者恰好对应编译器生成的挂起点 + 帧里的局部变量 + resume()。所以协程是代码变换 / 数据结构(中间状态存哪、怎么恢复),不是执行模型。一个挂起的协程 = 堆上一块存着"执行到哪、局部变量是什么"的帧,resume() 把它再驱动一步。

协程的所有分歧都归结到哪一个问题?有栈和无栈分别把"执行现场"存在哪里?

参考答案

归结到挂起时执行现场存哪。有栈给每个协程一整条独立完整栈,现场在运行时的栈里;无栈把函数编译成状态机,局部变量和执行位置打包成一个定长帧,现场在编译期确定的帧里。

什么是"函数染色"?为什么无栈协程会有,而有栈协程能规避?

参考答案

一个函数用了 co_await(异步色)后,想拿其结果的调用者也必须 co_await、于是也变协程,沿调用链向上传染。无栈只能在挂起点挂起,深处异步就得整链染色;有栈靠 hook 系统调用 + 独立栈,挂起藏在系统调用里,业务函数签名不变,故规避染色。

libco 为什么在 C++11 时代大受欢迎?它的杀手锏是什么?

参考答案

杀手锏是 hook 系统调用:把 read/write/connect 等替换成协程感知版,会阻塞时自动注册 epoll + 让出协程。配合独立栈,让海量存量同步阻塞代码一行不改就跑成高并发协程,无需重写成回调地狱。

无栈协程是 C++20 才发明的吗?C++20 相比 C++11 手写方案进步在哪?

参考答案

不是。C++11/C++03 就能用 Duff's device 宏(Protothreads、Boost.Asio asio::coroutine)手写状态机,用一个 int 记行号 switch 跳回。C++20 只是让编译器自动、类型安全地生成状态机:局部变量自动入帧、可放进任意控制流,消除了手写版的三条约束。

存量同步阻塞代码要异步化,选有栈还是无栈?新写百万并发服务呢?为什么?

参考答案

存量改造选有栈(libco):hook + 不改签名、零改造、任意深处可挂起,规避函数染色。新写高并发选 C++20 无栈:定长帧几十~几百字节、切换只动几个字段,能撑百万并发,代价是接受 co_await 函数染色。

最近更新: 2026/9/10 11:38
Prev
蓄水池抽样(Reservoir Sampling)与加权扩展
Next
Raft 与 Gossip:CP 强一致共识 vs AP 最终一致传播