libco 协程机制详解

libco 协程机制详解

目录


一、CPU 与汇编基础

1.1 什么是寄存器

CPU 内部有一组寄存器,可以理解为 CPU 的"临时草稿纸"——运算速度极快(一个时钟周期就能读写),但数量非常少(x86-64 只有 16 个通用寄存器)。CPU 做任何操作都要先把数据放到寄存器里。

内存(RAM):容量大(GB 级),速度慢(~100ns)
寄存器:    容量极小(几十个,每个 8 字节),速度极快(~0.3ns)

1.2 三个最特殊的寄存器

寄存器 含义 作用
RIP(指令指针) Instruction Pointer 指向下一条要执行的指令的内存地址。不能直接读写它,但 call / ret / jmp 会改变它。
RSP(栈指针) Stack Pointer 指向当前栈顶。每次函数调用、局部变量分配都通过移动 RSP 来实现。
RBP(基址指针) Base Pointer 指向当前函数栈帧的底部,用来访问局部变量和参数。

1.3 什么是栈?函数调用时发生了什么?

栈是一块"向下增长"的内存区域。想象一叠盘子:只能从顶部放入(push),从顶部取出(pop)。

         高地址
    ┌────────────┐
    │   main 的   │ ← main 函数的栈帧(局部变量等)
    │   栈帧      │
    ├────────────┤
    │  返回地址   │ ← main 调用 foo 时,call 指令自动压入
    ├────────────┤
    │   foo 的    │ ← foo 函数的栈帧
    │   栈帧      │
    ├────────────┤
    │  返回地址   │ ← foo 调用 bar 时,call 指令自动压入
    ├────────────┤
    │   bar 的    │ ← bar 函数的栈帧
    │   栈帧      │
    ├────────────┤ ← RSP(栈顶,当前是 bar 在执行)
    │   (空闲)    │
    │     ↓       │
    └────────────┘
         低地址

当 C 代码调用一个函数:

  1. call 指令自动把返回地址(call 的下一条指令地址)压栈
  2. 然后跳到被调用函数
  3. 被调用函数执行完后执行 ret,从栈顶弹出返回地址,跳回去

1.4 x86-64 调用约定

函数调用时,前 6 个参数优先用寄存器传递,多余的才用栈:

第1个参数  → RDI
第2个参数  → RSI
第3个参数  → RDX
第4个参数  → RCX
第5个参数  → R8
第6个参数  → R9

1.5 被调用者保存 vs 调用者保存

这是理解协程切换为什么要保存这些寄存器的关键:

  • 被调用者保存寄存器(callee-saved):如果一个函数要使用这些寄存器,它必须先保存原值,返回前恢复。包括:RBX, RBP, R12-R15。调用者可以放心:调用完回来这些寄存器的值不会变。

  • 调用者保存寄存器(caller-saved):调用者不能假定这些值在函数调用后保持不变。如果调用者自己需要这些值,它要自己保存。包括:RAX, RCX, RDX, RSI, RDI, R8-R11

协程切换本质上就是保存全部寄存器的当前值,然后恢复另一组寄存器的值。

1.6 RSP 的作用

RSP 只有一个作用:告诉 CPU "栈顶在哪里"

因为 CPU 在执行某些指令时,会自动对 RSP 指向的位置进行读写,然后自动移动 RSP。这些指令是:

push 某个值    →  先把 RSP 减 8,然后把值写入 [RSP]
                (不先知道栈顶在哪,往哪写?)

pop 到某个寄存器  →  从 [RSP] 读取值,然后把 RSP 加 8
                  (不知道栈顶在哪,从哪读?)

call 某个函数    →  先把 RSP 减 8,把返回地址写入 [RSP],然后跳到函数
                (本质上是一个 push + jmp)

ret            →  从 [RSP] 读返回地址,RSP 加 8,然后跳到该地址
                (本质上是一个 pop + jmp)

RSP 就是 push/pop/call/ret 这些指令的"读写指针"。它是硬件层面约定好的,CPU 执行这些指令时会自动读写 RSP 指向的位置并修改 RSP。

用生活比喻:RSP 就像一根自动移动的"书签"。想象一本活页笔记本,你只能在当前这一页写东西:

   ┌────────────┐
   │  第 1 页    │  已经写满了(main 的栈帧)
   ├────────────┤
   │  第 2 页    │  已经写满了(funcA 的栈帧)
   ├────────────┤
   │  第 3 页    │  ← RSP 指向这里,这一页正在写(funcB 的栈帧)
   ├────────────┤
   │  第 4 页    │  空白页
   ├────────────┤
   │  第 5 页    │  空白页
   └────────────┘

  push:翻到新的一页(RSP - 8),在上面写东西
  pop :读完当前页,翻回上一页(RSP + 8)
  call:在书上签个名(返回地址),翻到新的一页
  ret :翻回签过名的那一页

CPU 根本不关心 RSP 指向的是"真正的栈"还是"堆上分配的一块内存"。只要那块内存可读写,push/pop/call/ret 就正常干活。RSP 指向哪里,哪里就是栈。 这就是协程能工作的硬件基础。

1.7 RSP 在函数执行过程中会移动吗

回答这个问题要看具体的场景。

一个孤立的叶子函数(不调用任何子函数):函数体执行期间 RSP 不变

int add(int a, int b) {
    int sum = a + b;
    return sum;
}
add:
    pushq %rbp              # --- 开头(prologue)---
    movq  %rsp, %rbp        #
    subq  $16, %rsp         # RSP 减 16,给局部变量腾出空间(一次到位)

    # 函数体 —— 从头到尾 RSP 一动不动,所有访问都用 RBP + 偏移
    movl  %edi, -4(%rbp)
    movl  %esi, -8(%rbp)
    movl  -4(%rbp), %eax
    addl  -8(%rbp), %eax

    addq  $16, %rsp         # --- 结尾(epilogue)---
    popq  %rbp
    ret

