Kotlin 协程十一 —— 协作、互斥锁与共享变量

本节介绍协程间如何实现先后执行、互相等待等协作需求,并与 Java 线程的协作方式对比,说明协程如何简化这类操作。

1、协程间的协作与等待

协程之间默认是并行执行的,不论是同等级、父子还是无关协程。如果需要等待其他协程完成,可使用 Job.join()Deferred.await()

线程中类似的需求可以通过 Thread.join()FutureCompletableFuture 以及 CountDownLatch 实现。例如,CountDownLatch 适用于一个线程等待多个线程:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    // countdown 译为倒计时,latch 是门闩、插销,组合起来就是用于倒计时的插销
    val countDownLatch = CountDownLatch(2)
    thread {
        // await() 会在 CountDownLatch 内的 count 减到 0 时结束等待
        countDownLatch.await()
        println("Count in CountDownLatch is 0 now,I'm free!")
    }

    thread {
        sleep(1000)
        // countDown() 会调用原子操作让 CountDownLatch 内的 count 减 1
        countDownLatch.countDown()
        println("Invoke countDown,count: ${countDownLatch.count}")
    }

    thread {
        sleep(2000)
        countDownLatch.countDown()
        println("Invoke countDown,count: ${countDownLatch.count}")
    }
}

运行结果:

复制代码
Invoke countDown,count: 1
Count in CountDownLatch is 0 now,I'm free!
Invoke countDown,count: 0

在协程中,用 join 同样可以实现"一个等待多个"的效果:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    // 两个前置任务
    val preJob1 = launch {
        delay(1000)
    }

    val preJob2 = launch {
        delay(2000)
    }

    // 此协程需要等待两个协程执行之后再运行自己的内容
    launch {
        preJob1.join()
        preJob2.join()
        // 等待完前置任务,再做自己的事...
    }
}

线程也具备通过如上方式进行协作的能力,但由于线程的结构化管理比较麻烦,因此线程很少这样写。而由于协程具备结构化并发特性,join 使用起来比线程的 join 更安全、更便捷,因此在正式项目中使用较多。

此外,使用 Channel 也能实现类似 CountDownLatch 的效果,只需指定容量并等待固定次数的接收:

kotlin 复制代码
private fun channelSample() = runBlocking<Unit> {
    // 指定 Channel 的容量为 2
    val channel = Channel<Unit>(2)

    // 由于要等待两次发送数据才能继续执行后续代码,因此要 repeat(2) 接收
    launch {
        repeat(2) {
            channel.receive()
        }
    }
    
    launch {
        delay(1000)
        channel.send(Unit)
    }

    launch {
        delay(2000)
        channel.send(Unit)
    }
}

两个示例表明,协程中这些协作与等待 API 要比线程中的好用。

2、select:先到先得

select 是一种用于异步操作的选择器,它允许同时监听多个挂起操作,并在其中一个完成时立即执行相应的操作。核心特性包括:

  • 多路复用 :能够同时监控多个异步事件(如多个 Channel 的接收、多个 Deferred 的结果等),实现"等待多个事件,谁先回来先处理谁"的效果。
  • 非阻塞:它不会阻塞当前线程,而是挂起协程等待第一个可用结果,是解决"竞争"场景的有效工具。

onJoinJob 对象在 select 表达式中使用的特定子句,只能在 select 函数内使用,主要用于监听协程结束事件:

  • 监听协程完成 :它允许在 select 块中等待某个 Job 执行完毕(包括正常结束与取消)。
  • 参与竞争 :当 select 同时监听多个事件(例如监听一个 Channel 消息和一个协程的完成)时,如果该协程先执行完毕,onJoin 分支会被选中执行,从而实现对协程结束状态的即时响应。并且,onJoin 代码块的结果会作为 select 表达式的结果。
