1963年7月,Communications of the ACM发表了一篇题为《Design of a Separable Transition-Diagram Compiler》的论文。作者Melvin Conway提出了一个当时看来颇为新奇的概念:将编译器的词法分析阶段分解为多个"可以互相挂起和恢复"的执行单元。他将这些执行单元称为"coroutine"——协程。
Conway当时解决的是一个编译器设计问题:如何在单遍扫描中处理复杂的语法结构。他发现,如果能让词法分析器在读取到一个完整token后"暂停",等待语法分析器消费完毕后再"继续",代码会变得极其简洁。这个设计理念与当时流行的subroutine(子程序)形成了鲜明对比:子程序是被动的,调用者完全控制流程;协程是对等的,双方轮流"主持会议"。
六十年后,Conway的协程概念已成为现代并发编程的基石。从Go的goroutine到Java的Virtual Threads,从Kotlin的coroutine到Rust的async/await,协程以各种形式出现在主流语言中。但令人困惑的是,同样的"协程"二字,在不同语言中的行为可能截然不同。理解这些差异背后的设计权衡,是掌握现代并发编程的第一步。
一个问题,三种答案
考虑一个简单的并发问题:启动一百万个"任务",每个任务等待10秒后结束。不同语言的实现会消耗多少内存?
2023年的一项基准测试给出了令人惊讶的结果:
| 语言/运行时 | 100万任务内存消耗 |
|---|---|
| Rust (tokio) | 0.3 GB |
| Go (goroutine) | 2.4 GB |
| Java (Virtual Threads) | 1.1 GB |
| C# (async/await) | 1.2 GB |
| Node.js (Promise) | 0.9 GB |
| Python (asyncio) | 1.0 GB |
为什么同样是"轻量级并发",Go的内存消耗是Rust的8倍?为什么Java的Virtual Threads比goroutine更省内存?答案藏在每种语言的协程实现方式中。
有栈 vs 无栈:协程的根本分歧
协程的核心功能是"暂停执行、保存状态、稍后恢复"。实现这个功能的关键问题是:保存哪些状态?
有栈协程(Stackful Coroutine)
有栈协程为每个协程分配一个独立的栈空间,类似于操作系统为线程分配栈。当协程暂停时,整个栈的内容都被保留;当协程恢复时,可以从栈顶继续执行。
这种设计的优势是直观:协程可以在调用栈的任意深度暂停。缺点是内存开销大:每个协程至少需要一个栈空间,而栈通常会预留较大空间以防止溢出。
Go的goroutine、Lua的coroutine、Python的生成器(通过yield实现的协程)都属于有栈协程。Go在1.4版本之前使用分段栈,每个goroutine初始分配8KB栈空间;1.4之后改为连续栈,初始大小降到2KB,但仍需要为每个goroutine预留一个完整的栈。
无栈协程(Stackless Coroutine)
无栈协程不为每个协程分配独立栈,而是将协程的状态(局部变量、暂停点等)保存为一个状态机对象。当协程暂停时,只保存这个状态机对象;当协程恢复时,根据状态机恢复执行。
这种设计的优势是内存开销极小:一个协程可能只需要几十字节的内存。缺点是实现复杂,且协程只能在特定位置暂停。
C++20的协程、Rust的async/await、C#的async/await、Kotlin的协程都属于无栈协程。以Rust为例,编译器会将每个async函数转换为一个状态机类型,大小等于该函数中所有需要跨await点存活的变量之和。
2025年ACM发表的一项研究对比了两种设计的性能:有栈协程创建速度是无栈协程的2.4倍,但无栈协程的上下文切换速度是有栈协程的3.5倍,且内存占用更小。选择哪种设计,取决于应用场景:创建频繁但切换少的场景适合有栈协程;创建少但切换频繁的场景适合无栈协程。
Go调度器的演进:从GM到GMP
Go语言的调度器设计是理解现代协程调度机制的绝佳案例。从2009年Go语言发布至今,其调度器经历了三次重大演进。
第一代:单线程调度器(2009)
Go最初的调度器非常简单:所有goroutine运行在单个操作系统线程上。调度器维护一个全局的goroutine队列,按先进先出顺序执行。当一个goroutine被阻塞(如进行系统调用)时,整个程序都会被阻塞。
这个设计在单核时代还能工作,但随着多核处理器的普及,它成为了性能瓶颈。
第二代:GM模型(2012之前)
Go引入了M(Machine,操作系统线程)的概念,允许多个M并行执行goroutine。调度器维护一个全局的goroutine队列,多个M从这个队列中获取goroutine执行。
这个设计解决了并行问题,但引入了新的问题:全局队列需要加锁保护,在高并发场景下成为严重的竞争瓶颈。此外,M之间的工作负载不均衡:一个M可能积累了大量待执行的goroutine,而另一个M却空闲等待。
第三代:GMP模型(2012)
2012年,Dmitry Vyukov提出了著名的GMP模型设计,彻底解决了GM模型的问题。
GMP模型引入了P(Processor,逻辑处理器)的概念。P的数量默认等于CPU核心数,每个P维护一个本地的goroutine队列(Local Run Queue)。M必须持有一个P才能执行goroutine。
graph LR
subgraph G["G (Goroutine)"]
G1[G1]
G2[G2]
G3[G3]
end
subgraph P["P (Processor)"]
LRQ[Local Run Queue]
G1 --> LRQ
G2 --> LRQ
end
subgraph M["M (OS Thread)"]
CUR[Current G]
end
LRQ --> CUR
GRQ[Global Run Queue]
G3 --> GRQ
GRQ -.-> LRQ
GMP模型的核心创新是work-stealing算法:当一个P的本地队列空了,它会从全局队列或其他P的本地队列"偷取"goroutine。这个设计源自MIT的Cilk项目,由Robert Blumofe和Charles Leiserson在1999年的论文《Scheduling Multithreaded Computations by Work Stealing》中提出。
work-stealing的优势在于:
- 减少锁竞争:每个P优先访问自己的本地队列,减少全局锁争用
- 负载均衡:空闲的P主动寻找工作,而非等待中央调度器分配
- 缓存友好:goroutine倾向于在同一个P上执行,提高缓存命中率
栈管理:从分段到连续
Go调度器的另一个重大演进是栈管理机制。理解这个演进需要先理解一个基本问题:goroutine的栈应该多大?
分段栈(Segmented Stack,Go 1.3及之前)
Go最初采用分段栈设计:每个goroutine初始分配一个小的栈段(4KB或8KB),当栈空间不足时,分配一个新的栈段并链接到当前栈。这类似于Linux内核的栈溢出处理机制。
分段栈的问题在于"热分裂"(hot split):如果一个函数恰好在栈边界附近反复调用,就会频繁分配和释放栈段,造成严重的性能开销。
连续栈(Contiguous Stack,Go 1.4及之后)
2014年发布的Go 1.4彻底废弃了分段栈,改用连续栈:初始分配一个小栈(2KB),当栈空间不足时,分配一个更大的连续栈(通常是原来的两倍),将旧栈内容复制过去,并更新所有指向旧栈的指针。
连续栈消除了热分裂问题,但引入了新的挑战:如何高效地找到并更新所有指向旧栈的指针?Go的解决方案是使用精确的垃圾回收器——GC能够识别所有指向栈的指针,自然也能在栈迁移时更新它们。
栈的初始大小从8KB降到2KB,意味着在相同的内存预算下,Go 1.4可以创建四倍数量的goroutine。这是一个显著的改进,但代价是GC需要更频繁地扫描goroutine栈。
调度策略:从协作到抢占
协程调度面临一个根本性的挑战:如何处理长时间运行的任务?
协作式调度(Cooperative Scheduling,Go 1.13及之前)
在Go 1.13之前,goroutine采用协作式调度:goroutine必须在显式的"安全点"才能被调度器切换。这些安全点包括:
- 函数调用(编译器会在函数入口插入调度检查)
- channel操作
- mutex锁获取
- time.Sleep等显式让出
协作式调度的问题在于:如果一个goroutine执行一个紧凑的循环(如for {}),不包含任何安全点,它就会独占整个M,导致同M上的其他goroutine无法执行。
抢占式调度(Preemptive Scheduling,Go 1.14及之后)
2020年2月发布的Go 1.14引入了异步抢占机制:调度器可以向运行中的goroutine发送信号(Unix系统使用SIGURG),强制其暂停。
具体实现是:运行时有一个后台线程(sysmon)定期检查每个P是否运行超过10毫秒。如果超过,sysmon会向运行该P的M发送SIGURG信号。信号处理程序会保存当前goroutine的状态,并将其放回调度队列。
抢占式调度解决了协作式调度的公平性问题,但也引入了新的复杂性:
- 信号处理开销:频繁的信号会带来额外开销
- 与C代码的交互:当goroutine调用C代码时,无法安全地抢占
- 与调试器的冲突:调试器可能使用相同的信号
系统调用处理:Handoff与Netpoller
协程调度最复杂的部分之一是如何处理阻塞操作。当一个goroutine执行阻塞系统调用(如读取文件)时,它持有的M也会被阻塞。如果什么都不做,其他goroutine就无法利用这个M。
Handoff机制
Go的解决方案是:当M即将执行阻塞系统调用时,它会先释放持有的P,让其他M可以接管这个P继续执行其他goroutine。当系统调用返回时,M会尝试重新获取一个P;如果没有可用的P,这个M会休眠等待。
goroutine执行read系统调用
→ M释放P
→ 其他M获取P,继续执行其他goroutine
→ read返回
→ M尝试获取P
→ 如果成功,继续执行原来的goroutine
→ 如果失败,M进入休眠
Netpoller
对于网络I/O,Go采用了更高效的方式:Netpoller。Netpoller使用操作系统的非阻塞I/O机制(Linux使用epoll,Windows使用IOCP,macOS使用kqueue)将同步的网络操作转换为异步操作。
当一个goroutine执行网络读取时:
- 将文件描述符注册到Netpoller
- goroutine暂停,M释放P
- 当数据到达,Netpoller通知运行时
- 运行时将goroutine放回调度队列
这样,数千个goroutine可以同时等待网络I/O,而只需要少量M在epoll_wait上阻塞。
协程泄漏与调度饥饿:生产环境的隐形杀手
理解调度机制后,我们来看两个常见的生产问题。
协程泄漏(Goroutine Leak)
协程泄漏是指goroutine被创建后永远不会退出,持续消耗内存。常见原因包括:
- 无限等待channel:goroutine等待一个永远不会关闭的channel
- 缺少退出条件:goroutine的循环没有检查context或done channel
- 死锁:goroutine互相等待,都无法退出
检测协程泄漏的方法:
import "runtime"
func printGoroutineCount() {
for {
time.Sleep(time.Second)
fmt.Printf("Goroutines: %d\n", runtime.NumGoroutine())
}
}
在生产环境中,可以使用net/http/pprof暴露goroutine数量指标,配合监控系统设置告警阈值。
调度饥饿(Scheduler Starvation)
调度饥饿是指某些goroutine长期无法获得CPU时间。即使有了抢占式调度,调度饥饿仍可能发生:
- CPU密集型goroutine:虽然可以被抢占,但很快又会抢占CPU
- 优先级问题:Go调度器没有优先级概念,所有goroutine地位平等
- 全局队列饥饿:如果所有P都从本地队列偷取,全局队列的goroutine可能长期等待
Go 1.14引入了一个优化:每61次调度,P会优先检查全局队列,确保全局队列的goroutine不会被饿死。
对于更复杂的场景,可以通过以下方式缓解:
- 使用GOMAXPROCS限制并行度:减少CPU竞争
- 分离I/O密集和CPU密集任务:使用不同的goroutine池
- 实现工作队列:控制并发goroutine数量
其他语言的协程设计
Java Virtual Threads(Project Loom)
2023年发布的Java 21正式引入了Virtual Threads,这是Java对协程的实现。Virtual Threads的设计理念是"让每个请求一个线程"的编程模型成为可能,而不需要担心线程数量限制。
Virtual Threads的关键设计:
- 有栈协程:每个Virtual Thread有自己的栈,初始大小约为200-300字节
- Carrier Thread:Virtual Thread运行在Carrier Thread上,Carrier Thread是普通的操作系统线程
- Mount/Unmount:当Virtual Thread阻塞时,它会从Carrier Thread上"卸载";当阻塞解除时,重新"挂载"到Carrier Thread
与Go不同,Java的Virtual Threads没有自己的调度器——它们复用JVM的ForkJoinPool。这是一个有意的设计选择:Java希望Virtual Threads能够与现有的并发工具(如ExecutorService)无缝集成。
Kotlin Coroutines
Kotlin的协程是无栈协程,通过状态机转换实现。每个suspend函数被编译器转换为 Continuation接口的实现,保存了协程的状态。
Kotlin的设计强调结构化并发(Structured Concurrency):协程有明确的生命周期和作用域,父协程会等待所有子协程完成。这与Go的设计理念不同——Go的goroutine是独立的,父子goroutine之间没有自动的生命周期关联。
Rust async/await
Rust的async/await同样是无栈协程。编译器将async函数转换为Future trait的实现,每次poll()调用推进状态机一步。
Rust的独特之处在于零成本抽象:async函数在编译时完全展开为状态机,运行时开销极小。但代价是复杂性:用户需要处理Pin、Unpin等概念,理解为什么不能在async函数中持有跨await点的引用。
协程并非万能
协程提供了轻量级的并发抽象,但它不是万能的:
- CPU密集型任务:协程本质上是用户态的协作式调度,无法绕过操作系统获得真正的并行。对于CPU密集型任务,仍需要使用操作系统线程或进程。
- 阻塞系统调用:虽然Netpoller处理了网络I/O,但文件I/O、DNS解析等操作仍可能阻塞线程。
- 调试复杂性:当协程数量达到数百万时,理解程序的执行状态变得极其困难。
协程的价值在于:它让"每个连接一个执行流"的编程模型成为可能,而不需要担心资源耗尽。这是并发编程范式的根本性改变——从"如何高效地共享线程"转变为"如何高效地调度大量执行流"。
从Conway的编译器优化,到Go的GMP调度器,再到Java的Virtual Threads,协程走过了六十年的演进之路。每一次演进都源于同一个动机:让程序员能够以更自然的方式表达并发,同时让运行时以更高效的方式管理并发。这或许就是计算机科学进步的本质:不断缩小"人想表达的"和"机器能高效执行的"之间的鸿沟。
参考文献
- Conway, M. E. (1963). Design of a separable transition-diagram compiler. Communications of the ACM, 6(7), 396-408.
- Blumofe, R. D., & Leiserson, C. E. (1999). Scheduling multithreaded computations by work stealing. Journal of the ACM, 46(5), 720-748.
- Vyukov, D. (2012). Scalable Go Scheduler Design Doc. Google.
- Go 1.4 Release Notes. (2014). The Go Programming Language.
- Go 1.14 Release Notes. (2020). The Go Programming Language.
- Pressler, R. (2020). Project Loom: Fibers and Continuations for the Java Virtual Machine. OpenJDK.
- Elizarov, R. (2018). Kotlin Coroutines: Design and Implementation. KotlinConf.
- Anderson, T. E., Bershad, B. N., Lazowska, E. D., & Levy, H. M. (1992). Scheduler activations: Effective kernel support for the user-level management of parallelism. ACM Transactions on Computer Systems, 10(1), 79-107.