本文已参与「新人创作礼」活动,一起开启掘金创作之路。
这篇文章我们介绍协程中并发安全相关的知识。和多线程开发类似,多协程开发时,也需要关注并发是否安全。
在默认情况下,并发是不安全的。在协程中,也可以使用多线程相关的并发安全工具,如 CAS、锁、信号量等等。除此之外,协程本身也有一些方便的并发安全工具,如前面介绍过的 Channel,还有轻量级锁 Mutex,轻量级信号量 Semaphore。
一、并发不安全
在默认情况下,并发是不安全的,原因和线程不安全是一样的。协程会将变量拷贝一份保存到自己的工作内存,并且修改了变量后,不会立即回写到主内存。导致线程不安全。
举个例子:
runBlocking {
var count = 0
val job1 = GlobalScope.launch {
repeat(100000) {
count++
}
}
val job2 = GlobalScope.launch {
repeat(100000) {
count--
}
}
job1.join()
job2.join()
println("count: $count")
}
在这个例子中,我们开启一个协程,将 count 加 100000 次,再开启一个协程,将 count 减 100000 次。
按照常理,运行结果应该是 0。运行程序,输出如下:
count: 2751
结果并不是 0,并且多次运行会发现每次运行结果都不尽相同。这就是因为协程默认不是并发安全的。需要一些额外的工作来保证线程安全。
二、多线程中的并发安全工具
在协程中,也可以使用多线程中的并发安全工具。比如我们可以给这两个协程加上锁:
runBlocking {
var count = 0
val lock = ReentrantLock()
val job1 = GlobalScope.launch {
repeat(100000) {
lock.lock()
count++
lock.unlock()
}
}
val job2 = GlobalScope.launch {
repeat(100000) {
lock.lock()
count--
lock.unlock()
}
}
job1.join()
job2.join()
println("count: $count")
}
我们给两个协程加上了可重入锁,在加减操作之前,先将协程锁上,操作完再释放锁。这样就能保证并发安全了。运行程序,输出如下:
count: 0
不论运行多少次,结果都会是 0 了。
也可以使用 synchronized 关键字:
runBlocking {
var count = 0
val lock = Any()
val job1 = GlobalScope.launch {
repeat(100000) {
synchronized(lock) {
count++
}
}
}
val job2 = GlobalScope.launch {
repeat(100000) {
synchronized(lock) {
count--
}
}
}
job1.join()
job2.join()
println("count: $count")
}
通过 synchronized 关键字,也能保证并发安全。
还可以通过 CAS 保证对变量的修改是原子操作:
runBlocking {
val count = AtomicInteger(0)
val job1 = GlobalScope.launch {
repeat(100000) {
count.incrementAndGet()
}
}
val job2 = GlobalScope.launch {
repeat(100000) {
count.decrementAndGet()
}
}
job1.join()
job2.join()
println("count: $count")
}
运行程序,输出如下:
count: 0
所以多线程中的并发安全工具在多协程开发时也是适用的。
三、协程中的并发安全工具
协程中有其特有的并发安全工具,通常这些并发安全工具比多线程中的并发安全工具更轻量。包括以下几种:
- Channel:本质上是一个并发安全的消息通道。
- Mutex:它是一种轻量级锁,在使用上非常类似线程锁。之所以说它轻量,是因为它在获取不到锁时不会阻塞线程,而是挂起线程。
- Semaphore:协程中的信号量。
Channel 的内容我们前文已经介绍过,这里不再赘述。看一下 Mutex 的使用:
runBlocking {
var count = 0
val mutex = Mutex()
val job1 = GlobalScope.launch {
repeat(100000) {
mutex.withLock {
count++
}
}
}
val job2 = GlobalScope.launch {
repeat(100000) {
mutex.withLock {
count--
}
}
}
job1.join()
job2.join()
println("count: $count")
}
可以看到,和前文中的线程锁非常类似。定义一个 Mutex() 变量,通过 withLock() 函数给代码块加锁,保证并发安全。
运行程序,输出如下:
count: 0
另外,协程中的信号量和线程的信号量在使用上也是类似的。信号量有点类似于「通行证」,开发者可以设定通行证的数量,保证并发的任务不会过多。当信号量总量为 1 时,效果和锁是一样的。
使用示例:
runBlocking {
var count = 0
val semaphore = Semaphore(1)
val job1 = GlobalScope.launch {
repeat(100000) {
semaphore.withPermit {
count++
}
}
}
val job2 = GlobalScope.launch {
repeat(100000) {
semaphore.withPermit {
count--
}
}
}
job1.join()
job2.join()
println("count: $count")
}
注意这里使用的是 kotlinx.coroutines.sync 包下的 Semaphore 类。我们定义「通行证」数量为 1,通过 withPermit() 函数保证代码块的并发安全。
运行程序,输出如下:
count: 0
四、小结
本文我们简要介绍了协程中的一些并发安全工具。包括 Mutex、Semaphore。协程中的并发安全工具和线程中的并发安全工具很类似,不过他们更轻量,所以性能通常也更好。