kotlin 复制代码
fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(EmptyCoroutineContext)

    val job1 = scope.launch {
        delay(1000)
        println("job1 done")
    }

    val job2 = scope.launch {
        delay(2000)
        println("job2 done")
    }

    val job = scope.launch {
        val result = select {
            // select 只执行最先结束的 onJoin 回调
            job1.onJoin {
                1
            }

            job2.onJoin {
                2
            }
        }
        println("result: $result")
    }
    joinAll(job, job1, job2)
}

运行结果:

复制代码
job1 done
result: 1
job2 done

结果表明 select 只执行了先结束的 job1onJoin

select 还支持 Deferred.onAwaitChannel.onSendChannel.onReceive 等。此外还有个比较特殊的 onTimeout 用于设置超时,如果 select 内所有监听回调都没有在 onTimeout 指定的时间内完成,就由 onTimeout 作为 select 的返回值:

kotlin 复制代码
@OptIn(ExperimentalCoroutinesApi::class)
fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(EmptyCoroutineContext)

    val job1 = scope.launch {
        delay(1000)
        println("job1 done")
    }

    val deferred = scope.async {
        delay(2000)
        println("deferred done")
    }

    val channel = Channel<String>()

    val job = scope.launch {
        val result = select {
            // select 只执行最先结束的 onJoin 回调
            job1.onJoin {
                1
            }

            deferred.onAwait {
                2
            }

            channel.onSend("haha") {}
            /*channel.onReceive {}
            channel.onReceiveCatching {}*/

            onTimeout(500) {
                "Timeout!"
            }
        }
        println("result: $result")
    }
    joinAll(job, job1)
}

运行结果:

复制代码
result: Timeout!
job1 done

3、互斥锁和共享变量

多个协程访问共享资源时,可能产生竞态条件(race condition)。Kotlin 提供了多种机制保证线程安全,包括传统的线程锁和基于协程的互斥锁。

3.1 @Synchronized

Kotlin 使用 @Synchronized 注解代替 synchronized 关键字修饰方法。同步代码块则通过 synchronized(lock) { ... } 函数实现。它们与 Java 的同步机制本质相同,会阻塞当前线程(注意,不是协程)。

kotlin 复制代码
fun main() = runBlocking<Unit> {
    var number = 0
    val lock = Any()

    val thread1 = thread {
        repeat(100_000) {
            synchronized(lock) {
                number++
            }
        }
    }

    val thread2 = thread {
        repeat(100_000) {
            synchronized(lock) {
                number--
            }
        }
    }

    thread1.join()
    thread2.join()
    println("result: $number") // 输出 0
}

同样的代码结构也可以用在协程中:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    var number = 0
    val lock = Any()
    val scope = CoroutineScope(EmptyCoroutineContext)
    val job1 = scope.launch {
        repeat(100_000) {
            synchronized(lock) {
                number++
            }
        }
    }

    val job2 = scope.launch {
        repeat(100_000) {
            synchronized(lock) {
                number--
            }
        }
    }

    job1.join()
    job2.join()
    println("result: $number")
}

synchronized 会阻塞它所在协程的线程。这样做有点浪费,因为不止掐住了协程,连运行该协程代码的线程都被掐住了,但确实保证了线程安全。而且 synchronized 本来也是针对线程的,只不过从协程的角度看,如果可以只掐住协程,不影响运行该协程代码的线程就更好了。

ℹ 这个就好像 delaysleep 一样。delay 只挂起当前协程,不影响其所在的线程;而 sleep 会让整个线程休眠。因此在协程中,为了不影响整个线程,我们通常会使用 delay 仅挂起当前协程,而不会使用 sleep 为了让协程挂起而影响到整个线程。下一节要讲的 Mutex 可以解决这个问题。

Lock 的用法也大致相同,这里不多赘述。

3.2 Mutex

