
前几天同事找我吐槽,说 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) 注解。
对于简单的数值更新场景,原子操作非常合适。把 total 从 Int 改为 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()
在协程中,用 lock 和 unlock 包装对 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 提供了足够多的工具来应对。关键在于理解每种方案的适用场景,选择最简洁的那一个。
这篇文章我已经转发给开头那位同事了,希望对你也有用。