协程粉碎计划 | 协程并发的同步问题

·  阅读 1513
协程粉碎计划 | 协程并发的同步问题

本系列专栏 #Kotlin协程

前言

通过前面的文章介绍,我们已经很熟悉协程了,我们可以简单来概括一下。

首先协程可以看成运行在线程上更轻量的Task,它不会和特定线程绑定。其次协程第一大优势就是挂起函数,通过挂起函数的CPS转换我们可以实现挂起和恢复,从而可以用同步的方式写出异步的代码,避免回调地狱。协程第二大优势是结构化并发(带有层次和结构的并发),启动的协程都返回Job,从而让线程有父子线程的关系,可以操控父协程来控制多个协程。第三大优势就是Channel和Flow各种挂起函数API的类,可以帮助我们处理复杂的业务。

本篇文章将介绍不论在哪个编程语言中都会说的问题:并发编程。

正文

关于并发编程,其实是一个很复杂的知识点,涉及的东西非常多,包括CPU缓存、操作系统的复用、JVM指令和CPU指令原子性不一等知识,有兴趣的可以查看我另一个专栏,专门说并发编程的:

Java并发编程专栏 - 元浩875的专栏 - 掘金 (juejin.cn)

所谓并发编程的主要问题就是安全性,我们经常说某个类的方法不是线程安全的,这里简单来说一共有3个问题:CPU缓存导致的可见性问题,线程切换导致的原子性问题和指令重排序导致的有序性问题

在前面文章我们经常把协程看成轻量级的线程,所以这里我们先来看看协程的并发。

协程与并发

首先有个点我们要明确,并发编程的问题的前提是多线程,那下面代码:

fun main() = runBlocking {
    var i = 0
    // Default 线程池
    launch(Dispatchers.Default) {
        repeat(1000) {
            logX("i = $i")
            i++
        }
    }
    delay(1000L)
    println("i = $i")
}
复制代码

这段代码我们来仔细想一下,结果会是多少。

首先变量i在方法内,它在线程池中被自增,按照惯性思维这个i会被多个线程访问,当i被多个线程访问时,这里就会出现一个问题就是:共享变量i会被CPU执行完后保存在CPU缓存中还没来得及刷新到内存中便被其他线程处理,或者由于i++在CPU层面不是一个原子操作,会出现i++还没有执行完便切换线程,这都会导致i的值小于1000。

但是打印结果这个i是1000。这里是为什么呢

原因非常简单,这里launch就启动了一个协程,而且这个协程中没有调用挂起函数,它只在一个线程中运行,所以不存在并发问题。所以这里一定要分清楚线程和协程的关系,同时要记住并发问题的原因是多线程

那么看下面代码:

fun main() = runBlocking {
    var i = 0
    val jobs = mutableListOf<Job>()

    // 重复十次
    repeat(10){
        val job = launch(Dispatchers.Default) {
            repeat(1000) {
                i++
            }
            logX("job 线程")
        }
        jobs.add(job)
    }
    // 等待计算完成
    jobs.joinAll()
    println("i = $i")
}
复制代码

这里依旧在Default线程池上开启了10个协程,而且每个协程都自增1000次,那结果是10000吗 会发现这里的打印结果会不到10000,原因非常简单,打印如下:

================================
job 线程
Thread:DefaultDispatcher-worker-7 @coroutine#8
================================
================================
job 线程
Thread:DefaultDispatcher-worker-4 @coroutine#5
================================
================================
job 线程
Thread:DefaultDispatcher-worker-10 @coroutine#11
================================
================================
job 线程
Thread:DefaultDispatcher-worker-8 @coroutine#9
================================
================================
job 线程
Thread:DefaultDispatcher-worker-2 @coroutine#3
================================
================================
job 线程
Thread:DefaultDispatcher-worker-5 @coroutine#7
================================
================================
job 线程
Thread:DefaultDispatcher-worker-9 @coroutine#10
================================
================================
job 线程
Thread:DefaultDispatcher-worker-3 @coroutine#4
================================
================================
job 线程
Thread:DefaultDispatcher-worker-6 @coroutine#6
================================
================================
job 线程
Thread:DefaultDispatcher-worker-1 @coroutine#2
================================
i = 9623

Process finished with exit code 0

复制代码

会发现这里的子协程所运行的线程是不一样的,也正是因为这样,多个线程同时访问共享变量时就会出现并发问题,所以协程并发,一定要理解其本质,看共享变量是否同时被多个线程所访问。

使用Java的解决办法

我们知道不论是Kotlin还是Java都是运行在JVM上的,所以解决协程的并发问题,第一个思路就是使用Java中的同步手段,比如synchronized、Atomic、Lock等,那我们就使用synchronized来改造下面代码:

fun main() = runBlocking {
    var i = 0
    val jobs = mutableListOf<Job>()
    val lock = Any()

    // 重复十次
    repeat(10){
        val job = launch(Dispatchers.Default) {
            repeat(1000) {
                synchronized(lock){
                    i++
                }
            }
            logX("job 线程")
        }
        jobs.add(job)
    }
    // 等待计算完成
    jobs.joinAll()
    println("i = $i")
}
复制代码

