ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Kotlin协程本质与实战:从挂起状态机到结构化并发

Kotlin协程本质与实战:从挂起状态机到结构化并发 先给个结论放在这儿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 函数做一次 CPSContinuation Passing Style变换。什么叫 CPS你在代码里写的是先拿 user再拿 order编译器把它改成拿 user然后把一个叫 continuation 的东西传下去等结果好了再继续执行。简单说编译器在你的代码里插了一堆接着干的指令每个挂起点都会变成一次把后续要做什么保存起来的机会。写出来大概是这样suspend fun fetchUser(): User { ... }编译后它真正的签名实际上是fun fetchUser(continuation: ContinuationUser): 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: ContinuationAny?): Any? { class StateMachine(continuation) : ContinuationAny? { var label 0 var user: User? null override fun resumeWith(result: ResultAny?) { // 按 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 线程切换的正确姿势withContextwithContext是协程里切换线程的官方姿势。它的特点是切换线程但不改变当前协程的身份。也就是说它在挂起当前协程、去另一个线程执行后再回到调用线程继续。常见的错误是把这种切换理解成新开了一个协程。不是的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 或者 supervisorScopesuspend 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或手动 cancelcancel() 之后协程还在跑内部有不可取消的阻塞操作或吞了 CancellationException替换为 delay确认 catch 后重新抛出一个子协程异常导致整个页面协程全挂launch 默认异常向上传播给父协程父级用 SupervisorJob或局部 try-catchasync 内部异常没报错但结果不对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这需要按业务需求判断没有一个接口绝对的标准答案。我在实际项目里的体会是协程最大的价值不是把代码写得短而是把异步代码的生命周期和异常流管住了。很多项目真正崩溃的地方不是逻辑写不出来而是异步任务没人管、异常没人接。协程把这个最脏最乱的环节结构化地处理掉了。最后分享一个我自己的调试习惯遇到协程相关的问题先看作用域再查取消链最后才看调度器。作用域错了后面全是白调取消链断了资源迟早漏调度器选错顶多性能差一点不至于出大问题。按这个顺序排查绝大多数隐蔽问题都能快速定位。
返回列表