RSP 轨迹就是三段线:

RSP 值
  ↑
  │  pushq       subq           函数体              addq       popq   ret
  │  ───╲        ───╲     ╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶     ───╱       ───╱    ───╱
  │     ╲           ╲                           ╱          ╱       ╱
  │      ╲           ╲_________________________╱          ╱       ╱
  │       ╲                                                  ╱       ╱
  │        ╲________________________________________________╱       ╱
──┴──────────────────────────────────────────────────────────────────→ 时间
   -8B        -16B            不变             +16B      +8B     +8B

因为编译器在编译期就知道每个函数需要多少栈空间——数一数局部变量的大小和数量就行。所以它一次性 sub $N, %rsp 分配好,之后函数体里直接用 RBP + 偏移(或 RSP + 偏移)访问,不需要中途再调。

两种例外会让 RSP 在函数体中间移动:

  • alloca() 或变长数组(编译时不知道大小,只能运行时动态扣 RSP)
  • 函数内嵌汇编手动操作了 RSP

但通常的函数(会调用子函数的):RSP 在整个执行过程中是频繁变化的,比如 push/pop 指令、子函数 call/ret、函数 prologue/epilogue 等等。


二、栈空间的基础概念

2.1 栈空间什么时候分配

不是每次函数调用时创建的,而是一开始就分配好了一大块连续内存,函数调用只是在这块内存里移动栈指针(RSP)来"占用"或"释放"其中的一段。

分三种情况:

主线程的栈——进程创建时由内核分配:

你执行 ./a.out
  → shell 调用 fork() 复制自身
  → 子进程调用 execve("./a.out")
  → 内核加载 ELF 可执行文件
  → 内核在用户态虚拟地址空间里划出一块区域作为栈
  → 设置 RSP 指向栈顶
  → 跳转到 _start 入口开始执行

Linux 默认 8MB,可通过 ulimit -s 查看或修改。

子线程的栈——pthread_create 时由 glibc 用 mmap 分配:

pthread_create(&tid, NULL, worker_func, NULL);

// glibc 内部大致:
void *stack = mmap(NULL, 8*1024*1024, PROT_READ|PROT_WRITE,
                   MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0);
// 然后 clone() 系统调用创建线程时,把 RSP 设为 stack + 8MB

libco 协程的栈——co_create 时用 malloc 分配:

// co_routine.cpp:269
stStackMem_t* co_alloc_stackmem(unsigned int stack_size) {
    stStackMem_t* stack_mem = malloc(sizeof(stStackMem_t));
    stack_mem->stack_size = stack_size;
    stack_mem->stack_buffer = malloc(stack_size);  // 协程的"栈"就是这块堆内存
    stack_mem->stack_bp = stack_mem->stack_buffer + stack_size;  // 栈顶
    return stack_mem;
}

这里的认知转变:通常说"栈"和"堆"是内存的两块不同区域。但对协程来说,它的"栈"本质上是 malloc上分配的一块内存。CPU 不关心这块内存叫什么名字,只要 RSP 指向这里,它就是栈。

          进程虚拟地址空间(简化)
    ┌──────────────────────┐ 高地址
    │   内核空间            │
    ├──────────────────────┤
    │   主线程栈(8MB)     │ ← OS 分配,RSP 初始指向这里
    ├──────────────────────┤
    │          ↓           │
    │      (空隙)          │
    │          ↑           │
    ├──────────────────────┤
    │   堆 (malloc/mmap)   │ ← libco 协程的"栈"在这里分配
    ├──────────────────────┤
    │   数据段 (.data)     │
    ├──────────────────────┤
    │   代码段 (.text)     │
    └──────────────────────┘ 低地址

2.2 栈空间的作用

栈的作用:让函数调用能"回来",并让每个函数有自己的"工作台面"。

栈承载四样东西:

1. 返回地址——知道"做完以后回哪去"

main 调用 funcA,funcA 调用 funcB:

    ┌──────────────┐
    │  main 栈帧   │
    ├──────────────┤
    │ 返回到 main   │ ← funcA 执行完后,CPU 知道该回 main 的哪里
    ├──────────────┤
    │  funcA 栈帧  │
    ├──────────────┤
    │ 返回到 funcA  │ ← funcB 执行完后,CPU 知道该回 funcA 的哪里
    ├──────────────┤
    │  funcB 栈帧  │
    └──────────────┘ ← RSP

没有返回地址,函数执行完就不知道去哪了。call 指令自动压入返回地址,ret 指令自动弹出并跳回。

2. 局部变量——每个函数有自己的变量空间

void funcA() {
    int x = 10;       // x 存在 funcA 的栈帧里
    char buf[256];     // buf 存在 funcA 的栈帧里
    funcB();
    // funcB 返回后,x 还是 10,buf 完好无损
}

void funcB() {
    int y = 20;       // y 存在 funcB 的栈帧里,和 x 是不同的内存位置
}

3. 函数参数传递——x86-64 下超过 6 个参数时用栈传

int foo(int a, int b, int c, int d, int e, int f, int g, int h) {
    // a~f 用寄存器传(RDI, RSI, RDX, RCX, R8, R9)
    // g 和 h 通过栈传递——调用者在自己的栈帧里留好位置
}

4. 保存寄存器——函数调用前后寄存器值不被破坏

void funcA() {
    int important = 42;
    // 编译器可能把 important 放在 EBX 寄存器里
    funcB();  // funcB 如果用 EBX,按规矩必须先保存再恢复
    // 回来后 EBX 还是 42
    return important;
}

这就是"被调用者保存寄存器"(callee-saved)。函数的开头(prologue)保存这些寄存器,结尾(epilogue)恢复。保存的位置就是栈帧。

