你遇到过 Kotlin 协程中的竞争问题吗?

前几天同事找我吐槽,说 Kotlin 协程也有竞争问题。

他一直以为协程把线程安全的问题都解决了,毕竟官方文档没有着重讲,只是一个小章节讲述了一下。

我笑了:人家没提,是觉得你会处理,不是协程自己解决了。

他遇到的问题其实很典型:多个协程同时修改一个变量,结果数据对不上。这在多线程编程里叫竞争(Race Condition),协程也逃不掉。

下面用一个面包店的例子,来看看这个问题是怎么产生的,以及 Kotlin 提供了哪些应对手段。

从一个面包店说起

假设我们有一家面包店,需要处理 100,000 个随机订单(这蛋糕店也不小了):

kotlin 复制代码
private val random = Random(seed = 546)
private val menu = listOf("🎂", "🥧", "🧁")

val orders = List(100_000) { menu.random(random) }

每种烘焙食品对应不同的价格,用枚举类来表示,再配上一个 bake() 函数:

kotlin 复制代码
enum class BakedGood(val cost: Int) {
    CAKE(15), PIE(10), CUPCAKE(5)
}

fun bake(item: String): BakedGood = when (item) {
    "🎂"  -> BakedGood.CAKE
    "🥧"  -> BakedGood.PIE
    "🧁"  -> BakedGood.CUPCAKE
    else -> throw IllegalArgumentException()
}

先用单个协程来完成所有烘焙工作,把每笔订单的价格累加到总收入:

kotlin 复制代码
fun main() {
    var total = 0

    runBlocking(Dispatchers.Default) {
        val baker = launch {
            orders.forEach { item ->
                val good = bake(item)
                total += good.cost
            }
        }
    }

    println("Total income: $${String.format("%,d", total)}")
}

只有一个协程在写 total,一切正常。使用上面的随机种子 546,运行结果恰好是 $1,000,000。

现在把订单分给两个协程,各处理一半:

kotlin 复制代码
val baker = launch {
    orders.take(50_000).forEach { item ->
        val good = bake(item)
        total += good.cost
    }
}

val baker2 = launch {
    orders.drop(50_000).forEach { item ->
        val good = bake(item)
        total += good.cost
    }
}

再运行,结果很可能远低于 $1,000,000。

为什么会这样?

问题全在这里:total += good.cost 本质上等价于 total = total + good.cost

Kotlin 先读取 total 的当前值,再加上 good.cost,然后写回。

但由于两个协程并行执行,可能在一个协程读取 total 和写回之间,另一个协程已经修改了 total 的值。后者的更新就被覆盖了。

这就是竞争

虽然 Kotlin 不会自动防止竞争,但提供了多种工具来应对。下面我们逐一介绍。

原子操作

原子操作在 Kotlin 2.1 中被引入标准库。在你的 Kotlin 版本中可能仍属于实验性 API,需要添加 @OptIn(ExperimentalAtomicApi::class) 注解。

对于简单的数值更新场景,原子操作非常合适。把 totalInt 改为 AtomicInt

kotlin 复制代码
var total = AtomicInt(0)

AtomicInt 包装了底层的 Int 值,更新时可以使用 fetchAndAdd()compareAndSet() 等函数。

所以 total 本身不再需要重新赋值,var 可以改为 val

kotlin 复制代码
val total = AtomicInt(0)

读取底层值时调用 load()

kotlin 复制代码
println("Total income: $${String.format("%,d", total.load())}")

原子数字还实现了 plusAssign,所以 total += good.cost 依然可以直接使用,只需导入正确的包:

kotlin 复制代码
import kotlin.concurrent.atomics.plusAssign

这样,total += good.cost 就变成了一个原子操作,每次运行都能得到正确的 $1,000,000。

StateFlow

没想到吧,StateFlow 还能让你写出线程安全的代码!

StateFlow 也支持原子操作,改动方式和 AtomicInt 类似。

total 改为 MutableStateFlow

kotlin 复制代码
val total = MutableStateFlow(0)

update() 函数包装临界区:

