0%

Loading world

← All posts

异步与协程机制

5 min read5 views
Contents

Coroutine:从异步到栈式协程的实现分析

1. 开场:从“函数染色”想到异步

开头想从 Bob Nystrom 的文章 What Color Is Your Function? 讲起。文章用了一个很形象的比喻:假设函数有红色和蓝色,蓝色函数代表同步函数,红色函数代表异步函数;一旦一个函数调用了异步函数,它自己往往也不得不变成异步函数,颜色会不断向调用链传播。

嘿嘿,这里并不是要继续讨论“函数染色”本身,而是借这个问题引出另一个更底层的思考:

一个函数为什么不能停在某个位置,等条件满足之后再从原来的位置继续执行?这个“暂停”和“继续”究竟是怎么实现的?

这个问题正好把我们带到了异步和协程的实现内部。

1.1 什么是异步?

我们平时写同步代码时,控制流很自然:

调用函数 → 等待函数返回 → 使用返回值 → 继续执行

比如读取文件、访问网络或等待定时器时,当前函数会停在那里,直到操作完成。问题在于,等待期间可能并不是 CPU 在工作,而是磁盘、网络设备或其他外部条件还没有准备好。如果让当前线程一直等着,线程本身就被占住了。

异步做的事情,是把“发起操作”和“拿到结果”拆开:

发起操作 → 当前执行单元先去做别的事情 → 操作完成 → 回到原任务处理结果

这个“回到原任务”可以通过 callback、future/promise、事件循环,也可以通过 coroutine 的 await 来表达。异步并不等于执行得更快,也不等于多个任务一定在多个 CPU 上并行执行。它首先解决的是:

当一个任务处于等待状态时,不要让执行资源也一起闲置。

所以,从实现角度看,异步并不是一个孤立的关键字。只要程序允许任务暂时离开执行流,就必须有某个机制记录“任务现在等什么、完成后从哪里继续、接下来应该运行谁”。这个机制通常由 runtime 提供,包括事件循环、线程池、IO 回调和协程调度器等。

1.2 为什么需要协程?

早期为了实现“同时处理很多等待中的任务”,一种直接想法是:一个任务开一个 pthread。任务阻塞时,操作系统切换到另一个线程。

这个方法当然能工作,但线程不是免费的。每个线程都需要栈空间、线程控制块和内核调度资源,线程数量增加后还会带来更多的调度和缓存开销。更重要的是,很多任务真正需要 CPU 的时间很短,大部分时间只是在等待 IO;如果每个任务都长期占着一个线程,资源利用率并不理想。

因此,实际系统通常会把线程数量控制在一个有限范围内,再在线程之上调度更多任务。协程就是其中一种思路:任务在 await 某个操作时主动挂起,把当前线程让出来;调度器转而运行另一个没有被阻塞的任务。等 IO 或定时器完成后,runtime 再把原任务放回可运行队列。

协程和操作系统的进程/线程上下文切换在思想上很相似:都需要保存当前执行现场,再恢复另一个执行现场。但边界不同。操作系统切换时还要处理地址空间、特权级、内核栈等问题;用户态协程主要保存寄存器、栈指针和返回地址,并切换到另一块用户栈,通常不需要进入内核。

本项目还没有接入真实 IO,也没有实现完整的 await。测试里的 yield() 可以看成“任务主动表示自己暂时不继续执行”的最小替身。项目先把最核心的问题隔离出来:只要能保存和恢复执行现场,就能让多个任务在同一个线程中交替推进。

2. 整体设计思路

2.1 目标和范围

这个项目不是要实现一个完整的协程库,而是用一个尽可能小的实验回答下面的问题:

如果不依赖线程切换,也不依赖 C++20 编译器生成的 coroutine 状态机,能否只保存必要的执行上下文,让多个函数在各自的栈上交替执行?

为了把问题控制在“上下文切换”本身,当前实现做了几个简化:

  • 每个协程使用一块固定大小的独立栈;
  • 协程通过显式调用 yield() 主动让出执行权;
  • 任务没有参数、返回值和异常传播;
  • 调度器在同一个线程中顺序扫描协程;
  • 不接入真实 IO,yield() 只模拟等待点。

这些限制不是最终能力,而是为了让实现链路足够短,便于分析“一个函数如何暂停并恢复”。

2.2 核心对象之间的关系

可以把整个实现先抽象成三个层次:

Scheduler
    ├── 保存主线程的 main_ctx
    ├── 记录当前协程 current
    └── 管理多个 Coroutine
 
Coroutine
    ├── 用户函数 func
    ├── 独立栈 stack
    ├── 可恢复上下文 ctx
    └── 当前状态 state
 