被调用者保存寄存器是指被调用者在执行时要保存调用者使用过的寄存器中的值,保存在被调用者自己的栈帧中:

void callee() {
    int y = 99;        // 编译器想把 y 放到 RBX
    // 但 RBX 里存着 caller 的 x = 42
}
callee:
    pushq %rbx              # ① 把 RBX(caller 的 42)压入 callee 自己的栈帧

    movl  $99, %ebx         # ② 现在 RBX 可以随意用了(y = 99)
    # ... 函数体 ...

    popq  %rbx               # ③ 用完恢复,把 42 弹回 RBX
    ret                      # ④ 回到 caller,RBX 还是 42

栈帧视角:

调用 callee 前:                   callee 执行期间:
                                  ┌──────────────┐
    ┌──────────────┐              │  caller 栈帧  │
    │  caller 栈帧  │              ├──────────────┤
    │  RBX 存着 42  │              │  返回地址      │
    ├──────────────┤              ├──────────────┤
    │  返回地址      │              │  保存的 RBX=42 │ ← pushq %rbx 压在这里
    ├──────────────┤← RSP         ├──────────────┤← RSP
    │              │              │  (RBX 现在是99)│
    └──────────────┘              └──────────────┘

三、项目中用到的汇编指令详解

聚焦于 coctx_swap.S 中 x86-64 的部分。

3.1 movq——数据移动

movq %rax, 104(%rdi)   → 把 rax 的值存到 [rdi + 104] 这个内存位置
movq 48(%rsi), %rbp    → 把 [rsi + 48] 这个内存位置的值加载到 rbp

mov = move(移动/复制),q = quad-word(8 字节)。格式:movq 源, 目标

括号表示"以这个地址为基准,加上偏移量":

  • 104(%rdi) = "RDI 寄存器里的值 + 104 字节" 那个内存地址
  • (%rsi) = "RDI 的值 + 0" 那个内存地址

结合 C 结构体理解:rdi 指向 coctx_t 结构体,regs 数组在结构体开头:

struct coctx_t {
    void *regs[14];   // 偏移 0 ~ 111(14 × 8 = 112 字节,每个指针 8 字节)
    size_t ss_size;   // 偏移 112
    char *ss_sp;      // 偏移 120
};

所以 104(%rdi) 就是 regs[13](因为 13 × 8 = 104)。

3.2 leaq——加载有效地址

leaq (%rsp), %rax   → 把 RSP 的值(栈指针这个地址数值本身)复制到 RAX
leaq 8(%rsp), %rsp  → 把 RSP+8 这个地址值赋给 RSP(相当于 RSP += 8)

lea = Load Effective Address(加载有效地址)。

