Kotlin 协程(Coroutine) 并发安全与最佳实践

前言

在 Kotlin 的 协程(Coroutine) 中,虽然协程本身提供了一种简化并发编程的方式,但并不天然地解决所有的并发安全问题。当多个协程对共享状态进行读写操作时,仍可能出现并发安全问题。所以我们在使用协程时需要注意并发安全,避免产生相关的问题从而导致一些难以排查的问题。

协程中的并发安全问题

1、共享状态竞争

多个协程同时访问和修改共享的可变状态时,可能会导致状态不一致。我们来看一个官网的代码示例:

kotlin 复制代码
suspend fun massiveRun(action: suspend () -> Unit) {
    val n = 100  // 启动的协程数量
    val k = 1000 // 每个协程重复执行同一动作的次数
    val time = measureTimeMillis {
        coroutineScope { // 协程的作用域
            repeat(n) {
                launch {
                    repeat(k) { action() }
                }
            }
        }
    }
    println("Completed ${n * k} actions in $time ms")    
}

//sampleStart
var counter = 0

fun main() = runBlocking {
    withContext(Dispatchers.Default) {
        massiveRun {
            counter++
        }
    }
    println("Counter = $counter")
}

在上述代码中,counter++ 不是线程安全的操作,因为它包含三个步骤(读取、计算、写入),多个协程可能会交错执行这些步骤,导致最终值错误。

2、数据不可见性

某个协程对共享变量的更新,可能不会立即被其他协程看见,尤其是在多线程环境下,协程可能运行在不同的线程上。

3、死锁与资源竞争

协程的挂起与恢复可能导致锁资源竞争。如果协程挂起期间未正确释放锁,可能造成死锁。这与我们在使用线程时是一样的,对锁的使用不当、设计不合理就会导致死锁问题,而死锁问题往往很难排查。

解决并发安全问题的方式

1、使用线程安全的数据结构

对于简单的场景,可以直接使用线程安全的类,如:

  • AtomicInteger、AtomicLong 等
  • ConcurrentHashMap 等

对上述代码改造如下:

kotlin 复制代码
//sampleStart
var counter = AtomicInteger(0)

fun main() = runBlocking {
    withContext(Dispatchers.Default) {
        massiveRun {
            counter.incrementAndGet()
        }
    }
    println("Counter = $counter")
}
  • 优点:简单高效。
  • 缺点:只适用于某些特定的数据结构。

2、使用 Mutex(互斥锁)

kotlinx.coroutines.sync.Mutex 提供了协程友好的锁,用于保护共享状态。

对上述代码改造如下:

kotlin 复制代码
//sampleStart
var counter = 0

val mutex = Mutex()

fun main() = runBlocking {
    withContext(Dispatchers.Default) {
        massiveRun {
            mutex.withLock {
                counter++
            }
        }
    }
    println("Counter = $counter")
}
  • 优点:易于控制临界区,确保互斥。
  • 缺点:可能导致协程竞争锁资源,降低性能。

3、使用 Channel

Channel 是协程的线程安全队列,可以用来发送和接收数据,避免直接访问共享变量。

  • 优点:避免直接操作共享状态,易于扩展到生产者-消费者模型。
  • 缺点:实现复杂度较高。

4、使用 StateFlow 或 SharedFlow

StateFlow 是协程的状态容器,天然支持并发安全,用于维护和订阅状态的变化。

对上述代码改造如下:

kotlin 复制代码
//sampleStart
val counterFlow = MutableStateFlow(0)

fun main() = runBlocking {
    withContext(Dispatchers.Default) {
        massiveRun {
            counterFlow.value++  // StateFlow 线程安全
        }
    }
    println("Counter = $counterFlow")
}
  • 优点:简化状态管理,对状态变化有良好的订阅支持。
  • 缺点:适合事件流处理,对于高频率或复杂逻辑可能性能不佳。

5、使用 withContext 和单线程调度器

将共享资源的操作限制在单一线程中,避免多线程并发问题。

对上述代码改造如下:

kotlin 复制代码
val singleThreadContext = newSingleThreadContext("SingleThread")

suspend fun massiveRun(action: suspend () -> Unit) {
    val n = 100  // 启动的协程数量
    val k = 1000 // 每个协程重复执行同一动作的次数
    val time = measureTimeMillis {
        coroutineScope {
            repeat(n) {
                launch(singleThreadContext) {
                    repeat(k) { action() }
                }
            }
        }
    }
    println("Completed ${n * k} actions in $time ms")
}