Mutex 是计算机领域的专属词汇,全称是 mutual exclusion,即互斥。Kotlin 提供的 Mutex 是基于协程的、挂起式的,不同于前面两个是基于线程的、阻塞式的。Mutex 是协程自己的实现,它不阻塞线程,性能更好,使用也很方便:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    var number = 0
    val mutex = Mutex()
    val scope = CoroutineScope(EmptyCoroutineContext)
    val job1 = scope.launch {
        repeat(100_000) {
            try {
                mutex.lock()
                number++
            } finally {
                mutex.unlock()
            }
        }
    }

    val job2 = scope.launch {
        repeat(100_000) {
            mutex.withLock {
                number--
            }
        }
    }

    job1.join()
    job2.join()
    println("result: $number")
}

job1 内是常规用法,在操作共享变量前用 lock 加锁,在 finally 代码块中解锁。job2 内使用简便写法,withLock 将代码块内的代码放入 try 中执行,在 finally 中用 unlock 解锁:

kotlin 复制代码
@OptIn(ExperimentalContracts::class)
public suspend inline fun <T> Mutex.withLock(owner: Any? = null, action: () -> T): T {
    contract {
        callsInPlace(action, InvocationKind.EXACTLY_ONCE)
    }
    lock(owner)
    return try {
        action()
    } finally {
        unlock(owner)
    }
}

如果在协程中使用共享资源,推荐用 Mutex,它具有性能优势。但它基于协程,只能在协程中使用。

3.3 Semaphore

Java 还有一个 Semaphore,信号量,用于控制并发访问资源的数量,不直接解决竞态条件,而是用于限流或对象池。

在它的构造方法中指定它最多可以被几个线程持有,如果有多余指定数量的线程去获取 Semaphore 就会陷入等待:

kotlin 复制代码
val semaphore = Semaphore(3) // 最多允许 3 个并发
// 获取锁
semaphore.acquire()
try {
    // 使用资源
} finally {
    // 释放锁
    semaphore.release()
}

由于共享变量是只要有两个线程同时访问就会导致出错了,因此允许多个线程持有的 Semaphore 并不能用于解决竞态条件的问题,它是用来做性能控制的。你可以用它来实现类似线程池的功能,只不过实现出来的是自己定制的对象池:同一时间最多只有指定数量的对象在同时做事,满了之后如果再来新对象就得等着,直到有位置让出来,这些新对象才能开始做事。

Kotlin 提供了协程版本的 Semaphore,其操作是挂起式的。

3.4 其他 API

  • wait()notify()notifyAll() 在 Java 中已很少直接使用(被 synchronizedLock 替代),协程未提供类似 API。
  • AtomicIntegerCopyOnWriteArrayList 等线程安全类仍可在协程中使用,但注意它们可能会阻塞线程。
  • volatiletransient 在 Kotlin 中作为注解使用:@Volatile@Transient

3.5 示例

下面通过一个实际示例演示共享变量(共享可变状态)的线程安全问题。以下是问题代码:

kotlin 复制代码
var counter = 0

// 执行耗时 13ms
fun main() = runBlocking<Unit> {
    withContext(Dispatchers.Default) {
        massiveRun {
            counter++
        }
    }
    println("Counter = $counter")
}

suspend fun massiveRun(action: suspend () -> Unit) {
    val n = 100  // number of coroutines to launch
    val k = 1000 // times an action is repeated by each coroutine
    val time = measureTimeMillis {
        coroutineScope { // scope for coroutines
            repeat(n) {
                launch {
                    repeat(k) { action() }
                }
            }
        }
    }
    println("Completed ${n * k} actions in $time ms")
}

由于 100 个协程会从多个线程中同时并发增加计数器,所以运行结果极不可能打印 Counter = 100000

原子操作

使用 @Volatile 无济于事,因为它只能保证读写的原子性,但不能保证更大操作(本例是变量自增)的原子性。为了保证原子性,可以将 count 声明为 AtomicInteger,它具有原子的 incrementAndGet 操作:

kotlin 复制代码
val counter = AtomicInteger()

// 执行耗时 13ms
fun main() = runBlocking<Unit> {
    withContext(Dispatchers.Default) {
        massiveRun {
            counter.getAndIncrement()
        }
    }
    println("Counter = $counter")
}

