Kotlin 协程新手指南 —— 协程基础与挂起函数

协程是 Kotlin 语言中处理异步和并发任务的强大工具。它让异步代码写起来像同步代码一样直观,同时避免回调地狱和线程资源浪费。本文面向新手,从零开始讲解协程的核心概念、挂起函数的工作原理以及常用协程构建器的使用与区别。


什么是协程?

协程(Coroutine) 是一种轻量级的并发设计模式,可以在线程之上实现任务的挂起与恢复。你可以把协程理解成"可暂停的计算"------它可以在某个时刻挂起执行,稍后又从挂起的地方恢复继续运行,而不会阻塞底层线程。

在 Kotlin 中,协程由标准库和 kotlinx.coroutines 协程库共同支持。它不是一个简单的线程池封装,而是一种在语言层面提供的、带有挂起语义的异步编程工具。

协程 vs 线程

特性 协程 线程
资源开销 极低,一个线程可创建成千上万个协程 较高,线程创建和切换需要操作系统参与
调度方式 用户态调度(协程库控制) 内核态调度(操作系统控制)
阻塞与挂起 挂起(suspending)不阻塞线程 阻塞操作会占用线程资源
内存占用 每个协程占用少量内存(通常几十字节) 每个线程通常需要 1 MB 左右的栈空间
适用场景 高并发 I/O、异步任务、UI 事件处理 CPU 密集型任务、需要真正并行处理

简单理解:线程是系统级资源,协程是用户级资源。协程依赖线程来运行,但多个协程可以共享少数线程,通过挂起/恢复切换任务,避免了大量线程的创建和上下文切换开销。

协程的核心特点

  1. 轻量:可以在单个线程上运行上万个协程而不影响性能。
  2. 挂起不阻塞:协程挂起时,底层线程可以去执行其他协程,提升资源利用率。
  3. 结构化并发:协程的生命周期被限定在一个作用域内,父协程会自动等待子协程完成,避免资源泄漏。
  4. 代码同步风格 :使用挂起函数和 async/await 类似的语法,异步逻辑可以用顺序代码表达。

挂起函数

挂起函数(Suspending Function) 是带有 suspend 关键字的函数。它可以在执行过程中主动"挂起"自己,稍后"恢复",而不会阻塞底层线程。

kotlin 复制代码
suspend fun fetchUserData(): User {
    // 模拟网络请求,挂起等待结果
    delay(1000)
    return User("Alice")
}

上面 delay 是一个挂起函数,它会让当前协程挂起指定时间,但不会阻塞线程。当挂起结束时,协程恢复执行。

suspend 关键字

suspend 只是一个标记,告诉编译器这个函数需要在协程或另一个挂起函数中调用。它并不意味着函数一定会"在后台线程执行"或"异步返回"。它只表示函数内部可能挂起执行。

你可以在一个 suspend 函数中调用其他挂起函数,也可以调用普通函数。反过来,普通函数不能直接调用挂起函数,除非通过协程构建器启动协程。

suspend 不等于"在后台线程执行"

很多初学者会误以为加上 suspend 就自动变成异步并切换到后台线程。这是错误的。看下面例子:

kotlin 复制代码
suspend fun heavyCpuTask(): Int {
    // 这里仍然是当前线程,不会有任何线程切换!
    var sum = 0
    for (i in 1..1_000_000) sum += i
    return sum
}

这个挂起函数内部没有调用任何其他挂起函数,所以它永远不会挂起,会一直占用当前线程直到计算结束。suspend 本身不改变线程 。要切换线程,需要使用 withContext(Dispatchers.IO) 等调度器。

真正使协程变得"不阻塞线程"的关键是挂起点

挂起点

挂起点(Suspension Point) 是挂起函数被调用的地方,协程可能在那里暂停执行。如果一个挂起函数内部没有实际的挂起行为(比如只是普通计算),那么它不会产生真正的挂起。

常见的会产生挂起的挂起函数:

  • delay(timeMillis):延时后恢复。
  • withContext(dispatcher):切换线程上下文,执行完恢复。
  • 网络/数据库库提供的挂起 API(如 Room、Retrofit 支持挂起)。
  • yield():主动让出调度机会。

遇到挂起点时,协程可以暂停执行,让出底层线程,等到满足条件(如延时结束、I/O 完成)后再恢复执行。

挂起函数的底层:CPS 变换

编译器如何处理挂起函数?答案是 CPS(Continuation Passing Style,续体传递风格)变换

简单说,每个挂起函数在编译时会被变换成一个普通的、接受额外参数 Continuation<T> 的函数。Continuation 对象代表"挂起之后剩余的计算"。这个变换由编译器自动完成,开发者不需要关心细节。