关键区分

  • movq (%rsp), %rax → 去内存地址 RSP 处 8 字节数据放入 RAX(访问内存
  • leaq (%rsp), %rax → 把 RSP 的数值本身放入 RAX(不访问内存,只是算地址)

3.3 pushq——将数据压栈

pushq 72(%rsi)  → 两步操作:
                   1. RSP = RSP - 8(栈向下增长,先腾空间)
                   2. 把 [rsi+72] 的值写入 [RSP](放到新栈顶)

push 总是压 8 字节(在 64 位模式下)。

3.4 ret——从函数返回

ret  → 两步操作:
        1. 从栈顶弹出 8 字节 → 这就是返回地址
        2. RIP = 这个地址(跳到那里继续执行)

等价于 popq %rip(但 x86-64 不允许直接操作 RIP,所以用 ret 实现)。

3.5 xorq——按位异或

xorq %rax, %rax  → RAX = RAX ^ RAX = 0(清零,比 movq $0, %rax 更高效)

四、coctx_t 的 regs 数组布局

进入汇编之前,必须先理解 regs[] 的每个位置对应什么:

coctx_t 结构体:
┌─────────┬──────────┬──────────────────────┬─────────────────────┐
│  regs[] │ 偏移量    │ 保存的寄存器          │ 为什么要保存          │
├─────────┼──────────┼──────────────────────┼─────────────────────┤
│ regs[0] │   0      │ R15                  │ 被调用者保存(callee) │
│ regs[1] │   8      │ R14                  │ 被调用者保存          │
│ regs[2] │  16      │ R13                  │ 被调用者保存          │
│ regs[3] │  24      │ R12                  │ 被调用者保存          │
│ regs[4] │  32      │ R9                   │ 调用者保存(caller)  │
│ regs[5] │  40      │ R8                   │ 调用者保存            │
│ regs[6] │  48      │ RBP ← 栈基址          │ 被调用者保存          │
│ regs[7] │  56      │ RDI ← 第1个参数       │ 调用者保存            │
│ regs[8] │  64      │ RSI ← 第2个参数       │ 调用者保存            │
│ regs[9] │  72      │ 返回地址(RIP的值)    │ 决定"回到哪"          │
│ regs[10]│  80      │ RDX ← 第3个参数       │ 调用者保存            │
│ regs[11]│  88      │ RCX ← 第4个参数       │ 调用者保存            │
│ regs[12]│  96      │ RBX                  │ 被调用者保存          │
│ regs[13]│ 104      │ RSP ← 栈指针          │ ★ 最关键的一个       │
└─────────┴──────────┴──────────────────────┴─────────────────────┘

数组下标是连续的 0~13,内存中紧密排列,每个占用 8 字节(共 112 字节)。


五、coctx_swap 汇编代码逐行拆解

coctx_swap 的函数签名:

void coctx_swap(coctx_t *curr,    // RDI = 保存到哪里
                coctx_t *pending) // RSI = 从哪里恢复

第一阶段:保存当前协程的寄存器

leaq  (%rsp), %rax          # rax = 当前栈顶 rsp
                             # 为什么要中转?x86-64 不允许 RSP 直接作为某些
                             # 寻址模式的源操作数,所以先用 RAX 暂存

此时 CPU 状态:

RDI = curr 协程的 coctx_t 指针
RSI = pending 协程的 coctx_t 指针
RAX = 当前栈顶地址
movq  %rax, 104(%rdi)       # regs[13] = RSP  ← ★ 保存栈指针!
                             # 这是协程切换的第一核心。下次切回来时,
                             # RSP 恢复成这个值,局部变量、调用链就全回来了
movq  %rbx, 96(%rdi)        # regs[12] = RBX
movq  %rcx, 88(%rdi)        # regs[11] = RCX
movq  %rdx, 80(%rdi)        # regs[10] = RDX

这三个是通用数据寄存器,保存它们当前的数值。

movq  0(%rax), %rax         # rax = [RSP] = 返回地址
                             # 当前执行的 coctx_swap 是被 co_swap 函数通过
                             # call 指令调用的。call 自动把返回地址压在了栈顶。
                             # 所以 [RSP] = co_swap 函数里 call 的下一条指令地址
movq  %rax, 72(%rdi)        # regs[9] = 返回地址 ← ★ 第二核心
                             # 将来切回来时,ret 会跳回这个地址——
                             # 就像正常从 coctx_swap 返回一样,断点续行
movq  %rsi, 64(%rdi)        # regs[8] = RSI(pending 指针)
movq  %rdi, 56(%rdi)        # regs[7] = RDI(curr 指针)
movq  %rbp, 48(%rdi)        # regs[6] = RBP(栈基址)
movq  %r8,  40(%rdi)        # regs[5] = R8
movq  %r9,  32(%rdi)        # regs[4] = R9
movq  %r12, 24(%rdi)        # regs[3] = R12
movq  %r13, 16(%rdi)        # regs[2] = R13
movq  %r14, 8(%rdi)         # regs[1] = R14
movq  %r15, (%rdi)          # regs[0] = R15

保存完所有 14 个寄存器。此时 curr->ctx 里有了当前协程的一份完整 CPU 状态快照

xorq  %rax, %rax            # RAX = 0(返回值)

保存阶段完成。下面是恢复阶段。

第二阶段:恢复目标协程的寄存器

movq  48(%rsi), %rbp        # RBP ← regs[6],先恢复栈基址

虽然 RBP 和 RSP 都还没恢复,但访问 pending->regs 是通过 RSI 索引的,不依赖 RSP/RBP。

movq  104(%rsi), %rsp       # ★★★ RSP ← regs[13] ★★★
                             # 执行完这行后,栈指针 RSP 变成了目标协程的栈
                             # 所有 push/pop 操作现在发生在目标协程的栈上
                             # 局部变量访问现在访问的是目标协程的栈
                             # 你已经"在"目标协程的栈空间中了
movq  (%rsi), %r15          # R15 = pending->regs[0]
movq  8(%rsi), %r14         # R14 = pending->regs[1]
movq  16(%rsi), %r13        # R13 = pending->regs[2]
movq  24(%rsi), %r12        # R12 = pending->regs[3]
movq  32(%rsi), %r9         # R9  = pending->regs[4]
movq  40(%rsi), %r8         # R8  = pending->regs[5]
movq  56(%rsi), %rdi        # RDI = pending->regs[7]
movq  80(%rsi), %rdx        # RDX = pending->regs[10]
movq  88(%rsi), %rcx        # RCX = pending->regs[11]
movq  96(%rsi), %rbx        # RBX = pending->regs[12]
leaq  8(%rsp), %rsp         # RSP += 8(腾出一个空位)
pushq 72(%rsi)              # 把 regs[9](返回地址)压入新栈顶
                             # 对于首次启动的协程,这个值是 coctx_make 设置的
                             # CoRoutineFunc 的地址
                             # 对于被切出去的协程,这是它上次调 coctx_swap 的返回地址
movq  64(%rsi), %rsi        # RSI ← regs[8]
                             # 最后才恢复 RSI——在此之前 RSI 一直要用来索引
                             # pending 的 regs 数组
ret                         # 从栈顶弹出返回地址 → 跳到那里继续执行
                             # 如果 pending 是首次运行:跳到 CoRoutineFunc
                             # 如果 pending 是被切出去的:跳到 co_swap 的下一行
                             # 断点续行!

切换前后的对比

  切换前(运行 main 协程)              切换后(运行 A 协程)
 ═══════════════════════════           ═══════════════════════════
  寄存器:                              寄存器:
    RSP → main 的 8MB 栈                 RSP → A 的 128KB 栈(malloc 的堆内存)
    RBP → main 栈帧底部                  RBP → A 栈帧底部
    RDI → curr 的 ctx 指针               RDI → A 的 co 指针(传给 CoRoutineFunc)
    RSI → pending 的 ctx 指针             RSI → 0 或上次的值
    RBX,R12~R15 等 → main 的            RBX,R12~R15 等 → A 的

  栈内容:                              栈内容:
    ┌──────────┐                         ┌──────────┐
    │ main 栈帧 │                         │ A 的栈帧  │
    ├──────────┤                         ├──────────┤
    │ 返回地址   │ ← "回到 main 的        │ CoRoutineFunc│← 栈顶放着函数地址
    │ co_swap后  │    co_swap 下一行"      │ 地址        │  ret 之后就去这
    ├──────────┤ ← RSP                   ├──────────┤ ← RSP
    │          │                         │          │
    └──────────┘                         └──────────┘

  RIP → coctx_swap 中                    RIP → 先跳到 ret_addr,
        正在执行 movq 指令                      对首次启动就是 CoRoutineFunc
                                               对切回来的就是 co_swap 的下一行

六、coctx_make——为首次启动伪造上下文

coctx_make 为新协程初始化上下文,让第一次 coctx_swap 过去时能"返回"到 CoRoutineFunc

// coctx.cpp:110(x86-64)
int coctx_make(coctx_t* ctx, coctx_pfn_t pfn, const void* s, const void* s1) {
    // sp = 栈底 + 栈大小 - 8,然后 16 字节对齐
    char* sp = ctx->ss_sp + ctx->ss_size - sizeof(void*);
    sp = (char*)((unsigned long)sp & -16LL);

    memset(ctx->regs, 0, sizeof(ctx->regs));

    // ★ 关键:把函数地址放到栈顶 ★
    void** ret_addr = (void**)(sp);
    *ret_addr = (void*)pfn;          // 栈顶 = CoRoutineFunc 的地址

    ctx->regs[kRSP] = sp;            // regs[13] = 栈顶地址
    ctx->regs[kRETAddr] = (char*)pfn; // regs[9] = CoRoutineFunc 地址
    ctx->regs[kRDI] = (char*)s;      // regs[7] = co 指针 → 第一个参数
    ctx->regs[kRSI] = (char*)s1;     // regs[8] = 0 → 第二个参数
    return 0;
}

它在做一件很简单的事:伪造一个"刚从 coctx_swap 返回"的假象

  1. 设置 RSP 指向新协程的栈顶
  2. 栈顶存放 CoRoutineFunc 的地址 → ret 会跳过去
  3. RDI = co 指针,RSI = 0 → 相当于 CoRoutineFunc(co, 0) 的参数已经准备好了

coctx_swap 执行恢复路径时:

  • RSP 变为这个新栈顶
  • pushq 72(%rsi)CoRoutineFunc 压栈
  • ret 弹出并跳转到 CoRoutineFunc
  • RDI = co 指针(第一个参数已经就位)
  • RSI = 0(第二个参数已经就位)

为什么把函数地址放在栈顶?回想 ret 指令:它从栈顶弹出一个地址,然后跳过去。如果栈顶正好是 CoRoutineFunc 的地址,ret 就会跳到 CoRoutineFunc

栈内存布局(栈向下增长,所以栈顶在低地址):

高地址(ss_sp + ss_size)
 ┌────────────────────┐
 │   CoRoutineFunc    │ ← sp 指向这里(ret_addr),ret 时被弹出
 ├────────────────────┤
 │   未使用            │
 │       ...           │
 │   未使用            │
 ├────────────────────┤
 │   (栈空间)          │
 └────────────────────┘ ← ss_sp(栈底,低地址)

为什么需要一个中间层 CoRoutineFunc 而不是直接跳到用户函数?

  1. 用户函数 return 后需要自动设置 cEnd = 1
  2. 需要自动 yield 回调用者
  3. 用户不需要关心协程生命周期管理

七、co_swap C 层封装

// co_routine.cpp:635
void co_swap(stCoRoutine_t* curr, stCoRoutine_t* pending_co) {
    stCoRoutineEnv_t* env = co_get_curr_thread_env();

    // 1. 记录当前栈顶位置(用局部变量的地址估算)
    char c;
    curr->stack_sp = &c;    // &c ≈ 当前的 RSP 位置

    // 2. 共享栈模式:把当前占用栈的协程内容 memcpy 出去
    if (!pending_co->cIsShareStack) {
        env->pending_co = NULL;
        env->occupy_co = NULL;
    } else {
        env->pending_co = pending_co;
        stCoRoutine_t* occupy_co = pending_co->stack_mem->occupy_co;
        pending_co->stack_mem->occupy_co = pending_co;
        env->occupy_co = occupy_co;
        if (occupy_co && occupy_co != pending_co)
            save_stack_buffer(occupy_co);   // memcpy 旧协程的栈到 save_buffer
    }

    // 3. ★ 汇编上下文切换 ★
    coctx_swap(&(curr->ctx), &(pending_co->ctx));
    //   %rdi = curr 的 ctx(保存到这里)
    //   %rsi = pending_co 的 ctx(从这里恢复)
    //   执行完这行以后,你已经"在" pending_co 中了

    // 4. 当被切换回来时,从共享栈的 save_buffer 恢复栈内容
    stCoRoutineEnv_t* curr_env = co_get_curr_thread_env();
    stCoRoutine_t* update_occupy_co = curr_env->occupy_co;
    stCoRoutine_t* update_pending_co = curr_env->pending_co;

    if (update_occupy_co && update_pending_co
        && update_occupy_co != update_pending_co) {
        if (update_pending_co->save_buffer
            && update_pending_co->save_size > 0) {
            memcpy(update_pending_co->stack_sp,
                   update_pending_co->save_buffer,
                   update_pending_co->save_size);
        }
    }
}

char c; &c 是函数里的局部变量地址。局部变量存在于栈帧里,所以 &c 就是当前 RSP 附近的一个地址。libco 用这个 trick 拿到一个近似的栈顶位置,目的是在共享栈模式下估算栈实际用了多少空间

void save_stack_buffer(stCoRoutine_t* occupy_co) {
    stStackMem_t* stack_mem = occupy_co->stack_mem;
    // 一共用了多少 = 栈顶部 - 当前栈位置
    int len = stack_mem->stack_bp - occupy_co->stack_sp;
    occupy_co->save_buffer = (char*)malloc(len);
    occupy_co->save_size = len;
    // memcpy "用过的部分" 到 save_buffer
    memcpy(occupy_co->save_buffer, occupy_co->stack_sp, len);
}
    ┌────────────────────┐ ← stack_bp(栈顶部,ss_sp + 128KB)
    │                    │
    │  协程运行过程中     │
    │  push / call 等     │
    │  实际使用了的区域    │ ← len = stack_bp - stack_sp
    │                    │
    ├────────────────────┤ ← stack_sp(调用 co_swap 时 &c 的地址)
    │  未使用的栈区域     │
    └────────────────────┘ ← stack_bp - 128KB(栈底部)

八、协程完整生命周期

8.1 线程环境初始化

当第一次调用 co_create 时(co_routine.cpp:521):

int co_create(stCoRoutine_t **ppco, const stCoRoutineAttr_t *attr,
              pfn_co_routine_t pfn, void *arg) {
    if (!co_get_curr_thread_env()) {       // 检查 __thread 变量
        co_init_curr_thread_env();          // 为空则初始化
    }
    stCoRoutine_t *co = co_create_env(..., pfn, arg);
    *ppco = co;
    return 0;
}

co_init_curr_thread_env(行 742)做了这几件事:

void co_init_curr_thread_env() {
    // 1. 分配线程环境(per-thread,存在 __thread 变量里)
    gCoEnvPerThread = calloc(1, sizeof(stCoRoutineEnv_t));
    stCoRoutineEnv_t *env = gCoEnvPerThread;

    // 2. 创建一个"主协程",它代表线程本身
    stCoRoutine_t *self = co_create_env(env, NULL, NULL, NULL);
    self->cIsMain = 1;

    // 3. 初始化 main 的 ctx(只清零,不需要设置 regs)
    coctx_init(&self->ctx);

    // 4. 把 main 协程压入调用栈
    env->pCallStack[env->iCallStackSize++] = self;

    // 5. 创建 epoll 实例和时间轮(60000 槽 = 60秒 × 1ms)
    stCoEpoll_t *ev = AllocEpoll();
    SetEpoll(env, ev);
}

此时调用栈状态:

pCallStack[0] = main 协程
iCallStackSize = 1

8.2 创建协程

co_create_env(行 461):

stCoRoutine_t *co_create_env(stCoRoutineEnv_t *env,
                              const stCoRoutineAttr_t *attr,
                              pfn_co_routine_t pfn, void *arg) {
    // 1. 确定栈大小(默认 128KB,上限 8MB,4KB 对齐)
    // 2. 分配 stCoRoutine_t
    stCoRoutine_t *lp = malloc(sizeof(stCoRoutine_t));
    memset(lp, 0, sizeof(stCoRoutine_t));

    lp->env = env;
    lp->pfn = pfn;    // 用户函数
    lp->arg = arg;

    // 3. 分配栈内存
    stStackMem_t* stack_mem;
    if (at.share_stack) {
        stack_mem = co_get_stackmem(at.share_stack);  // 从共享栈池取
    } else {
        stack_mem = co_alloc_stackmem(at.stack_size);  // malloc 独立栈
    }
    lp->stack_mem = stack_mem;

    // 4. 设置上下文中的栈信息(ss_sp 指向栈底部)
    lp->ctx.ss_sp = stack_mem->stack_buffer;
    lp->ctx.ss_size = at.stack_size;

    lp->cStart = 0;   // 还没启动过
    lp->cEnd = 0;     // 还没结束
    lp->cIsMain = 0;
    return lp;
}

此时协程 A 只是分配了内存,还没有初始化 CPU 寄存器上下文。这要等到第一次 co_resume 时由 coctx_make 完成。

8.3 首次 co_resume

// co_routine.cpp:558
void co_resume(stCoRoutine_t *co) {
    stCoRoutineEnv_t *env = co->env;
    // 获取当前在运行的协程(调用栈栈顶)
    stCoRoutine_t *lpCurrRoutine = env->pCallStack[env->iCallStackSize - 1];

    if (!co->cStart) {
        // 第一次 resume,初始化上下文
        coctx_make(&co->ctx, (coctx_pfn_t)CoRoutineFunc, co, 0);
        co->cStart = 1;
    }
    // 把目标协程压入调用栈
    env->pCallStack[env->iCallStackSize++] = co;
    // 切换
    co_swap(lpCurrRoutine, co);
}

此时调用栈:pCallStack[0] = main, pCallStack[1] = AiCallStackSize = 2)。

