Kotlin 并发安全解析:协程与线程的差异与实践

Kotlin 并发安全解析:协程与线程的差异与实践

前言

在开发过程中,我们经常听到"线程安全"这个概念,尤其是在多线程环境下。线程安全性指的是确保多个线程并发访问共享资源时,资源不会出现意外的状态更改或数据不一致的问题。随着 Kotlin 协程的广泛使用,另一个重要的概念也逐渐引起了关注------协程安全

尽管协程和线程存在概念上的区别,但协程安全性和线程安全性之间有着很多相似之处。它们都需要确保当多个执行单元(线程或协程)并发操作共享状态时,不会出现竞态条件(Race Condition)。本文将通过一个示例,深入探讨如何确保协程安全,以及它与线程安全的区别。

协程与线程:概念上的差异

  • 线程(Thread) 是操作系统管理的执行单元。多个线程可以同时执行,并在多核 CPU 上并行处理任务。线程的开销较大,需要上下文切换,容易引发线程安全问题。
  • 协程(Coroutine) 是轻量级的、由语言级别管理的执行单元。协程不像线程那样被操作系统调度,它们是由编程语言的运行时调度。协程可以在同一个线程上并发执行,并通过挂起与恢复来避免阻塞线程。

尽管协程本质上可以在一个线程上运行,但它们仍然是并发的。也就是说,多个协程可以在同一个线程中并发执行,因此在处理共享状态时仍可能面临并发问题。

多协程并发(基于IO调度器)

以下代码展示了一个模拟银行的场景,在这个场景中我们通过协程来执行"花费金钱"的操作。

kotlin 复制代码
class BankAccount {
    private var balance = 1000
    private val lock = ReentrantLock()

    suspend fun spendMoney1(amount: Int = 500) {
        log("Trying to acquire lock")
        lock.lock()
        try {
            if (balance >= amount) {
                delay(1000)
                balance -= amount
            }
        } catch (e: Exception) {
            e.printStackTrace()
            log("${e.message}")
        } finally {
            lock.unlock()
        }
    }
}

fun main() = runBlocking {
    val bankAccount = BankAccount()
    val jobs = List(3) {
        launch(Dispatchers.IO) {
            bankAccount.spendMoney1()
        }
    }
    jobs.forEach {
        it.join()
    }
    log(bankAccount.getBank())
}

在这个示例中,并发 3 个协程(基于IO调度器)来调用 bank.spendMoney1() 函数。在 spendMoney1 函数中,我们检查当前余额是否足够,如果余额足够,就进行支出操作。

运行上面代码,输出:

csharp 复制代码
[DefaultDispatcher-worker-2] Trying to acquire lock
[DefaultDispatcher-worker-1] Trying to acquire lock
[DefaultDispatcher-worker-3] Trying to acquire lock

只看到获取锁,却看不到结果,难道是死锁了?我们加一行日志:

kotlin 复制代码
suspend fun spendMoney1(amount: Int = 500) {
    log("Trying to acquire lock")
    lock.lock()
    try {
        if (balance >= amount) {
            delay(1000)
            balance -= amount
        }
    } catch (e: Exception) {
        e.printStackTrace()
        log("${e.message}")
    } finally {
        log("Trying to unlock")
        lock.unlock()
    }
}

再次运行:

css 复制代码
[DefaultDispatcher-worker-1] Trying to acquire lock
[DefaultDispatcher-worker-3] Trying to acquire lock
[DefaultDispatcher-worker-2] Trying to acquire lock
[DefaultDispatcher-worker-1] Trying to unlock
[DefaultDispatcher-worker-1] Trying to unlock

从日志上看,DefaultDispatcher-worker-1 尝试 unlock 两次,这很奇怪。DefaultDispatcher-worker-1 获取锁一次,却释放两次锁,第二次释放肯定失败的,这也许就是没有结果输出的原因。再次修改代码如下:

kotlin 复制代码
suspend fun spendMoney2(amount: Int = 500) {
    log("Trying to acquire lock")
    lock.lock()
    try {
        if (balance >= amount) {
            log("delay 1000 " + delay(1000))
            balance -= amount
        }
    } catch (e: Exception) {
        e.printStackTrace()
        log("${e.message}")
    } finally {
        if (lock.isHeldByCurrentThread) {
            log("Trying to unlock")
            lock.unlock()
        } else {
            log("Lock not held by current thread")
        }
    }
}

同样并发三次:

kotlin 复制代码
fun main() = runBlocking {
    val bankAccount = BankAccount()
    val jobs = List(3) {
        launch(Dispatchers.IO) {
            bankAccount.spendMoney2()
        }
    }
    jobs.forEach {
        it.join()
    }
    log(bankAccount.getBank())
}

输出结果:

