Kotlin 协程完全指南

Kotlin 协程经常被概括为"轻量级线程",这句话方便入门,却不够准确。协程不是一种新线程,也不会让阻塞代码自动变成非阻塞代码。它更像一套由编译器、标准库和协程库共同提供的可暂停任务模型:任务等待时保存执行现场、让出承载它的线程,条件满足后再在合适的线程上继续执行。

真正掌握协程,不能只会写 launchwithContext,还要理解四件事:

  • suspend 到底改变了什么;
  • 协程由谁启动、由谁取消、失败会影响谁;
  • DispatcherCoroutineScopeJob 各负责什么;
  • 在 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)
}

代码看起来和同步调用一样,但如果 getUsergetOrders 是正确实现的挂起函数,等待网络响应期间不会一直占用当前线程。编译器会把"调用前"和"调用后"拆成可恢复的阶段。

这也是协程最重要的价值:用接近顺序代码的写法表达异步流程,同时保留取消、异常传播和生命周期管理能力。


2. 协程、线程与进程有什么区别

概念 由谁管理 调度成本 是否可并行 主要职责
进程 操作系统 资源与地址空间隔离
线程 操作系统 在 CPU 上执行指令
协程 语言运行时与协程库 取决于所在线程 组织可暂停的并发任务

多个协程可以复用少量线程。例如一千个协程同时等待网络,并不意味着一定创建一千个线程。等待中的协程通常只保留少量状态,不占着线程空转。

但要避免三个误解:

  1. 协程不是线程。 协程最终仍要在线程上运行。
  2. 协程不等于并行。 单线程上多个协程可以并发交替执行,但不能同时占用多个 CPU 核。
  3. 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()
}

真实生成代码更复杂,但核心只有三步:

  1. label 记住执行到哪个挂起点;
  2. 用字段保存挂起后仍然需要的局部变量;
  3. 未完成时返回特殊标记,完成后通过 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. 异常传播:launchasync 与监督作用域

10.1 普通父子作用域

在普通 JobcoroutineScope 中,非取消异常通常遵循:

  1. 失败的子任务取消;
  2. 父任务被取消;
  3. 其他兄弟任务被取消;
  4. 异常继续向上交给根协程处理。
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 collectLatestflatMapLatest 与搜索

搜索输入变化时,旧请求结果通常已经过期:

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
允许兄弟任务独立失败 supervisorScopeSupervisorJob
切到适合阻塞 IO 的执行环境 withContext(Dispatchers.IO)
限制某类任务并发数 limitedParallelism / Semaphore
表达持续变化的数据 Flow
暴露页面当前状态 StateFlow
生命周期可见时收集 Flow repeatOnLifecycle / collectAsStateWithLifecycle
与 Composable 节点绑定任务 LaunchedEffect
超时后抛异常 withTimeout
超时后返回 null withTimeoutOrNull
桥接可取消回调 API suspendCancellableCoroutine
需要跨进程重启最终执行的后台任务 WorkManager

21. 面试和自查时最值得回答的问题

协程为什么轻量?

因为挂起时通常保存的是状态机和必要局部变量,不需要每个任务长期独占一个操作系统线程及其线程栈;大量等待型任务可以复用较少线程。

suspend 为什么不能直接从普通函数调用?

挂起函数需要一个 Continuation 作为后续计算的恢复入口。协程构建器或另一个挂起函数会提供这条 Continuation 链。

delayThread.sleep 有什么区别?

delay 挂起协程并让出线程,支持取消;Thread.sleep 阻塞当前线程,在睡眠期间线程无法执行其他协程。

launchasync 怎么选?

需要结果用 async/await,不直接返回结果用 launch。如果并不需要并发,优先直接调用挂起函数,结构更简单。

为什么一个子协程失败会取消兄弟任务?

普通结构化并发把同一作用域视为一个整体操作:必要分支失败后,继续其他分支通常没有意义。如果业务允许局部失败,显式使用监督作用域并分别处理结果。

在 Android 中为什么不建议随手 CoroutineScope(...)

因为任务没有和组件生命周期绑定,容易在页面销毁后继续运行。Scope 应由 ViewModel、Lifecycle、应用级组件或调用者持有并负责取消。


22. 总结:判断协程代码是否可靠的六个问题

阅读或设计一段协程代码时,依次问:

  1. 谁拥有它? Scope 的生命周期是否清晰?
  2. 在哪里执行? 有没有在主线程做阻塞 IO 或重计算?
  3. 如何结束? 父任务取消后,底层工作能否真正停止?
  4. 如何失败? 异常由谁处理,会不会错误地取消兄弟任务?
  5. 是否真要并发? 并发是否带来收益,是否设置上限?
  6. 怎样测试? Dispatcher、时间和外部依赖能否被控制?

Kotlin 协程真正解决的,不只是"少写几层回调",而是让异步任务拥有清楚的结构:作用域决定所有权,Job 连接生命周期,Dispatcher 决定执行位置,Continuation 支撑暂停与恢复,结构化并发规定取消和失败如何传播。

掌握这套模型后,launchasync、Flow 或 Android 生命周期 API 就不再是零散技巧,而是同一套并发设计在不同场景下的表达方式。

相关推荐
我爱写代码i1 小时前
BeLink - 支持生成多种URL 缩短网址PHP源码
开发语言·php·短网址php源码
Java后端的Ai之路1 小时前
20、Python - 备忘录模式
开发语言·人工智能·python·外观模式·备忘录模式
统计学小王子3 小时前
数学建模国赛倒计时4天——《统计模型精讲(混合效应模型R语言实现)》
开发语言·数学建模·r语言
知无不研3 小时前
c语言中循环的介绍与简单应用
c语言·开发语言·算法·循环·for·while
郝学胜-神的一滴3 小时前
Qt 高级编程 045:坐标体系深度实战
开发语言·c++·windows·python·qt·程序人生
dsyyyyy11013 小时前
JS复习,难点,易错点总结
开发语言·javascript·ecmascript
“AI国潮设计-小江”4 小时前
Python实战 | SDXL精准控制“普宁英歌舞×星空蛋糕”IP落地,附核心Prompt与商用授权思路
开发语言·人工智能·python·prompt·aigc
小刘在重生~5 小时前
Java常用类|String类详解 + BigDecimal精准计算(含面试题)
java·开发语言·python
YYYing.6 小时前
【C++进阶系列 (二)】关于线程堆栈的那些事——函数调用过程
开发语言·c++·函数·线程堆栈