这是针对特定问题的最快解决方案。它适用于普通计数器、集合、队列和其他标准数据结构以及对它们的基本操作。但是,它不容易扩展到复杂状态或没有现成的线程安全实现的复杂操作。

线程封闭

线程封闭是解决共享可变状态问题的一种方法,即把对共享状态的所有访问都限制在单个线程中。让协程使用单线程上下文很容易实现线程封闭:

kotlin 复制代码
@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)
val counterContext = newSingleThreadContext("CounterContext")
var counter = 0

// 执行耗时 334ms
@OptIn(ExperimentalCoroutinesApi::class)
fun main() = runBlocking<Unit> {
    withContext(Dispatchers.Default) {
        massiveRun {
            // 细粒度线程封闭
            withContext(counterContext) {
                counter++
            }
        }
    }
    println("Counter = $counter")
    counterContext.close()
}

这段代码运行速度非常慢,因为它进行了细粒度的线程封闭(Thread confinement fine-grained)。每个单独的增量操作都会使用 withContext(counterContext) 块从多线程 Dispatchers.Default 上下文切换到单线程上下文。

但实际上,线程封闭通常是以大块为单位进行的,例如将大块的状态更新业务逻辑限制在单个线程中。像如下示例那样一开始就在单线程上下文中运行每个协程:

kotlin 复制代码
val counterContext = newSingleThreadContext("CounterContext")
var counter = 0

// 执行耗时 15ms
fun main() = runBlocking {
    // confine everything to a single-threaded context
    withContext(counterContext) {
        massiveRun {
            counter++
        }
    }
    println("Counter = $counter")
}

互斥

互斥是使用一个临界区保护所有共享状态的修改,该临界区永远不会被并发执行。在阻塞世界中,通常会使用 synchronizedReentrantLock 来实现。协程的替代方案称为 MutexMutex.lock() 是一个挂起函数,它不会阻塞线程。并且还有一个方便的 withLock 扩展函数,它代表了 mutex.lock(); try { ... } finally { mutex.unlock() } 模式:

kotlin 复制代码
val mutex = Mutex()
var counter = 0

// 执行耗时 108ms
fun main() = runBlocking<Unit> {
    withContext(Dispatchers.Default) {
        massiveRun {
            mutex.withLock {
                counter++
            }
        }
    }
    println("Counter = $counter")
}

这是一个细粒度的锁,执行耗时要比粗粒度锁长。但是在某些情况下是一个很好的选择。比如必须定期修改某些共享状态,但没有自然的线程将状态限制在其中时。

Actor 模式

Actor 模式是一种将状态和行为封装在一个独立协程中,并通过消息进行交互的模式。

Actor 模式是一个强大的并发模型。其核心思想是:

  • 独立实体:Actor 是一个独立的计算实体,在协程中运行。
  • 封装状态:Actor 拥有自己的私有状态(如计数器、列表等),外部无法直接访问或修改,从而避免了数据竞争。
  • 消息驱动:Actor 之间的唯一交互方式是发送不可变的消息。
  • 顺序处理 :Actor 内部的 channel 就像一个"邮箱",它从邮箱中按接收顺序一条一条地取出消息并处理,这个机制确保了状态变更不会相互干扰。

在实现该模式时,简单的 Actor 可以写成一个函数,具有复杂状态的 Actor 更适合写成一个类。

第一步是定义 Actor 将要处理的消息类,适合做成密封类:

kotlin 复制代码
sealed class CounterMsg // Message types for counterActor
object IncCounter : CounterMsg() // one-way message to increment counter
class GetCounter(val response: CompletableDeferred<Int>) : CounterMsg() // a request with reply

接下来定义一个函数,使用 actor 构建器启动 Actor:

kotlin 复制代码
// This function launches a new counter actor
fun CoroutineScope.counterActor() = actor<CounterMsg> {
    var counter = 0 // actor state
    for (msg in channel) { // iterate over incoming messages
        when (msg) {
            is IncCounter -> counter++
            is GetCounter -> msg.response.complete(counter)
        }
    }
}