switch_context(old, next)
    ├── 把当前寄存器和栈指针保存到 old
    └── 从 next 恢复寄存器和栈指针

其中最关键的不是 Scheduler,而是 switch_context。调度器只负责决定“下一次运行谁”;真正让函数暂停后继续执行的,是上下文保存和恢复。

2.3 一次完整运行的控制流

以协程 A 为例,第一次运行的大致过程是:

  1. Scheduler 调用 A 的 resume();
  2. resume() 设置 current 和 active,然后把主线程现场保存到 main_ctx;
  3. 汇编切换到 A 的独立栈;
  4. A 第一次恢复时没有真实的暂停现场,因此通过预设的返回地址进入 coroutine_entry;
  5. coroutine_entry 调用用户函数 A;
  6. A 执行到 yield(),把自己的现场保存到 A 的 ctx,再切回 main_ctx;
  7. Scheduler 继续运行其他协程;
  8. 下一次 resume(A) 时,恢复 A 的 ctx,A 从上一次 yield() 的下一条路径继续执行;
  9. A 的函数返回后,入口函数把状态改为 Finished,再切回调度器。

这就是整个项目的核心闭环:

保存主线程现场

切到协程栈并执行

yield:保存协程现场

切回主线程

恢复另一个协程

2.4 设计选择

本项目选择的是栈式、协作式协程,而不是状态机式、抢占式协程:

选择 当前方案 选择原因 代价
协程表示 独立栈 + Context 保留自然的函数调用栈,便于理解暂停/恢复 依赖 ABI 和汇编
调度方式 协作式 切换点明确,不需要处理中断和任意位置抢占 任务不主动 yield() 时会阻塞调度器
栈管理 每个任务固定 64 KiB 实现简单,栈生命周期直观 没有栈溢出检测,空间利用不灵活
任务入口 void 函数指针 先验证机制,接口最小 不支持参数、返回值和捕获 lambda

所以,这个项目的重点不是证明这种方案最适合工程实践,而是用最少的机制展示协程的底层工作方式。

3. 实现代码

3.1 构建文件:CMakeLists.txt

cmake_minimum_required(VERSION 3.20)
 
project(Coroutine)
 
enable_language(ASM)
 
set(CMAKE_CXX_STANDARD 20)
 
include_directories(
    include
)
 
add_executable(
    coroutine_test
 
    test/main.cpp
 
    src/coroutine.cpp
    src/scheduler.cpp
    src/context.S
)

3.2 上下文接口:include/context.h

#pragma once 
struct Context{
    void* sp;
    void* x19;
    void* x20;
    void* x21;
    void* x22;
    void* x23;
    void* x24;
    void* x25;
    void* x26;
    void* x27;
    void* x28;
    void* fp;
    void* lr;
};
extern "C"
void switch_context(
    Context* old,
    Context* next
);

3.3 协程接口:include/coroutine.h

#pragma once
#include "context.h"
class Scheduler;
extern "C"
void coroutine_entry();
void yield();
class Coroutine{
    public:
        using Func = void(*)();
        enum class State{
            Ready,
            Running,
            Suspended,
            Finished
        };
        Coroutine(Func f,Scheduler* scheduler);
        void resume();
        void run();
        void finish();
        bool finished()const;
 
    private:
        Context ctx{};
        Func func;
        char stack[64*1024];
        State state = State::Ready;
        Scheduler* scheduler;
        friend class Scheduler;
        friend void coroutine_entry();
        friend void yield();
};

3.4 调度器接口:include/scheduler.h

#pragma once
#include<vector>
#include<memory>
#include"coroutine.h"
#include"context.h"
class Scheduler{
    public:
        void add(Coroutine::Func func);
        void run();
    private:
        Context main_ctx{};
        Coroutine* current = nullptr;
        std::vector<std::unique_ptr<Coroutine>>coroutines;
        inline static thread_local Scheduler* active = nullptr;
        friend class Coroutine;
        friend void yield();
        friend void coroutine_entry();
 
};

3.5 上下文切换:src/context.S

