强曰为道

与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

第6章:协程 —— 用户态的并发原语

第6章:协程 —— 用户态的并发原语

6.1 什么是协程?

协程(Coroutine)是一种可以暂停和恢复执行的程序组件。与函数只能从头到尾执行一次不同,协程可以在执行过程中**让出(yield)控制权,之后从暂停的地方恢复(resume)**执行。

类比:协程就像一本可以随时插入书签、合上、下次从书签处继续阅读的书。而普通函数就像一张收据——要么一口气读完,要么丢掉从头来。

协程 vs 函数

特性函数(Function)协程(Coroutine)
执行模型一次性执行完毕可多次暂停和恢复
调用关系调用方等待被调方返回可互相让出控制权
状态保存无(栈帧销毁)有(保存执行上下文)
入口始终从头开始可从上次暂停处继续

历史

协程的概念比线程还要古老:

  • 1958年:Melvin Conway 首次提出"协程"一词,用于编译器设计
  • 1963年:Simula 语言中使用协程实现离散事件模拟
  • 1975年:Donald Knuth 在 TAOCP 中描述协程
  • 2003年:C# 2.0 引入 yield return(迭代器协程)
  • 2012年:Go 语言以 goroutine 普及协程
  • 2015年:Python 3.5 引入 async/await(原生协程)
  • 2019年:Rust 引入 async/await
  • 2020年:C++20 引入协程

6.2 协程的基本操作

创建、让出、恢复

# Python — 最简单的协程演示
def simple_coroutine():
    print("→ 协程启动")
    yield 1                    # 让出控制权,返回值 1
    print("→ 协程恢复")
    yield 2                    # 再次让出
    print("→ 协程结束")

# 创建协程(不会立即执行)
coro = simple_coroutine()

# 恢复执行
print(next(coro))     # → 协程启动 → 输出 1
print(next(coro))     # → 协程恢复 → 输出 2
# next(coro)          # → 协程结束 → StopIteration

双向通信(Send)

def accumulator():
    total = 0
    while True:
        value = yield total   # yield 当前总和,接收新值
        if value is None:
            break
        total += value

acc = accumulator()
next(acc)               # 启动协程(必须先 next/send(None))
print(acc.send(10))     # 发送 10,返回 10
print(acc.send(20))     # 发送 20,返回 30
print(acc.send(30))     # 发送 30,返回 60

状态机视图

协程状态转换:

  ┌─────┐
  │创建  │  coro = my_coroutine()
  └──┬──┘
     │
     ▼
  ┌─────┐  next()/send()  ┌──────┐
  │挂起  │ ──────────────► │执行中 │
  │SUSPENDED│              │RUNNING│
  └──┬──┘  ◄────────────── └──┬──┘
     │      yield              │
     │                         │
     ▼                    StopIteration
  ┌─────┐                   │
  │完成  │ ◄─────────────────┘
  │CLOSED│
  └─────┘

6.3 对称协程 vs 非对称协程

非对称协程(Asymmetric Coroutine)

也称为半对称协程(Semi-coroutine),是现代语言中最常见的形式。

特征

  • 协程只能将控制权归还给它的调用者
  • 存在明确的"调用"和"返回"关系
  • 类似函数调用,但可以暂停
# Python 的生成器就是非对称协程
def generator():
    yield 1    # 控制权归还给调用方
    yield 2    # 控制权归还给调用方

for val in generator():
    print(val)

代表语言:Python、JavaScript、Rust、C#、C++

对称协程(Symmetric Coroutine)

特征

  • 协程可以直接将控制权转移给任意其他协程
  • 没有固定的调用关系
  • 使用 transfer(target) 操作
-- Lua 的协程是对称协程
local co1, co2

co1 = coroutine.create(function()
    print("co1: 开始")
    coroutine.transfer(co2)   -- 直接转移到 co2
    print("co1: 恢复")
end)

co2 = coroutine.create(function()
    print("co2: 执行")
    coroutine.transfer(co1)   -- 直接转移到 co1
end)