主代码用 counterActor 发送不同类型的消息即可:

kotlin 复制代码
// 执行耗时 250ms
fun main() = runBlocking<Unit> {
    val counter = counterActor()
    withContext(Dispatchers.Default) {
        massiveRun {
            counter.send(IncCounter)
        }
    }

    val response = CompletableDeferred<Int>()
    counter.send(GetCounter(response))
    println("Counter = ${response.await()}")
    counter.close()
}

Actor 是一个协程,协程是按顺序执行的,因此将状态限定在特定协程中是解决共享可变状态问题的解决方案。实际上,Actor 可以修改自己的私有状态,但只能通过消息相互影响(避免了任何锁的需求)。

在负载下,Actor 比锁更高效,因为这种情况下它总是有工作要做,不需要切换到不同的上下文。这意味着 Actor 可以同时处理多个消息,无需获取和释放锁的开销。此外,使用 Actor 可以简化并发系统的设计,因为它允许并发组件之间进行自然而直观的通信。

需要注意的是,actor 这个 API 被标记为 @ObsoleteCoroutinesApi。之所以要被废弃,是因为设计局限:

  • 平台局限:它仅适用于 JVM 目标平台,无法用于 Kotlin Multiplatform 项目。
  • 功能简单 :本质上只是对 Channel 的一层简单封装。
  • 设计停滞:官方曾计划设计更完善的 Actor API,但因优先级较低长期搁置。

官方推荐的基础替代方法是自行组合 Channel 与一个后台协程手动实现 actor 的逻辑。这种方式灵活,但需要自己处理生命周期和状态管理:

kotlin 复制代码
class CounterActor {
    private val channel = Channel<CounterMsg>()
    private var count = 0

    sealed class CounterMsg {
        object IncCounter : CounterMsg()
        class GetCounter(val response: CompletableDeferred<Int>) : CounterMsg()
    }

    fun start() = CoroutineScope(Dispatchers.Default).launch {
        for (msg in channel) {
            when (msg) {
                is CounterMsg.IncCounter -> count++
                is CounterMsg.GetCounter -> msg.response.complete(count)
            }
        }
    }

    suspend fun get(): Int {
        val response = CompletableDeferred<Int>()
        channel.send(CounterMsg.GetCounter(response))
        return response.await()
    }

    suspend fun count() {
        channel.send(CounterMsg.IncCounter)
    }
}

// 执行耗时 150ms
fun main() = runBlocking<Unit> {
    val counter = CounterActor().also { it.start() }
    withContext(Dispatchers.Default) {
        massiveRun {
            counter.count()
        }
    }

    println("Counter = ${counter.get()}")
}

4、ThreadLocal

ThreadLocal 提供线程局部变量,在同一个线程内共享,在每个线程中都有独立的副本,读写也相互独立。

Java 变量按作用域由小到大可以划分为局部变量(方法内)、成员变量(类内)、静态变量(全局),ThreadLocal 介于局部变量和静态变量之间,范围比方法大,比静态全局小,只在当前线程范围内有效。

ThreadLocal 是对 Java 线程一个很关键的能力补充。前面提过,协程相对线程的一大优势就是具备结构化管理的能力,线程则不具备。但在开发中有结构化管理的需求,这时就要用 ThreadLocal。有了它之后,在同一个线程里执行的多个方法之间就可以共享变量了,且该共享变量只对当前线程有效,跨线程时还是独立的,因此 ThreadLocal 本身通常会作为静态变量存在。

ThreadLocal 在协程中的等价物是什么?有什么东西是跨方法的、针对协程的局部变量吗?有!CoroutineContext 就是协程里的 ThreadLocal