//sampleStart
var counter = 0

@Test
fun main() = runBlocking {
    withContext(Dispatchers.Default) {
        massiveRun {
            counter++
        }
    }
    println("Counter = $counter")
}
  • 优点:简单明了,完全避免了并发。不需要锁机制。
  • 缺点:单线程性能受限,可能成为瓶颈。

优劣对比及适用场景

方案 优点 缺点 适用场景
线程安全数据结构 简单高效,直接使用 适用范围有限,仅支持固定结构 简单计数器等
Mutex 灵活性高,适合保护复杂临界区 可能导致性能下降,存在锁竞争 多协程并发修改同一数据
Channel 线程安全,易实现生产者-消费者模型 实现复杂度较高,可能影响性能 消息队列、事件传递
StateFlow 简化状态管理,天然线程安全 性能有限,高频操作不适用 UI 状态同步
单线程调度器 逻辑简单,完全避免并发问题 性能受限,单线程可能成为瓶颈 严格顺序依赖的场景

总结与推荐

优先级排序:

  1. 如果操作简单,优先使用线程安全的数据结构,如 AtomicInteger。
  2. 如果需要保护复杂的共享状态,优先考虑 Mutex。
  3. 在需要流式状态管理时,推荐使用 StateFlow。
  4. 如果是生产者-消费者模型,使用 Channel。
  5. 对于顺序执行或线程受限的场景,使用单线程调度器。

建议:

  1. 在设计时尽量减少对共享状态的依赖,尽可能让任务无状态化。
  2. 根据需求权衡性能和并发安全,选择最合适的解决方案。

StateFlow 和 SharedFlow 为什么是天然线程安全的

1、什么是 StateFlow 和 SharedFlow?

  • StateFlow 是一种能够持有状态并对其变化进行订阅的热流(Hot Flow),主要用于状态管理。

  • SharedFlow 是通用的、支持多订阅者的热流,适用于事件的广播或数据共享。

两者都是 Kotlin 协程的 Flow API 的扩展,基于 kotlinx.coroutines 实现。

2、天然线程安全性

StateFlow 和 SharedFlow 的实现基于协程库内部的线程安全机制:

  • 它们的状态更新和数据传递操作全部封装在一个线程安全的实现中。

  • 使用了 原子操作(Atomic Operations)并发控制 来确保状态的读写安全。

示例代码说明线程安全性:

kotlin 复制代码
//sampleStart
val counterFlow = MutableStateFlow(0)

fun main() = runBlocking {
    withContext(Dispatchers.Default) {
        massiveRun {
            counterFlow.value += 1  // 线程安全的自增
        }
    }
    println("Counter = $counterFlow")
}

上述代码中,尽管多个协程同时修改 counterFlow.value,由于 StateFlow 使用了 CAS(Compare-And-Swap) 操作和内部同步机制,状态始终保持一致性。

3、为什么是天然线程安全的?

基于原子操作:

StateFlow 和 SharedFlow 的核心操作(如值更新)是基于 CAS,确保状态更新是不可分割的原子操作,避免了数据竞争。

内部状态保护:

这些类内部使用了 Volatile 修饰符或同步块,确保共享变量在多个线程之间的可见性。

背后的调度器模型:

协程本身由 Dispatchers 驱动,而这些调度器提供了线程池或单线程的安全性保障。

并发控制:

对于复杂场景(如订阅者的注册与事件分发),通过锁(如 Mutex)和等待通知机制来确保安全。

Mutex 详解

1、什么是 Mutex?

Mutex 是协程库提供的一个 协程友好的互斥锁,它允许多个协程安全地访问共享资源。

不同于传统的线程锁(如 ReentrantLock),Mutex 针对协程进行了优化,支持挂起操作而非阻塞线程。

2、Mutex 的核心特性

挂起而非阻塞:

如果一个协程尝试获取已被持有的锁,它将被挂起,而非阻塞整个线程。这使得其他协程或任务可以继续运行。

公平性支持:

Mutex 默认是公平锁,按照请求的顺序授予锁,从而避免线程饥饿。

重入支持:

协程可以重复获取同一个锁(类似 Java 的 ReentrantLock)。

3、使用 Mutex 的场景

保护共享资源:

