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中的大多数异步代码。

kotlincoroutinesconcurrencyandroidspring-boot