协程, Coroutine
协程别名: 微线程,纤程。英文:Coroutine, Green threads, fibers
传统编程语言中子程序或者函数是层级调用的,函数可以调用其它函数, 调用者需要等待被调用者结束之后继续执行, 函数调用是通过栈实现的. 一个线程就是按顺序执行一个或几个子函数, 函数调用只有一个入口和一个出口.
协程看上去也是函数,但是执行过程中在子程序内部可以中断,然后执行别的函数, 然后再被调度回来执行.
- 协程比线程有更高的执行效率, 协程没有线程切换的开销
- 协程在用户空间调度, 不涉及系统调用或任何阻塞调用, 不需要用来守卫关键区块的同步性原语(primitive)比如互斥锁、信号量等,并且不需要来自操作系统的支持
- 协程不需要多线程的锁机制, 为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。
- 协程是协作式多任务的, 线程典型是抢占式多任务的
- 因为协程是一个线程执行,那怎么利用多核CPU呢?最简单的方法是多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。
使用抢占式调度的线程实现协程,但是会失去某些利益(特别是对硬性实时操作的适合性和相对廉价的相互之间切换)。
协程是语言层级的构造,可看作一种形式的控制流,而线程是系统层级的构造。
名词对照
coroutine、goroutine、virtual thread、轻量级线程、协程、虚拟线程、用户态线程、绿色线程——这些名词基本上在说同一类东西,只是来自不同语言和时代:
| 术语 | 说明 |
|---|---|
| coroutine(协程) | 最广义的概念,包括协作式(asyncio)和抢占式(goroutine)两类 |
| goroutine | Go 的实现,抢占式,属于协程的子集 |
| virtual thread(虚拟线程) | Java 21 的叫法,强调"就是线程,只是轻量" |
| green thread(绿色线程) | 早期术语,泛指用户态管理的线程 |
| 用户态线程 / 轻量级线程 | 描述实现机制的技术术语 |
命名不同有历史原因:
- Go 作者刻意不叫"协程",因为传统协程是协作式的,而 goroutine 是运行时抢占式调度的,更像轻量级线程
- Java 叫"虚拟线程"是为了向后兼容——复用
ThreadAPI,让现有代码改动最小
严格说,asyncio 协程(协作式、无栈)和 goroutine / 虚拟线程(抢占式、有栈)不是一回事,但常被混称为"协程"。
为什么需要用户态线程
OS 线程太重了
一个 OS 线程的代价:
- 默认栈大小 1~8 MB(Linux 默认 8MB)
- 创建/销毁需要系统调用,耗时微秒级
- 上下文切换需要陷入内核,保存/恢复大量寄存器
- 线程数受 OS 限制,通常几千到几万个
典型场景:一个 HTTP 服务,每个请求一个线程,1 万并发连接 → 80GB 内存光用在栈上,还没开始干活。
用户态线程的代价:
- 栈从 2KB 起,按需增长
- 创建/切换在用户空间完成,不需要系统调用
- 百万级并发是常见操作
C10K 问题
1999 年提出的 C10K 问题:如何用一台服务器同时处理 1 万个并发连接?
1:1 模型(1 个用户线程 = 1 个 OS 线程)在高并发下的三个致命伤:
1. 内存墙
10,000 线程 × 8MB 栈 = 80GB 内存
大部分线程在等 I/O,栈空间白白占用。
2. 调度开销
OS 不知道哪些线程在等 I/O、哪些在干活,盲目调度所有线程。线程越多,无效的上下文切换越多,CPU 大量时间浪费在切换本身。
3. 开发者被迫写回调地狱
为了压缩线程数,开发者用回调/Promise 链写异步代码,业务逻辑被拆得零碎:
// 回调地狱:本质是在用少量线程模拟并发
fetchUser(id, (user) => {
fetchOrders(user, (orders) => {
fetchItems(orders[0], (items) => {
// 越来越深...
})
})
})
用户态线程的解法
让运行时管理大量轻量级执行单元,用同步的写法写异步的代码:
// goroutine:看起来是同步的,实际遇到 I/O 会自动让出
go func() {
resp := http.Get(url) // 阻塞时自动切换到其他 goroutine
process(resp)
}()
运行时在背后做的事:
- 维护一个工作队列,goroutine / 虚拟线程放在队列里
- 用少量 OS 线程(通常等于 CPU 核心数)去执行队列里的任务
- 某个任务遇到 I/O 阻塞时,运行时自动把它挂起,换另一个任务上来跑
- I/O 完成后,运行时自动把它放回队列继续执行
开发者完全感知不到这个过程,写的代码看起来就是普通的顺序调用。
用户态线程是把"并发调度"从开发者的责任转移给了语言运行时的责任。
高并发方案的演进
第一阶段:线程-per-连接(BIO)
每个连接 → 一个 OS 线程,简单直接
问题:1 万连接 = 1 万线程,内存和切换开销不可接受
第二阶段:NIO + Reactor 模式(Netty)
少量线程(= CPU 核心数)+ epoll 多路复用
一个线程监听所有连接的 I/O 事件,有事件来了再处理
Netty 解决了线程数量的问题,但代价是开发者要用 Handler/Pipeline 写回调风格的代码,业务逻辑被拆得零碎,可读性差:
// Netty 风格:业务逻辑被拆散在各个 Handler 里
public void channelRead(ChannelHandlerContext ctx, Object msg) {
// 不能阻塞!一阻塞整个线程池就卡住
// 异步操作还要手动 addListener...
}
第三阶段:虚拟线程 / goroutine
解决了 Netty 解决的问题,同时还给了开发者顺序写法:
| 方案 | 线程数 | 写法复杂度 | 开发者负担 |
|---|---|---|---|
| BIO 线程-per-连接 | 多(不可扩展) | 简单顺序 | 低 |
| Netty NIO | 少(可扩展) | 回调/Handler | 高 |
| 虚拟线程/goroutine | 少(可扩展) | 简单顺序 | 低 |
Netty 把并发复杂性从 OS 层转移到了框架层,开发者还是要自己处理;虚拟线程把复杂性彻底封进了运行时,开发者终于不用管了。这就是为什么虚拟线程出来之后,Netty 这类框架的核心价值被大幅削弱了。
虚拟线程 vs NIO 的选择
对于 Java 21+ 的项目,Spring Boot 3.2+ 一行配置开启虚拟线程:
spring.threads.virtual.enabled=true
开启后,Tomcat 的每个请求跑在虚拟线程上,可以用最简单的阻塞式写法(JDBC、RestTemplate、普通 Socket)达到和 NIO 相近的吞吐量:
// 虚拟线程下,阻塞 I/O 代价极低,直接写顺序代码
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
var result = jdbcTemplate.query(...); // 阻塞,但自动让出
return result;
});
}
虚拟线程够用的场景(99% 的业务系统):
- 微服务、REST API、数据库查询
- 大量连接但每个连接处理逻辑不复杂
- 瓶颈在并发连接数和开发效率
NIO 仍有优势的场景:
- 极低延迟(金融交易、游戏服务器)
- 每秒百万级消息(Kafka、Netty 自身的实现)
- 需要零拷贝、堆外内存等底层精细控制
- 榨干单机极限吞吐
虚拟线程像自动挡——绝大多数人开车够用,省心;NIO 像赛车手开手动挡——极限性能更高,但需要专业技巧,普通场景没必要。
生成器
生成器,也叫作"半协程",是协程的子集。
https://www.liaoxuefeng.com/wiki/1016959663602400/1017968846697824
https://zh.wikipedia.org/wiki/%E5%8D%8F%E7%A8%8B
有栈协程
有栈协程的好处,由于栈帧可以直接完全保存运行期上下文(主要是寄存器值),因此可以在任何时刻暂停协程的运行,这就很方便地支持了抢占式的调度器。
无栈协程
无栈协程的上下文一般通过类似结构体的方式保存在内存中,它依赖使用者显式地切换协程,否则协程不会主动让出执行权。
另外,有栈协程更方便将同步代码改造为异步代码,就像 Go 的例子一样,只需加上 go 关键字就可以了。而无栈协程,同步改造为异步则更为复杂,甚至会导致牵一发动全身(async 关键字扩散问题)。
Rust 无栈协程
既然已经有了有栈协程,那么无栈协程是否还有优势呢。答案是肯定的!
通常,无栈协程在内存空间和协程上下文切换的效率更高。值得说明的是,无栈协程并不是说不需要运行时的栈空间,而是和协程的创建者共用同一块运行时的栈空间。
如果一定要用一句话概括无栈协程,那就是:无栈协程可以看做是有状态的函数(generator),每次执行时会根据当前的状态和输入参数,得到(generate)输出,但不一定为最终结果。
进程、线程、轻量级进程、协程对比
进程
操作系统中最核心的概念是进程,分布式系统中最重要的问题是进程间通信。
进程是"程序执行的一个实例",担当分配系统资源的实体。进程创建必须分配一个完整的独立地址空间。
进程切换只发生在内核态,两步:
- 切换页全局目录以安装一个新的地址空间
- 切换内核态堆栈和硬件上下文
另一种说法类似:
- 保存 CPU 环境(寄存器值、程序计数器、堆栈指针)
- 修改内存管理单元 MMU 的寄存器
- 转换后备缓冲器 TLB 中的地址转换缓存内容标记为无效
线程
书中的定义:线程是进程的一个执行流,独立执行它自己的程序代码。
维基百科:线程(英语: thread)是操作系统能够进行运算调度的最小单位。
线程上下文一般只包含 CPU 上下文及其他的线程管理信息。线程创建的开销主要取决于为线程堆栈的建立而分配内存的开销,这些开销并不大。线程上下文切换发生在两个线程需要同步的时候,比如进入共享数据段。
用户级线程主要缺点在于对引起阻塞的系统调用的调用会立即阻塞该线程所属的整个进程。内核实现线程则会导致线程上下文切换的开销跟进程一样大,所以折衷的方法是轻量级进程(Lightweight)。在 Linux 中,一个线程组基本上就是实现了多线程应用的一组轻量级进程。
语言层面实现轻量级进程的比较少,stackless python、erlang 支持,java 并不支持。
协程(通用概念)
协程可以通过 yield 来调用其它协程。通过 yield 方式转移执行权的协程之间不是调用者与被调用者的关系,而是彼此对称、平等的。协程的起始处是第一个入口点,在协程里,返回点之后是接下来的入口点。子例程的生命期遵循后进先出(最后一个被调用的子例程最先返回);相反,协程的生命期完全由他们的使用的需要决定。
线程和协程的区别:一旦创建完线程,你就无法决定他什么时候获得时间片、什么时候让出时间片了,你把它交给了内核。而协程编写者可以有一是可控的切换时机,二是很小的切换代价。从操作系统有没有调度权上看,协程就是因为不需要进行内核态的切换,所以会使用它。协程:用户态的轻量级的线程。
协程有助于实现:
- 状态机:在一个子例程里实现状态机,这里状态由该过程当前的出口/入口点确定,可以产生可读性更高的代码。
- 角色模型:并行的角色模型,例如计算机游戏。每个角色有自己的过程,但他们自愿地向顺序执行各角色过程的中央调度器交出控制(这是合作式多任务的一种形式)。
- 产生器:有助于输入/输出和对数据结构的通用遍历。
Go 实现: Goroutine
- routine, [ruːˈtiːn], 例程
- coroutine, [kəruːˈtiːn], 协同程序, 协程
Goroutine 是 Go 中最基本的执行单元。每一个 Go 程序至少有一个 goroutine:主 goroutine,当程序启动时被自动创建。
goroutine 采用了一种 fork-join 的模型。每个协程至少需要消耗 2KB 的空间(go stack)。
Goroutine 有着和 Java 线程完全不同的调度机制,Java 线程模型中线程和 KSE(Kernel space Entity)是 1:1 的关系,一个用户线程对应一个 KSE。而 Goroutine 和 KSE 是多对多的对应关系。虽然 Goroutine 的调度机制不如由内核直接调度的线程机制效率那么高,但是由于 Goroutine 间的切换可以不涉及内核级切换,所以代价小很多。
Goroutine 是 Golang 中轻量级线程的实现,由 Go Runtime 管理。Golang 标准库提供的所有系统调用操作(当然也包括所有同步 IO 操作),都会出让 CPU 给其他 Goroutine。
goroutine 初始时只给栈分配很小的空间,然后随着使用过程中的需要自动地增长。这就是为什么 Go 可以开千千万万个 goroutine 而不会耗尽内存。Go 1.4 开始使用的是连续栈,而这之前使用的分段栈。
调度器
分段栈(Segmented Stacks)
分段栈(segmented stacks)是 Go 语言最初用来处理栈的方案。当创建一个 goroutine 时,Go 运行时会分配一段 8KB 字节的内存用于栈供 goroutine 运行使用。
每个 go 函数在函数入口处都会有一小段代码,这段代码会检查是否用光了已分配的栈空间,如果用光了,这段代码会调用 morestack 函数。
morestack 函数会分配一段新内存用作栈空间,接下来它会将有关栈的各种数据信息写入栈底的一个 struct 中,包括上一段栈的地址。然后重启 goroutine,从导致栈空间用光的那个函数(Foobar)开始执行。这就是所谓的"栈分裂(stack split)"。在新栈的底部,插入了一个栈入口函数 lessstack,用于从 Foobar 返回时调整栈指针,使得我们返回到前一段栈空间,随后释放新栈段。
分段栈的问题
栈缩小是一个相对代价高昂的操作。如果在一个循环中调用的函数遇到栈分裂,进入函数时增加栈空间(morestack),返回时释放栈段(lessstack),性能方面开销很大。
连续栈(Continuous Stacks)
Go 现在使用的是连续栈方案。goroutine 在栈上运行,当用光栈空间,新方案创建一个两倍于原 stack 大小的新 stack,并将旧栈拷贝到其中。
- 当栈实际使用的空间缩小时,go 运行时不用做任何事情(栈的收缩是垃圾回收的过程中实现的,当检测到栈只使用了不到 1/4 时,栈缩小为原来的 1/2)
- 当栈再次增长时,运行时也无需做任何事情,只需重用之前分配的空闲空间
旧栈数据复制到新栈
Go 实现了精确的垃圾回收,运行时知道每一块内存对应的对象的类型信息。在复制之后,会进行指针的调整:对当前栈帧之前的每一个栈帧,对其中的每一个指针,检测指针指向的地址,如果指向地址是落在旧栈范围内的,则将它加上一个偏移使它指向新栈的相应地址。这个偏移值等于新栈基地址减旧栈基地址。
Continuation
所谓 Continuation 就是保存接下来要做的事情的内容(the rest of the computation)。举个简单例子,我在写文档,突然接到电话要外出,这时我存档,存档的数据就是 Continuation(继续即将的写作),然后等会儿回来,调入存档,继续写作。Continuation 这个概念就协程来说就是协程保护的现场。而对于函数来说就是保存函数调用现场——Stack Frame 值和寄存器,以供以后调用继续从 Continuation 处执行。
实际上任何程序都可以通过 CPS(Continuation Passing Style)类型转换为使用 Continuation 的形式。
https://www.cnblogs.com/riceball/archive/2008/01/19/continuation.html http://www.blogjava.net/killme2008/archive/2010/03/23/316273.html
goroutine id
import (
"fmt"
"github.com/cihub/seelog"
"runtime"
"strconv"
"strings"
)
func GoroutineId() int {
defer func() {
if err := recover(); err != nil {
seelog.Error("panic recover:panic info:", err)
}
}()
var buf [64]byte
n := runtime.Stack(buf[:], false)
idField := strings.Fields(strings.TrimPrefix(string(buf[:n]), "goroutine "))[0]
id, err := strconv.Atoi(idField)
if err != nil {
panic(fmt.Sprintf("cannot get goroutine id: %v", err))
}
return id
}
Java 实现: 虚拟线程(Virtual Threads)
Java 21(2023)通过 JEP 444 正式引入虚拟线程。
Go goroutine 和 Java 虚拟线程本质类似——都是 M:N 用户态线程(大量轻量级线程复用少量 OS 线程),都由运行时调度而非 OS 内核,但有以下区别:
| Go goroutine | Java 虚拟线程 | |
|---|---|---|
| 引入时间 | Go 1.0(2012),设计从 2009 年起 | Java 21(2023,稳定版) |
| 调度模型 | GMP(G goroutine, M OS thread, P processor) | 虚拟线程 → 平台线程(carrier thread) |
| 通信模型 | channel(CSP 风格) | 共享内存 + 锁(传统 Java 模型不变) |
| API 风格 | go func() 新语法 |
复用 Thread API,向后兼容 |
| 栈 | 有栈协程,动态增缩(2KB 起) | 有自己的栈,按需增长 |
| 陷阱 | 较少 | synchronized 块或 native 调用会导致虚拟线程"钉住"(pin)到平台线程,削弱扩展性 |
Java 从 1995 年诞生,等了将近 30 年才在 21 版本引入虚拟线程。
Python 实现: asyncio 协程
Python 的情况比较特殊,目前没有等价于 goroutine 或 Java 虚拟线程的内置 M:N 用户态线程。
asyncio(Python 3.4+,2014)
async/await 语法,基于事件循环:
- 本质是无栈协程,协作式调度
- 单线程:所有协程跑在同一个 OS 线程上,靠
await主动让出 - 适合 I/O 密集型,不能利用多核 CPU
async def fetch():
await asyncio.sleep(1) # 必须主动让出,否则不切换
这和 goroutine 的关键区别:goroutine 由运行时抢占式调度,asyncio 协程必须主动 await 才能切换。
greenlet / gevent(第三方库)
greenlet:有栈协程,可任意切换,接近 goroutine 的用法gevent:基于 greenlet + monkey-patching,自动将标准库 I/O 变为异步,最接近 M:N 模型- 但都是第三方库,不是语言标准
GIL 是根本障碍
Python 长期有 GIL(全局解释器锁),即使创建多个线程,同一时刻也只有一个线程执行 Python 字节码。Python 并发因此分两条路:
- I/O 密集 → asyncio / gevent
- CPU 密集 → multiprocessing(多进程)
Python 3.13(2024)引入实验性无 GIL 模式(PEP 703,--disable-gil),允许 OS 线程真正并行,但这是"去掉锁让 OS 线程跑得更快",不是引入用户态轻量线程,和虚拟线程/goroutine 思路不同。
各语言协程对比
| 模型 | 标准库 | 多核 CPU | |
|---|---|---|---|
| Go goroutine | M:N 有栈,运行时抢占调度 | ✅ 内置(Go 1.0) | ✅ |
| Java 虚拟线程 | M:N 有栈,运行时调度 | ✅ Java 21 | ✅ |
| Python asyncio | 1:1 无栈,事件循环协作式 | ✅ 内置(Python 3.4) | ❌ |
| Python gevent | M:N 有栈,协作式 | ❌ 第三方 | ❌(GIL) |