Kotlin中的协程:launch、async和scope
1. 概述
协程是Kotlin处理异步代码的方式,无需callback hell或阻塞线程。本文解释launch、async、suspend fun和CoroutineScope的工作原理,以及何时使用哪个。读完后,你将能够编写简洁的并发代码,了解如何控制协程的生命周期,并使用JUnit 5进行测试。
2. 必要的依赖
协程在Kotlin标准库中不可用,你需要添加kotlinx-coroutines-core。
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core</artifactId>
<version>1.8.1</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-test</artifactId>
<version>1.8.1</version>
<scope>test</scope>
</dependency>
如果你使用Spring Boot,spring-boot-starter-webflux或spring-boot-starter模块已经包含了支持controller中suspend fun的Kotlin扩展。
3. suspend fun:协程的基本单元
suspend是一个关键字,用于标记一个函数可以被暂停(suspend)而不阻塞线程。
suspend fun fetchOrder(orderId: Long): Order {
delay(200) // 模拟I/O调用,不阻塞真实的线程
return Order(orderId, "PENDING")
}
data class Order(val id: Long, val status: String)
delay()是一个内置的suspend函数,不同于Thread.sleep(),因为它不会在等待期间持有线程。suspend fun只能从另一个suspend fun或从coroutine builder中调用(launch、async、runBlocking)。
**注意:**从普通函数(非suspend)直接调用
suspend fun会导致编译错误。这是Kotlin强制你明确声明异步上下文的机制。
4. launch:即发即忘的协程
launch创建一个新的协程,返回Job,不返回计算结果——当你只需要“运行并忘记”时使用。
fun main() = runBlocking {
val job: Job = launch {
println("在线程上开始处理订单:${Thread.currentThread().name}")
val order = fetchOrder(1L)
println("订单状态:${order.status}")
}
println("launch已返回,协程并行运行")
job.join() // 等待协程完成
}
launch已返回,协程并行运行
在线程上开始处理订单:main
订单状态:PENDING
runBlocking创建一个scope来在main中运行协程(仅用于演示/测试,不要在实际生产代码中使用)。launch不阻塞当前线程——它安排协程运行并立即返回。job.join()才是你等待协程完成的地方。
5. async:返回结果的协程
async类似于launch,但返回Deferred<T>——一个“promise”,你通过调用.await()来获取结果。
suspend fun fetchCustomer(customerId: Long): String {
delay(150)
return "Customer-$customerId"
}
suspend fun loadOrderDetail(orderId: Long, customerId: Long): String = coroutineScope {
val orderDeferred = async { fetchOrder(orderId) }
val customerDeferred = async { fetchCustomer(customerId) }
val order = orderDeferred.await()
val customer = customerDeferred.await()
"订单 ${order.id} (${order.status}) 属于 $customer"
}
两个async命令并行运行(concurrent),不是顺序执行。这里的总等待时间约为~200ms(200ms和150ms的最大值)而不是350ms。await()才是获取结果的暂停点——如果你在每个async后立即调用await()而不是分开调用,代码将顺序执行,失去并行优势。
// 错误:顺序执行,失去async的意义
val order = async { fetchOrder(orderId) }.await()
val customer = async { fetchCustomer(customerId) }.await()
**注意:**上面的代码在语法上没有错误,但破坏了
async的目的——每个await()在创建后立即阻塞,所以两个协程永远不会并发运行。
6. CoroutineScope:谁管理协程的生命周期
每个协程都必须属于一个CoroutineScope。Scope决定:协程能活多久、何时取消、异常如何传播。
class OrderService {
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
fun processOrderAsync(orderId: Long) {
serviceScope.launch {
val order = fetchOrder(orderId)
println("已处理订单 ${order.id}")
}
}
fun shutdown() {
serviceScope.cancel() // 取消在此scope中运行的所有子协程
}
}
serviceScope作为class的一个field保存——这是避免“孤儿”协程在对象不再使用后继续运行的方式。SupervisorJob()帮助一个子协程失败不会取消其他子协程(不同于普通的Job(),其中一个子协程失败会取消所有同级协程)。
6.1. coroutineScope vs CoroutineScope
不要混淆coroutineScope { ... }(suspend函数,小写)和CoroutineScope(...)(创建新scope的构造函数,大写)。
suspend fun loadDashboard(): String = coroutineScope {
// coroutineScope等待内部的所有子协程完成
// 然后返回,它使用父协程的context
val a = async { fetchOrder(1L) }
val b = async { fetchCustomer(2L) }
"${a.await()} - ${b.await()}"
}
coroutineScope是一个suspend函数构造器,用于将多个子协程分组在一起,确保结构化并发:函数只在内部所有async/launch完成时才返回(或其中一个失败则全部被取消)。它不创建新的独立scope,而是继承当前context——与第6节的CoroutineScope(...)不同,后者创建一个独立的scope,在函数结束时不会自动被取消。
7. Dispatcher:协程在哪个线程上运行
Dispatcher决定协程在哪里运行——类似于在Java中选择Executor。
suspend fun readConfigFile(path: String): String = withContext(Dispatchers.IO) {
// Dispatchers.IO用于阻塞I/O(读取文件、调用JDBC...)
File(path).readText()
}
suspend fun heavyCompute(data: List<Int>): Long = withContext(Dispatchers.Default) {
// Dispatchers.Default用于CPU密集型工作
data.sumOf { it.toLong() * it }
}
withContext将当前协程切换到不同的dispatcher运行,然后在完成后自动切换回原来的dispatcher——当你需要调用阻塞代码(JDBC、文件I/O)而不想占用Dispatchers.Default或Dispatchers.Main的主线程时非常有用(在Android上)。
| Dispatcher | 用于 |
|---|---|
Dispatchers.Default |
CPU密集型计算(解析、排序、计算) |
Dispatchers.IO |
阻塞I/O(文件、JDBC、网络阻塞) |
Dispatchers.Main |
更新UI(仅在Android/JavaFX上可用) |
Dispatchers.Unconfined |
很少使用,在初始调用线程上运行 |
8. 在协程中处理异常
launch和async中的异常传播方式不同——这是最常见的bug来源。
fun main() = runBlocking {
// launch:异常立即抛出,如果不捕获可能会使scope崩溃
val job = launch {
try {
throw IllegalStateException("订单不存在")
} catch (e: IllegalStateException) {
println("在launch中捕获到错误:${e.message}")
}
}
job.join()
// async:异常被保存在Deferred中,只在调用await()时抛出
val deferred = async {
throw IllegalStateException("客户不存在")
}
try {
deferred.await()
} catch (e: IllegalStateException) {
println("在async中捕获到错误:${e.message}")
}
}
在launch中捕获到错误:订单不存在
在async中捕获到错误:客户不存在
使用async时,如果你忘记调用.await(),异常会无声地被“吞掉”直到Deferred被垃圾回收或你在某个地方调用await()——这很容易在生产环境中导致难以调试的bug。
9. 为协程编写测试
使用kotlinx-coroutines-test中的runTest来测试suspend函数,无需等待实际的delay。
class OrderServiceTest {
@Test
fun `fetchOrder返回PENDING状态`() = runTest {
val order = fetchOrder(1L)
assertThat(order.id).isEqualTo(1L)
assertThat(order.status).isEqualTo("PENDING")
}
@Test
fun `loadOrderDetail并行运行两个协程`() = runTest {
val result = loadOrderDetail(orderId = 1L, customerId = 2L)
assertThat(result).contains("Order 1")
assertThat(result).contains("Customer-2")
}
}
runTest自动使用虚拟时间——fetchOrder内的delay(200)不会真的让测试运行200ms慢,它会立即跳过逻辑恢复点。这就是为什么你不应该在单元测试中使用runBlocking来测试协程:它等待真实的时间,导致测试套件随着测试数量增加而变慢。
10. 常见错误
- 使用
GlobalScope:协程在应用程序的整个生命周期中存活,不会随任何生命周期被取消——会导致泄漏。始终创建与特定class/component相关的scope,如第6节所示。 - 在生产代码中调用
runBlocking:它真的阻塞当前线程,破坏了协程的目的。只在main()、测试或必须连接阻塞代码和suspend代码的地方使用。 - 忘记在
async上调用await():异常被吞掉,协程运行但没有人等待结果——浪费资源且难以追踪错误。 - 混淆将
Dispatchers.IO用于CPU密集型工作:Dispatchers.IO有一个大得多的线程池(为I/O等待优化),用于繁重计算会降低调度效率。
**注意:**如果你的服务不需要真实的并发(只是顺序调用几个I/O),协程可能是过度设计。有时一个简单的顺序调用
suspend fun,不需要async/launch,已经足够清晰且更容易维护。
11. 结论
suspend fun是基本单位,launch用于不需要返回结果的情况,async用于需要并行运行并通过await()获取结果的情况。CoroutineScope决定生命周期——始终将scope与特定component绑定,避免GlobalScope,优先使用coroutineScope/SupervisorJob来控制结构化并发。掌握这三个概念足以用安全且易于测试的方式编写Kotlin中的大多数异步代码。