异步与协程精讲 / 第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 线程 | 2MB | 20GB | ❌ 不可行 |
| 有栈协程(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 模型实现简洁高效的并发编程。
扩展阅读
- A Tutorial Introduction to Coroutine Theory — Lewis Baker
- Coroutines in C — Simon Tatham
- Stackful Coroutine vs Stackless Coroutine — C++ 提案
- Go scheduler — Daniel Morsing
- Coroutines: Basics — Joshua Haberman