Kotlin 协程 (十四) ——— 并发安全

1,336 阅读2分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

这篇文章我们介绍协程中并发安全相关的知识。和多线程开发类似,多协程开发时,也需要关注并发是否安全。

在默认情况下,并发是不安全的。在协程中,也可以使用多线程相关的并发安全工具,如 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。协程中的并发安全工具和线程中的并发安全工具很类似,不过他们更轻量,所以性能通常也更好。