8.4 CoRoutineFunc——协程入口

// co_routine.cpp:444
static int CoRoutineFunc(stCoRoutine_t *co, void *) {
    if (co->pfn) {
        co->pfn(co->arg);    // 执行用户函数
    }
    co->cEnd = 1;             // 标记:我已结束

    stCoRoutineEnv_t *env = co->env;
    co_yield_env(env);        // 让出 CPU,永不再返回

    return 0;
}

8.5 co_yield——让出 CPU

用户在协程中调用 co_yield_ct() 或等 co_poll 内部 yield:

void co_yield_ct() {
    co_yield_env(co_get_curr_thread_env());
}

void co_yield_env(stCoRoutineEnv_t *env) {
    // 调用者的协程(pCallStack 倒数第二个)
    stCoRoutine_t *last = env->pCallStack[env->iCallStackSize - 2];
    // 当前协程(pCallStack 倒数第一个)
    stCoRoutine_t *curr = env->pCallStack[env->iCallStackSize - 1];

    env->iCallStackSize--;   // pop 当前协程

    co_swap(curr, last);      // 保存 curr 的寄存器 → curr.ctx
                               // 恢复 last 的寄存器 ← last.ctx
                               // 回到 last 的 co_swap 下一行!
}

8.6 完整生命周期的 CPU 视角

