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 的基本用法和区别

在协程库中,launch 和 async 是两种常用的协程构建器,用于启动新协程。它们都必须在协程作用域(如 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() 本身也是挂起函数,会等待结果并返回。
  • 用途 :需要并行执行多个任务并获取它们的返回结果(类似 Future 的 get,但非阻塞挂起)。
  • 异常处理 :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 协程中,常用的协程构建器 有四类:runBlocking、launch、async、coroutineScope。它们的差异主要在于作用域类型 、是否阻塞当前线程 以及适用场景。

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

  • 作用 :创建一个协程作用域 ,并在该作用域内执行指定的挂起代码块。它会等待内部所有子协程(包括 launch、async 等)完成后才继续执行,不会阻塞底层线程,而是挂起当前协程。
  • 返回类型 :返回代码块的最后一行结果(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 变换将其转换为状态机,实现挂起点的控制。
  • 挂起点 是实际暂停协程的地方,常见挂起函数如 delay、withContext。
  • 使用 launch 和 async 可以启动新协程,区别在于是否返回值;runBlocking 用于连接同步和异步世界,应谨慎使用;coroutineScope 则提供了安全的挂起式作用域。

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

相关推荐
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台2 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone2 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc2 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo3 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077003 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work3 天前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼3 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work3 天前
ch21 签名、校验与发版
kotlin
AFinalStone3 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui