一文搞懂 Kotlin 协程基础
随着软件系统变得越来越复杂,处理并发和异步操作成为了现代开发的常态。无论是移动应用需要进行网络请求而不阻塞主线程,还是后端服务需要同时处理大量客户端连接,高效、简洁地管理并发是关键。传统的解决方案,如多线程、回调函数(Callback)或者 Future/Promise,在简化异步编程方面各有其局限性,常常导致代码变得复杂、难以阅读和维护,甚至引发“回调地狱”(Callback Hell)或线程管理噩梦。
Kotlin 协程(Coroutines)正是在这样的背景下应运而生,它为 Kotlin 提供了一种轻量级、可协程化的(composable)方式来编写异步和非阻塞代码。协程的出现,极大地提高了 Kotlin 在处理并发任务时的优雅度和效率。
本文旨在深入浅出地讲解 Kotlin 协程的基础知识,帮助你“一文搞懂”协程的核心概念,从而自信地开始使用它。
第一章:为什么需要协程?传统异步编程的痛点
在深入协程之前,我们先回顾一下传统异步编程的一些常见问题:
-
线程管理复杂性:
- 创建和销毁线程开销较大。
- 线程数量受限,大量线程会导致上下文切换开销,降低性能。
- 线程间的通信(如共享数据)需要加锁,容易引入死锁或竞态条件。
- 手动管理线程生命周期容易出错。
-
回调地狱(Callback Hell):
- 当一个异步操作完成后需要执行另一个异步操作时,通常会使用嵌套的回调函数。
- 多层嵌套的回调函数使得代码可读性极差,逻辑难以跟踪。
- 错误处理变得分散和复杂。
-
阻塞带来的问题:
- 在UI线程上执行耗时操作(如网络请求、文件读写)会导致UI冻结,用户体验差。
- 在服务器端,如果一个请求线程被阻塞(等待I/O),该线程就无法处理其他请求,浪费资源。
-
Future/Promise 的局限性:
- 虽然比纯回调有所改进,链式调用一定程度上缓解了回调地狱,但在处理复杂的流程(如条件分支、循环)时,仍然不够直观,不如同步代码。
Kotlin 协程提供了一种编写异步代码的新方式,它使得异步代码看起来和写同步、阻塞的代码一样简单直观,但实际上是非阻塞的。
第二章:什么是 Kotlin 协程?核心概念初探
协程,可以理解为是一种“轻量级的线程”。但这个比喻并不完全准确,协程与线程的最大区别在于它们的调度方式和资源消耗:
- 线程: 由操作系统内核进行调度,抢占式多任务。创建和切换开销大,数量受系统资源限制。
- 协程: 由用户空间的库(Kotlin 协程库)进行调度,协作式多任务。可以在不阻塞线程的情况下挂起(Suspend)和恢复(Resume),创建和切换开销极小,数量可以非常多。
协程运行在线程之上。一个线程可以运行一个或多个协程。当一个协程被挂起时,它所占用的线程可以去执行其他协程或其他任务,而不会被阻塞。当挂起的协程满足恢复条件时(例如网络请求返回数据),它可以被安排到 同一个 或 另一个 线程上继续执行。
协程的核心思想是“可挂起的计算”(Suspendable Computation)。这意味着你可以编写一段代码,其中包含一个标记为suspend
的函数调用,当执行到这个suspend
函数时,当前的协程可以被挂起,而底层的线程可以去做其他事情。当suspend
函数完成其工作(例如网络请求返回结果)后,协程可以从之前挂起的地方恢复执行。
这种挂起和恢复的能力,让我们可以用看似同步的顺序结构来表达异步逻辑,从而避免了回调的嵌套,极大地提高了代码的可读性和可维护性。
第三章:协程的关键构建块
要理解和使用协程,我们需要掌握几个核心概念和关键词:
3.1 suspend
函数
suspend
是 Kotlin 协程中最核心的关键词之一。
- 定义: 用
suspend
关键字修饰的函数称为挂起函数。 - 作用: 挂起函数可以在执行过程中暂停(挂起)而不阻塞其所在的线程,并在稍后恢复执行。
- 约束: 挂起函数只能在协程或者另一个挂起函数中调用。
示例:
“`kotlin
import kotlinx.coroutines.*
// 这是一个普通的挂起函数
suspend fun performTask() {
println(“Task started on ${Thread.currentThread().name}”)
// delay 是一个库提供的挂起函数,它会挂起当前协程,而不是阻塞线程
delay(1000) // 模拟耗时操作,挂起1秒
println(“Task finished on ${Thread.currentThread().name}”)
}
fun main() = runBlocking { // runBlocking 是一个协程构建器,用于启动一个阻塞当前线程的协程
println(“Main function started on ${Thread.currentThread().name}”)
performTask() // 在 runBlocking 协程中调用挂起函数
println(“Main function finished on ${Thread.currentThread().name}”)
}
“`
输出示例:
Main function started on main
Task started on main
Task finished on main
Main function finished on main
注意观察输出中的线程名,performTask
在挂起前后可能(在这个简单的例子中是同一个)使用同一个线程,但在 delay
期间,主线程并没有被阻塞,runBlocking
内部的机制允许它在等待 delay
结束时做其他事情(尽管在这个例子中没有其他事情可做)。delay
的关键在于它挂起了协程,而不是阻塞了线程。
3.2 CoroutineScope
(协程作用域)
- 定义:
CoroutineScope
定义了协程的生命周期。它允许你启动新的协程(使用launch
或async
)并管理它们的生命周期。 - 作用:
CoroutineScope
实现了结构化并发(Structured Concurrency)。这意味着在一个CoroutineScope
中启动的协程(子协程)的生命周期会与其父协程或作用域的生命周期关联。当作用域被取消时,所有在其内部启动的协程也会被取消。 - 重要性: 避免协程泄漏,确保所有后台任务在其相关组件(如 Activity, ViewModel, Service)销毁时能够被正确取消。
常见的获取 CoroutineScope
的方式:
GlobalScope
:一个全局的协程作用域,它的生命周期只与整个应用的生命周期绑定。不推荐在大多数情况下使用GlobalScope.launch { ... }
,因为它启动的协程无法被结构化地管理和取消,容易导致协程泄漏。- 通过
CoroutineScope(coroutineContext)
手动创建。 - 许多库提供了预定义的作用域,例如 Android 中的
lifecycleScope
(在 Activity/Fragment 中)和viewModelScope
(在 ViewModel 中)。 - 协程构建器 (
launch
,async
,runBlocking
) 本身也会创建一个内部的CoroutineScope
。
示例:
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking { // runBlocking 创建一个 CoroutineScope
println(“runBlocking scope started”)
// launch 在 runBlocking 作用域中启动一个子协程
val job1 = launch {
println("Child Coroutine 1 started")
delay(2000)
println("Child Coroutine 1 finished")
}
// launch 在 runBlocking 作用域中启动另一个子协程
val job2 = launch {
println("Child Coroutine 2 started")
delay(1000)
println("Child Coroutine 2 finished")
}
println("Waiting for children to complete...")
// runBlocking 作用域会等待其所有子协程完成
//job1.join() // 可以显式等待,但 runBlocking 默认就会等
//job2.join()
println("runBlocking scope finished")
}
“`
输出示例:
runBlocking scope started
Child Coroutine 1 started
Child Coroutine 2 started
Child Coroutine 2 finished
Child Coroutine 1 finished
Waiting for children to complete...
runBlocking scope finished
可以看到,runBlocking
协程作为父协程,会等待 job1
和 job2
这两个子协程都执行完毕后才结束。这就是结构化并发的一个体现。
3.3 CoroutineContext
(协程上下文)
- 定义:
CoroutineContext
是一个元素的集合,这些元素共同定义了协程的行为。 - 作用: 它包含了协程的各种元数据,例如:
Job
:控制协程的生命周期,处理取消和父子关系。Dispatcher
:决定协程在哪个线程或线程池上执行。CoroutineName
:协程的名称,用于调试。CoroutineExceptionHandler
:处理协程中未捕获的异常。
- 继承性: 新创建的协程会继承其父协程的上下文,但可以通过传入新的上下文元素来覆盖。
示例:
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
// CoroutineContext 包含了 Job (由 runBlocking 提供) 和 Dispatcher (默认是主线程或 common pool)
println(“My Job in runBlocking: ${coroutineContext[Job]}”)
println(“My Dispatcher in runBlocking: ${coroutineContext[CoroutineDispatcher]}”) // 或 coroutineContext.dispatcher
val job = launch(Dispatchers.Default + CoroutineName("MyCoroutine")) {
// 这个协程的上下文是 runBlocking 的上下文 + Dispatchers.Default + CoroutineName("MyCoroutine")
println("Inside launched coroutine:")
println("My Job: ${coroutineContext[Job]}")
println("My Dispatcher: ${coroutineContext[CoroutineDispatcher]}")
println("My Name: ${coroutineContext[CoroutineName]}")
}
job.join()
}
“`
输出示例:
My Job in runBlocking: StandaloneCoroutine{Active}@...
My Dispatcher in runBlocking: Dispatchers.Main or kotlinx.coroutines.internal.LimitedDispatcher@... (具体的取决于环境)
Inside launched coroutine:
My Job: StandaloneCoroutine{Active}@...
My Dispatcher: Dispatchers.Default
My Name: CoroutineName(MyCoroutine)
这个例子展示了如何通过 +
操作符组合上下文元素,以及如何在协程内部访问其上下文。
3.4 Job
- 定义:
Job
是协程上下文中的一个元素,它代表着一个正在运行的协程。 - 作用:
Job
对象可以用来管理协程的生命周期,主要操作包括:start()
: 启动协程(如果它是延迟启动的)。join()
: 挂起当前协程,直到 Job 完成。cancel()
: 取消 Job 的执行。isActive
: 检查 Job 是否处于活动状态。- 监听 Job 的状态变化。
示例:
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
repeat(1000) { i ->
println(“Coroutine: $i …”)
delay(100) // 挂起,提供取消的机会
}
}
delay(500) // 等待一段时间
println("Main: I'm tired of waiting!")
job.cancel() // 取消协程
job.join() // 等待协程取消完成
println("Main: Now I can quit.")
}
“`
输出示例:
Coroutine: 0 ...
Coroutine: 1 ...
Coroutine: 2 ...
Coroutine: 3 ...
Coroutine: 4 ...
Main: I'm tired of waiting!
Main: Now I can quit.
注意,协程的取消是协作式的。这意味着协程需要主动配合取消操作。像 delay
、yield
等库提供的挂起函数是可取消的挂起函数,它们内部会检查协程的取消状态。如果你在协程中执行计算密集型任务或者阻塞调用,而没有定期检查 isActive
或调用可取消的挂起函数,协程将无法被取消。
3.5 Dispatcher
(调度器)
- 定义:
Dispatcher
决定了协程在哪个线程或线程池上执行。它是CoroutineContext
的一个重要元素。 - 作用: 将协程的任务分派到合适的线程上执行,确保不会阻塞UI线程,或者在进行大量I/O操作时使用合适的线程池。
Kotlin 协程库提供了几种标准的调度器:
Dispatchers.Main
:专为UI框架设计(如 Android 的主线程)。协程在这个调度器上启动时,会在UI线程上执行。需要引入特定平台的依赖(如kotlinx-coroutines-android
)。Dispatchers.IO
:用于执行阻塞的 I/O 操作(如网络请求、文件读写)。它使用一个按需创建、容量较大的线程池。Dispatchers.Default
:用于执行CPU密集型任务。它使用一个共享的后台线程池,线程数默认为CPU核心数。Dispatchers.Unconfined
:非受限调度器。协程在哪个线程启动,就在哪个线程执行,直到第一个挂起点。挂起恢复后,会在负责恢复的线程上继续执行。不推荐在大多数场景下使用,因为它可能导致协程在意外的线程上执行。- 可以通过
newSingleThreadDispatcher("MyThread")
或newFixedThreadPoolContext(4, "MyPool")
创建自定义调度器(使用后需要close()
释放资源)。
示例:
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
// 在 Default 调度器上启动一个协程
launch(Dispatchers.Default) {
println(“Default Coroutine started on ${Thread.currentThread().name}”)
delay(100)
println(“Default Coroutine finished on ${Thread.currentThread().name}”)
}
// 在 IO 调度器上启动一个协程
launch(Dispatchers.IO) {
println("IO Coroutine started on ${Thread.currentThread().name}")
delay(100) // delay 是可取消的挂起函数,不会阻塞 IO 线程池
println("IO Coroutine finished on ${Thread.currentThread().name}")
}
// runBlocking 默认在当前线程(主线程)上运行
println("runBlocking Coroutine started on ${Thread.currentThread().name}")
delay(200)
println("runBlocking Coroutine finished on ${Thread.currentThread().name}")
}
“`
输出示例(线程名可能不同):
runBlocking Coroutine started on main
Default Coroutine started on DefaultDispatcher-worker-1
IO Coroutine started on DefaultDispatcher-worker-2
IO Coroutine finished on DefaultDispatcher-worker-2
Default Coroutine finished on DefaultDispatcher-worker-1
runBlocking Coroutine finished on main
可以看到,不同的协程被分派到了不同的线程池中的线程上执行。runBlocking
运行在主线程,而 launch
到 Default
和 IO
的协程则运行在对应的后台线程。
3.6 协程构建器 (launch
和 async
)
协程构建器用于启动新的协程。最常用的有两个:
-
launch
:- 用途: 启动一个不关心返回值的协程。用于执行“动作”或“副作用”。
- 返回: 返回一个
Job
对象,可用于管理协程的生命周期(取消、等待完成)。 - 异常处理: 如果协程内部发生未捕获的异常,会传播给父协程或通过
CoroutineExceptionHandler
处理。
-
async
:- 用途: 启动一个需要返回值的协程。用于执行“计算”。
- 返回: 返回一个
Deferred<T>
对象,它是Job
的一个子类,并且有一个await()
挂起函数,用于获取协程计算的结果。 - 异常处理: 异常会在调用
await()
时抛出。
示例:launch
用于并行执行动作
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
println(“Starting tasks…”)
// 启动第一个任务 (无需返回值)
val job1 = launch {
delay(1000)
println("Task 1 done")
}
// 启动第二个任务 (无需返回值)
val job2 = launch {
delay(1500)
println("Task 2 done")
}
// 等待所有任务完成
job1.join()
job2.join()
println("All tasks finished.")
}
“`
示例:async
用于并行执行计算并获取结果
“`kotlin
import kotlinx.coroutines.
import kotlin.system.
suspend fun doSomethingUsefulOne(): Int {
delay(1000L) // pretend we are doing something useful here
return 13
}
suspend fun doSomethingUsefulTwo(): Int {
delay(1000L) // pretend we are doing something useful here, too
return 29
}
fun main() = runBlocking {
val time = measureTimeMillis {
// 使用 async 并行执行两个耗时计算
val one = async { doSomethingUsefulOne() }
val two = async { doSomethingUsefulTwo() }
// 使用 await() 获取结果,await() 是挂起函数
// 如果 async 还没完成,await() 会挂起当前协程等待结果
println("The answer is ${one.await() + two.await()}")
}
println("Completed in $time ms") // 总时间应该接近 1000ms,因为是并行执行
}
“`
输出示例:
The answer is 42
Completed in 10xx ms (x取决于具体执行时间)
对比不使用 async
顺序调用 doSomethingUsefulOne()
和 doSomethingUsefulTwo()
的情况,总时间会接近 2000ms。这体现了 async
结合 await
实现并发计算的能力。
3.7 runBlocking
- 用途:
runBlocking
是一个特殊的协程构建器,主要用于连接阻塞世界和协程世界。它启动一个新的协程并阻塞当前线程,直到协程及其所有子协程完成。 - 场景: 主要用于
main
函数或测试中,不应在协程内部或生产环境中用于阻塞UI线程或后端请求处理线程。 - 返回: 返回协程执行的结果(如果是表达式主体)。
示例:
“`kotlin
import kotlinx.coroutines.*
fun main() { // 普通的阻塞式 main 函数
println(“Before runBlocking”)
runBlocking { // 启动一个协程,并阻塞 main 线程
println("Inside runBlocking")
delay(1000) // 在协程中挂起
println("Inside runBlocking after delay")
}
println("After runBlocking")
}
“`
输出示例:
Before runBlocking
Inside runBlocking
Inside runBlocking after delay
After runBlocking
可以看到,main
函数在调用 runBlocking
后被阻塞了大约1秒,直到 runBlocking
内部的协程执行完毕。
第四章:结构化并发(Structured Concurrency)
结构化并发是协程设计的一个核心原则,由 CoroutineScope
和 Job
共同实现。它的核心思想是将协程组织成一个父子层级结构,并确保:
- 生命周期关联: 父协程(或 CoroutineScope)的取消会传播到其所有子协程。
- 完成等待: 父协程会等待其所有子协程完成。
示例:取消的传播
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val scope = CoroutineScope(Job()) // 创建一个独立的作用域
val job = scope.launch { // 在作用域中启动一个父协程
launch { // 父协程中启动一个子协程1
repeat(5) { i ->
try {
println("Child 1: $i")
delay(500) // 挂起点,检查取消
} finally {
// finally 块可以用来清理资源
if (currentCoroutineContext().isActive) {
println("Child 1: Still active?") // 不应该打印
} else {
println("Child 1: Cleaning up...")
}
}
}
}
launch { // 父协程中启动一个子协程2
repeat(5) { i ->
println("Child 2: $i")
delay(300) // 挂起点,检查取消
}
}
}
delay(1000) // 等待一段时间让子协程执行
println("Main: Cancelling scope...")
scope.cancel() // 取消作用域
job.join() // 等待父协程(和其所有子协程)完成取消
println("Main: Scope is cancelled.")
}
“`
输出示例(可能因时序略有差异):
Child 2: 0
Child 1: 0
Child 2: 1
Child 1: 1
Child 2: 2
Main: Cancelling scope...
Child 2: Cleaning up...
Child 1: Cleaning up...
Main: Scope is cancelled.
从输出可以看到,当父作用域(scope
)被取消时,其内部的子协程也接收到了取消信号,并执行了清理逻辑。这就是结构化并发带来的好处:你只需要管理父 Job 或 Scope 的生命周期,子协程的生命周期会自动与之关联。
第五章:一个综合示例:并行获取数据
假设我们需要从两个不同的API获取数据,然后将它们组合起来。使用协程和 async
可以很方便地实现并行请求。
“`kotlin
import kotlinx.coroutines.
import kotlin.system.
// 模拟从 API 1 获取数据,这是一个挂起函数
suspend fun fetchDataFromApi1(): String {
println(“Fetching data from API 1…”)
delay(1200) // 模拟网络延迟
println(“Finished fetching from API 1”)
return “Data from API 1”
}
// 模拟从 API 2 获取数据,这是一个挂起函数
suspend fun fetchDataFromApi2(): String {
println(“Fetching data from API 2…”)
delay(800) // 模拟网络延迟
println(“Finished fetching from API 2”)
return “Data from API 2”
}
fun main() = runBlocking {
val time = measureTimeMillis {
println(“Starting parallel data fetch…”)
// 在 IO 调度器上使用 async 并行启动两个数据获取任务
val data1Deferred = async(Dispatchers.IO) { fetchDataFromApi1() }
val data2Deferred = async(Dispatchers.IO) { fetchDataFromApi2() }
// 等待两个任务都完成,并获取结果
val data1 = data1Deferred.await()
val data2 = data2Deferred.await()
println("Combining results: $data1 and $data2")
}
println("Total time taken: $time ms")
}
“`
输出示例:
Starting parallel data fetch...
Fetching data from API 1...
Fetching data from API 2...
Finished fetching from API 2
Finished fetching from API 1
Combining results: Data from API 1 and Data from API 2
Total time taken: 12xx ms (取决于具体执行时间,接近最长的那个延迟)
这个例子清晰地展示了:
- 如何使用
async
启动需要返回值的并行任务。 - 如何使用
await()
挂起当前协程,等待并行任务的结果。 - 如何使用
Dispatchers.IO
将I/O密集型任务分派到合适的线程池。 - 整个过程用
runBlocking
包裹,使其可以在main
函数中运行并等待协程完成。 - 通过
measureTimeMillis
可以看到,总耗时接近两个任务中最长的一个,证明了任务是并行执行的。
第六章:常见误区与使用建议
- 不要滥用
GlobalScope
:GlobalScope
启动的协程生命周期不受控,容易导致内存泄漏和意外行为。总是优先使用结构化并发,通过CoroutineScope
或已有的作用域(如lifecycleScope
,viewModelScope
,runBlocking
内部)启动协程。 suspend
不等于在后台线程运行:suspend
只是标记函数可以被挂起/恢复,它本身不指定在哪里执行。具体的执行线程由Dispatcher
决定。一个协程可以在多个不同的线程之间跳转。- 理解阻塞与挂起:
- 阻塞(Blocking): 使当前线程停止执行,直到任务完成。会浪费线程资源。例如
Thread.sleep()
。 - 挂起(Suspending): 暂停协程的执行,但不阻塞线程。线程可以去做其他工作。例如
delay()
。
- 阻塞(Blocking): 使当前线程停止执行,直到任务完成。会浪费线程资源。例如
- 协程取消是协作式的: 你的协程代码内部需要通过调用可取消的挂起函数(如
delay
,yield
,withContext
等)或定期检查isActive
来响应取消信号。长时间运行的计算循环需要显式检查isActive
。 runBlocking
的适用场景: 仅用于main
函数、单元测试或需要桥接阻塞代码和协程代码的特定场景。绝不应该在应用的UI层或处理并发请求的后端服务中使用runBlocking
,它会阻塞负责的线程。- 正确选择
Dispatcher
: 根据任务类型选择合适的调度器。I/O 密集型用Dispatchers.IO
,CPU 密集型用Dispatchers.Default
,UI 更新用Dispatchers.Main
。 - 异常处理: 在
launch
中,未捕获的异常会传播;在async
中,异常会在调用await()
时抛出。了解协程的异常传播规则对于编写健壮的代码至关重要(基础篇不深入,但需知晓)。
总结
Kotlin 协程提供了一种强大而优雅的方式来处理异步和并发编程。通过掌握以下核心概念,你就能迈出协程之旅坚实的第一步:
suspend
函数: 可挂起而不阻塞线程的代码块。CoroutineScope
: 定义协程的生命周期和结构化并发。CoroutineContext
: 协程的配置信息,包含Job
和Dispatcher
等。Job
: 协程的句柄,用于管理生命周期和取消。Dispatcher
: 决定协程在哪执行(哪个线程/线程池)。launch
和async
: 启动协程的构建器,分别用于无需结果的动作和需要结果的计算。- 结构化并发: 利用
CoroutineScope
和Job
实现父子协程的生命周期管理和取消传播。 - 挂起 vs 阻塞: 协程的核心在于挂起而非阻塞。
协程的强大远不止于此,还有通道(Channels)、流(Flows)、选择(Select)等高级概念,以及详细的异常处理和测试策略。但理解并熟练运用本文介绍的基础知识,是你深入协程世界的基础。
现在,抛开复杂的回调和难管的线程吧,用 Kotlin 协程写出更清晰、更简洁、更高效的异步代码!
希望这篇长文能帮助你彻底搞懂 Kotlin 协程的基础!祝你编码愉快!