假设 main 协程创建了协程 A,A 调用 co_poll(socket_fd, timeout),socket_fd 还没数据:

时刻 1: main 调用 co_resume(A)
  CPU 执行:
    coctx_make(&A.ctx, CoRoutineFunc, A, 0)
    → 伪造上下文:RSP=A栈顶,RDI=A指针,ret=CoRoutineFunc
    co_swap(main, A)
    → 保存 main 的 14 个寄存器 → main.ctx.regs[]
    → 恢复 A 的 14 个寄存器 ← A.ctx.regs[]
    → RSP 切到 A 的栈
    → ret → CoRoutineFunc → 用户函数

时刻 2: A 的用户函数需要等 I/O,调用 co_poll
  CPU 执行: co_poll_inner
    注册 socket_fd 到 epoll
    AddTimeout → 加入时间轮
    co_yield_ct → co_yield_env → co_swap(A, main)
      保存 A 的 14 个寄存器 → A.ctx.regs[]
      恢复 main 的 14 个寄存器 ← main.ctx.regs[]
      RSP 切回 main 的栈
      ret → 回到 co_resume 中 co_swap 的下一行

时刻 3: main 进入 co_eventloop
  CPU 执行: epoll_wait(1ms)
    socket_fd 还没数据 → 空返回
    TakeAllTimeout → 没超时 → 空
    循环继续... epoll_wait(1ms)...