在多协程并发访问共享变量时,避免状态不一致。

实现协程之间的临界区控制:

限制某段代码在某一时刻只能被一个协程执行。

4、Mutex 的核心方法

lock() 和 unlock() 手动获取和释放锁

kotlin 复制代码
val mutex = Mutex()

suspend fun criticalSection() {
    mutex.lock() // 获取锁
    try {
        // 临界区代码
    } finally {
        mutex.unlock() // 确保释放锁
    }
}

withLock() 推荐的方法,用于自动管理锁的获取和释放。

kotlin 复制代码
val mutex = Mutex()

suspend fun criticalSection() {
    mutex.withLock {
        // 临界区代码
    }
}

5、Mutex 可能导致死锁的场景

锁重入的死锁

虽然 Mutex 支持协程的重入,但在多个协程嵌套调用同一资源时,可能会导致死锁。例如:

kotlin 复制代码
val mutex = Mutex()

suspend fun operationA() {
    mutex.withLock {
        operationB() // 递归调用会尝试重新获取同一把锁
    }
}

suspend fun operationB() {
    mutex.withLock {
        // 临界区
    }
}

多个锁交叉获取的死锁

如果多个协程以不同的顺序获取多个锁,可能会形成死锁。

kotlin 复制代码
val lock1 = Mutex()
val lock2 = Mutex()

suspend fun operation1() {
    lock1.withLock {
        delay(50) // 模拟操作
        lock2.withLock { /* 临界区 */ }
    }
}

suspend fun operation2() {
    lock2.withLock {
        delay(50) // 模拟操作
        lock1.withLock { /* 临界区 */ }
    }
}

在这种情况下,operation1 和 operation2 都会等待对方释放锁,导致死锁。

6、如何避免死锁?

Mutex 本身无法完全避免死锁问题,但通过合理的使用方式,可以极大地减少发生死锁的风险。合理设计协程的并发逻辑,并避免复杂的锁依赖关系,是避免死锁的根本解决方法

保持锁获取的顺序一致

多个锁的获取顺序应保持一致,以避免交叉锁定。

kotlin 复制代码
suspend fun operation1() {
    lock1.withLock {
        lock2.withLock { /* 临界区 */ }
    }
}

suspend fun operation2() {
    lock1.withLock {
        lock2.withLock { /* 临界区 */ }
    }
}

减少锁的粒度

尽可能减少锁的范围,只在绝对必要的临界区内持有锁,从而减少锁竞争和死锁的可能性。

kotlin 复制代码
suspend fun operation() {
    // 执行非临界区代码
    lock.withLock {
        // 仅锁住临界区
    }
}

使用超时

使用超时来避免协程无限期等待锁,可以通过 withTimeout 来实现:

kotlin 复制代码
import kotlinx.coroutines.withTimeout

val mutex = Mutex()

suspend fun safeLock() {
    try {
        withTimeout(1000) { // 1秒超时
            mutex.withLock {
                // 临界区代码
            }
        }
    } catch (e: TimeoutCancellationException) {
        println("获取锁超时,避免了死锁")
    }
}

避免嵌套锁定

尽量避免在 withLock 内部再次调用另一个 withLock,改为拆分逻辑,减少锁的嵌套。

使用 tryLock(非阻塞锁)

Mutex 提供了 tryLock 方法,可以尝试获取锁而不会阻塞:

kotlin 复制代码
if (mutex.tryLock()) {
    try {
        // 临界区代码
    } finally {
        mutex.unlock()
    }
} else {
    println("锁已被占用")
}

使用更高层次的并发控制工具

如果业务逻辑允许,可以使用更高级的并发工具,比如 StateFlow、Channel 或 atomic 变量,避免显式使用锁。

7、Mutex 的优缺点

优点 缺点
协程友好,挂起而非阻塞线程 存在锁竞争时会降低并发性能
提供线程间的临界区保护 可能导致死锁(如果不正确释放锁)
支持公平性,防止线程饥饿 实现稍微复杂,尤其是在高并发场景
相关推荐
Devil枫2 小时前
Kotlin高级特性深度解析
android·开发语言·kotlin
ChinaDragonDreamer2 小时前
Kotlin:2.1.20 的新特性
android·开发语言·kotlin
雨白12 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
kk爱闹14 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空16 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭16 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日17 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安17 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑17 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟21 小时前
CTF Web的数组巧用
android