这里对 i++ 这个操作用synchronized给包裹起来了,根据Java的内存模型我们可以知道,当使用synchronized时可以保证同时只有一个线程访问临界区,同时lock的解锁Happens Before对lock的加锁,所以这里的结果就是10000。

或者使用;

var i : AtomicInteger = AtomicInteger(0)
复制代码

线程安全的原子类也可以实现。

但是这有个问题,比如上面使用synchronized关键字来同步一个代码块,这时如果在这个代码块中调用了挂起函数,如下:

repeat(10){
    val job = launch(Dispatchers.Default) {
        repeat(1000) {
            synchronized(lock){
                //这里代码直接报错
                prepare()
                i++
            }
        }
        logX("job 线程")
    }
    jobs.add(job)
}
复制代码

这个代码是无法编译的:

image.png

提示挂起点在一个临界区内,这里为什么不可以 我们可以想象一下挂起函数的本质,这里当调用prepare()函数时,i++代码会被CPS成回调中的代码,这里明显无法确保正确的处理同步,因为必须要等prepare()恢复后再进行i++,这明显不符合多线程安全要求。

所以我们需要扩展思路,使用Kotlin协程当中的并发思路。

Kotlin协程并发思路

既然Kotlin的协程设计为更轻量的线程,那它自己应该也有应对协程并发的方法,现在我们就来看看协程有哪些方法。

单线程并发

当对Java程序员说出这个词,估计会被打。因为我们平时说的并发肯定指的是多线程,这里却来了个单线程并发。所以我们要抛弃固有思想,前面说了协程是比线程更轻量,可以成千上万个协程运行在一个线程上,所以当协程面临并发问题的时候,可以首先考虑:是否真的需要多线程

比如下面代码:

val mySingleDispatcher = Executors.newSingleThreadExecutor {
    Thread(it, "MySingleThread").apply {
        isDaemon = true
    }
}.asCoroutineDispatcher()

fun main() = runBlocking {
    var i = 0
    val jobs = mutableListOf<Job>()

    // 重复十次
    repeat(10){
        val job = launch(mySingleDispatcher) {
            repeat(1000) {
                    i++
            }
            logX("job 线程")
        }
        jobs.add(job)
    }
    // 等待计算完成
    jobs.joinAll()
    println("i = $i")
}
复制代码

这里开启的10个协程都运行在mySingleDispatcher上,所以不存在并发问题,这就是单线程并发。

不过这种方式只能用于简单的、对性能要求不高的任务,因为不管在单线程上如何并发,在系统底层,它都是一个线程在忙,所以对性能没有提升,而对于复杂的业务需要多线程来提高效率时,这种方式就不适合。

Mutex

在Java中,我们可以使用Lock之类的同步锁来完成保证并发的安全性,但是Java的锁是阻塞式的,会大大影响协程的非阻塞的特性。所以在协程中,官方提供了非阻塞的锁:Mutex,我们来看看下面代码:

fun main() = runBlocking {
    var i = 0
    val jobs = mutableListOf<Job>()
    val mutex = Mutex()

    // 重复十次
    repeat(10){
        val job = launch(Dispatchers.Default) {
            repeat(1000) {
                mutex.lock()
                i++
                mutex.unlock()
            }
            logX("job 线程")
        }
        jobs.add(job)
    }
    // 等待计算完成
    jobs.joinAll()
    println("i = $i")
}
复制代码

上面代码我们使用mutex作为锁,在临界区前后进行加锁和解锁。

这个Mutex和Java中的锁最大区别是支持挂起和恢复,我们来看看源码定义:

public suspend fun lock(owner: Any? = null)
复制代码

这里是一个挂起函数,也就是当一个协程尝试去获取锁时,发现锁已经被其他协程获得时,会挂起,当锁可用时再恢复。但是上面代码的使用却有很大的安全隐患,比如下面代码:

repeat(1000) {
    mutex.lock()
    i++
    if (i == 5000){
        i / 0  //故意制造异常
    }
    mutex.unlock()
}
复制代码

比如这里的代码在mutex.lock()和mutex.unlock()之间发生了异常,就会导致mutex.unlock()不会被调用,这时其他协程就会获取不到mutex,会一直挂起,协程提供了一个扩展函数:mutex.withLock{}

我们就使用这个扩展函数来优化一下上面的代码:

repeat(1000) {
    mutex.withLock {
        i++
    }
}
复制代码

那这里是为什么呢 我们可以看一下withLock的源码:

public suspend inline fun <T> Mutex.withLock(owner: Any? = null, action: () -> T): T {
    contract { 
        callsInPlace(action, InvocationKind.EXACTLY_ONCE)
    }

    lock(owner)
    try {
        return action()
    } finally {
        unlock(owner)
    }
}
复制代码

会发现这里进行了finally代码块包裹,来确保即使发生了异常,也能确保解锁。

总结

其实除了上面方法外,协程还有Actor模型可以解决并发的同步问题,但是这里先不做介绍,因为其API还不够完善。

本章内容主要涉及点的并发编程,对于并发编程来说首先要搞清楚其不安全性的原因,本质还是底层多线程同时修改共享变量导致的

所以这里提供了2个思路,一个是Java的解决方法,比如synchronized关键字,另一个是把协程看成更轻量的线程而提出的方法:单线程并发和非阻塞锁Mutex。

分类:
Android
标签:
收藏成功!
已添加到「」, 点击更改