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 、协程2 和 协程3 同时检查
balance
,发现余额足够。 - 协程1 暂时挂起(由于
delay(1000)
),然后 协程2 完成了支出操作,余额减少了。 - 协程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 线程安全
虽然协程和线程在调度方式上有所不同,但它们都需要确保共享状态的并发访问是安全的。以下是它们的区别与相似之处:
-
相似之处:
- 协程和线程都可以并发运行,因此当它们访问共享状态时,都可能引发竞态条件。
- 同样的工具,如
Mutex
或Atomic
类,都可以分别用来确保协程和线程的并发操作是安全的。
-
区别:
- 线程是由操作系统管理的,切换线程有较大的开销,而协程则是由 Kotlin 运行时管理的,它们的开销很小。
- 线程安全通常涉及锁定某个共享资源,使得其他线程在操作完成之前无法访问该资源。协程安全则更多地依赖于语言级别的挂起与恢复机制,使用
Mutex
等工具来避免并发修改共享状态。
总结
协程并不自动保证对共享状态的安全访问,即便是在主线程上。与线程安全类似,协程安全同样需要同步工具来防止竞态条件。在协程中不要使用 Java 的线程锁(如 ReentrantLock
),因为它们无法正确处理协程的挂起与恢复。相反,应该使用 Kotlin 协程库提供的 Mutex
等工具来确保协程安全,通过使用 Mutex
等机制,可以确保在协程并发修改共享状态时不会出现数据不一致问题。理解协程安全与线程安全的差异与相似之处,是编写健壮且高效代码的关键。