时刻 4: socket_fd 收到数据
  CPU 执行: epoll_wait(1ms) 返回
    data.ptr → stPollItem_t
    OnPollPreparePfn → stPoll_t 放入 active 链表
    OnPollProcessEvent → co_resume(A)
      co_swap(main, A)
        保存 main 的 14 个寄存器 → main.ctx.regs[]
        恢复 A 的 14 个寄存器 ← A.ctx.regs[]
        RSP 切回 A 的栈
        ret → ★ 回到 co_poll_inner 中 co_yield_env 之后的代码!

时刻 5: A 继续执行
  CPU 执行: co_poll_inner 的清理代码(注销 epoll、回填 revents)
    返回到用户函数
    用户函数继续处理数据...
    用户函数 return
    → CoRoutineFunc: cEnd=1, co_yield_env → co_swap(A, main)
      → 回到 main 协程

九、协程调度与事件循环

libco 的调度:事件循环驱动,非抢占,完全是协程主动让出。 没有多线程,没有时间片轮转,没有优先级。一个线程,一个事件循环,多个协程。

9.1 整体结构

co_eventloop  ─── 调度心脏,在一个线程里无限循环
    │
    ├─ epoll_wait(1ms)  ── 等 I/O 事件
    ├─ TakeAllTimeout ── 取过期的定时器
    └─ 逐个 co_resume  ── 唤醒等待中的协程

9.2 co_poll_inner——协程如何等待 I/O

// co_routine.cpp:917
int co_poll_inner(stCoEpoll_t *ctx, struct pollfd fds[],
                  nfds_t nfds, int timeout, poll_pfn_t pollfunc) {
    if (timeout == 0) return pollfunc(fds, nfds, timeout);

    // 1. 构造 stPoll_t 对象
    stPoll_t& arg = *(stPoll_t*)malloc(sizeof(stPoll_t));
    arg.iEpollFd = ctx->iEpollFd;
    arg.fds = calloc(nfds, sizeof(pollfd));
    arg.nfds = nfds;

    // 为每个 fd 创建 stPollItem_t
    arg.pPollItems = malloc(nfds * sizeof(stPollItem_t));

    // 设置回调——被唤醒时谁来 co_resume
    arg.pfnProcess = OnPollProcessEvent;  // 回调会调用 co_resume
    arg.pArg       = co_self();           // 参数:当前协程自己

    // 2. 将每个 fd 注册到 epoll
    for (nfds_t i = 0; i < nfds; i++) {
        arg.pPollItems[i].pSelf = arg.fds + i;
        arg.pPollItems[i].pPoll = &arg;
        arg.pPollItems[i].pfnPrepare = OnPollPreparePfn;

        struct epoll_event &ev = arg.pPollItems[i].stEvent;
        if (fds[i].fd > -1) {
            ev.data.ptr = arg.pPollItems + i;  // 事件触发时找回对应 item
            ev.events = PollEvent2Epoll(fds[i].events);
            co_epoll_ctl(epfd, EPOLL_CTL_ADD, fds[i].fd, &ev);
        }
    }

    // 3. 加入时间轮(超时机制)
    arg.ullExpireTime = GetTickMS() + timeout;
    AddTimeout(ctx->pTimeout, &arg, now);

    // 4. ★ 让出 CPU ★
    co_yield_env(co_get_curr_thread_env());
    // ═══════════════════════════════════════════
    // 协程在此挂起……
    // ... 等待 epoll 事件或超时 ...
    // 事件循环调用 co_resume(co) 后,从这里继续执行!
    // ═══════════════════════════════════════════

    iRaiseCnt = arg.iRaiseCnt;   // 有多少个 fd 触发了

    // 5. 清理:从 epoll 和时间轮中注销
    RemoveFromLink<stTimeoutItem_t, stTimeoutItemLink_t>(&arg);
    for (nfds_t i = 0; i < nfds; i++) {
        if (fds[i].fd > -1)
            co_epoll_ctl(epfd, EPOLL_CTL_DEL, fd, &arg.pPollItems[i].stEvent);
        fds[i].revents = arg.fds[i].revents;  // 回填 revents
    }
    free(arg.pPollItems);
    free(arg.fds);
    free(&arg);
    return iRaiseCnt;
}

关键数据流epoll_event.data.ptr → stPollItem_t → stPoll_t → stTimeoutItem_t → pArg = 协程指针 → pfnProcess = co_resume

9.3 co_eventloop——事件循环

