先给个结论放在这儿:Kotlin 协程不是线程,不是异步框架,也不是什么运行时新开出来的"轻量级线程池"。它是编译器帮你把一段可以暂停的代码自动改写成状态机,再配合一套调度和取消机制,让异步代码写得像同步一样直白。这篇文章我打算把协程从"是什么"讲到"怎么用",再讲到"踩坑怎么排查",全程用我实际调过的代码说话,你照着思路走一遍基本就通了。
1. 先搞清楚协程到底是个啥
1.1 协程本质是"可挂起的计算"
很多人第一次接触协程,脑子里默认把它当成"轻量级线程"。这个类比方向是对的,但不准确。线程的切换是操作系统内核干的,协程的挂起和恢复是代码自己干的,不需要内核参与。线程切换要进内核态,有上下文切换的代价,而协程切换纯粹是用户态的一次函数调用级操作,代价小得多。
更准确的说法是:协程是一个"可挂起的计算"。所谓挂起(suspend),就是当前执行到某个点时,把现场保存下来,然后让出控制权,等条件满足了再从之前那个点继续往下走。这个"现场保存 + 恢复执行"的机制,在 Android 开发里最常见的用途就是网络请求回调的扁平化。
suspend fun loadUserAndOrder() { val user = api.fetchUser() // 挂起点:等网络返回 val order = api.fetchOrder(user.id) // 挂起点:依赖前一步结果 show(user, order) }这段代码看起来是同步顺序执行的,但实际运行过程中,fetchUser 发起网络请求后,协程就挂起了,主线程没有被卡住,UI 照常能滑。等到网络结果回来了,协程再自动恢复,接着执行下一行。这就是协程最核心的体验:写法是同步的,行为是异步的。
1.2 它到底解决了什么问题
在协程出现之前,Android 上处理异步主要是三件套:回调、线程池、RxJava。
回调的问题在于嵌套。一个页面里有两三个有依赖关系的请求,回调套回调的效果就是缩进地狱,改一个逻辑得翻半天:
api.fetchUser { user -> api.fetchOrder(user.id) { order -> api.fetchPayment(order.id) { payment -> show(payment) } } }线程的问题在于资源。你当然可以用线程池解决问题,但线程的数量是受限的,每个线程都有一份独立的栈空间,开多了内存和切换成本都上来了。而且线程天然没有"取消"和"结构化"的概念:一个任务启动了,你很难优雅地在界面销毁时把它连带着子任务一起停掉。
RxJava 的问题在于学习曲线和语法成本。它本身很强大,但操作符的抽象层级高,团队里每个人理解程度不一样,代码风格就容易跑偏。
协程把这几个问题一起解决了:代码线性化书写,没有嵌套回调;挂起不占线程,挂起的协程不需要一个线程在那干等;配合结构化并发,可以做到任务和页面生命周期绑定,页面销毁时所有子任务一起取消。这三个能力是协程能成为 Android 异步标配的根本原因。
不过有一点必须说清楚:协程不是银弹。它解决的是"异步逻辑的组织方式"问题,而不是"异步操作的性能"问题。网络请求该慢还是慢,CPU 密集计算该卡还是卡,这些得靠缓存、算法、调度去解决,协程只是让你写起来更舒服。
2. 协程的核心机制:挂起与恢复
2.1 suspend 与 CPS 变换
要理解协程,关键是要知道 suspend 函数被编译之后变成了什么。Kotlin 编译器会在编译期把 suspend 函数做一次 CPS(Continuation Passing Style)变换。
什么叫 CPS?你在代码里写的是"先拿 user,再拿 order",编译器把它改成"拿 user,然后把一个叫 continuation 的东西传下去,等结果好了再继续执行"。简单说,编译器在你的代码里插了一堆"接着干"的指令,每个挂起点都会变成一次"把后续要做什么保存起来"的机会。
写出来大概是这样:
suspend fun fetchUser(): User { ... }编译后它真正的签名实际上是:
fun fetchUser(continuation: Continuation<User>): Any? { ... }注意返回值变成了Any?,因为函数可能真的返回了结果(没有挂起),也可能挂起了,挂起时返回一个COROUTINE_SUSPENDED标记。每次调用到挂起点,如果条件没准备好,就返回 COROUTINE_SUSPENDED,把后面要做的事交给 continuation 继续。这就是挂起的真正含义。
2.2 状态机:编译器在背后做了什么
一个 suspend 函数里有多个挂起点时,编译器不会真的把你代码拆成多个函数,而是用状态机的方式在同一个函数里做跳转。每个挂起点对应一个状态编号,恢复执行时根据编号跳到对应的位置继续。
举个典型例子:
suspend fun loadData() { val user = fetchUser() // 挂起点 0 val order = fetchOrder(user.id) // 挂起点 1 println(order) }编译器大概会把它改写成类似下面的逻辑(伪代码,方便理解):
fun loadData(continuation: Continuation<Any?>): Any? { class StateMachine(continuation) : Continuation<Any?> { var label = 0 var user: User? = null override fun resumeWith(result: Result<Any?>) { // 按 label 跳到对应位置 } } val sm = continuation as? StateMachine ?: StateMachine(continuation) when (sm.label) { 0 -> { sm.label = 1 val res = fetchUser(sm) // 挂起,继续传入状态机 if (res == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED sm.user = res as User // 继续走到 1 } 1 -> { val user = sm.user sm.label = 2 val res = fetchOrder(user.id, sm) if (res == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED println(res) } } }你看到没有,编译器把局部变量(比如 user)搬到了状态机对象的字段里,这样协程挂起再恢复时,之前的变量还在,不会丢。这也是为什么协程代码里的局部变量在挂起前后都能正常访问。
理解这个以后,两个常见疑问就顺带解决了。第一,协程挂起不阻塞线程,因为恢复动作是通过 continuation 调回来的,线程在此期间可以去做别的任务。第二,协程创建和切换的开销为什么比线程小很多?因为状态机就是一个对象 + 一次函数调用,不涉及内核态切换。
2.3 调度器与线程切换
挂起恢复之后在哪个线程上继续跑,这是 Dispatcher 管的事。
withContext(Dispatchers.IO) { ... }这行代码的意思是:把里面这段代码放到 IO 线程池执行,如果调用方当前已经在 IO 线程,就继续执行不切线程。withContext内部会做一次线程切换检查,只有确实需要切换时才切换。这也是为什么在协程里频繁用withContext(Dispatchers.Main)切回主线程,代价不像想象中那么高——它本质是一个基于 continuation 的调度动作。
Dispatchers.Main 用于 UI 操作,Dispatchers.IO 用于网络、磁盘等阻塞操作,Dispatchers.Default 用于 CPU 密集计算。这三个背后的线程模型不同:Main 是 Android 主线程的 Handler 封装,IO 是一个弹性线程池,Default 是跟 CPU 核心数相关的共享线程池。日常开发里记住一条原则:不要在主线程做任何可能阻塞的事,不要在 IO 线程做 UI 操作,更不要拿着某个线程不放手。
3. 真正用起来:Scope、launch、async 与结构化并发
3.1 CoroutineScope 和结构化并发
结构化并发是 Kotlin 协程设计里最值钱的理念。它规定:协程必须在某个作用域(CoroutineScope)里启动,作用域负责管理协程的生命周期,作用域结束时,它内部的协程全部取消。
你可以把一个 CoroutineScope 理解成一个项目的项目经理:你在这个项目里派出去的活(子协程),都由这个项目经理统一管理。项目解散了,没干完的活全部停掉,不允许你在项目解散后还偷偷加班。
Android 上最典型的用法是跟 ViewModel 绑定:
class MainViewModel : ViewModel() { private val scope = viewModelScope fun load() { scope.launch { val data = repository.fetchData() updateUi(data) } } }viewModelScope会在 ViewModel 的 onCleared 时自动取消所有协程。这就避免了回调式异步最常见的病:网络请求发出去了,用户退出页面了,结果回来时才更新一个已经不存在的 UI,轻则浪费资源,重则空指针崩溃。
我自己在项目里还常用一个自定义 scope 绑定页面生命周期:
class MainActivity : AppCompatActivity() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) override fun onDestroy() { scope.cancel() super.onDestroy() } }这里用了SupervisorJob()而不是Job(),原因是 SupervisorJob 下某个子协程挂了不会牵连兄弟协程。这个区别后面异常处理部分还会细讲。
3.2 launch 和 async 怎么选
launch用于执行一个不需要返回结果的任务,返回值是 Job,你可以用 Job 去取消任务或者等待任务结束。async用于执行一个需要返回结果的任务,返回值是 Deferred,可以理解为一个"未来的结果"。
有个常见误解是"async 就是用来并发请求的,launch 就是用来做普通任务的"。更准确的理解应该是:你需要拿到结果就用 async,不需要结果就用 launch。并发不是 async 独有的,launch 也能并发,只是 launch 没法把结果传回来。
经典用法是并发请求多个无依赖接口:
suspend fun loadHomeData(): HomeData = coroutineScope { val bannerDeferred = async(Dispatchers.IO) { api.fetchBanner() } val userDeferred = async(Dispatchers.IO) { api.fetchUserInfo() } val listDeferred = async(Dispatchers.IO) { api.fetchFeedList() } HomeData( banner = bannerDeferred.await(), user = userDeferred.await(), list = listDeferred.await() ) }注意我把async放在coroutineScope {}内部,这样有一个关键好处:如果其中一个请求失败,其他还没完成的请求会一起被取消,不会出现一个请求等另一个请求的孤儿状态。如果你只是想各自跑各自的、失败互不影响,那就得用 SupervisorJob + 单独处理异常的写法,别把 async 一股脑塞进同一个 coroutineScope 里。
3.3 线程切换的正确姿势:withContext
withContext是协程里切换线程的官方姿势。它的特点是:切换线程,但不改变当前协程的身份。也就是说,它在挂起当前协程、去另一个线程执行后,再回到调用线程继续。
常见的错误是把这种切换理解成"新开了一个协程"。不是的,withContext 不会创建新的协程(除非指定了新的 CoroutineStart),它只是在同一个协程里换了执行上下文。
实际编码中我推荐的做法是:在仓库层用 withContext 包装阻塞操作,上层调用处不关心线程细节:
class UserRepository(private val api: ApiService) { suspend fun getUser(id: Long): User { return withContext(Dispatchers.IO) { api.getUser(id) } } }上层 ViewModel 里直接调用repository.getUser(id),不需要也没办法知道底层跑在哪个线程。协程的上下文会自动传递,你从哪个作用域启动,异常和取消就会沿着这个链路往上传。这种"底层决定线程、上层只管业务"的方式,能让整个项目的线程管理集中在少数几个地方,不会到处都是 Dispatchers.IO 散弹枪。
3.4 关于 scope 的选型
总结一下实际项目里怎么选 scope:
| 使用场景 | 推荐 scope | 说明 |
|---|---|---|
| ViewModel 中加载数据 | viewModelScope | 自动随 ViewModel 销毁而取消 |
| Activity/Fragment 中临时任务 | lifecycleScope | 自动随生命周期回调取消,也支持延迟启动 |
| 顶层单例对象里的后台任务 | 自定义 Scope + SupervisorJob | 生命周期跟随 App,注意手动管理 |
| 需要多个协程结果聚合 | coroutineScope { } | 内部任何一个失败,整体取消,便于合并异常 |
有一个我要重点提醒的反面教材:GlobalScope。它代表一个全局作用域,协程没有宿主、不受任何生命周期约束,用完不会自动取消。如果你在 Activity 里用 GlobalScope.launch 发起网络请求,页面销毁之后它还在后台跑,结果回来还去更新 UI,这就是典型的协程泄漏。GlobalScope 只适合极少数明确要脱离生命周期运行的任务(比如统计 SDK 上报),而且必须自己管理好取消。新人刚学协程时最容易在这个地方栽跟头。
4. 实战踩坑与排查经验
4.1 协程泄漏与取消机制
协程泄漏是生产环境里比内存泄漏更隐蔽的问题。Java 的内存泄漏你还能通过 Profiler 抓到对象引用来分析,协程泄漏是它一直在跑,只是没人管它。
检查协程是否泄漏,我这里有一个很实用的排查思路:在页面销毁时,主动打日志看协程状态。
scope.cancel() Log.d("Test", "scope.isActive = ${scope.isActive}") // cancel 之后应为 false如果取消后 scope 还是 active,说明有协程没有响应取消。这通常发生在两种情况下:第一种是把挂起函数包在不支持取消的阻塞代码里,比如:
scope.launch { Thread.sleep(5000) // 不可取消!协程取消不会中断线程阻塞 updateUi() }第二种是捕获了 CancellationException。取消在协程里的实现是抛出异常,你在 catch 的时候如果把异常吞了,协程就取消不掉了。
scope.launch { try { delay(1000) } catch (e: CancellationException) { // 取消被吞掉了,协程仍然认为自己在正常结束 } }正确的做法是 catch 之后重新抛出,或者用 finally 清理资源而不是吞掉异常。另外要记住:Thread.sleep 这种阻塞操作不会响应取消,必须换成 delay,或者用 withContext(Dispatchers.IO) 把阻塞操作发到 IO 线程,这样取消至少能中断协程的执行流程。
4.2 异常处理与 SupervisorJob
协程的异常传播规则是很多项目出 bug 的根源。两个核心规则:
第一,launch 的异常如果没人处理,会传播给父级,最终触发 CoroutineExceptionHandler。Android 上默认会走到线程的未捕获异常处理器,也就是可能直接让应用崩溃。第二,async 期望异常最终被 await 消费,如果你 await 了,异常在 await 调用处抛出;如果不 await,异常可能被静默吞掉。
这里最容易踩的场景是:在 coroutineScope 里一个子协程抛异常,整个父子协程链路全部取消,你只是想在某个局部做重试,结果整个页面逻辑都断了。解决办法就是 SupervisorJob 或者 supervisorScope:
suspend fun loadItems() = supervisorScope { val result = try { repository.fetchItems() } catch (e: IOException) { emptyList() } updateUi(result) }supervisorScope 的语义是:子协程的失败不会取消其他兄弟协程,也不会向上传播。我处理列表页多个独立区块加载时就用它,每个区块有自己的失败兜底,彼此不影响。
再补充一个重点:CoroutineExceptionHandler 并不能替代 try-catch。它是全局兜底用的,正常业务里的异常希望你还是用 try-catch 显式处理,别把希望寄托在全局异常兜底上。Handler 只在异常"逃逸"时才起作用,而逃逸本身往往已经意味着代码结构有问题。
4.3 常见问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 页面销毁后还有日志在打印任务没停 | 用了 GlobalScope 或生命周期未绑定 | 换 viewModelScope/lifecycleScope,或手动 cancel |
| cancel() 之后协程还在跑 | 内部有不可取消的阻塞操作,或吞了 CancellationException | 替换为 delay;确认 catch 后重新抛出 |
| 一个子协程异常导致整个页面协程全挂 | launch 默认异常向上传播给父协程 | 父级用 SupervisorJob,或局部 try-catch |
| async 内部异常没报错但结果不对 | async 异常被延迟到 await 时才抛出,甚至被静默丢弃 | 在 async 块内 try-catch 或确保一定会 await |
| 快速连续点按钮导致重复加载 | 协程老任务没有被取消 | 保存 Job,新任务前 job.cancel(),或用 Mutex 防重入 |
| 主线程卡顿但没有明显主线程 IO | 可能在协程里直接用了没有 withContext 的阻塞调用,或错误地使用 runBlocking | 用 withContext(Dispatchers.IO) 包裹阻塞操作 |
最后一个反直觉的问题再说一次:有的同学喜欢在单元测试或启动代码里写 runBlocking。runBlocking 是阻塞式启动协程,通常只用于测试和 main 函数的顶部。在 Android 的主线程里用 runBlocking,等于把主线程卡在协程上等结果,跟直接主线程做 IO 没有任何区别,还会额外引入死锁风险。我看到过不少新人把测试代码里的 runBlocking 习惯带到生产代码里,属于必须纠正的操作。
5. 协程的边界:什么情况别硬上协程
5.1 协程不适用的场景
协程在 Android 异步领域是主力,但它也有明确的边界。第一个边界是密集计算型任务。协程挂起恢复节省的是线程上下文切换的开销,但 CPU 密集计算的耗时跟协程没关系,你把一个大数运算扔到 Dispatchers.Default 里,它只是不卡主线程了,运算时间该多少还是多少。这种场景真正的优化手段是并行算法的拆分、缓存策略,或者是把计算挪到更合适的执行环境。
第二个边界是大量短任务并发。协程虽然比线程轻量,但并不是零成本,每次 launch 都要创建 continuation、调度、切线程。你要是循环一万次 launch 去做一个本来 for 循环就能解决的问题,那纯粹是给 GC 增加负担。协程适合的是"场景多、但每个场景背后有异步等待"的任务,不是"数量极大但本身秒完"的任务。
第三个边界是跨进程或跨设备的复杂状态同步。协程只解决单进程内的异步编排,分布式系统里那些状态一致性、事务边界问题,不是靠协程能解决的,别把不同层面的问题混为一谈。
5.2 和 Flow 的搭配
协程的进阶里绕不开 Flow。Flow 是建立在协程之上的响应式数据流库,用来处理多个值或者持续产生的异步数据。现在项目里常见的数据流是 Room 的查询结果、网络分页、WebSocket 推送这类场景。
Flow 和协程的关系可以这么理解:协程是"一口气执行到底"的线,Flow 是"一根水管",数据持续从源头流到下游。水管本身也是协程驱动的,但它的价值在于提供背压处理(buffer、conflate)、冷热流区分、以及一系列操作符。
repository.observeData() .map { it.map { item -> item.toUiModel() } } .flowOn(Dispatchers.IO) .catch { e -> emit(emptyList()) } .collect { uiModels -> render(uiModels) }这里flowOn(Dispatchers.IO)决定了上游在哪个线程做 map 转换,collect在下游执行,中间自动做了线程切换。用 Flow 改写回调式的监听器,代码比接口回调清晰太多,而且因为是协程实现的,取消依然符合结构化并发原则。
我在实际项目里的分界线是:如果接口只需要一个结果,用 suspend 函数;如果需要持续不断地返回多个结果,或者要做流式操作符组合,用 Flow。不要让所有的接口都改成 Flow,那是过度设计。什么时候补一个回调式接口,什么时候换成 Flow,这需要按业务需求判断,没有一个接口绝对的标准答案。
我在实际项目里的体会是,协程最大的价值不是把代码写得短,而是把异步代码的生命周期和异常流管住了。很多项目真正崩溃的地方,不是逻辑写不出来,而是异步任务没人管、异常没人接。协程把这个最脏最乱的环节结构化地处理掉了。最后分享一个我自己的调试习惯:遇到协程相关的问题,先看作用域,再查取消链,最后才看调度器。作用域错了,后面全是白调;取消链断了,资源迟早漏;调度器选错,顶多性能差一点,不至于出大问题。按这个顺序排查,绝大多数隐蔽问题都能快速定位。