协程容易被讲成「轻量级线程」,但真正决定它好不好用的是结构化并发——用作用域把协程的生命周期、取消和异常框在一棵树上,从而不泄漏、不拖死、不吞异常。
下面按使用顺序展开:作用域与 Job 生命周期 → 上下文与调度 → 启动取消与异常 → 并发安全 → Channel 与多路复用。
一、作用域与 Job 生命周期:结构化并发不泄漏
协程作用域规定子协程的生命周期与取消边界。coroutineScope 会等所有子任务结束,其中一个失败则取消兄弟;supervisorScope 让子失败互不影响,适合 UI 上并列请求。Android 用 viewModelScope、lifecycleScope 绑定组件销毁,GlobalScope 会泄漏。子协程默认继承父 Job 与 Dispatcher,withContext 只换调度器不换 Job 树。自定义 CoroutineScope 要在 onDestroy 里 cancel;structured concurrency 的要点是:不要把 Job() 造成孤儿根。测试用 runTest,生产避免在作用域外 launch 后不管。
Job 描述协程的生命周期:New、Active、Completing、Cancelling、Cancelled、Completed。launch 默认立即 Active;CompletableJob 可手动 complete。父 Job 完成前会等所有子 Job,取消则向下传播。join 挂起直到终态,invokeOnCompletion 注册一次性回调。isActive/isCompleted/isCancelled 是观察窗口,不要用它们做同步锁。SupervisorJob 切断子失败向父传播,适合并列任务。结构化并发的核心是「不泄漏 Job」:Android 用 viewModelScope 绑定销毁,测试用 runTest 的 scheduler。出现僵尸协程时先查 Job 树:谁是 parent、是否被 Job() 孤立成根。
什么时候用:任何需要「一组任务一起生一起死」的地方都用作用域包住;并列且互不拖累的独立任务用 supervisorScope,请求链路上的串行依赖用普通 coroutineScope。
二、上下文与调度:用 + 叠加元素,调度器决定 resume 线程
CoroutineContext 是协程的元素集合,用 + 叠加。常见元素包括 Job(生命周期)、CoroutineDispatcher(线程)、CoroutineName(调试名)和 CoroutineExceptionHandler。子协程默认继承父上下文,withContext 只替换指定元素。调度器决定 resume 发生在哪条线程:Main.immediate 适合已在主线程时避免再 post,IO 适合阻塞调用,Default 适合 CPU。不要把 Dispatcher 当锁;共享状态仍要同步。自定义元素需实现 CoroutineContext.Element。Android 里 viewModelScope 已带 SupervisorJob + Main.immediate。排查问题时先打印 coroutineContext,确认 Job 是否已取消、Dispatcher 是否被意外覆盖。
什么时候用:在主线程恢复 UI 用 Main.immediate;文件/网络阻塞用 IO;CPU 密集用 Default。需要临时换线程就用 withContext,不要新开作用域。
三、启动与取消:协作式取消与启动模式
launch 返回 Job 做「开火不管」,async 返回 Deferred 要 await 才拿到结果或异常。启动模式有 DEFAULT、LAZY、ATOMIC、UNDISPATCHED:LAZY 等 start/join,UNDISPATCHED 在当前线程跑到第一个挂起点。取消是协作式的:cancel 后遇到 delay/yield 或检查 isActive 才会停;CPU 死循环必须自己判断。cancelAndJoin 等清理完成。withTimeout 超时抛 TimeoutCancellationException。父取消会取消子。资源释放放 finally,必要时 withContext(NonCancellable)。Android 页面销毁靠作用域自动 cancel,不要另开无主 launch。
两个容易踩的坑
- CPU 死循环不会自己停。
cancel只在遇到挂起点或检查isActive时生效,纯计算循环要自己判断。 - 资源释放放 finally。真正需要「即使被取消也要做完」的清理,用
withContext(NonCancellable)包住。
什么时候用:开火不管的任务用 launch;要拿结果的用 async;延迟到真正需要时才初始化的用 LAZY。
四、异常处理:异常沿作用域向上传播
Kotlin 协程异常默认沿作用域向上传播到父协程。CoroutineExceptionHandler 只对启动体的根协程生效,async 的异常在 await/awaitAll 时抛出。SupervisorJob 让子失败不取消兄弟,适合 UI 范围多个独立任务。try/catch 会住当前挂起点的异常,但住不住子协程 launch 里的崩溃。CancellationException 是正常取消信号,不要当业务错误打点。withContext 的异常也会传到调用方。
业务上建议:网络层转 Result,ViewModel 用 SupervisorJob + Handler 收尾,避免在深处 catch 后吞掉。测试用 runTest 验证失败不会拖死整个作用域。
什么时候用:UI 上多个并列请求用 SupervisorJob 隔离失败;需要在根协程统一兜底时用 CoroutineExceptionHandler;async 的结果要在 await 处处理异常。
五、并发安全:用结构化共享状态代替锁
Kotlin 协程并发安全不靠线程锁,而是用结构共享状态。多个协程读写同一变量时,Mutex、Semaphore、Atomic 和 actor/单线程 Dispatcher 都能用。Mutex.lock 是挂起点,不要在持锁时调阻塞 IO,也不要把普通线程锁和协程 Mutex 混用。StateFlow/SharedFlow 可以把状态收到流里,但改值要在同一线程或用 update。避免在多个 Dispatchers.Default 上直接 ++ 计数器。withContext 切线程不会自动带锁。测试可用 runTest 加 StandardTestDispatcher 控制时间。产线常见死锁是 Mutex 再进入另一个也要同一 Mutex 的 withContext。优先考虑不共享可变状态。
三个容易踩的坑
- 持 Mutex 时别调阻塞 IO。
Mutex.lock是挂起点,里面塞阻塞调用会卡住协程。 - 别把线程锁和协程 Mutex 混用。二者语义不同,混用极易死锁。
- 改共享状态要收口。
StateFlow/SharedFlow的update要在同一线程,计数器不要在多Default上直接++。
什么时候用:先考虑不共享可变状态;必须共享时小临界区用 Mutex,纯计数用 Atomic,需要串行处理消息用 actor 或单线程 Dispatcher。
六、Channel 与多路复用:select 等多个事件之一
Channel 是协程间的管道:Rendezvous 无缓冲、Buffered 有容量、CONFLATED 只保最新、UNLIMITED 不限队列。send 在缓冲满时挂起,receive 在空时挂起。关闭用 close,消费端用 for 遍历或 consumeEach。生产者-消费者模型里要明确谁关闭 Channel,避免双方互等。fan-out 可多协程 receive,fan-in 可多协程 send 到同一 Channel。与 Flow 相比,Channel 是热流且每个元素只被一个接收者拿走。取消时 send/receive 会抛 CancellationException,要用与资源清理结合。能用 callbackFlow/channelFlow 的场景优先走 Flow,仅在需要精确背压与多消费者竞争时直接暴露 Channel。
多路复用体现在 select 与 Channel。一个协程可同时挂起在多个 onReceive/onSend 上,谁先就绪谁走,其余被取消。这比多线程里 select/轮询更轻,因为挂起不占线程。典型用法是 timeout 加业务 Channel:超时走 onTimeout 分支。selectUnbiased 可避免始终优先第一个分支。注意 select 里不要再嵌套长时间挂起,否则难以取消其他 clause。
三个容易踩的坑
- 多路复用不等于并发计算。
select解决的是「等多个事件中的一个」,CPU 密集仍要用多Dispatcher工作者;能用async+awaitAll表达的并行就不必上select。 - Channel 谁关要想清,生产者-消费者互等会死锁。
selectUnbiased避偏袒,否则第一个分支总是被优先。
什么时候用:需要多消费者竞争同一数据流或精确背压时直接暴露 Channel;需要「超时或任意一路先到」时用 select;否则优先 Flow。
附:协程速查表
| 想做的事 | 用什么 | 不要用什么 |
|---|---|---|
| 一组任务一起生一起死 | coroutineScope |
GlobalScope、作用域外 launch |
| 并列任务互不拖累 | supervisorScope + SupervisorJob |
普通作用域(一崩全取消) |
| 绑定组件生命周期 | viewModelScope / lifecycleScope |
手动 Job() 成孤儿根 |
| 主线程恢复 UI | Main.immediate |
把 Dispatcher 当锁 |
| 阻塞 IO | Dispatchers.IO |
Dispatchers.Default |
| 开火不管 | launch |
需要结果却用 launch |
| 拿结果 | async + await |
滥用 async 当 launch |
| 延迟初始化 | LAZY 启动模式 |
无所谓地 DEFAULT |
| 统一兜底异常 | 根协程 CoroutineExceptionHandler |
在深处 catch 吞掉 |
| 共享可变状态 | Mutex / Atomic / actor |
线程锁混用、多 Default 直接 ++ |
| 多路等一个事件 | select + onTimeout |
select 里嵌套长挂起 |
| 多消费者竞争数据流 | Channel |
能用 Flow 还硬暴露 Channel |
Kotlin 协程精讲:从结构化并发到 Channel 多路复用
https://lautung.com/archives/kotlin-coroutines
评论