csharp 复制代码
[DefaultDispatcher-worker-1] Trying to acquire lock
[DefaultDispatcher-worker-2] Trying to acquire lock
[DefaultDispatcher-worker-3] Trying to acquire lock
[DefaultDispatcher-worker-1] delay 1000 kotlin.Unit
[DefaultDispatcher-worker-1] Trying to unlock
[DefaultDispatcher-worker-1] delay 1000 kotlin.Unit
[DefaultDispatcher-worker-1] Lock not held by current thread

从日志来看,DefaultDispatcher-worker-2 获取锁之后,delay 发生了线程切换,然后不能正确释放锁,导致无法正确输出结果,也就是说在协程的环境下使用线程的锁是有问题的。

多协程并发(单线程调度器)

kotlin 复制代码
suspend fun spendMoney3(amount: Int = 500) {
    if (balance >= amount) {
        delay(1000)
        balance -= amount
    }
}

fun main() = runBlocking {
    val bankAccount = BankAccount()
    val jobs = List(3) {
        launch {
            bankAccount.spendMoney3()
        }
    }
    jobs.forEach {
        it.join()
    }
    log(bankAccount.getBank())
}

输出结果:

csharp 复制代码
[main] -500

竟然不是0。

为什么即使在主线程上也会出现问题?

虽然我们没有显式地切换到其他线程,并且所有协程都在主线程(UI 线程)上执行,但由于协程的并发特性,问题仍然可能发生。

竞态条件(Race Condition)

多个协程在并发访问共享资源(如 balance)时,可能会发生竞态条件。例如:

  1. 协程1协程2协程3 同时检查 balance,发现余额足够。
  2. 协程1 暂时挂起(由于 delay(1000)),然后 协程2 完成了支出操作,余额减少了。
  3. 协程1 恢复执行,但它的检查是在挂起前进行的,因此它认为余额仍然足够,并继续支出,从而导致余额被错误地扣减三次。

这种竞态条件在并发编程中非常常见,虽然我们没有使用多个线程,但多个协程仍然会并发执行,导致数据不一致的问题。

协程安全的解决方案

为了解决这个问题,我们需要确保当多个协程并发访问共享状态时,它们的操作是安全的。即使协程是轻量级的,它们也需要一些同步机制来确保协程之间不会相互干扰。

在 Kotlin 协程中,我们可以使用 Mutex 来确保协程安全。Mutex 是一种轻量级的锁,它可以确保在同一时刻,只有一个协程可以访问临界区代码(如余额的修改操作)。

使用 Mutex 确保协程安全

通过使用 Mutex,我们可以确保多个协程在修改共享状态时不会互相干扰。以下是修改后的代码:

kotlin 复制代码
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

class BankAccount {
    private val mutex = Mutex()
    private var balance = 1000

    suspend fun spendMoney4(amount: Int = 500) {
        mutex.withLock {
            if (balance >= amount) {
                delay(1000)
                balance -= amount
            }
        }
    }

    fun getBank(): Int {
        return balance
    }
}

fun main() = runBlocking {
    val bankAccount = BankAccount()
    val jobs = List(3) {
        launch {
            bankAccount.spendMoney4()
        }
    }
    jobs.forEach {
        it.join()
    }
    log(bankAccount.getBank())
}

Mutex 避免了多个协程并发修改共享状态时发生的竞态条件。即便这些协程都在主线程上运行,Mutex 也确保了协程之间的操作不会交错。

协程安全 vs 线程安全

虽然协程和线程在调度方式上有所不同,但它们都需要确保共享状态的并发访问是安全的。以下是它们的区别与相似之处:

  • 相似之处

    • 协程和线程都可以并发运行,因此当它们访问共享状态时,都可能引发竞态条件。
    • 同样的工具,如 MutexAtomic 类,都可以分别用来确保协程和线程的并发操作是安全的。
  • 区别

    • 线程是由操作系统管理的,切换线程有较大的开销,而协程则是由 Kotlin 运行时管理的,它们的开销很小。
    • 线程安全通常涉及锁定某个共享资源,使得其他线程在操作完成之前无法访问该资源。协程安全则更多地依赖于语言级别的挂起与恢复机制,使用 Mutex 等工具来避免并发修改共享状态。

总结

协程并不自动保证对共享状态的安全访问,即便是在主线程上。与线程安全类似,协程安全同样需要同步工具来防止竞态条件。在协程中不要使用 Java 的线程锁(如 ReentrantLock),因为它们无法正确处理协程的挂起与恢复。相反,应该使用 Kotlin 协程库提供的 Mutex 等工具来确保协程安全,通过使用 Mutex 等机制,可以确保在协程并发修改共享状态时不会出现数据不一致问题。理解协程安全与线程安全的差异与相似之处,是编写健壮且高效代码的关键。

相关推荐
每次的天空13 分钟前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭41 分钟前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日2 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安2 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑2 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟6 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡7 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi007 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体
zhangphil9 小时前
Android理解onTrimMemory中ComponentCallbacks2的内存警戒水位线值
android
你过来啊你9 小时前
Android View的绘制原理详解
android