协程本来就具备结构化管理能力,你完全不需要在协程内使用 ThreadLocal,使用 CoroutineContext 即可。但开发中难免要与 Java 代码协作,如果想在协程代码里访问老代码里的 ThreadLocal 对象,还是要使用它。但直接在协程环境中使用 ThreadLocal 是有风险的,主要原因是协程的挂起恢复机制ThreadLocal线程绑定机制存在本质冲突,可能会导致以下问题:

  1. 核心问题:数据错乱与丢失
    • 原因:ThreadLocal 的数据绑定在特定线程上,而协程是可挂起、可恢复 的,且运行在线程池中。
    • 表现:
      • 挂起前后的线程切换:协程在挂起点前运行在线程 A,恢复后可能在线程 B 执行。此时 ThreadLocal.get() 获取的是线程 B 的数据,导致之前在线程 A 设置的数据"丢失"或读到错误的默认值。
      • 数据污染:如果协程在线程 A 中修改了 ThreadLocal 数据,挂起后线程 A 被线程池回收并分配给其他协程任务,新任务会读取到上一个协程遗留的脏数据。
  2. 内存泄漏风险加剧
    • 原因:协程通常运行在线程池(如 Dispatchers.IODispatchers.Default)中,这些线程是长生命周期的,且会被反复复用。
    • 表现:如果在协程结束时没有显式调用 remove() 清理数据,ThreadLocal 中的值(强引用)会一直留在线程的 ThreadLocalMap 中,随着线程池的存活导致内存泄漏。这与线程池使用 ThreadLocal 的问题一致,但在协程频繁切换线程的场景下更隐蔽。

协程只能保证在执行挂起函数之后依然运行在刚才的 ContinuationInterceptor 所管理的某一个线程池上,不能保证同一个线程。

示例代码:

kotlin 复制代码
val kotlinLocalString = ThreadLocal<String>()

fun main() = runBlocking {
    val scope = CoroutineScope(EmptyCoroutineContext)
    val job = scope.launch(Dispatchers.Default) {
        kotlinLocalString.set("Coroutine!")

        // 同时启动大量协程,让线程池所有线程都处于忙碌状态
        // 这样 delay 恢复时大概率被调度到不同线程
        val busyJobs = (1..10).map {
            launch {
                // 注意用 sleep 而不是 delay,目的是真正占住线程
                Thread.sleep(2000)
            }
        }
        // 挂起函数,执行完毕恢复时不保证还在挂起前的线程
        delay(1000)
        // 再次打印 ThreadLocal 的值,发现输出结果为 null
        println(kotlinLocalString.get())
        busyJobs.forEach { it.cancel() }
    }
    job.join()
}

由于线程池复用同一线程的概率很高导致碰巧仍输出 Coroutine! 的情况居多,所以创建 busyJobs 在每个协程内都用 Thread.sleep() 占用线程,迫使 job 在执行完 delay 后在其他线程恢复,再次打印 kotlinLocalString 的值就大概率为 null 了。

使用 ThreadLocal 的扩展函数 asContextElement 把参数值封装到返回值 ThreadLocalElement 中。ThreadLocalElement 作为一个附加的上下文元素,会保留给定 ThreadLocal 的值,并在每次协程切换其上下文时恢复它的值:

kotlin 复制代码
fun main() = runBlocking {
    val scope = CoroutineScope(EmptyCoroutineContext)
    val job = scope.launch(Dispatchers.Default) {
        // 把 ThreadLocalElement 追加到 withContext 参数中,这样在
        // withContext 内,kotlinLocalString 的值一定是 Coroutine!
        withContext(kotlinLocalString.asContextElement("Coroutine!")) {
            val busyJobs = (1..10).map {
                launch {
                    Thread.sleep(2000)
                }
            }
            delay(1000)
            println(kotlinLocalString.get())
            busyJobs.forEach { it.cancel() }
        }
    }
    job.join()
}

launch 也是类似的用法:

kotlin 复制代码
val threadLocal = ThreadLocal<String>()

