TL;DR

协程的概念与 Kotlin 编译器的工作原理

大家好,我是 ONDA 后端开发工程师 Gunner(정재훈),负责酒店运营管理解决方案的开发。

今天聊聊 Kotlin 环境下协程(Coroutine)的工作原理。

协程能把基于回调的代码改写成顺序执行的样子。看例子:

// 回调版本
fun postItem(item: Item) {
	requestTokenAsync { token ->
		createPostAsync(token, item) { post ->
      processPost(post)
    }
  }
}

// 协程版本
suspend fun postItem(item: Item) {
	val token = requestToken()
	val post = createPost(token, item)
	processPost(post)
}

协程让你摆脱回调地狱,代码看着也顺眼。它怎么做到的?

1. 协程与挂起点

维基百科对协程的定义:

"Coroutines are computer program components that allow execution to be suspended and resumed, …" —— 维基百科

协程是能让执行流程暂停和恢复的程序组件。

Kotlin 协程也能暂停和恢复执行 —— 暂停的位置叫 挂起点(suspension point)。在 IntelliJ IDE 里,挂起点会被标出来:

挂起点出现在调用 suspend 修饰的函数时。上图里 requestToken()createPost()processPost() 都是 suspend 函数。

执行到挂起点,原流程暂停,执行完 suspend 函数后再恢复。内部怎么实现的? 看例子:

suspend fun main() {
	println("Before")

	suspendCoroutine { continuation ->
		thread {
			println("Suspended")
			Thread.sleep(1000)
			continuation.resumeWith(Result.success(Unit))
			println("Resumed")
		}
	}

	println("After")
}

调用 suspendCoroutine 时 main() 暂停。传入的 lambda 里调用 continuation.resumeWith() 恢复原函数执行。这样挂起点暂停执行,suspend 函数跑完后,从暂停位置继续。

2. Continuation 就是回调

讲协程必提 Continuation。常见说法: "Kotlin 协程用 CPS(Continuation Passing Style) 实现暂停与恢复"。

前面例子里 suspendCoroutine 的 lambda 参数名就叫 continuation。Continuation 是老概念,Scheme 语言里用 call/cc 函数(call-with-current-continuation)操作它。

Continuation 让你暂停原函数,suspend 函数执行完后恢复原函数。它装了什么能做到这些?

工作中途暂停去干别的事,完事后想接着干,得记住:

  • 进度到哪了(该从哪接着干)
  • 暂停时的环境(比如打开的 Excel 文件和参考资料)

这两样齐了才能继续。

函数暂停与恢复也一样,需要:

  • 执行到哪一步了
  • 暂停时的上下文变量值(比如 varA = 10, varB = "This is string")

把这些信息装进 continuation,暂停时保存,恢复时取出来用,原函数就能继续干。

听着耳熟? 对,回调(callback)

回调是传一个 lambda,在特定时刻执行。Continuation 也一样 —— 只是执行时机不同:

  • Continuation: lambda 立即执行,原流程暂停,lambda 执行完后原流程恢复
  • Callback: 原流程立即继续,lambda 在某个时刻才执行

执行时机不同,本质上回调和 continuation 是一回事。

3. Kotlin 编译器自动转换

前面讲了挂起点和 continuation。谁来做这些处理?

Kotlin 编译器。编译器看到 suspend 修饰的函数,会把代码改成:

// Kotlin
suspend fun createPost(token: Token, item: Item): Post { ... }

// 编译器转成等价的 Java 代码
// Java/JVM
Object createPost(Token token, Item item, Continuation<Post> cont) { ... }

Continuation 接口长这样:

// Kotlin ~v1.2
interface Continuation<in T> {
	val context: CoroutineContext
	fun resume(value: T)
	fun resumeWithException(exception: Throwable)
}

// Kotlin v1.3~
interface Continuation<in T> {
	val context: CoroutineContext
	fun resumeWith(result: Result<T>)
}

调用 resumeWith 传结果值,恢复原函数执行。但每个挂起点都要暂停与恢复,怎么记住执行到哪了?

Kotlin 用 标签(label) 标记。例子:

suspend fun requestToken() { ... }

suspend fun createPost(token, item) { ... }

