深入理解 Kotlin 协程 (二):万剑归宗,揭秘 Kotlin 协程的精妙取舍

深入浅出:协程到底是什么?

大家都知道线程的概念,但对于协程来说可能就有些陌生了。很多人刚接触协程,心中都会产生疑问:协程是什么?

长期以来,业界对协程都没有一个统一且精确的定义,各执一词。这常常会让初学者感到困惑,陷入一种"好像懂了,但又没明白"的状态:觉得每个人说得都有道理,但想用的时候却无从下手。

其实协程最核心的概念非常简单、纯粹:就是函数或一段程序能够被主动挂起,稍后再在挂起的位置恢复执行

对我们来说,协程与线程的区别在于:线程开始执行任务后,执行过程中不会暂停(是连续执行的),线程之间是抢占式的调度,没有任何协作;而协程会主动暂停,把运行的机会让给其他协程,协程之间是协作式的调度。

协程的挂起和恢复完全由开发者控制,它通过主动挂起让出运行权来实现协作,因此其本质就是讨论控制程序流程的机制。

协程的两大分类

按调用栈来分

函数调用栈是用来保存函数调用时的状态信息的数据结构。由于协程支持挂起和恢复,所以需要保存挂起点的状态。线程也会因为 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")

首先创建了两个协程:producerCoroutineconsumerCoroutine,分别代表生产者和消费者,执行的任务分别是 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 的调度并不明显,没有类似于 yieldresume 的函数,代码示例如下:

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 协程的厉害之处。

相关推荐
常利兵12 小时前
解锁Kotlin:数据类与密封类的奇妙之旅
android·开发语言·kotlin
jzlhll1232 天前
kotlin flow去重distinctUntilChanged vs distinctUntilChangedBy
android·开发语言·kotlin
jinanwuhuaguo3 天前
最新更新版本,OpenClaw v2026.4.2 深度解读剖析:Task Flow 重磅回归与安全架构的全面硬化
android·开发语言·人工智能·回归·kotlin·安全架构·openclaw
ForteScarlet3 天前
从 Kotlin 编译器 API 的变化开始: 2.3.20
android·开发语言·后端·ios·开源·kotlin
hnlgzb4 天前
请详细解释一下MVVM这个设计模型
android·kotlin·android jetpack·compose
夏沫琅琊4 天前
Kotlin 基础(一)
kotlin
夏沫琅琊4 天前
Android API 发送短信技术文档
android·kotlin