fun main() = runBlocking {
    threadLocal.set("main")
    println("Pre-main: ${Thread.currentThread()}, ${threadLocal.get()}")
    // 使用 asContextElement 后,job 内两次输出的值都是 "launch",否则为 null
    val job = launch(Dispatchers.Default + threadLocal.asContextElement("launch")) {
        println("Launch start: ${Thread.currentThread()}, ${threadLocal.get()}")
        yield()
        println("After yield: ${Thread.currentThread()}, ${threadLocal.get()}")
    }
    job.join()
    println("Post-main: ${Thread.currentThread()}, ${threadLocal.get()}")
}

运行结果:

复制代码
Pre-main: Thread[#1,main,5,main], main
Launch start: Thread[#33,DefaultDispatcher-worker-2,5,main], launch
After yield: Thread[#33,DefaultDispatcher-worker-2,5,main], launch
Post-main: Thread[#1,main,5,main], main

5、协程与线程交互

由于老项目或者一些尚未支持协程的第三方库可能还是用线程进行并发管理,假如新项目使用协程要用到线程的并发功能,就需要在协程中接入线程代码;反之,老项目无法使用协程,只能用线程的情况下,但第三方库只提供了协程的并发支持,就需要在线程中接入协程。

5.1 协程调用线程代码

先看如何将线程代码接入协程,这里主要是指在协程环境中调用线程写的回调型 API。比如对于线程 + 回调完成 Retrofit 网络请求的代码:

kotlin 复制代码
private fun request() {
    gitHub.contributorsCall("square", "retrofit")
    	// enqueue 会进入子线程发送网络请求,在拿到结果通过回调返回
    	// 结果之前,再切回到主线程之中
        .enqueue(object : Callback<List<Contributor>> {
            override fun onResponse(
                call: Call<List<Contributor>>,
                contributors: retrofit2.Response<List<Contributor>>
            ) {
                // 倘若这里需要利用 contributors 发出二次请求,那么就会出现嵌套回调
                showContributors(contributors)
            }

            override fun onFailure(p0: Call<List<Contributor>>, p1: Throwable) {
                TODO("Not yet implemented")
            }
        })
}

使用 suspendCoroutine 包裹上面的函数体,可以将其改造为挂起函数:

kotlin 复制代码
private suspend fun callbackToSuspend() = suspendCoroutine {
    gitHub.contributorsCall("square", "retrofit")
        .enqueue(object : Callback<List<Contributor>> {
            override fun onResponse(
                call: Call<List<Contributor>>,
                contributors: retrofit2.Response<List<Contributor>>
            ) {
                // 1.传回数据
                it.resume(response.body() ?: emptyList())
            }

            override fun onFailure(p0: Call<List<Contributor>>, p1: Throwable) {
                // 2.如果发生异常,会结束 suspendCoroutine 并抛出 t 这个异常
                it.resumeWithException(t)
            }
        })
}

调用 callbackToSuspend 时要为其加上 try-catch

kotlin 复制代码
	override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        lifecycleScope.launch {
            try {
                val contributors = callbackToSuspend()
                showContributors(contributors)
            } catch (e: Exception) {
                // 异常处理
            }
        }
    }

此外,还有 suspendCancellableCoroutine,唯一区别是它支持取消。由于取消协程是一个正常需求,大多数时候我们都希望可以取消协程,因此几乎都会使用 suspendCancellableCoroutine。在取消时,可以通过 invokeOnCancellation 这个钩子做一些取消时的收尾工作:

kotlin 复制代码
suspendCancellableCoroutine {
    // 注册取消时的回调函数
    it.invokeOnCancellation { ... }
}

suspendCoroutine 不会 自动配合结构化并发中的取消机制。它是 Kotlin 协程基础库(kotlin.coroutines)中的 API,设计初衷是连接回调与协程,它仅仅是将回调转换为挂起函数。它并不属于 kotlinx.coroutines 的结构化并发体系,当协程被取消时,如果不做额外处理,suspendCoroutine 挂起的代码会继续挂起,不会抛出异常,也不会停止,这往往会导致协程泄漏或资源浪费。比如:

kotlin 复制代码
suspend fun fetchData(): String = suspendCoroutine { cont ->
    // 调用一个第三方库,它没有取消功能
    expensiveNetworkCall { result ->
      cont.resume(result)
    }
}

当外部调用 cancel 取消协程时,suspendCoroutine 并不知道协程被取消了,它会一直等待 expensiveNetworkCall 的回调,即使结果已经没人需要了。协程会一直处于挂起状态,直到那个回调最终触发(或者超时),这就是所谓的"协程泄漏"。

为了解决这个问题,kotlinx.coroutines 提供了 suspendCancellableCoroutine。这是我们在业务开发中更应该使用的 API,它的源码简化如下:

kotlin 复制代码
public suspend inline fun <T> suspendCancellableCoroutine(
    crossinline block: (CancellableContinuation<T>) -> Unit
): T = suspendCoroutine { cont ->
      // 将普通的 Continuation 包装成 CancellableContinuation
      val cancellable = CancellableContinuationImpl(cont, ...)
      // ...
      block(cancellable)
}

当协程被取消时,CancellableContinuation 会做两件事:

  1. 拦截恢复:如果回调试图在取消后恢复协程,它会被拦截,防止无效操作。
  2. 执行清理:如果你注册了 invokeOnCancellation,它会在这里被调用,让你有机会清理资源(比如关闭 Socket、取消 Retrofit Call)。

因此,只要代码哪怕有一点点取消的可能,都应该优先使用 suspendCancellableCoroutine

kotlin 复制代码
suspend fun load(): Bitmap = suspendCancellableCoroutine { cont ->
    val call = api.asyncLoad { bitmap ->
        cont.resume(bitmap)
    }

    // 重点!当协程取消时,取消底层的网络请求
    cont.invokeOnCancellation { 
        call.cancel() 
    }
}

总结:

  • suspendCoroutine:来自 kotlin.coroutines,只管挂起和恢复,不管取消。如果协程被取消,它会一直挂起直到回调到来。
  • suspendCancellableCoroutine:来自 kotlinx.coroutines,是 suspendCoroutine 的加强版,自动响应取消 ,并提供 invokeOnCancellation 钩子让你清理底层资源。

所以在实际开发中,几乎应该总是使用 suspendCancellableCoroutine,除非你非常确定那段代码永远不会被取消。

5.2 线程调用协程代码

runBlocking 在线程环境中启动协程,相比其他协程构建器有两点特殊之处:

  1. 无需 CoroutineScope 即可启动协程。
  2. 会阻塞当前线程。

有此差异的原因是 runBlocking 定位不同,它用于桥接线程与协程,将挂起式代码转换成阻塞式代码(或者说在线程环境中调用协程封装的代码):

kotlin 复制代码
fun main() = runBlocking {
    // contributors() 是挂起函数
    val contributors = gitHub.contributors("square", "retrofit")
}

在普通业务代码中应避免使用 runBlocking

相关推荐
lsx2024062 小时前
Perl 哈希
开发语言
楼田莉子2 小时前
仿muduo的高并发服务器——前置知识讲解和时间轮模块
服务器·开发语言·c++·后端·学习
花间相见2 小时前
【MS-Swift实战】:LoRA原理+核心参数(r/alpha)调参指南(适配Qwen-1.8B医疗场景)
开发语言·r语言·swift
小江的记录本2 小时前
【分布式】分布式核心组件——分布式限流:固定窗口、滑动窗口、漏桶、令牌桶算法,网关层/服务层限流实现
java·分布式·后端·python·算法·安全·面试
Kapaseker2 小时前
我再也不用求设计做阴影了 — Compose 阴影
android·kotlin
Hanson,2 小时前
SpringBoot前后端分离框架中,在请求头加入签名
java·spring boot·后端
求知也求真佳2 小时前
S03|待办写入:让 AI 不再走一步忘一步,多步任务不再跑偏
开发语言·agent
不懂的浪漫2 小时前
一次设备映射缓存设计:用多索引 Map 把高频查询从遍历变成直接命中
java·算法·spring·缓存