协程是 Kotlin 语言中处理异步和并发任务的强大工具。它让异步代码写起来像同步代码一样直观,同时避免回调地狱和线程资源浪费。本文面向新手,从零开始讲解协程的核心概念、挂起函数的工作原理以及常用协程构建器的使用与区别。
什么是协程?
协程(Coroutine) 是一种轻量级的并发设计模式,可以在线程之上实现任务的挂起与恢复。你可以把协程理解成"可暂停的计算"------它可以在某个时刻挂起执行,稍后又从挂起的地方恢复继续运行,而不会阻塞底层线程。
在 Kotlin 中,协程由标准库和 kotlinx.coroutines 协程库共同支持。它不是一个简单的线程池封装,而是一种在语言层面提供的、带有挂起语义的异步编程工具。
协程 vs 线程
| 特性 | 协程 | 线程 |
|---|---|---|
| 资源开销 | 极低,一个线程可创建成千上万个协程 | 较高,线程创建和切换需要操作系统参与 |
| 调度方式 | 用户态调度(协程库控制) | 内核态调度(操作系统控制) |
| 阻塞与挂起 | 挂起(suspending)不阻塞线程 | 阻塞操作会占用线程资源 |
| 内存占用 | 每个协程占用少量内存(通常几十字节) | 每个线程通常需要 1 MB 左右的栈空间 |
| 适用场景 | 高并发 I/O、异步任务、UI 事件处理 | CPU 密集型任务、需要真正并行处理 |
简单理解:线程是系统级资源,协程是用户级资源。协程依赖线程来运行,但多个协程可以共享少数线程,通过挂起/恢复切换任务,避免了大量线程的创建和上下文切换开销。
协程的核心特点
- 轻量:可以在单个线程上运行上万个协程而不影响性能。
- 挂起不阻塞:协程挂起时,底层线程可以去执行其他协程,提升资源利用率。
- 结构化并发:协程的生命周期被限定在一个作用域内,父协程会自动等待子协程完成,避免资源泄漏。
- 代码同步风格 :使用挂起函数和
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"
}
执行步骤:
- 协程开始执行
stepOne()。 - 遇到
delay(1000),这是一个挂起点。协程将当前状态(即将从delay后的代码恢复)保存到Continuation对象中,然后挂起,释放底层线程。 - 线程可以去做其他工作(比如执行另一个协程)。
- 1 秒后,定时器触发,协程调度器拿到这个协程的
Continuation,在某个线程上调用它的resume方法。 - 协程从挂起点之后恢复执行,
stepOne()返回"Hello"赋值给a。 - 接着调用
stepTwo(a),遇到delay(500)再次挂起,重复上述挂起/恢复过程。 - 最终
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 等高级主题。祝你协程学习之旅愉快!