深入浅出:协程到底是什么?
大家都知道线程的概念,但对于协程来说可能就有些陌生了。很多人刚接触协程,心中都会产生疑问:协程是什么?
长期以来,业界对协程都没有一个统一且精确的定义,各执一词。这常常会让初学者感到困惑,陷入一种"好像懂了,但又没明白"的状态:觉得每个人说得都有道理,但想用的时候却无从下手。
其实协程最核心的概念非常简单、纯粹:就是函数或一段程序能够被主动挂起,稍后再在挂起的位置恢复执行。
对我们来说,协程与线程的区别在于:线程开始执行任务后,执行过程中不会暂停(是连续执行的),线程之间是抢占式的调度,没有任何协作;而协程会主动暂停,把运行的机会让给其他协程,协程之间是协作式的调度。
协程的挂起和恢复完全由开发者控制,它通过主动挂起让出运行权来实现协作,因此其本质就是讨论控制程序流程的机制。
协程的两大分类
按调用栈来分
函数调用栈是用来保存函数调用时的状态信息的数据结构。由于协程支持挂起和恢复,所以需要保存挂起点的状态。线程也会因为 CPU 调度权的转移而中断,它的中断状态会保存到调用栈中,因此我们可以按照协程保存状态时是否开辟了对应的调用栈来分类。
- 有栈协程 (Stackful Coroutine): 协程有着自己的独立调用栈,该协程实现类似于线程,不同点主要体现在调度上。
- 无栈协程 (Stackless Coroutine): 协程没有自己独立的调用栈,挂起点状态的维持主要通过状态机或是闭包等语法实现。
有栈协程的好处是可以在任意函数调用层级的任意位置挂起,并转移调度权,但缺陷是需要开辟专属的栈内存,增加了内存开销。
Go 语言中的 Goroutines 可以认为是有栈协程的特例(尽管官方不承认),不过其栈内存可以动态扩容或收缩,一般远小于内核线程的栈空间,在内存方面较为轻量。
Kotlin 协程通常被认为是一种无栈协程,但它却能做到在挂起函数范围内的任意调用层级嵌套挂起,也就是说启动一个 Kotlin 协程后,我们可以在其中任意嵌套挂起函数(这正是有栈协程的重要特性之一)。
既然没有开辟独立的调用栈,那么 Kotlin 是怎么做到在深层嵌套中挂起并恢复的呢?
其中的关键在于 Kotlin 编译器的状态机机制和续体传递(Continuation Passing Style, CPS),编译器偷偷改造了我们的 suspend 函数,将挂起点前后的局部变量变成了状态机类的成员变量,这样就实现了在堆内存中保存函数的执行状态。
有栈协程则不需要这么麻烦,任意函数都可以使用栈内存来保存挂起点状态。
kotlin
suspend fun fun1() {
println("this is fun1!")
fun2()
}
suspend fun fun2() {
println("this is fun2!")
nowInSuspend()
}
suspend fun nowInSuspend() = suspendCoroutine<Unit> { continuation ->
println("now in suspend!")
// 真实场景中,必须调用 resume() 来恢复协程,否则协程会一直挂起
continuation.resume(Unit)
}
上述代码在调用 fun2() 时,并没有真正挂起,调用 nowInSuspend() 时才真正挂起了。
按调度方式来分
在协程调度过程中,还可以按照协程调度权的转移目标来划分,分别为对称协程和非对称协程:
- 非对称协程 (Asymmetric Coroutine): 协程转移调度权时,只能还给它的调用者。协程之间不对等,存在着调用和被调用关系。
- 对称协程 (Symmetric Coroutine): 任何一个协程都是相互独立且平等的,没有父子关系。拥有调度权的协程,可以将调度权转移给其他任何一个协程。
因为协程最接近函数(而非线程),函数就是一个典型的非对称模型:函数调用后需要回到调用处返回结果,所以协程最自然的实现通常就是非对称的。
对称协程在直觉上则更靠近线程。它只需在中间添加一个"调度中心",所有协程在挂起时都把控制权交还给中心,由中心来决定下一个执行的对象,这样所有协程就完全平等了。
经典语言的协程实现
协程的核心就是挂起和恢复,我们可以观察以下几门语言的实现,来深入理解它的本质。
Python 的 Generator (无栈、非对称)
Python 的 Generator 是典型的无栈协程实现,我们可以在任意函数中通过调用 yield 来挂起当前函数,返回一个生成器(Generator),yield 的参数是 next(gen) 调用的返回值,再次调用 next 则可以恢复函数的执行。
python
import time
def numbers():
i = 0
while True:
yield(i) #... 1️⃣
i += 1
time.sleep(1)
gen = numbers()
print(f"[0] {next(gen)}") #... 2️⃣
print(f"[1] {next(gen)}") #... 3️⃣
for i in gen: #... 4️⃣
print(f"[Loop] {i}")
运行上述代码,在 2️⃣ 处调用 next(gen),因为 numbers() 函数之前没有执行过,所以会先执行 i = 0,然后会在 1️⃣ 处调用 yield 传出 0 值,在 2️⃣ 处打印:[0] 0。
接着在 3️⃣ 处调用 next(gen),会恢复 numbers() 函数的执行,从上次挂起的位置(1️⃣)接着向下执行,再次执行到 1️⃣ 处时,会再次通过 yield 挂起,传出 1,接着在 3️⃣ 处会打印:[1] 1。后续通过循环不断地获取值,逻辑类似。
但是,Python 的 Generator 不支持嵌套 ,也就是在上述的 numbers 函数中嵌套调用 yield,那么 numbers 的调用将无法挂起。
python
def numbers():
i = 0
while True:
yield_here(i) #... 1️⃣
i += 1
time.sleep(1)
def yield_here(i):
yield(i)
此时如果调用 gen = numbers(),程序将会进入死循环。
为什么呢?根本原因是我们把 yield 提取到了 yield_here() 函数中,此时,numbers() 不再包含着 yield 关键字,就是一个普通的同步函数。所以根本不会返回一个生成器给外部,而是会在主流程中执行 while True 的死循环。
这就证明了 Python 的 Generator 就是一个非对称无栈协程的实现,由于无栈的特性,所以无法在任意层次中自由挂起。
Lua 的标准库实现 (有栈、非对称)
Lua 的协程是教科书级别的实现,它提供了几个清晰的 API,来让开发者灵活地控制协程。
coroutine.create: 创建协程,并返回一个协程实例。参数是函数类型,作为协程将要执行的任务。coroutine.yield: 挂起当前协程。coroutine.resume: 恢复协程。如果是第一次执行 resume,参数会作为协程任务(函数)的入参。
Lua 的协程有多种状态,分别为:创建(CREATED)、挂起(SUSPENDED)、运行(RUNNING)、结束(DEAD)。
如果你对上述 API 还不熟悉,请看如下代码:
Lua
function producer()
for i = 0, 3 do
print("send "..i)
coroutine.yield(i) --... 2️⃣
end
print("End Producer")
end
function consumer(value)
repeat
print("receive "..value)
value = coroutine.yield() --... 4️⃣
until(not value)
print("End Consumer")
end
producerCoroutine = coroutine.create(producer)
consumerCoroutine = coroutine.create(consumer)
repeat
status, product = coroutine.resume(producerCoroutine) --... 1️⃣
coroutine.resume(consumerCoroutine, product) --... 3️⃣
until(not status)
print("End Main")
首先创建了两个协程:producerCoroutine 和 consumerCoroutine,分别代表生产者和消费者,执行的任务分别是 producer() 和 consumer() 函数。
进入主循环,先在 1️⃣ 处调用 resume(),恢复了 producer() 函数的执行。在 2️⃣ 处调用了 yield(0) 将协程挂起,刚刚 1️⃣ 处的 resume() 调用会接收到返回值 0(即 product 等于 0)。随后在 3️⃣ 处将 0 作为参数恢复 consumer() 函数的执行,因为是第一次执行,所以 0 会作为函数的初始入参,而不会作为 4️⃣ 处的返回值,打印:
Plaintext
send 0
receive 0
consumer 函数接着在 4️⃣ 处进行了挂起,此时控制权又回到了主流程手中。接着循环 3 次,协程执行完毕,主流程结束。
这里我们可以提取出几个关键概念:
- 协程的执行体: 即协程体,主要是协程要执行的函数。
- 协程的控制实例: 也就是协程的描述类,我们通过创建协程返回的实例来控制状态流转。
- 协程的状态: 在调用流程转移前后,协程的状态(挂起点、捕获的变量等)会发生变化。
这些概念在 Kotlin 中都有对应:协程体就是 suspend 闭包,控制实例就是 Continuation,状态由状态机维护。
Go 的 go routine (有栈优化、对称)
Go 语言对并发做了大量封装,go routine 的调度并不明显,没有类似于 yield 和 resume 的函数,代码示例如下:
go
package main
import (
"fmt"
"time"
)
func main() {
// 可读可写的无缓冲 Channel
channel := make(chan int) // .......... 1️⃣
var readChannel <-chan int = channel // 只读 Channel
var writeChannel chan<- int = channel // 只写 Channel
// reader
go func() { // ........................ 2️⃣
fmt.Println("wait for read")
for i := range readChannel { // ... 3️⃣
fmt.Println("read", i)
}
fmt.Println("read end")
}() // ............................... 4️⃣
// writer
go func() {
for i := 0; i < 3; i++ {
fmt.Println("write", i)
writeChannel <- i // .......... 5️⃣
time.Sleep(time.Second)
}
close(writeChannel)
}()
// 阻塞主程序,等待子协程执行完毕
time.Sleep(time.Second * 4)
}
go routine 的启动方式非常简单,在函数调用前加上 go 关键字即可启动。
上述代码中创建的 Channel 并没有缓冲区,这意味着写操作会一直挂起直到发生交汇(读操作执行),反之,读操作也会挂起等待写操作,这一点很关键,理解了这一点,就不难看懂上述流程。
在这个过程中,reader 和 writer 是平等的,都在等待彼此。读写操作实际上就是在 go routine 之间平等地转移调度权。因此可认为 go routine 是极其轻量级的对称协程实现。
总结:为什么说 Kotlin 是协程设计的典范?
看了这么多语言的实现,我们会发现:
- Python 属于非对称、无栈协程,虽然简单,但无法实现嵌套层级挂起,局限性很大。
- Lua 是非对称、有栈协程,功能非常完整,但开辟栈内存会带来额外的开销。
- Go 在语言原生层面实现了并发,通过极其轻量的对称协程和 Channel 通信,极大简化了并发难度。
我们再回过头来看 Kotlin 的协程,会发现它的架构设计简直精彩,可以说是协程设计的典范:
如果我们想要像有栈协程那样,可以在任何地方随意挂起,我们通常要去修改底层的运行环境(比如去改造 Java 虚拟机),这会导致严重的依赖,并且让协程的调度变得"黑盒"。
Kotlin 并没有这么做,而是选择了内存开销极小的无栈架构 ,并在代码层面,引入了 suspend 关键字,让开发者自己明确需要挂起的地方。在底层,利用编译器的状态机转化(CPS 机制) ,突破了无栈的限制,支持了有栈协程才具备的任意层级嵌套挂起能力。
这样做既不需要去修改底层的运行环境,又把挂起和恢复的控制权交给了开发者,这种精妙的平衡设计,正是 Kotlin 协程的厉害之处。