kotlin 复制代码
// 原始代码
suspend fun getUser(): User {
    val data = fetchFromNetwork()
    return parse(data)
}

// 近似编译后的代码(概念简化)
fun getUser(continuation: Continuation<User>): Any? {
    // 内部通过状态机实现挂起和恢复
}

编译器会把挂起函数内部拆分成多个状态(state),每个挂起点之前是一个状态。当挂起函数恢复时,根据当前状态跳转到对应位置继续执行。这种状态机的实现方式既高效又避免了额外的线程开销。

挂起与恢复的完整流程

用一个实际例子说明挂起与恢复的过程:

kotlin 复制代码
suspend fun stepOne(): String {
    delay(1000)  // 挂起点
    return "Hello"
}

suspend fun stepTwo(name: String): String {
    delay(500)   // 挂起点
    return "$name World"
}

suspend fun main() {
    val a = stepOne()      // (1) 挂起等待
    val b = stepTwo(a)     // (2) 挂起等待
    println(b)             // 打印 "Hello World"
}

执行步骤

  1. 协程开始执行 stepOne()
  2. 遇到 delay(1000),这是一个挂起点。协程将当前状态(即将从 delay 后的代码恢复)保存到 Continuation 对象中,然后挂起,释放底层线程。
  3. 线程可以去做其他工作(比如执行另一个协程)。
  4. 1 秒后,定时器触发,协程调度器拿到这个协程的 Continuation,在某个线程上调用它的 resume 方法。
  5. 协程从挂起点之后恢复执行,stepOne() 返回 "Hello" 赋值给 a
  6. 接着调用 stepTwo(a),遇到 delay(500) 再次挂起,重复上述挂起/恢复过程。
  7. 最终 println(b) 输出结果,协程完成。

整个过程没有阻塞任何线程。delay 期间线程可以处理其他任务,这就是协程实现高并发的核心机制。

launch 和 async 的基本用法和区别

在协程库中,launchasync 是两种常用的协程构建器,用于启动新协程。它们都必须在协程作用域(如 CoroutineScope)中调用。

launch

  • 返回值Job,表示一个任务,可以通过 job.join() 等待其完成,或者 job.cancel() 取消。
  • 用途 :执行"发射后不管"的任务,不关心返回结果。类似于线程中的 Runnable
  • 异常处理 :默认情况下,launch 中的未捕获异常会立即抛出,并传播到父协程或作用域。
kotlin 复制代码
import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        delay(1000)
        println("Task done")
    }
    println("Waiting...")
    job.join()  // 等待协程完成
    println("Finished")
}

async

  • 返回值Deferred<T>,它是 Job 的子类型,带有一个结果值。可以通过 await() 获取结果,await() 本身也是挂起函数,会等待结果并返回。
  • 用途 :需要并行执行多个任务并获取它们的返回结果(类似 Futureget,但非阻塞挂起)。
  • 异常处理async 内部的异常会在调用 await() 时抛出,如果未调用 await() 且不处理异常,异常可能被静默忽略(取决于作用域)。
kotlin 复制代码
fun main() = runBlocking {
    val deferred = async {
        delay(1000)
        return@async 42
    }
    println("Calculating...")
    val result = deferred.await()  // 挂起等待结果
    println("Result: $result")
}

区别总结

