Kotlin 协程经常被概括为"轻量级线程",这句话方便入门,却不够准确。协程不是一种新线程,也不会让阻塞代码自动变成非阻塞代码。它更像一套由编译器、标准库和协程库共同提供的可暂停任务模型:任务等待时保存执行现场、让出承载它的线程,条件满足后再在合适的线程上继续执行。
真正掌握协程,不能只会写 launch 和 withContext,还要理解四件事:
suspend到底改变了什么;- 协程由谁启动、由谁取消、失败会影响谁;
Dispatcher、CoroutineScope与Job各负责什么;- 在 Android 中怎样把一次请求、持续状态和一次性事件放到正确的生命周期里。
本文从心智模型讲到实现原理,再用完整代码串起日常开发。示例不写死依赖版本,实际项目请使用当前稳定且彼此兼容的 Kotlin、kotlinx-coroutines、AndroidX Lifecycle 和测试库版本。
1. 先用一句话理解协程
协程是可以暂停并在稍后恢复的计算过程;暂停的是任务,不一定是线程。
假设需要依次请求用户和订单:
kotlin
suspend fun loadDashboard(userId: String): Dashboard {
val user = userApi.getUser(userId)
val orders = orderApi.getOrders(user.id)
return Dashboard(user, orders)
}
代码看起来和同步调用一样,但如果 getUser、getOrders 是正确实现的挂起函数,等待网络响应期间不会一直占用当前线程。编译器会把"调用前"和"调用后"拆成可恢复的阶段。
这也是协程最重要的价值:用接近顺序代码的写法表达异步流程,同时保留取消、异常传播和生命周期管理能力。
2. 协程、线程与进程有什么区别
| 概念 | 由谁管理 | 调度成本 | 是否可并行 | 主要职责 |
|---|---|---|---|---|
| 进程 | 操作系统 | 高 | 是 | 资源与地址空间隔离 |
| 线程 | 操作系统 | 中 | 是 | 在 CPU 上执行指令 |
| 协程 | 语言运行时与协程库 | 低 | 取决于所在线程 | 组织可暂停的并发任务 |
多个协程可以复用少量线程。例如一千个协程同时等待网络,并不意味着一定创建一千个线程。等待中的协程通常只保留少量状态,不占着线程空转。
但要避免三个误解:
- 协程不是线程。 协程最终仍要在线程上运行。
- 协程不等于并行。 单线程上多个协程可以并发交替执行,但不能同时占用多个 CPU 核。
suspend不等于后台线程。 挂起函数默认继承调用者上下文,不会因为加了suspend就自动切到 IO 线程。
并发与并行
- 并发(Concurrency):多个任务在时间上重叠推进,强调任务组织。
- 并行(Parallelism):多个任务在同一时刻由不同 CPU 核执行,强调实际执行。
kotlin
coroutineScope {
val profile = async { userApi.getProfile() }
val messages = async { messageApi.getMessages() }
HomeData(profile.await(), messages.await())
}
两个请求可以并发等待。它们是否在不同线程上运行,由挂起函数的实现和协程上下文决定。
3. 最小环境与第一个协程
普通 Kotlin/JVM 项目需要 kotlinx-coroutines-core;Android 项目通常再引入 kotlinx-coroutines-android,以获得 Dispatchers.Main。使用 ViewModel、Lifecycle 或协程测试时,再加入相应的 AndroidX 与 kotlinx-coroutines-test 依赖。
kotlin
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:<version>")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:<version>")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:<version>")
}
最小示例:
kotlin
fun main() = runBlocking {
launch {
delay(300)
println("child finished")
}
println("parent continues")
}
输出通常是:
text
parent continues
child finished
这里有四个角色:
runBlocking创建一个作用域,并阻塞当前线程直到内部任务结束;launch启动子协程,立即返回Job;delay挂起当前协程,但不阻塞承载线程;- 外层作用域会等待子协程完成,这就是结构化并发的基本表现。
runBlocking主要用于main函数、命令行入口和部分测试桥接。不要在 Android 主线程或服务器请求线程中用它等待异步结果。
4. suspend 的本质:可暂停,不是自动异步
4.1 suspend 只是能力标记
kotlin
suspend fun calculate(): Int = 1 + 1
这个函数没有并发、没有切线程,也没有真正发生暂停。suspend 表示它允许调用其他挂起函数,并且调用方必须处于协程或另一个挂起函数中。
下面的函数虽然标了 suspend,仍然会阻塞线程:
kotlin
suspend fun wrongDownload(): ByteArray {
return blockingHttpClient.execute() // 仍然阻塞当前线程
}
包装遗留阻塞 API 时,应明确切换执行环境:
kotlin
suspend fun download(): ByteArray = withContext(Dispatchers.IO) {
blockingHttpClient.execute()
}
如果底层库本身提供基于回调或 NIO 的非阻塞挂起 API,通常不需要额外包一层 Dispatchers.IO。
4.2 编译器会生成状态机
看一段简单代码:
kotlin
suspend fun fetchText(): String {
val token = requestToken()
val data = requestData(token)
return data.trim()
}
概念上,编译器会把它改造成一个带 Continuation 参数、能记录执行位置的状态机:
kotlin
// 伪代码,只用于理解
fun fetchText(continuation: Continuation<String>): Any? {
when (continuation.label) {
0 -> {
continuation.label = 1
val result = requestToken(continuation)
if (result === COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
continuation.token = result
}
1 -> {
continuation.token = continuation.result
}
}
continuation.label = 2
val data = requestData(continuation.token, continuation)
if (data === COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
return data.trim()
}
真实生成代码更复杂,但核心只有三步:
- 用
label记住执行到哪个挂起点; - 用字段保存挂起后仍然需要的局部变量;
- 未完成时返回特殊标记,完成后通过
Continuation.resumeWith把结果送回来。
因此"暂停协程"不是冻结整条线程栈,而是保存恢复所需的少量状态。
4.3 Continuation 是恢复入口
简化后的接口如下:
kotlin
interface Continuation<in T> {
val context: CoroutineContext
fun resumeWith(result: Result<T>)
}
它包含:
context:协程运行所需的上下文;resumeWith:成功或失败时恢复后续计算。
回调 API 可以通过 suspendCancellableCoroutine 转成可取消的挂起函数:
kotlin
suspend fun LocationClient.awaitLocation(): Location =
suspendCancellableCoroutine { continuation ->
val callback = object : LocationCallback {
override fun onSuccess(location: Location) {
if (continuation.isActive) {
continuation.resume(location)
}
}
override fun onError(error: Throwable) {
continuation.resumeWithException(error)
}
}
requestLocation(callback)
continuation.invokeOnCancellation {
cancelRequest(callback)
}
}
桥接时最重要的是处理取消:协程被取消后,要同步注销回调或取消底层请求,避免资源继续工作和重复恢复。
5. 从启动到恢复:协程是怎样运行的
可以把一次协程执行简化为下面的流程:
Async API Worker Thread Dispatcher Coroutine Builder 调用方 Async API Worker Thread Dispatcher Coroutine Builder 调用方 #mermaid-svg-GqjUjnobRhmkFr0c{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GqjUjnobRhmkFr0c .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GqjUjnobRhmkFr0c .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GqjUjnobRhmkFr0c .error-icon{fill:#552222;}#mermaid-svg-GqjUjnobRhmkFr0c .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GqjUjnobRhmkFr0c .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GqjUjnobRhmkFr0c .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GqjUjnobRhmkFr0c .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GqjUjnobRhmkFr0c .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GqjUjnobRhmkFr0c .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GqjUjnobRhmkFr0c .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GqjUjnobRhmkFr0c .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GqjUjnobRhmkFr0c .marker.cross{stroke:#333333;}#mermaid-svg-GqjUjnobRhmkFr0c svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GqjUjnobRhmkFr0c p{margin:0;}#mermaid-svg-GqjUjnobRhmkFr0c .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GqjUjnobRhmkFr0c text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-GqjUjnobRhmkFr0c .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GqjUjnobRhmkFr0c .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-GqjUjnobRhmkFr0c .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-GqjUjnobRhmkFr0c .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-GqjUjnobRhmkFr0c #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-GqjUjnobRhmkFr0c .sequenceNumber{fill:white;}#mermaid-svg-GqjUjnobRhmkFr0c #sequencenumber{fill:#333;}#mermaid-svg-GqjUjnobRhmkFr0c #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-GqjUjnobRhmkFr0c .messageText{fill:#333;stroke:none;}#mermaid-svg-GqjUjnobRhmkFr0c .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GqjUjnobRhmkFr0c .labelText,#mermaid-svg-GqjUjnobRhmkFr0c .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-GqjUjnobRhmkFr0c .loopText,#mermaid-svg-GqjUjnobRhmkFr0c .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-GqjUjnobRhmkFr0c .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GqjUjnobRhmkFr0c .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-GqjUjnobRhmkFr0c .noteText,#mermaid-svg-GqjUjnobRhmkFr0c .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-GqjUjnobRhmkFr0c .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GqjUjnobRhmkFr0c .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GqjUjnobRhmkFr0c .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GqjUjnobRhmkFr0c .actorPopupMenu{position:absolute;}#mermaid-svg-GqjUjnobRhmkFr0c .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-GqjUjnobRhmkFr0c .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GqjUjnobRhmkFr0c .actor-man circle,#mermaid-svg-GqjUjnobRhmkFr0c line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-GqjUjnobRhmkFr0c :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 线程可以执行其他任务 launch / async提交 Continuation在线程上开始执行调用挂起 API返回 COROUTINE_SUSPENDED数据就绪,resume调度后续 Continuation完成或抛出异常
注意"恢复"不保证发生在原来的物理线程上。协程保持的是上下文语义,调度器可以让后续代码在另一个合适的线程继续。线程局部变量因此需要谨慎使用;如必须传播,应使用协程提供的上下文机制。
6. CoroutineContext:协程随身携带的配置集合
协程上下文不是一个简单配置对象,而是一组以 Key 索引的元素。最常见的元素有:
| 元素 | 作用 |
|---|---|
Job |
生命周期、父子关系、取消和完成状态 |
CoroutineDispatcher |
决定 Continuation 在哪里执行 |
CoroutineName |
给日志和调试器提供可读名称 |
CoroutineExceptionHandler |
处理未被消费的根协程异常 |
上下文可以通过 + 组合:
kotlin
val scope = CoroutineScope(
SupervisorJob() +
Dispatchers.Default +
CoroutineName("sync-scope")
)
相同 Key 的右侧元素会替换左侧元素。子协程通常继承父上下文,但会拥有自己的 Job,从而形成任务树。
Scope、Context、Job 的关系
text
CoroutineScope
└── coroutineContext
├── Job 负责生命周期与父子关系
├── CoroutineDispatcher 负责在哪执行
├── CoroutineName 负责调试标识
└── 其他上下文元素
CoroutineScope 本身不是线程池,也不是一个正在运行的协程。它只是为新协程提供上下文和生命周期边界。
7. 三个最常用的协程构建器
7.1 launch:启动不直接返回值的任务
kotlin
val job: Job = scope.launch {
repository.refresh()
}
job.cancel()
适合:更新缓存、收集 Flow、响应 UI 事件等不需要直接返回结果的任务。异常如果没有在内部处理,会按照父子规则向上传播。
7.2 async:并发计算并返回结果
kotlin
val deferred: Deferred<User> = scope.async {
repository.loadUser()
}
val user = deferred.await()
async 的异常会在 await() 时重新抛出,但在普通父子作用域中,子任务失败也会先取消父作用域。不要把 async 当成"异常保险箱"。
只有任务互不依赖且确实值得并发时才使用它:
kotlin
suspend fun loadHome(): HomeData = coroutineScope {
val banners = async { repository.loadBanners() }
val products = async { repository.loadProducts() }
HomeData(banners.await(), products.await())
}
下面这种写法仍然是串行的:
kotlin
val a = async { loadA() }.await()
val b = async { loadB() }.await()
7.3 withContext:切换上下文并返回结果
kotlin
suspend fun parseFile(file: File): Model = withContext(Dispatchers.IO) {
val text = file.readText() // 阻塞 IO
withContext(Dispatchers.Default) {
parser.parse(text) // CPU 密集计算
}
}
withContext 会等待代码块完成,更适合"去某个上下文做完这件事再回来",而不是创建一个需要手动管理的新任务。
8. 结构化并发:协程最重要的设计原则
结构化并发要求并发任务存在明确的父子关系:
- 父任务知道有哪些子任务;
- 父任务完成前会等待所有子任务;
- 父任务取消时,子任务一起取消;
- 普通作用域中,一个子任务失败会取消兄弟任务并向上传播。
#mermaid-svg-4jcOFT3rxUSINpiA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4jcOFT3rxUSINpiA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4jcOFT3rxUSINpiA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4jcOFT3rxUSINpiA .error-icon{fill:#552222;}#mermaid-svg-4jcOFT3rxUSINpiA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4jcOFT3rxUSINpiA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4jcOFT3rxUSINpiA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4jcOFT3rxUSINpiA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4jcOFT3rxUSINpiA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4jcOFT3rxUSINpiA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4jcOFT3rxUSINpiA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4jcOFT3rxUSINpiA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4jcOFT3rxUSINpiA .marker.cross{stroke:#333333;}#mermaid-svg-4jcOFT3rxUSINpiA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4jcOFT3rxUSINpiA p{margin:0;}#mermaid-svg-4jcOFT3rxUSINpiA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-4jcOFT3rxUSINpiA .cluster-label text{fill:#333;}#mermaid-svg-4jcOFT3rxUSINpiA .cluster-label span{color:#333;}#mermaid-svg-4jcOFT3rxUSINpiA .cluster-label span p{background-color:transparent;}#mermaid-svg-4jcOFT3rxUSINpiA .label text,#mermaid-svg-4jcOFT3rxUSINpiA span{fill:#333;color:#333;}#mermaid-svg-4jcOFT3rxUSINpiA .node rect,#mermaid-svg-4jcOFT3rxUSINpiA .node circle,#mermaid-svg-4jcOFT3rxUSINpiA .node ellipse,#mermaid-svg-4jcOFT3rxUSINpiA .node polygon,#mermaid-svg-4jcOFT3rxUSINpiA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4jcOFT3rxUSINpiA .rough-node .label text,#mermaid-svg-4jcOFT3rxUSINpiA .node .label text,#mermaid-svg-4jcOFT3rxUSINpiA .image-shape .label,#mermaid-svg-4jcOFT3rxUSINpiA .icon-shape .label{text-anchor:middle;}#mermaid-svg-4jcOFT3rxUSINpiA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4jcOFT3rxUSINpiA .rough-node .label,#mermaid-svg-4jcOFT3rxUSINpiA .node .label,#mermaid-svg-4jcOFT3rxUSINpiA .image-shape .label,#mermaid-svg-4jcOFT3rxUSINpiA .icon-shape .label{text-align:center;}#mermaid-svg-4jcOFT3rxUSINpiA .node.clickable{cursor:pointer;}#mermaid-svg-4jcOFT3rxUSINpiA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4jcOFT3rxUSINpiA .arrowheadPath{fill:#333333;}#mermaid-svg-4jcOFT3rxUSINpiA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4jcOFT3rxUSINpiA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4jcOFT3rxUSINpiA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4jcOFT3rxUSINpiA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4jcOFT3rxUSINpiA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4jcOFT3rxUSINpiA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4jcOFT3rxUSINpiA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4jcOFT3rxUSINpiA .cluster text{fill:#333;}#mermaid-svg-4jcOFT3rxUSINpiA .cluster span{color:#333;}#mermaid-svg-4jcOFT3rxUSINpiA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4jcOFT3rxUSINpiA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4jcOFT3rxUSINpiA rect.text{fill:none;stroke-width:0;}#mermaid-svg-4jcOFT3rxUSINpiA .icon-shape,#mermaid-svg-4jcOFT3rxUSINpiA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4jcOFT3rxUSINpiA .icon-shape p,#mermaid-svg-4jcOFT3rxUSINpiA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4jcOFT3rxUSINpiA .icon-shape .label rect,#mermaid-svg-4jcOFT3rxUSINpiA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4jcOFT3rxUSINpiA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4jcOFT3rxUSINpiA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4jcOFT3rxUSINpiA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Screen Job
Load Profile
Load Orders
Collect Notifications
Network Call
Database Query
当 Screen Job 被取消,整棵子树都有确定的结束方式。这解决了传统回调和随意启动线程常见的"页面消失了,任务还在回调"问题。
coroutineScope
所有子任务都成功,作用域才返回;任一子任务失败,其他子任务会被取消。
kotlin
suspend fun loadRequiredData(): PageData = coroutineScope {
val user = async { loadUser() }
val permission = async { loadPermission() }
PageData(user.await(), permission.await())
}
适合"要么全部成功,要么整个操作失败"的任务。
supervisorScope
一个子任务失败时,不会自动取消其他子任务:
kotlin
suspend fun loadIndependentCards(): List<CardResult> = supervisorScope {
listOf(::loadWeather, ::loadNews, ::loadStocks)
.map { loader ->
async {
runCatching { loader() }
.fold(CardResult::Success, CardResult::Failure)
}
}
.awaitAll()
}
适合首页多个独立卡片:天气失败,不应阻止新闻展示。监督只改变兄弟任务之间的失败影响,不会替你展示或记录异常。
为什么尽量不用 GlobalScope
GlobalScope 的任务没有业务所有者,通常会活到进程结束:
kotlin
// 不推荐:调用方无法可靠等待、取消或测试
GlobalScope.launch {
repository.sync()
}
应用级任务应使用显式注入的 applicationScope;页面级任务使用 viewModelScope 或生命周期作用域。关键不是禁止"长任务",而是让长任务有清楚的所有者。
9. Job、取消与超时
9.1 取消是协作式的
调用 job.cancel() 不会粗暴终止线程,而是把 Job 标记为取消,并让协程在可检查取消的位置退出。大多数 kotlinx.coroutines 挂起函数都会检查取消。
kotlin
val job = scope.launch {
while (isActive) {
delay(1_000)
pollOnce()
}
}
job.cancelAndJoin()
CPU 密集循环若没有挂起点,需要主动检查:
kotlin
suspend fun calculate(items: List<Item>): Result {
var result = Result.empty()
for ((index, item) in items.withIndex()) {
currentCoroutineContext().ensureActive()
result = result.consume(item)
if (index % 100 == 0) yield()
}
return result
}
ensureActive():已取消时抛出CancellationException;yield():检查取消,并给同一调度器上的其他协程执行机会;isActive:适合控制循环。
9.2 不要吞掉取消异常
CancellationException 用异常机制快速退出多层调用,但它代表正常取消信号。
kotlin
try {
repository.load()
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
showNetworkError(e)
}
尤其要小心 catch (e: Exception) 和旧版 runCatching 风格的宽泛捕获。如果取消异常被吞掉,页面销毁后任务可能继续执行。
9.3 finally 与不可取消清理
协程取消后仍会执行 finally:
kotlin
try {
resource.use()
} finally {
resource.close()
}
如果清理本身必须调用挂起函数,可只把很小的必要部分放入 NonCancellable:
kotlin
finally {
withContext(NonCancellable) {
transaction.finish()
}
}
不要把大段业务逻辑放进 NonCancellable,否则会破坏生命周期控制。
9.4 超时
kotlin
val result = withTimeout(5_000) {
repository.load()
}
超时会抛出 TimeoutCancellationException。不想抛异常时可用:
kotlin
val result: Data? = withTimeoutOrNull(5_000) {
repository.load()
}
超时仍是协作式取消:底层阻塞 API 若不响应取消,线程可能继续被占用。
10. 异常传播:launch、async 与监督作用域
10.1 普通父子作用域
在普通 Job 或 coroutineScope 中,非取消异常通常遵循:
- 失败的子任务取消;
- 父任务被取消;
- 其他兄弟任务被取消;
- 异常继续向上交给根协程处理。
kotlin
scope.launch {
coroutineScope {
launch { loadA() }
launch { error("B failed") }
launch { loadC() } // 通常会被取消
}
}
10.2 CoroutineExceptionHandler 不是通用 try/catch
它主要处理根 launch 协程未捕获的异常:
kotlin
val handler = CoroutineExceptionHandler { context, throwable ->
logger.error("Failed in ${context[CoroutineName]}", throwable)
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + handler)
它不能恢复已经失败的协程,也不会代替业务层的错误建模。async 的结果通常应由调用者通过 await() 和 try/catch 处理。
10.3 在正确边界转换错误
网络层可以抛出技术异常,Repository 在业务边界转换,ViewModel 再映射为 UI 状态:
kotlin
suspend fun loadArticle(id: String): ArticleResult {
return try {
ArticleResult.Success(api.getArticle(id).toDomain())
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
ArticleResult.Offline
} catch (e: HttpException) {
ArticleResult.ServerError(e.code())
}
}
不要在最底层把所有异常都变成 null,否则上层无法区分"确实没有数据"和"请求失败"。
11. Dispatcher 应该怎么选
| Dispatcher | 适合任务 | 注意事项 |
|---|---|---|
Dispatchers.Main |
UI 更新、轻量状态处理 | 不执行阻塞 IO 或重计算 |
Dispatchers.Main.immediate |
已在主线程时立即执行 | 常由框架/库代码使用,业务代码不必滥用 |
Dispatchers.IO |
文件、阻塞网络、数据库驱动等阻塞 IO | 不是让所有代码"变快"的万能池 |
Dispatchers.Default |
JSON 大解析、图片计算、排序、加密等 CPU 任务 | 线程数大致面向 CPU 核心数 |
limitedParallelism(n) |
限制某类任务同时执行数 | 适合保护外部资源或串行化特定操作 |
| 自定义 Executor Dispatcher | 接入特殊线程模型 | 必须明确关闭,避免泄漏线程 |
Main-safe:把线程选择封装在执行工作的层
一个挂起函数如果能安全地从主线程调用,可以称为 main-safe:
kotlin
class UserRepository(
private val api: BlockingUserApi,
private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun loadUser(id: String): User = withContext(ioDispatcher) {
api.loadUser(id)
}
}
ViewModel 不需要知道 Repository 内部使用文件、网络还是数据库:
kotlin
viewModelScope.launch {
val user = repository.loadUser(userId)
_uiState.update { it.copy(user = user) }
}
注入 Dispatcher 还能让测试不依赖真实线程池。
限制并发量
kotlin
private val imageDispatcher = Dispatchers.IO.limitedParallelism(4)
suspend fun generateThumbnails(files: List<File>) = coroutineScope {
files.map { file ->
async(imageDispatcher) { thumbnailer.create(file) }
}.awaitAll()
}
协程很轻量,但数据库连接、Socket、文件句柄和服务端吞吐量不是无限的。批量任务仍然需要背压或并发上限。
12. Android 中 Scope 应该放在哪里
| 生命周期 | 推荐 Scope | 典型任务 |
|---|---|---|
| 单次挂起函数调用 | coroutineScope |
并行组合多个依赖操作 |
| ViewModel | viewModelScope |
加载页面数据、处理 UI 事件 |
| LifecycleOwner | lifecycleScope |
与 Activity/Fragment 生命周期直接相关的工作 |
| 可见状态收集 | repeatOnLifecycle |
页面可见时收集 Flow |
| Compose 节点 | LaunchedEffect |
与某个 Composable 实例绑定的副作用 |
| 应用级 | 注入的 applicationScope |
必须跨页面完成的同步、日志或缓存任务 |
| 保证最终完成的持久后台任务 | WorkManager | 可延迟、受约束、进程重启后继续的任务 |
Fragment 中安全收集 Flow
kotlin
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
render(state)
}
}
}
repeatOnLifecycle 会在进入目标状态时启动收集,离开时取消内部子协程,再次进入时重新启动。Fragment 应优先使用 viewLifecycleOwner,避免 View 已销毁后仍操作旧视图。
Compose 中收集状态
kotlin
@Composable
fun ArticleRoute(viewModel: ArticleViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ArticleScreen(
state = uiState,
onRetry = viewModel::retry,
)
}
collectAsStateWithLifecycle 适合把 Flow 转成 Compose State。只执行一次或随 key 重启的组合副作用,使用 LaunchedEffect:
kotlin
LaunchedEffect(articleId) {
viewModel.open(articleId)
}
13. Flow:协程世界里的异步数据流
挂起函数通常返回一个结果;Flow<T> 可以随时间发出多个值。
kotlin
fun observeArticles(): Flow<List<Article>> =
articleDao.observeAll()
.map { entities -> entities.map(ArticleEntity::toDomain) }
13.1 冷流
普通 flow {} 默认是冷流:每个收集者开始收集时,代码块都会重新执行。
kotlin
val numbers = flow {
println("start producer")
emit(1)
emit(2)
}
两个收集者通常会打印两次 start producer。如果上游是昂贵的网络轮询,要考虑共享。
13.2 StateFlow
StateFlow 是热流,始终有当前值,适合表达可持续读取的 UI 状态:
kotlin
private val _uiState = MutableStateFlow(ArticleUiState())
val uiState: StateFlow<ArticleUiState> = _uiState.asStateFlow()
fun select(id: String) {
_uiState.update { old -> old.copy(selectedId = id) }
}
用 stateIn 把冷流转为 ViewModel 共享状态:
kotlin
val uiState: StateFlow<ArticleUiState> = repository.observeArticles()
.map { articles -> ArticleUiState(articles = articles) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ArticleUiState(isLoading = true),
)
13.3 SharedFlow
SharedFlow 也是热流,但没有"必须有当前值"的语义,适合广播。是否用它承载导航、Snackbar 等一次性效果,要根据消费语义设计,并明确旋转屏幕、无订阅者和重复订阅时的行为。
kotlin
private val _effects = MutableSharedFlow<UiEffect>(extraBufferCapacity = 1)
val effects = _effects.asSharedFlow()
fun save() {
viewModelScope.launch {
repository.save()
_effects.emit(UiEffect.ShowMessage("保存成功"))
}
}
一次性效果没有对所有场景都完美的通用容器。如果事件不能丢失,应将"是否已处理"建模为持久状态,或在业务层提供可确认消费的队列,而不是仅依赖内存事件流。
13.4 Flow 的线程与异常
flowOn 改变的是它上游的执行上下文:
kotlin
fun parseRecords(): Flow<Record> = flow {
file.lineSequence().forEach { line -> emit(parser.parse(line)) }
}
.flowOn(Dispatchers.IO)
catch 只能捕获它上游的异常:
kotlin
repository.observeArticles()
.map(::toUiModel)
.catch { throwable -> emit(emptyList()) }
.collect(::render)
同样不要把取消当成普通失败吞掉。使用 retry/retryWhen 时还要设置条件和上限,避免离线状态下无限快速重试。
13.5 collectLatest、flatMapLatest 与搜索
搜索输入变化时,旧请求结果通常已经过期:
kotlin
val searchResults = query
.debounce(300)
.map(String::trim)
.distinctUntilChanged()
.flatMapLatest { keyword ->
if (keyword.isEmpty()) flowOf(emptyList())
else repository.search(keyword)
}
flatMapLatest 会在新关键词到来时取消旧的内部 Flow。前提仍然是底层操作支持协作式取消。
14. 一个完整的 ViewModel 实战
下面把状态、事件、Repository、并发请求和异常处理组合起来。
14.1 定义稳定的 UI 状态
kotlin
data class ProfileUiState(
val isLoading: Boolean = false,
val user: User? = null,
val posts: List<Post> = emptyList(),
val errorMessage: String? = null,
)
14.2 Repository 保证 main-safe
kotlin
class ProfileRepository(
private val api: ProfileApi,
private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun loadUser(userId: String): User = withContext(ioDispatcher) {
api.getUser(userId).toDomain()
}
suspend fun loadPosts(userId: String): List<Post> = withContext(ioDispatcher) {
api.getPosts(userId).map(PostDto::toDomain)
}
}
如果 Retrofit 的接口已经原生声明为 suspend,其网络等待由适配器处理,通常不必仅为了网络调用再套 withContext(IO)。这里假设 ProfileApi 是阻塞式接口,用来展示 main-safe 封装。
14.3 ViewModel 拥有页面任务
kotlin
class ProfileViewModel(
private val repository: ProfileRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
fun load(userId: String) {
loadJob?.cancel()
loadJob = viewModelScope.launch {
_uiState.update {
it.copy(isLoading = true, errorMessage = null)
}
try {
val result = coroutineScope {
val user = async { repository.loadUser(userId) }
val posts = async { repository.loadPosts(userId) }
user.await() to posts.await()
}
_uiState.value = ProfileUiState(
user = result.first,
posts = result.second,
)
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "网络不可用,请稍后重试",
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "加载失败",
)
}
}
}
}
}
这里的关键点:
viewModelScope在 ViewModel 清理时自动取消;- 新的
load会取消旧任务,避免旧结果覆盖新页面; - 两个独立请求用
coroutineScope + async并发; - 任一必要数据失败,整体加载失败;
- 取消异常继续向上抛出;
- UI 只观察一个不可变
StateFlow。
如果用户信息和动态列表允许独立失败,可改用 supervisorScope 并分别把结果建模到状态里。
15. 并发共享状态与线程安全
协程不会自动消除数据竞争。只要多个协程可能在多线程调度器上同时读写可变数据,就仍然需要同步策略。
原子更新 StateFlow
kotlin
private val counter = MutableStateFlow(0)
fun increment() {
counter.update { it + 1 }
}
不要写成 _state.value = _state.value + 1 并假设它天然原子。
Mutex 保护挂起临界区
kotlin
private val mutex = Mutex()
private val cache = mutableMapOf<String, User>()
suspend fun getUser(id: String): User = mutex.withLock {
cache[id] ?: api.loadUser(id).also { cache[id] = it }
}
注意:把慢网络请求放在锁内会阻塞其他调用者进入这段逻辑。实际缓存可采用"记录进行中的 Deferred"、双重检查或专门的数据源设计,避免不必要的长临界区。
单线程语义或 Actor 思路
对状态修改特别复杂时,可以让所有命令通过 Channel 进入单一处理协程,使状态转移串行发生。这样便于推理,但需要明确 Channel 容量、关闭方式和背压策略。
16. Channel 与 Flow 怎么选
| 需求 | 优先选择 |
|---|---|
| 一个异步函数返回一次结果 | suspend fun |
| 观察随时间变化的数据 | Flow |
| 始终可读取当前状态 | StateFlow |
| 多订阅者广播 | SharedFlow |
| 生产者与消费者传递工作项 | Channel |
| 需要明确请求---响应并发结构 | coroutineScope + async |
Channel 更像并发队列:发送与接收存在容量和背压语义。Flow 更像声明式数据管道。不要仅因为"有多个值"就使用 Channel,也不要把所有一次性命令都硬塞进 StateFlow。
17. 如何测试协程代码
使用 runTest 可以在测试调度器中运行协程,并虚拟控制 delay:
kotlin
@Test
fun `load exposes profile when requests succeed`() = runTest {
val repository = FakeProfileRepository(
user = User("1", "Ada"),
posts = listOf(Post("Hello")),
)
val viewModel = ProfileViewModel(repository)
viewModel.load("1")
advanceUntilIdle()
assertEquals("Ada", viewModel.uiState.value.user?.name)
assertEquals(1, viewModel.uiState.value.posts.size)
assertFalse(viewModel.uiState.value.isLoading)
}
注入 Dispatcher
kotlin
class FileRepository(
private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun read(file: File): String = withContext(ioDispatcher) {
file.readText()
}
}
测试中传入 StandardTestDispatcher(testScheduler),可以让代码受同一个测试调度器控制。
测试要验证什么
- 初始、加载、成功、失败状态的顺序;
- 新请求能否取消旧请求;
- 父任务取消后子任务是否停止;
- 一个并行分支失败时,其他分支应取消还是继续;
- 超时、重试和退避是否符合预期;
- Flow 在重新订阅时是否意外重复昂贵工作。
避免在测试里使用真实 delay、依赖真实线程竞争或用 Thread.sleep 猜任务何时完成。
18. 常见错误与正确写法
错误 1:给阻塞函数加 suspend 就认为非阻塞
kotlin
suspend fun load() = blockingCall()
suspend 不改变函数内部的阻塞性质。应该改用非阻塞 API,或把阻塞调用放到合适的 Dispatcher。
错误 2:无所有者地创建 Scope
kotlin
fun refresh() {
CoroutineScope(Dispatchers.IO).launch { repository.refresh() }
}
每次调用都创建孤立 Scope,调用方无法取消。应该让 Scope 由 ViewModel、Lifecycle、应用组件或调用者拥有。
错误 3:所有任务都扔进 Dispatchers.IO
UI 状态更新、CPU 计算和非阻塞网络并不都适合 IO。根据任务性质选择,并尽量由真正执行阻塞工作的底层函数负责切换。
错误 4:连续 async().await() 假装并发
先创建全部 Deferred,再统一 await;如果任务必须串行,直接写顺序挂起调用更清楚。
错误 5:吞掉 CancellationException
宽泛捕获时重新抛出取消异常,或者只捕获能处理的具体异常。
错误 6:用 CoroutineExceptionHandler 处理所有业务错误
Handler 是未捕获根异常的最后边界,不能替代局部 try/catch、领域错误类型和 UI 状态。
错误 7:在 finally 中执行很长的不可取消任务
只把不可中断的最小清理动作放进 NonCancellable。其他工作应交给拥有独立生命周期的组件。
错误 8:把短命页面任务当成可靠后台任务
viewModelScope 无法保证进程被杀后继续。需要系统保证最终调度的持久任务,应使用 WorkManager,并让操作具备幂等性。
错误 9:忽略并发上限
kotlin
items.map { async { upload(it) } }.awaitAll()
当 items 有数万条时会瞬间创建大量工作。应分批、使用 limitedParallelism、Semaphore,或用有界 Channel 建立工作队列。
错误 10:把一次性事件和页面状态混为一谈
状态应该能在任何时刻重新读取并正确渲染;事件需要明确"是否允许丢失、是否只消费一次、重订阅怎么办"。先确定产品语义,再选 StateFlow、SharedFlow、Channel 或持久队列。
19. 性能与调试建议
给重要协程命名
kotlin
scope.launch(CoroutineName("article-sync")) {
repository.sync()
}
可读名称能让协程调试器、线程转储和日志更容易理解。
关注真正的阻塞点
发现卡顿时不要只看函数有没有 suspend,要检查:
- 是否在 Main 上调用阻塞 SDK;
- 大 JSON 解析或图片处理是否占用 Main;
- 是否在锁内执行网络请求;
- 是否创建了无上限并发;
- Flow 是否因多个订阅者重复启动上游;
- 取消后底层请求是否真的停止。
不要过早切换上下文
频繁、细碎的 withContext 会增加调度与阅读成本。以"完整的一段阻塞 IO"或"完整的一段 CPU 计算"为边界切换,比每一行都切一次更合理。
在边界记录完整信息
日志至少包含任务名、业务标识、耗时、取消或失败原因。取消通常是正常控制流,不应全部按严重错误上报。
20. API 选择速查表
| 我想做什么 | 推荐 API |
|---|---|
| 执行一个挂起操作并返回结果 | suspend fun |
| 在已有作用域中启动无返回值任务 | launch |
| 并发计算多个必要结果 | coroutineScope + async |
| 允许兄弟任务独立失败 | supervisorScope 或 SupervisorJob |
| 切到适合阻塞 IO 的执行环境 | withContext(Dispatchers.IO) |
| 限制某类任务并发数 | limitedParallelism / Semaphore |
| 表达持续变化的数据 | Flow |
| 暴露页面当前状态 | StateFlow |
| 生命周期可见时收集 Flow | repeatOnLifecycle / collectAsStateWithLifecycle |
| 与 Composable 节点绑定任务 | LaunchedEffect |
| 超时后抛异常 | withTimeout |
超时后返回 null |
withTimeoutOrNull |
| 桥接可取消回调 API | suspendCancellableCoroutine |
| 需要跨进程重启最终执行的后台任务 | WorkManager |
21. 面试和自查时最值得回答的问题
协程为什么轻量?
因为挂起时通常保存的是状态机和必要局部变量,不需要每个任务长期独占一个操作系统线程及其线程栈;大量等待型任务可以复用较少线程。
suspend 为什么不能直接从普通函数调用?
挂起函数需要一个 Continuation 作为后续计算的恢复入口。协程构建器或另一个挂起函数会提供这条 Continuation 链。
delay 和 Thread.sleep 有什么区别?
delay 挂起协程并让出线程,支持取消;Thread.sleep 阻塞当前线程,在睡眠期间线程无法执行其他协程。
launch 和 async 怎么选?
需要结果用 async/await,不直接返回结果用 launch。如果并不需要并发,优先直接调用挂起函数,结构更简单。
为什么一个子协程失败会取消兄弟任务?
普通结构化并发把同一作用域视为一个整体操作:必要分支失败后,继续其他分支通常没有意义。如果业务允许局部失败,显式使用监督作用域并分别处理结果。
在 Android 中为什么不建议随手 CoroutineScope(...)?
因为任务没有和组件生命周期绑定,容易在页面销毁后继续运行。Scope 应由 ViewModel、Lifecycle、应用级组件或调用者持有并负责取消。
22. 总结:判断协程代码是否可靠的六个问题
阅读或设计一段协程代码时,依次问:
- 谁拥有它? Scope 的生命周期是否清晰?
- 在哪里执行? 有没有在主线程做阻塞 IO 或重计算?
- 如何结束? 父任务取消后,底层工作能否真正停止?
- 如何失败? 异常由谁处理,会不会错误地取消兄弟任务?
- 是否真要并发? 并发是否带来收益,是否设置上限?
- 怎样测试? Dispatcher、时间和外部依赖能否被控制?
Kotlin 协程真正解决的,不只是"少写几层回调",而是让异步任务拥有清楚的结构:作用域决定所有权,Job 连接生命周期,Dispatcher 决定执行位置,Continuation 支撑暂停与恢复,结构化并发规定取消和失败如何传播。
掌握这套模型后,launch、async、Flow 或 Android 生命周期 API 就不再是零散技巧,而是同一套并发设计在不同场景下的表达方式。