kotlin协程推出至今已成为 Android 开发人员的必备技能,但直到今天仍然有很多关于kotlin协程底层的争议。本篇文章围绕kotlin协程底层结合着一些基础讲解,希望可以探究明白kotlin到底是什么,当然,笔者知识有限而如果有不周错误之处希望大家指出。
很多语言中都有协程这一概念,很多人把这些不同语言的协程混为一谈,统称协程是 “轻量级线程” 。但深究其理,像 Go 那样具有独立栈的协程真正作为代码运行单元的协程才能说是轻量级线程,像kotlin这种语言层面实现的无栈协程,只能说是轻量级任务,kotlin协程是依靠状态机模拟栈实现的,完全不符合轻量级线程的定义,下面会详细说到。
协程的启动
协程本质上 = Context + Job + Dispatcher + Continuation。其中Job和Dispatcher是Context的核心元素,这部分和协程作用域密切相关,放在作用域的内容中。Continuation(续体)是解决回调地狱的重要元素,放在解决回调地狱的内容中。这里先看一下协程的三种启动方式
runBlocking:启动一个新的协程并阻塞调用它的线程,直到里面的代码执行完毕,返回值是协程体中的最后一行。但是只能在当前线程运行事件循环,一般仅用在调试阶段,不会用于实际开发。launch:启动一个协程但不会阻塞调用线程,必须要在协程作用域(CoroutineScope)中才能调用。无业务返回值(业务返回值的定义是函数或协程执行完成后,向调用方返回的、与具体业务逻辑相关的结果数据),launch返回的Job是一个协程的句柄,下面协程作用域会详细说。async:Deferred<T>启动一个协程但不会阻塞调用线程,必须要在协程作用域(CoroutineScope)中才能调用。以Deferred对象的形式返回协程任务。Deferred是Job的子接口,可以通过await方法得到协程体中最后一行的业务返回值。
协程作用域
协程作用域本质是一个管理协程的容器,核心作用就是规定其所管理协程的生命周期,避免内存泄漏或程序崩溃。通过runBlocking、launch和async启动的协程体等同于协程作用域,支持嵌套启动任意多的子协程,这也就是结构化并发,当父任务完成或者异常终止时,子任务也会相应地被处理。
内置作用域
安卓开发有如下内置的作用域
-
runBlocking:阻塞式作用域(测试 / 主线程临时阻塞)
runBlocking 不是普通的 CoroutineScope,但它会创建一个阻塞当前线程的协程作用域,直到作用域内所有协程执行完毕。因此仅用于测试代码或 main 函数,严禁在安卓主线程 / 生产代码中使用(会阻塞线程)。
// runBlocking 构建的作用域,阻塞当前线程
fun testRunBlockingScope() = runBlocking {
launch { // 继承 runBlocking 的作用域
delay(1000)
Log.d("scope", "runBlocking 内的协程")
}
}
-
GlobalScope:全局作用域
GlobalScope 是 Kotlin 提供的全局静态作用域,生命周期与应用进程绑定,无法自动取消,容易导致内存泄漏。启动的协程不受任何组件生命周期管理,即使页面销毁,协程仍会运行。
fun testGlobalScope() {
GlobalScope.launch { // 全局作用域的协程
delay(1000)
Log.d("scope", "GlobalScope 协程")
}
}
-
- 安卓官方库提供的与组件生命周期绑定的作用域,协程会随组件销毁自动取消,是安卓开发的首选
| 作用域 | 绑定的组件 | 用途 |
|---|---|---|
lifecycleScope | Activity/Fragment | 执行与页面生命周期绑定的协程(比如请求数据、更新 UI) |
viewModelScope | ViewModel | 执行与 ViewModel 生命周期绑定的协程(比如数据请求,页面销毁后 ViewModel 仍存活时协程继续) |
自定义作用域
除了系统内置的作用域外,我们还可以自定义协程作用域。我们可以理解协程作用域是为协程制定统一的运行规则(比如默认跑在哪个线程、异常怎么处理、什么时候全部取消),而 “自定义作用域” 就是我们自己来制定这些规则,想要自己制定规则,我们有必要继续深挖一下协程作用域
协程作用域CoroutineScope本身是一个接口,源码极其简洁。CoroutineScope的核心只有一个属性coroutineContext。因此,CoroutineScope包含的“内容”本质上是 coroutineContext所封装的协程上下文元素。
public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}
CoroutineContext(协程上下文)是一个键值对集合,主要由如下几个元素组成。
-
- Job(作业):协程生命周期的管理者
Job是 CoroutineContext的核心元素之一,提供了控制协程生命周期(启动、取消、等待完成)、查询状态(是否活跃 / 完成 / 取消)的所有接口,协程构建器(launch/async)都会返回 Job 相关对象,Job的生命周期如下。
| 状态 | 说明 | 对应属性 |
|---|---|---|
| New(新建) | 协程已创建但未启动(仅手动创建 Job 时会出现,launch 会自动启动) | isActive = false |
| Active(活跃) | 协程正在执行 | isActive = true |
| Completing | 协程执行完毕,但子 Job 还未完成(内部状态,对外表现为 Active) | - |
| Cancelling | 协程被取消,正在执行收尾逻辑(如 finally) | isCancelled = true |
| Cancelled | 协程已取消且收尾完成 | isCompleted = true + isCancelled = true |
| Completed | 协程正常执行完毕(无取消、无异常) | isCompleted = true + isCancelled = false |
对于可取消的Job(有挂起点,如 delay()、withContext() ),可以直接调用job.cancel()取消,终止整个协程。
每个协程对应一个 Job,作用域的 Job通常作为“父 Job”,其管理的所有子协程(通过作用域启动的协程)会形成父子 Job 树,父 Job 取消时所有子 Job 会被级联取消(结构化并发的核心),并且普通Job会因子协程异常导致父级失效,前者是为了避免资源泄漏程序崩溃的有利设计,后者则完全不符合业务逻辑。
因此在Job的基础上诞生了一个特殊的子类SupervisorJob,重写了异常传播逻辑:当子协程抛出未捕获的异常时,异常仅终止当前子协程,不会传播到父 SupervisorJob,父级和其他子协程可以继续执行。
像上面安卓内置的**lifecycleScope和viewModelScope就用的是SupervisorJob**
-
- Dispatcher(调度器):协程运行的线程/线程池
-
Dispatcher(调度器)决定了协程运行的线程或线程池。它是CoroutineContext中最常用的元素之一,通过Dispatchers工厂类创建。常见调度器如下Dispatchers.Main:主线程(适用于 Android UI 更新、iOS 主线程等);Dispatchers.IO:IO 线程池(适用于网络请求、文件读写、数据库操作等阻塞 IO 任务);Dispatchers.Default:CPU 密集型线程池(适用于复杂计算、排序、解析等 CPU 密集型任务);Dispatchers.Unconfined:无固定调度器(协程在当前线程启动,挂起后恢复时可能切换到其他线程,慎用)。
-
- CoroutineExceptionHandler异常处理机制
CoroutineExceptionHandler用于处理作用域内未try-catch捕获的异常(仅对“根协程”有效,子协程的异常需通过其他方式处理)。当协程抛出未捕获的异常(如 RuntimeException)时,异常处理器会被触发,一般就用作日志记录。注意,对于try-catch捕获了的异常,是不会触发CoroutineExceptionHandler的。
val handler = CoroutineExceptionHandler { _, e ->
println("捕获异常: $e")
}
val scope = CoroutineScope(
SupervisorJob() +
Dispatchers.IO +
handler
)
//会触发CoroutineExceptionHandler
scope.launch {
throw RuntimeException("Oops!") // 未被捕获的异常
}
用launch启动协程遇到未捕捉的异常直接就触发CoroutineExceptionHandler了,但是对于async启动的协程,async 会暂存异常,直到调用 .await() 时才抛出。如果不await仍然不会触发CoroutineExceptionHandler。
val deferred = async {
throw IOException()
}
//异常被暂存,不会触发 CoroutineExceptionHandler
// 必须await
deferred.await() // 异常未被捕获,向上传播
- 4.取消模型
普通子协程的 Job 执行完任务才会自动结束,我们使用自定义的协程作用域并不能关联页面的生命周期,当用户退出某个耗时页面不希望再进行时,如果不手动取消该协程仍然会进行下去,因此一般补充一个取消的方法
fun clear() {
job.cancel()
}
综合以上几点,我们把以上的几个要点全部组装一起,就写出一个标准的Scope了
class UseCaseScope(
dispatcher: CoroutineDispatcher
) : CoroutineScope {
private val job = SupervisorJob()
override val coroutineContext =
job +
dispatcher +
CoroutineName("UseCaseScope")
fun clear() {
job.cancel()
}
}
解决回调地狱
回调地狱,即多层回调嵌套的问题,例如获取用户信息 → 根据用户 ID 获取订单 → 根据订单 ID 获取商品详情,我们需要将下游函数嵌套在上游函数的回调中。对这样的代码很容易牵一发动全身,导致代码难以维护。Kotlin为解决这个问题采用了底层构建函数suspendCancellableCoroutine,用于将基于回调(Callback)或 Future 的异步 API 封装成 suspend 函数,从而让传统异步代码能无缝融入协程世界。
要了解回调地狱的底层逻辑,还需要学习协程的底层逻辑,深入状态机和continuation续体。简单来说,状态机就是把代码拆成“状态”,按顺序一步步走。而continuation内部1.包装了跨挂起点仍然存活的局部变量和当前执行位置的对象,2.协程上下文(CoroutineContext) 3.恢复执行的逻辑(resumeWith方法) 。具体看下面例子
// 原始挂起函数
suspend fun fetchData(): String {
val data1 = loadFromNetwork() // 挂起点1(假设 loadFromNetwork 是挂起函数)
val data2 = process(data1) // 普通代码(非挂起)
val data3 = loadFromDb(data2) // 挂起点2(假设 loadFromDb 是挂起函数)
return data3
}
//业务代码
object UserRepositoryCoroutine {
// 1. 封装验证输入为挂起函数
suspend fun loadFromNetwork(input: String): String {
return suspendCancellableCoroutine { continuation ->
UserRepository.loadFromNetwork(input, object : ValidateCallback {
override fun onLoadFromNetworkSuccess(input: String) {
continuation.resume(input) // 成功了,恢复协程,返回结果
}
override fun onLoadFromNetwork(msg: String) {
continuation.resumeWithException(IllegalArgumentException(msg))
}
})
}
}
suspend fun loadFromDb(token: String): UserInfo {
return suspendCancellableCoroutine { continuation ->
UserRepository.loadFromDb(token, object : UserInfoCallback {
override fun onLoadFromDbSuccess(userInfo: UserInfo) {
continuation.resume(userInfo)
}
override fun onLoadFromDbError(msg: String) {
continuation.resumeWithException(SecurityException(msg))
}
})
}
}
}
上面挂起函数代码和业务代码经过编译器编译后会转换为类似如下的状态机逻辑。
class FetchDataStateMachine(
private val completion: Continuation<String>
) : Continuation<String> {
var state = 0
var data1: String? = null
var data2: String? = null
override fun resumeWith(result: Result<String>) {
val outcome = invokeSuspend(result) // 实际编译器生成的入口方法
if (outcome === COROUTINE_SUSPENDED) return // 挂起时直接返回
// 非挂起情况处理结果
completion.resumeWith(outcome)
}
private fun invokeSuspend(result: Result<String>): Any? {
return try {
when (state) {
0 -> {
state = 1
val r = loadFromNetwork(this) //传入当前状态机状态,检查是否挂起
if (r === COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED
}
// 调用挂起函数返回 COROUTINE_SUSPENDED,这里暂停执行释放线程,当前协程的 Continuation 被保存起来,直到该挂起函数调用resume(),然后继续以 Dispatcher选择的线程来执行它
}
1 -> {
data1 = result.getOrThrow()
data2 = process(data1!!)//非挂起函数,继续执行
state = 2
loadFromDb(data2!!, this)
if (r === COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED
}
// loadFromDb 挂起,同上
}
2 -> {
val data3 = result.getOrThrow()
// 最终返回结果,不再挂起
data3
}
else -> error("Invalid state")
}
} catch (e: Throwable) {
CoroutineSingletons.RESUME_WITH_EXCEPTION
}
}
}
//业务代码的状态机伪代码,以loadFromNetwork为例
internal class LoadFromNetworkStateMachine(
initialValue: String,
private val completion: Continuation<String> // 这个 completion 是外层 fetchData 的 continuation
) : ContinuationImpl {
var state = 0
var input: String? = null
lateinit var continuationFromSuspendCancellable: Continuation<String> //代码里的那个 continuation
override fun invokeSuspend(result: Result<String>): Any? {
return try {
when (state) {
0 -> {
this.input = initialValue
state = 1
// 1. 创建一个 Continuation 给 suspendCancellableCoroutine 的 lambda
val myLocalContinuation = object : Continuation<String> {
override fun resumeWith(result: Result<String>) {
// 2. 当这个 myLocalContinuation.resumeWith 被调用时
//其实就是你在外层回调里调用 continuation.resume(input) 触发的 这会再次进入 invokeSuspend,但 state 已经是 1 了
invokeSuspend(result)
}
}
// 3. 把 myLocalContinuation 传给 validateInput 的回调
UserRepository.validateInput(this.input, object : ValidateCallback {
override fun onLoadFromNetworkSuccess(input: String) {
// 手动调用,它实际上调用的是 myLocalContinuation.resume(input)
myLocalContinuation.resume(input)
}
// ...
})
return COROUTINE_SUSPENDED
}
1 -> {
// 5. 当 myLocalContinuation.resume(input) 被调用后,程序会跳到这里
val result = result.getOrThrow()
// 6. 将结果返回给最初调用 loadFromNetwork 的地方(也就是 fetchData 函数)
completion.resume(result)
return result
}
else -> error("...")
}
} catch (e: Throwable) {
completion.resumeWithException(e)
}
}
}
从这里我们可以看出,Kotlin 协程的本质是编译器的语法糖—— 编译器会把包含挂起操作的 suspend 函数自动编译成一个有限状态机,每个挂起点都对应一个状态。
因为JVM 不允许也无法安全地捕获和恢复线程调用栈,所以编译器把 suspend 函数转换成状态机,用 Continuation 对象模拟栈帧。 上面的代码完全是栈帧的操作,保存执行上下文(局部变量 + 执行位置)+ 控制执行流程的功能如出一辙。
而整个回调的过程的核心就是挂起函数返回COROUTINE_SUSPENDED,if (outcome === COROUTINE_SUSPENDED) return此时 if条件为真,于是 resumeWith自己也 return了。至此,整个协程的本次执行周期结束了。线程被释放,可以去执行其他任务(比如渲染UI、处理别的协程)。但是 FetchDataStateMachine对象并没有被销毁, 它作为一个 Continuation对象,其内部的状态(state=1, data1=null等)都被完整地保留了下来。
当挂起的回调函数成功时调用continuation.resume(input),实际上是重新调用 invokeSuspend(result)以回到原来的协程域里继续执行下面的代码。这就是协程回调将异步代码 “伪装” 成同步代码的底层逻辑。
Kotlin的协程到底是什么
在看完上面的内容基本了解了kotlin协程的底层逻辑后,我们大概知道kotlin协程的原理。开篇说了,kotlin实现的是语言层面的无栈协程,因此轻量级线程完全不适用于kotlin的协程,只能说是轻量级任务。Kotlin 协程的挂起、恢复、调度都是以函数(挂起函数 suspend fun)为基本单位的,协程的执行流程被拆解为多个挂起函数的调用链,而非像线程那样以 “整个执行体” 为单位调度,它真正的并发能力来自“挂起不占线程”,而不是线程池本身。
但是!上面一切都围绕着挂起函数来说的,如果有 10,000 个协程任务,而且里面完全没有任何挂起点(纯 CPU 计算), Kotlin 协程还能否像 Go 协程一样高效调度? 答案是不能。 此时10,000 个任务会被放入线程池队列由N 个工作线程(≈ CPU 核心数)执行,一个线程一次跑一个任务,跑完才能换下一个。这正是 Kotlin 协程不是轻量级线程的铁证。
最后总结一下,Kotlin 并没有改变 JVM 的线程模型,它只是通过语言层面的突破,让函数可以中途返回并在未来恢复执行,从而实现“挂起不占线程”,用少量线程复用大量 I/O 任务。一旦协程内部没有挂起点,它就会退化为普通线程池任务, 这也是 Kotlin 协程无法被视为“轻量级线程”的根本原因。