coroutine.transfer(co1)  -- 从主线程转移到 co1

对比

特性非对称协程对称协程
控制权转移只能归还给调用者可转移到任意协程
调用关系有明确的调用层次扁平,无层级
实现复杂度较简单较复杂
代表Python、Rust、C#Lua、Modula-2
使用场景迭代器、async/await协作式多任务

6.4 有栈协程 vs 无栈协程

这是协程实现方式的根本分类。

有栈协程(Stackful Coroutine)

每个协程拥有独立的调用栈

协程 A 的栈        协程 B 的栈       主线程的栈
┌──────────┐     ┌──────────┐     ┌──────────┐
│ func_c() │     │ func_f() │     │ main()   │
│ func_b() │     │ func_e() │     │          │
│ func_a() │     │ func_d() │     │          │
└──────────┘     └──────────┘     └──────────┘

特点

优点缺点
可在任意调用深度让出每个协程需要独立栈空间(通常 2KB-8KB)
不需要修改已有函数签名栈空间分配/管理有额外开销
可包装已有同步代码为异步实现复杂度高

代表:Go(goroutine)、Erlang、Lua、Boost.Fiber、Ruby Fiber

无栈协程(Stackless Coroutine)

协程状态保存在一个状态对象中,没有独立调用栈。

协程状态对象:
┌─────────────────────────┐
│ 当前挂起点(suspend point)│
│ 局部变量 a = 10          │
│ 局部变量 b = "hello"     │
│ 上下文数据 ...            │
└─────────────────────────┘

特点

优点缺点
内存占用极小(几十到几百字节)只能在函数顶层让出(await/yield 处)
编译器可深度优化不能在嵌套调用深处直接让出
零成本抽象需要语言层面支持或代码转换

代表:Rust(async/await)、Python(async/await)、C++20、C#、JavaScript

关键区别

# 有栈协程 — 可以在任意深度让出
def deep_function():
    result = some_calculation()
    yield result           # ✅ 可以在这里让出
    return more_work()

# 无栈协程 — 只能在标记处让出
async def deep_function():
    result = await some_calculation()   # ✅ await 标记
    # 普通函数调用内部即使有异步操作,也无法从这里让出
    return more_work()

6.5 协程 vs 线程

这是初学者最常问的问题。

本质区别

特性线程(Thread)协程(Coroutine)
调度方操作系统内核用户态程序
切换开销高(~1-10μs,需内核态切换)低(~10-100ns,用户态切换)
内存占用大(默认栈 1-8MB)小(有栈 2-8KB,无栈 几十字节)
并发数量数千级别数万到数百万级别
数据竞争需要锁/原子操作通常无竞争(协作式调度)
抢占/协作抢占式(Preemptive)协作式(Cooperative)
CPU 密集型✅ 适合❌ 不适合(需要抢占式调度)
I/O 密集型✅ 但开销大✅ 非常适合

抢占式 vs 协作式

抢占式调度(线程):
  线程A: ████░░░░████░░░░████    ← OS 随时中断
  线程B: ░░░░████░░░░████░░░░    ← OS 随时切换

协作式调度(协程):
  协程A: ████──────████──────    ← 主动让出
  协程B: ──────████──────████    ← 在让出点切换
调度方式优点缺点
抢占式公平、不受 bug 影响切换开销大、需要锁
协作式切换开销小、无竞争一个协程不让出则全部卡住

注意:Go 的 goroutine 是协作式调度,但从 Go 1.14 开始引入了基于信号的异步抢占,避免了长时间运行的 goroutine 阻塞调度器。

内存对比

假设同时运行 10,000 个并发任务:

方案单个栈大小10,000 个总计可行性
OS 线程2MB20GB❌ 不可行
有栈协程(Go)2KB 初始20MB✅ 可行
无栈协程(Rust)~100 字节1MB✅ 极高效

6.6 协程的调度模型

对称调度(Symmetric Scheduling)