// co_routine.cpp:793
void co_eventloop(stCoEpoll_t *ctx, pfn_co_eventloop_t pfn, void *arg) {
    co_epoll_res *result = ctx->result;

    for (;;) {
        // ===== 第 1 步:等 I/O 事件(最多 1ms)=====
        int ret = co_epoll_wait(ctx->iEpollFd, result, 10240, 1);
        // 为什么 1ms?不能让 epoll_wait 长时间阻塞,
        // 因为定时器可能随时到期,而且这个线程
        // 可能还在运行其他没挂起的协程

        stTimeoutItemLink_t *active  = ctx->pstActiveList;
        stTimeoutItemLink_t *timeout = ctx->pstTimeoutList;
        memset(timeout, 0, sizeof(stTimeoutItemLink_t));

        // ===== 第 2 步:epoll 事件 → active 链表 =====
        for (int i = 0; i < ret; i++) {
            stTimeoutItem_t *item = result->events[i].data.ptr;
            // 这就是之前 epoll_ctl 时塞的 data.ptr
            // 通过它一路找到 stPoll_t → pArg = 协程指针

            if (item->pfnPrepare) {
                item->pfnPrepare(item, result->events[i], active);
                // OnPollPreparePfn:
                //   1. 把 epoll 事件转为 poll revents
                //   2. iRaiseCnt++
                //   3. 父 stPoll_t 从时间轮移除
                //   4. 父 stPoll_t 加入 active 链表
            } else {
                AddTail(active, item);
            }
        }

        // ===== 第 3 步:取所有过期的定时器 =====
        unsigned long long now = GetTickMS();
        TakeAllTimeout(ctx->pTimeout, now, timeout);
        // 标记超时
        stTimeoutItem_t *lp = timeout->head;
        while (lp) { lp->bTimeout = true; lp = lp->pNext; }

        // ===== 第 4 步:合并 timeout → active =====
        Join<stTimeoutItem_t, stTimeoutItemLink_t>(active, timeout);

        // ===== 第 5 步:逐个处理 =====
        lp = active->head;
        while (lp) {
            PopHead(active);

            // 处理虚假唤醒
            if (lp->bTimeout && now < lp->ullExpireTime) {
                AddTimeout(ctx->pTimeout, lp, now);
                lp = active->head;
                continue;
            }

            lp->pfnProcess(lp);
            //  ↑ OnPollProcessEvent:
            //    stCoRoutine_t *co = (stCoRoutine_t*)lp->pArg;
            //    co_resume(co);
            //      → pCallStack push
            //      → co_swap(main, co)
            //      → 协程活过来了!

            lp = active->head;
        }

        // ===== 第 6 步:用户回调 =====
        if (pfn && pfn(arg) == -1) break;
    }
}

9.4 调度链路图

socket 收到数据
  → 内核
  → epoll_wait 返回该 fd 的 epoll_event
  → event.data.ptr
  → stPollItem_t
  → .pfnPrepare = OnPollPreparePfn
  → 父 stPoll_t 进入 active 链表
  → .pfnProcess = OnPollProcessEvent
  → .pArg = 协程 A 指针
  → co_resume(协程 A)
  → co_swap(main, A)
  → 协程 A 活过来了

9.5 与 OS 线程调度的对比

OS 线程调度 libco 协程调度
谁触发切换 硬件定时器中断(抢占) 协程主动调 yield(协作)
根本机制 时钟中断 → 内核 → 切换线程 co_poll/co_yield → 换 RSP
调度策略 时间片轮转 / 优先级 无策略,先到先处理
内核态 每次切换要进内核 全程用户态
数据结构 就绪队列、优先级队列 链表(active / timeout)
唤醒方式 内核把线程放入就绪队列 把 stPoll_t 放入 active 链表

libco 的调度不是"协程 A → 协程 B → 协程 A"这种轮转。 它是:协程 A 等待 I/O 时主动 yield 给 main,main 在事件循环里等 epoll,I/O 到了才 co_resume。协程之间不直接切换,都通过 main 协程中转。


十、co_cond 条件变量

基于双向链表 + 时间轮实现:

  • co_cond_timedwait:创建等待项 → 加入 cond 链表 → 可选超时注册到时间轮 → yield
  • co_cond_signal:从链表取出一个等待项 → 放入 epoll active 列表 → 下轮事件循环时 co_resume
  • co_cond_broadcast:将所有等待项都放入 active 列表

十一、共享栈模式

多个协程共享少量物理栈(比如 100 万个协程共享 10 个 128KB 的栈):

切换出时:memcpy → 把当前协程的栈内容保存到 save_buffer
切换入时:memcpy ← 从 save_buffer 恢复栈内容

代价是每次切换多一次 memcpy(整个栈大小),但内存节省巨大。


十二、总结

三个核心要点

  1. 栈切换是根本coctx_swap 替换 RSP,每个协程拥有独立的 C 调用栈。这就是为什么协程里可以写任意深的嵌套函数调用——因为它和线程一样有完整的栈。

  2. 调用栈而非就绪队列:libco 用 pCallStack[128] 管理协程间的嵌套调用关系。resume 是 push,yield 是 pop。协程之间不直接切换,都通过 main 协程中转。

  3. epoll + 时间轮 = 统一的等待机制:I/O 等待和超时被统一到同一个 stTimeoutItem_t 体系。协程只做一件事——yield 自己,把 co_resume 的回调挂到事件上。事件循环检测到事件后调用 co_resume 唤醒协程。

对比:普通函数调用 vs 协程切换

普通函数调用 协程切换
返回地址 call 自动压栈 手动保存在 regs[9],恢复时手动 push + ret
局部变量 栈帧复用,回到 caller 后变量还在 必须切到协程自己的栈,否则被覆盖
参数 寄存器 + 栈 保存在 regs[7](RDI) 和 regs[8](RSI)
寄存器保护 编译器自动生成 push/pop 手动保存/恢复全部 14 个 regs
栈指针 在同一条栈内移动 regs[13](RSP) 完全替换,跳到另一块内存

一句话

只改了 RSP 一个寄存器,栈就换了。 但要保证换栈之后程序能正确运行,所有寄存器和返回地址都必须一起还原到目标协程上次被切出去时的状态。

CPU 根本不关心 RSP 指向的是 OS 分配的栈还是 malloc 分配的堆内存——只要那块内存可读写,push/pop/call/ret 就正常干活。RSP 指向哪里,哪里就是栈。 这就是协程能工作的硬件基础。

posted @ 2026-05-31 13:24  ccstring  阅读(1)  评论(0)    收藏  举报