.text
.global _switch_context
_switch_context:
    mov x2,sp
    str x2,[x0]
    str x19,[x0,#8]
    str x20,[x0,#16]
    str x21,[x0,#24]
    str x22,[x0,#32]
    str x23,[x0,#40]
    str x24,[x0,#48]
    str x25,[x0,#56]
    str x26,[x0,#64]
    str x27,[x0,#72]
    str x28,[x0,#80]
    str x29,[x0,#88]
    str x30,[x0,#96]
    ldr x19,[x1,#8]
    ldr x20,[x1,#16]
    ldr x21,[x1,#24]
    ldr x22,[x1,#32]
    ldr x23,[x1,#40]
    ldr x24,[x1,#48]
    ldr x25,[x1,#56]
    ldr x26,[x1,#64]
    ldr x27,[x1,#72]
    ldr x28,[x1,#80]
    ldr x29,[x1,#88]
    ldr x30,[x1,#96]
    ldr x2,[x1]
    mov sp,x2
    ret

3.6 协程实现:src/coroutine.cpp

#include"coroutine.h"
#include"scheduler.h"
#include<cstdint>
#include<iostream>
 
bool Coroutine::finished()const{
    return state == State::Finished;
}
 
Coroutine::Coroutine(Func f,Scheduler* scheduler){
    func = f;
    this->scheduler = scheduler;
    state = State::Ready;
 
    void* stack_top = stack + sizeof(stack);
    stack_top = reinterpret_cast<void*>(
        reinterpret_cast<uintptr_t>(stack_top)&~uintptr_t(0xF)
    );
 
    ctx.sp = stack_top;
    ctx.lr = reinterpret_cast<void*>(coroutine_entry);
}
 
void Coroutine::resume(){
    if(state==State::Finished){
        return;
    }
 
    scheduler->current=this;
    Scheduler::active=scheduler;
    state = State::Running;
 
    switch_context(&scheduler->main_ctx,&ctx);
 
    scheduler->current=nullptr;
    Scheduler::active=nullptr;
}
 
void Coroutine::run(){
    func();
}
 
void Coroutine::finish(){
    state = State::Finished;
}
 
extern "C"
void coroutine_entry(){
    Scheduler* scheduler = Scheduler::active;
    Coroutine* current = scheduler->current;
 
    current->run();
    current->finish();
 
    switch_context(&current->ctx,&scheduler->main_ctx);
}
 
void yield(){
    Scheduler* scheduler = Scheduler::active;
    Coroutine* current = scheduler->current;
    current->state = Coroutine::State::Suspended;
 
    switch_context(&current->ctx,&scheduler->main_ctx);
}

3.7 调度器实现:src/scheduler.cpp

#include<scheduler.h>
 
void Scheduler::add(Coroutine::Func func){
    coroutines.push_back(std::make_unique<Coroutine>(func,this));
}
 
void Scheduler::run(){
    bool all_finished = false;
 
    while(!all_finished){
        all_finished = true;
 
        for(auto& coroutine:coroutines){
            if(!coroutine->finished()){
                all_finished=false;
                coroutine->resume();
            }
        }
    }
}

3.8 测试程序:test/main.cpp

#include <iostream>
 
#include "scheduler.h"
 
void taskA()
{
    std::cout << "A1\n";
 
    yield();
 
    std::cout << "A2\n";
 
    yield();
 
    std::cout << "A3\n";
}
 
void taskB()
{
    std::cout << "B1\n";
 
    yield();
 
    std::cout << "B2\n";
 
    yield();
 
    std::cout << "B3\n";
}
 
int main()
{
    Scheduler scheduler;
 
    scheduler.add(taskA);
    scheduler.add(taskB);
 
    scheduler.run();
 
    std::cout << "scheduler finished\n";
 
    return 0;
}

4. 代码讲解

4.1 Context 保存的到底是什么?

Context 不是保存一个抽象的“函数状态”,而是保存恢复机器执行所需要的一组值。当前结构的内存布局如下:

偏移 字段 含义
0 sp 当前栈指针
8 到 80 x19 到 x28 ARM64 被调用者保存寄存器
88 fp,也就是 x29 当前栈帧的帧指针
96 lr,也就是 x30 函数返回地址

这里的字段顺序必须和 context.S 中的偏移完全一致。C++ 编译器并不知道汇编代码的这些约定,因此 Context 实际上是 C++ 和汇编之间的一份手写 ABI 合同。

为什么没有保存所有寄存器?因为 switch_context 本身是一个函数调用。按照 ARM64 调用约定,调用者保存寄存器本来就允许被函数调用破坏;跨函数调用需要保持的状态主要由被调用者保存寄存器承担。当前实现保存 x19 到 x28、x29、x30,以及 sp,正是为了恢复调用链所需要的最小集合。

4.2 context.S 如何完成切换?

switch_context 接收两个指针:

x0 = old:保存当前现场的位置
x1 = next:要恢复的目标现场

前半段使用 str 指令,把当前 sp、x19 到 x30 写入 old。后半段使用 ldr 从 next 恢复寄存器,最后执行:

ldr x2,[x1]
mov sp,x2
ret

这里的关键是 ret。ret 会跳转到恢复后的 x30,也就是 next.lr 指向的位置。因此:

  • 第一次恢复一个新协程时,x30 被预先设置为 coroutine_entry,ret 进入协程入口;
  • 从 yield() 恢复时,x30 是上一次切换调用保存的返回地址,ret 回到 yield() 调用之后;
  • 从协程结束路径恢复时,x30 指向调度器现场,ret 回到 Scheduler::run()。

同一个 switch_context 因此同时承担了“启动协程”和“恢复协程”两个职责。

另外,代码中的全局符号是 _switch_context,而 C++ 中声明的是 switch_context。这是 Darwin 平台 C 符号命名的结果:C++ 代码中的 C linkage 函数在链接层对应带前导下划线的符号。这个细节也说明,手写汇编和平台 ABI 是绑定在一起的。

4.3 Coroutine 构造函数为什么要设置栈和 lr?

每个 Coroutine 都包含一个 64 KiB 的栈数组:

char stack[64*1024];

构造函数取出栈顶并向下对齐到 16 字节边界:

void* stack_top = stack + sizeof(stack);
stack_top = reinterpret_cast<void*>(
    reinterpret_cast<uintptr_t>(stack_top)&~uintptr_t(0xF)
);
ctx.sp = stack_top;

ARM64 ABI 要求栈保持适当对齐。若恢复后的 sp 不满足要求,简单的输出测试可能仍然通过,但复杂的函数调用、对象操作或向量指令可能出现问题。

新协程还没有真实的暂停现场,所以构造函数人为设置:

ctx.lr = reinterpret_cast<void*>(coroutine_entry);

这样第一次 resume() 恢复 ctx 后,汇编中的 ret 会进入 coroutine_entry,而不是跳转到一个不存在的调用者。

4.4 resume() 如何进入和离开协程?

resume() 先处理运行时指针:

scheduler->current=this;
Scheduler::active=scheduler;
state = State::Running;

current 表示当前调度器正在运行哪一个协程,active 是线程局部的当前调度器。yield() 和 coroutine_entry() 没有显式接收 Scheduler 参数,因此需要通过 active 找到当前运行环境。

随后调用:

switch_context(&scheduler->main_ctx,&ctx);

这次调用并不会立即返回。它先把当前调度器的执行现场保存到 main_ctx,然后恢复协程 ctx,切换栈并通过 ret 进入协程。

当协程之后调用 yield(),或者在 coroutine_entry() 中执行结束,控制权会切回 main_ctx。此时 resume() 从 switch_context 调用之后继续执行,并清理 current 和 active:

scheduler->current=nullptr;
Scheduler::active=nullptr;

因此,resume() 的一次调用可以理解为“让这个协程运行到下一次主动挂起或结束”。

4.5 yield() 为什么能从原位置继续?

yield() 首先找到当前协程,并把状态改为 Suspended:

Scheduler* scheduler = Scheduler::active;
Coroutine* current = scheduler->current;
current->state = Coroutine::State::Suspended;

然后把当前协程现场保存到自己的 ctx,并恢复调度器现场:

switch_context(&current->ctx,&scheduler->main_ctx);

注意 old 和 next 的方向:

协程 yield:
    old  = 当前协程 ctx
    next = 调度器 main_ctx
 
调度器 resume:
    old  = 调度器 main_ctx
    next = 协程 ctx

下一次 resume() 恢复协程 ctx 时,sp 回到原来的协程栈,lr 回到 yield() 调用之后,所以 yield() 会像普通函数一样返回,用户函数也就从暂停点后面继续执行。

这也是栈式协程最核心的地方:它不是重新调用任务函数,而是恢复上一次函数调用现场。

4.6 coroutine_entry() 为什么不能直接 return?

coroutine_entry() 是所有新协程的统一入口:

void coroutine_entry(){
    Scheduler* scheduler = Scheduler::active;
    Coroutine* current = scheduler->current;
 
    current->run();
    current->finish();
 
    switch_context(&current->ctx,&scheduler->main_ctx);
}

它负责三件事:

  1. 找到当前调度器和当前协程;
  2. 调用用户函数,并在正常返回后标记 Finished;
  3. 把控制权切回调度器。

用户函数执行结束后,不能直接从 coroutine_entry() 返回。因为它是在一块人为构造的协程栈上启动的,并没有一个普通的上层调用者等待它返回。直接 return 可能会使用一个不存在或无效的返回地址。显式切回 main_ctx,才是正确的结束路径。

4.7 Scheduler 如何实现轮转?

Scheduler 通过 vector 和 unique_ptr 持有所有协程:

std::vector<std::unique_ptr<Coroutine>>coroutines;

add() 创建协程并保存它。run() 反复扫描这个容器:

for(auto& coroutine:coroutines){
    if(!coroutine->finished()){
        all_finished=false;
        coroutine->resume();
    }
}
~~
Share