调度器
  │
  ├──► 协程 A ──transfer──► 协程 B ──transfer──► 协程 C
  │         ◄─transfer───────────────transfer──┘
  │
  └──► 协程自己决定转移给谁

非对称调度(Asymmetric Scheduling)

调度器(事件循环)
  │
  ├──► 运行协程 A
  │      │ yield
  │      ▼
  ├──► 运行协程 B
  │      │ yield
  │      ▼
  ├──► 运行协程 C
  │      │ yield
  │      ▼
  └──► 回到调度器,选择下一个就绪的协程

N:M 调度模型

Go 的 GMP 模型是 N:M 调度的经典实现:

N 个 goroutine → M 个 OS 线程

G (Goroutine)   : 用户级协程,数量可达数百万
M (Machine)     : OS 线程,数量通常等于 CPU 核数
P (Processor)   : 逻辑处理器,持有运行队列

┌──────────────────────────────────────────┐
│              Go Scheduler                │
│                                          │
│  ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐       │
│  │ P0  │ │ P1  │ │ P2  │ │ P3  │       │
│  │[G,G]│ │[G]  │ │[G,G]│ │[G]  │       │
│  └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘       │
│     │       │       │       │            │
│     ▼       ▼       ▼       ▼            │
│  ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐       │
│  │ M0  │ │ M1  │ │ M2  │ │ M3  │       │
│  │(线程)│ │(线程)│ │(线程)│ │(线程)│       │
│  └─────┘ └─────┘ └─────┘ └─────┘       │
└──────────────────────────────────────────┘

6.7 协程的组合与通信

通信方式一:通道(Channel)

// Go — Channel 通信
func producer(ch chan<- int) {
    for i := 0; i < 10; i++ {
        ch <- i
    }
    close(ch)
}

func consumer(ch <-chan int) {
    for v := range ch {
        fmt.Println(v)
    }
}

func main() {
    ch := make(chan int, 5) // 带缓冲的通道
    go producer(ch)
    go consumer(ch)
}

通信方式二:Future/Promise

# Python — 使用 Future 通信
async def producer(future: asyncio.Future):
    await asyncio.sleep(1)
    future.set_result("数据已准备好")

async def consumer(future: asyncio.Future):
    result = await future  # 等待 producer 设置结果
    print(f"收到: {result}")

通信方式三:共享状态(需要同步)

// Rust — Arc<Mutex<T>> 共享状态
use std::sync::{Arc, Mutex};

let shared = Arc::new(Mutex::new(Vec::new()));

let shared_clone = shared.clone();
tokio::spawn(async move {
    let mut data = shared_clone.lock().unwrap();
    data.push(42);
});

6.8 业务场景:协程在 Web 框架中的应用

场景

一个高并发 API 服务器,每个请求需要查询数据库、调用外部 API、缓存结果。

# Python — FastAPI + 协程
from fastapi import FastAPI
import httpx
import asyncio

app = FastAPI()

@app.get("/dashboard/{user_id}")
async def get_dashboard(user_id: int):
    # 并发执行三个独立操作
    async with httpx.AsyncClient() as client:
        user_task = client.get(f"https://api/users/{user_id}")
        orders_task = client.get(f"https://api/orders?user={user_id}")
        recommendations_task = client.get(f"https://api/recs/{user_id}")

        user_resp, orders_resp, recs_resp = await asyncio.gather(
            user_task, orders_task, recommendations_task
        )

    return {
        "user": user_resp.json(),
        "orders": orders_resp.json(),
        "recommendations": recs_resp.json(),
    }

6.9 本章小结

要点说明
协程定义可暂停和恢复的程序组件
对称 vs 非对称控制权转移方式不同
有栈 vs 无栈实现方式不同,各有利弊
协程 vs 线程调度方、开销、并发数量的根本区别
抢占式 vs 协作式线程是抢占式的,协程通常是协作式的
N:M 调度Go 的 GMP 模型,高效映射协程到 OS 线程

下一章预告:我们将深入 Go 语言的 goroutine,看看它是如何用 CSP 模型实现简洁高效的并发编程。


扩展阅读