suspend fun processPost(post) { ... }

suspend fun postItem(item: Item) {
  // LABEL 0
	val token = requestToken()
  // LABEL 1
	val post = createPost(token, item)
  // LABEL 2
	processPost(post)
}

每个挂起点分配一个 label。记住 label 编号,恢复时从对应 label 继续:

suspend fun postItem(item: Item) {
	switch (label) {
		case 0:
			val token = requestToken()
		case 1:
			val post = createPost(token, item)
		case 2:
			processPost(post)
	}
}

Label 信息存哪? 前面说了,在 continuation 里。用 continuation 里的 label 做分支,从暂停处恢复:

fun postItem(item: Item, cont: Continuation) {
	val sm = object : CoroutineImpl { ... }
	when (sm.label) {
		0 -> val token = requestToken(sm)
		1 -> val post = createPost(token, item, sm)
		2 -> processPost(post)
	}
}

有点眉目了。首次执行时创建 continuation,后续挂起点复用它。

但还缺东西: label 保存在哪? label 为 1 时调用 createPost 要用的 item 值从哪来? 执行到那行时用 resumeWith 恢复,原始 item 参数不在了吧?

对。所以还得把状态(state)也存进 continuation,用时取出来:

fun postItem(item: Item, cont: Continuation) {
	val sm = object : CoroutineImpl { ... }
	when (sm.label) {
		0 -> {
			sm.item = item
			sm.label = 1
			val token = requestToken(sm)
		}
		1 -> {
			val item = sm.item
			val token = sm.result as Token
			sm.label = 2
			val post = createPost(token, item, sm)
		}
		2 -> processPost(post)
	}
}

挂起点记录、状态管理由 suspendCoroutine() 和 continuation 负责。Kotlin 编译器处理 suspend 修饰符时,自动改写代码。

实际编译出的字节码反编译后是复杂的 Java 代码,这里用 Kotlin 伪代码演示核心逻辑 —— 大体思路是这样。

4. 协程对 Future-like 类型的扩展支持

前面例子展示了如何把回调风格改成顺序风格。但这样写性能不会提升,回调也没啥大问题,除了风格差异还有别的优势吗?

协程的优势是高效利用闲置线程。挂起点暂停原任务,在特定线程池执行其他任务,原线程空闲出来给别的工作用。

多个 async 调用同时发出一起处理时,如果都是 suspend 调用,线程利用效率高。还有一个好处: JVM 的各种 async 库都有自己的 Future-like 类型:

  • Guava: ListenableFuture
  • RxJava: Observable
  • JDK8: CompletableFuture

类不同,但基本原理跟 Future 一样。Kotlin 协程提供了与这些 Future-like 类型互操作的扩展代码。

只要库有对应扩展,就能用统一方式取值 —— 比如协程里用 await(),不管对象是 ListenableFuture、CompletableFuture、Observable 还是 Promise,都一个写法。这通过 Kotlin 扩展(extension)实现,协程让你用相同方式对接不同库:

// 用 Guava 的 Java 函数
public ListenableFuture<Image> guavaLoadImageAsync(String name) { … }

// 用 RxJava 的 Java 函数
public Observable<Image> rjLoadImageAsync(String name) { … }

// 合并两张图的 Java 函数
public Image combineImage(Image image1, Image image2) { … }

// Kotlin 里用协程调用
// 获取两张图,返回 CompletableFuture
fun combineImagesAsync(name1: String, name2: String): CompletableFuture<Image> = future {
	val future1 = guavaLoadImageAsync(name1)
	val future2 = rjLoadImageAsync(name2)
	combineImages(future1.await(), future2.await())
}

💡 Kotlin 编译器总结

  1. Kotlin 编译器转换带 suspend 修饰符的函数。

  2. 转换后的函数用 continuation(像回调一样)管理挂起点和状态,实现暂停/恢复。

  3. 暂停的原线程可以干别的活,效率更高。

  4. 协程提供的 Future-like 扩展,让你用统一写法操作不同 Future-like 库。

今天介绍了协程概念和 Kotlin 编译器的工作原理。多亏这些 Future-like 扩展,协程能用一致的代码风格对接各种库。谢谢。