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执行网络读取时:

  1. 将文件描述符注册到Netpoller
  2. goroutine暂停,M释放P
  3. 当数据到达,Netpoller通知运行时
  4. 运行时将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,协程走过了六十年的演进之路。每一次演进都源于同一个动机:让程序员能够以更自然的方式表达并发,同时让运行时以更高效的方式管理并发。这或许就是计算机科学进步的本质:不断缩小"人想表达的"和"机器能高效执行的"之间的鸿沟。


参考文献

  1. Conway, M. E. (1963). Design of a separable transition-diagram compiler. Communications of the ACM, 6(7), 396-408.
  2. Blumofe, R. D., & Leiserson, C. E. (1999). Scheduling multithreaded computations by work stealing. Journal of the ACM, 46(5), 720-748.
  3. Vyukov, D. (2012). Scalable Go Scheduler Design Doc. Google.
  4. Go 1.4 Release Notes. (2014). The Go Programming Language.
  5. Go 1.14 Release Notes. (2020). The Go Programming Language.
  6. Pressler, R. (2020). Project Loom: Fibers and Continuations for the Java Virtual Machine. OpenJDK.
  7. Elizarov, R. (2018). Kotlin Coroutines: Design and Implementation. KotlinConf.
  8. 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.