特性 launch async
返回类型 Job Deferred<T>(继承自 Job
是否返回结果 是,通过 await() 获取
异常处理 立即抛出(除非在作用域内特殊处理) await() 时抛出
典型场景 日志记录、UI 更新、发送通知等不需要结果的异步操作 并发请求多个 API、并行计算等需要结果的场景

重要async 抛出的异常不会立即传播 ,而是在 await() 时才抛。如果你 async 启动了协程但没 await,异常会被静默吞掉!这是 async 一个常见的坑。

kotlin 复制代码
// 危险代码
viewModelScope.launch {
    async {
        throwRuntimeException("被吞了")    // ⚠️ 没人 await,异常可能被吞
    }
    delay(1000)
}

协程构建器对比(runBlocking、launch、async、coroutineScope)

在 Kotlin 协程中,常用的协程构建器 有四类:runBlockinglaunchasynccoroutineScope。它们的差异主要在于作用域类型是否阻塞当前线程 以及适用场景

1. runBlocking

  • 作用 :启动一个新的协程,并阻塞当前线程,直到该协程及其所有子协程完成。
  • 返回类型 :直接返回协程代码块的最后一行结果(T)。
  • 适用场景 :仅在桥接非协程世界与协程世界时使用,例如 main 函数中测试协程、或在传统同步代码中临时调用挂起函数。不应在协程体内部使用,因为它会阻塞线程。
  • 特点:它会为协程创建一个顶层的独立作用域,不继承外部挂起上下文。
kotlin 复制代码
fun main() {
    runBlocking {
        delay(1000)
        println("Inside runBlocking")
    }
    println("After runBlocking")  // 等待内部执行完才输出
}

2. launch

  • 作用 :在给定的协程作用域中启动一个新的协程,不阻塞 当前线程。立即返回 Job
  • 返回类型Job
  • 适用场景:用于执行不需要返回结果的后台任务,是结构化并发中的常用构建器。
  • 特点 :继承外部协程的上下文,父协程会等待所有 launch 子协程完成。
kotlin 复制代码
fun main() = runBlocking {
    launch {
        delay(500)
        println("launch task done")
    }
    println("main continues")  // 立即输出,不会等待 launch
}

3. async

  • 作用 :启动一个新的协程,并返回一个 Deferred 对象,可通过 await() 获取结果。不阻塞当前线程。
  • 返回类型Deferred<T>
  • 适用场景:并发执行多个有结果的任务,然后汇总结果。
  • 特点 :类似于 launch,但可以返回值;同样遵循结构化并发,父协程会等待 async 子协程(即调用 await 时等待)。
kotlin 复制代码
fun main() = runBlocking {
    val result1 = async { compute(1) }
    val result2 = async { compute(2) }
    println("Total: ${result1.await() + result2.await()}")
}

4. coroutineScope

  • 作用 :创建一个协程作用域 ,并在该作用域内执行指定的挂起代码块。它会等待内部所有子协程(包括 launchasync 等)完成后才继续执行,不会阻塞底层线程,而是挂起当前协程。
  • 返回类型 :返回代码块的最后一行结果(R)。
  • 适用场景:在已有的挂起函数中创建独立的作用域,确保所有子任务完成后才返回。常用于并行分解任务。
  • 特点 :如果任意子协程抛出异常,coroutineScope 会取消所有其他子协程,并将异常重新抛出。这提供了类似 runBlocking 的等待语义,但是挂起而不是阻塞
kotlin 复制代码
suspend fun performOperations() = coroutineScope {
    val job1 = launch { delay(1000); println("op1") }
    val job2 = launch { delay(500); println("op2") }
    // 等待 job1、job2 都完成,才退出 coroutineScope
    "Done"
}

suspend fun main() {
    val result = performOperations()
    println(result)  // 输出顺序: op2, op1, Done
}

对比表格

构建器 阻塞当前线程? 返回类型 等待子协程完成? 能否返回结果 典型使用场景
runBlocking 是(阻塞) T 可以 桥接非协程代码,测试或 main 函数
launch 否(立即返回) Job 仅当父协程等待时 发射并忘记的后台任务
async 否(立即返回) Deferred<T> 仅当父协程等待时 并发执行任务并收集结果
coroutineScope 否(挂起等待) R 可以 在挂起函数内创建子作用域,等待所有子任务

总结

  • 协程 提供了比线程更轻量、更可控的并发模型,通过挂起与恢复实现非阻塞等待。
  • 挂起函数suspend)是协程的核心组件,编译器通过 CPS 变换将其转换为状态机,实现挂起点的控制。
  • 挂起点 是实际暂停协程的地方,常见挂起函数如 delaywithContext
  • 使用 launchasync 可以启动新协程,区别在于是否返回值;runBlocking 用于连接同步和异步世界,应谨慎使用;coroutineScope 则提供了安全的挂起式作用域。

掌握这些基础概念后,你可以开始编写高效、可读的异步代码,并借助 Kotlin 协程的结构化并发特性避免常见的资源泄漏问题。下一步可以学习 Dispatchers(调度器)、ChannelFlow 等高级主题。祝你协程学习之旅愉快!

相关推荐
2601_961766641 小时前
【分享】分身空间 2.3.7[特殊字符]生活工作互不打扰
android·生活
百度搜知知学社1 小时前
抖音双模块架构:兼容全安卓版本并支持登录
android·架构·安卓·登录·兼容性·抖音
文阿花1 小时前
Echarts实现柱状3D扇形图
android·3d·echarts
故渊at2 小时前
第六板块:Android 安全与权限体系 | 第十九篇:SELinux 强制访问控制与沙箱机制
android·安全·访问控制·selinux·权限体系·沙箱机制
千里马学框架2 小时前
重学Perfetto浏览器在线抓取trace及高频sql分享
android·sql·智能手机·架构·aaos·perfetto·车机
plainGeekDev2 小时前
批量写入 → Room 事务
android·java·kotlin
杉氧2 小时前
Kotlin 协程深度解析①:内核解密——揭秘 suspend 挂起函数的灵魂
android·kotlin
以身入局2 小时前
ViewStub 讲解
android