kotlin 复制代码
val baker = launch {
    orders.take(50_000).forEach { item ->
        val good = bake(item)
        total.update { it + good.cost }
    }
}

val baker2 = launch {
    orders.drop(50_000).forEach { item ->
        val good = bake(item)
        total.update { it + good.cost }
    }
}

读取值时需要通过 value 属性:

kotlin 复制代码
println("Total income: $${String.format("%,d", total.value)}")

这里的核心,实际上是利用了 update 函数的内部实现是加了同步的:

Kotlin 复制代码
private fun updateState(expectedState: Any?, newState: Any): Boolean {
    var curSequence: Int
    var curSlots: Array<StateFlowSlot?>? // benign race, we will not use it
    synchronized(this) { // 同步
        val oldState = _state.value
        // ......
    }
}

这个方案技术上可行,但 StateFlow 本意是让你持续观察值的变化,我们的场景并不需要这个特性,所以不推荐在这里用。

不过,如果你在 Compose 应用的 ViewModel 中做类似工作,StateFlow 会是自然的选择。

Mutex

互斥锁的原理和银行的自动取款机一样------一次只能有一个人进去,在他出来之前,其他人在外头只能等着。

Kotlin 的 Mutex 就是这台取款机。用法很简单:

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

在协程中,用 lockunlock 包装对 total 的更新:

kotlin 复制代码
mutex.lock()
total += good.cost
mutex.unlock()

一个协程锁定互斥锁后,其他试图锁定的协程会被挂起等待,这样就不会出现同时更新的问题。

不过,直接用 lock()/unlock() 有个问题:如果中途抛出异常,锁就不会被释放。更安全的写法是:

kotlin 复制代码
mutex.lock()
try {
    total += good.cost
} finally {
    mutex.unlock()
}

写起来太啰嗦了。好在 Mutex 提供了 withLock() 函数来简化这个模式:

kotlin 复制代码
mutex.withLock { total += good.cost }

Mutex 好用,不过如果忘记释放锁,程序可能会卡住,用的时候得注意。

limitedParallelism

限制(Confinement)的核心思想是:只要所有对变量的访问都发生在同一个执行单元上,就不会出现竞争。

一种方式是创建单线程调度器:

kotlin 复制代码
val synchronized = newSingleThreadContext("synchronized")

然后用 withContext 包装临界区:

kotlin 复制代码
withContext(synchronized) { total += good.cost }

newSingleThreadContext() 需要手动关闭以释放原生资源,所以官方把它标记为需要小心使用的 API。

更常见的做法是使用 limitedParallelism(1)

kotlin 复制代码
val synchronized = Dispatchers.Default.limitedParallelism(1)

使用方式不变,用 withContext(synchronized) 包装临界区即可。

limitedParallelism() 虽然不保证限制在特定线程上,但保证同一时刻在该调度器上运行的协程数量不超过指定值(这里传入 1),效果等同于单线程。

这种方式我越来越喜欢。过去我更偏好互斥锁,因为 mutex.withLock() 的意图一目了然。但 limitedParallelism() 同样有效,而且不存在忘记释放锁的风险,是很多 Kotlin 开发者管理跨协程状态的首选。

Actor

Actor 的思路是将状态限制在单个协程中,所有状态变更通过消息传递来完成。

先定义消息类型:

kotlin 复制代码
sealed interface TotalMessage {
    data class Increase(val amount: Int) : TotalMessage
    data object Print : TotalMessage
}

然后创建 actor

kotlin 复制代码
val total = actor<TotalMessage> {
    var amount = 0

    for (message in channel) {
        when (message) {
            is TotalMessage.Increase -> amount += message.amount
            is TotalMessage.Print    -> println("Total income: $${String.format("%,d", amount)}")
        }
    }
}

Actor 会创建一个协程,循环等待消息并处理。向它发送消息来更新状态:

kotlin 复制代码
val baker = launch {
    orders.take(50_000).forEach { item ->
        val good = bake(item)
        total.send(TotalMessage.Increase(good.cost))
    }
}

完整代码如下:

