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 等机制,可以确保在协程并发修改共享状态时不会出现数据不一致问题。理解协程安全与线程安全的差异与相似之处,是编写健壮且高效代码的关键。

相关推荐
闲暇部落32 分钟前
‌Kotlin中的?.和!!主要区别
android·开发语言·kotlin
诸神黄昏EX3 小时前
Android 分区相关介绍
android
大白要努力!4 小时前
android 使用SQLiteOpenHelper 如何优化数据库的性能
android·数据库·oracle
Estar.Lee4 小时前
时间操作[取当前北京时间]免费API接口教程
android·网络·后端·网络协议·tcp/ip
Winston Wood4 小时前
Perfetto学习大全
android·性能优化·perfetto
Dnelic-7 小时前
【单元测试】【Android】JUnit 4 和 JUnit 5 的差异记录
android·junit·单元测试·android studio·自学笔记
Eastsea.Chen9 小时前
MTK Android12 user版本MtkLogger
android·framework
长亭外的少年16 小时前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
建群新人小猿19 小时前
会员等级经验问题
android·开发语言·前端·javascript·php
1024小神20 小时前
tauri2.0版本开发苹果ios和安卓android应用,环境搭建和最后编译为apk
android·ios·tauri