kotlin 复制代码
@OptIn(ObsoleteCoroutinesApi::class)
fun main() = runBlocking(Dispatchers.Default) {
    val total = actor<TotalMessage> {
        var amount = 0

        for (message in channel) {
            when (message) {
                is TotalMessage.Increase -> amount += message.amount
                is TotalMessage.Print    -> println("Total income: $${String.format("%,d", amount)}")
            }
        }
    }

    coroutineScope {
        val baker = launch {
            orders.take(50_000).forEach { item ->
                val good = bake(item)
                total.send(TotalMessage.Increase(good.cost))
            }
        }

        val baker2 = launch {
            orders.drop(50_000).forEach { item ->
                val good = bake(item)
                total.send(TotalMessage.Increase(good.cost))
            }
        }
    }

    total.send(TotalMessage.Print)
    total.close()
}

注意最后的 close() 调用------由于结构化并发,runBlocking 会等待 actor 协程完成,而 actor 协程在 channel 关闭前不会结束。没有 close() 的话,程序会一直挂起。

当前的 Actor API 已被标记为过时(obsolete),但功能稳定。

坦率地说,我不太喜欢 actor------相比原子操作方案,这里的代码量多了不少。

在已经使用 channel 和消息传递的场景中(比如用协程构建工作流),actor 可能很合适。但对大多数通用场景,更简单的方案是更好的选择。

当然,这里只是让大家了解 Kotlin 还有这种用法,actor 并不推荐在大多数场景中使用。

避免共享

在面包店的例子中,我们只是在计算一个数字,而不是构建一个长期运行的应用。

这种情况下,很多时候可以完全避免共享可变状态:

kotlin 复制代码
fun main() {
    val total = runBlocking(Dispatchers.Default) {
        val subtotal1 = async {
            orders.take(50_000).sumOf { item -> bake(item).cost }
        }

        val subtotal2 = async  {
            orders.drop(50_000).sumOf { item -> bake(item).cost }
        }

        subtotal1.await() + subtotal2.await()
    }

    println("Total income: $${String.format("%,d", total)}")
}

每个协程只负责计算自己那部分的总额,最后再把结果相加。没有可变变量,没有竞争,这是最干净的方案。

一点想法

面对这么多选择,怎么判断哪种最合适?

能避免共享可变状态就避免------彻底消除竞争的风险。

虽然有一条铁律:解决竞争最好的方法就是没有竞争。但是实际项目可能不是那么完美。在无法避免的情况下,可以选择如下的一些方案:

  • 最简单的可行方案 :基本类型用原子操作;需要响应状态变化时用 StateFlow
  • 更复杂的更新 :互斥锁或 limitedParallelism(1) 都可以。
  • Actor:有些开发者很喜欢,但对大多数场景来说偏重了。

协程不会自动防止竞争,但 Kotlin 提供了足够多的工具来应对。关键在于理解每种方案的适用场景,选择最简洁的那一个。


这篇文章我已经转发给开头那位同事了,希望对你也有用。

相关推荐
2601_961194021 小时前
27考研资料|百度网盘|夸克网盘
android·xml·考研·ios·iphone·xcode·webview
故渊at1 小时前
第二板块:Android 四大组件标准化学理 | 第十篇:ContentProvider 数据共享与 SQLite 引擎
android·jvm·数据库·sqlite·contentprovider
与水同流1 小时前
Android13 AIDL HAL服务实现Demo
android·hal·aidl
AsiaLYF2 小时前
Kotlin MutableSharedFlow: emit vs tryEmit 详解
开发语言·前端·kotlin
吴梓穆2 小时前
Python 基础语法2 if 运算符 循环
android·开发语言·python
流星白龙2 小时前
【MySQL高阶】27.事务(2)-锁
android·mysql·adb
我命由我123452 小时前
Kotlin 开发 - Kotlin 反引号转义关键字
android·java·开发语言·java-ee·kotlin·android jetpack·android runtime
码云骑士2 小时前
【1.2Java基础】Win10环境变量配置详解-从原理到排雷
android·java
AI玫瑰助手2 小时前
Python函数:匿名函数lambda的定义与使用场景
android·java·python