协程(12) | 协程的并发

2,542 阅读8分钟

前言

在学习协程最开始的时候,我们说过要脱离线程封装库的思维,把协程看成运行在线程上的Task,但是随着后面我们学习了Dispatcher,可以结合withContext函数、自定义CoroutineScope等方法来指定协程运行的线程,所以又不得不和线程打交道。

一旦和多线程打交道,就必须要解决线程安全问题。

正文

关于并发编程,其实是一个很复杂的知识点,涉及的东西非常多,包括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>()
    //重复10次
    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,这是因为子协程所运行的线程是不一样的,也正是因为这样,多个线程同时访问共享变量时就会出现并发问题。

所以协程并发,一定要理解其本质,看共享变量是否同时被多个线程所访问

使用Java的解决办法

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

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

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

这里对i++这个操作用synchronized给包裹起来了,根据Java的内存模型我们可以知道,当使用synchronized时可以保证同时只有一个线程访问临界区,同时lock的解锁Happens Beforelock的加锁,所以这里的结果就是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程序员说出这个词,估计会被打。因为我们平时说的并发肯定指的是多线程,这里却来了个单线程并发。所以我们要抛弃固有思想,前面说了协程是比线程更轻量,可以成千上万个协程运行在一个线程上,所以当协程面临并发问题的时候,可以首先考虑:是否真的需要多线程

比如下面代码:

//单线程线程池的CoroutineDispatcher
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计算模型

这里还介绍一种通用的并发模型,即Actor。关于这个概念,在很多其他编程领域有涉及,在Actor模型中,所有计算都是在Actor中,而且是基于消息的。

这里基于消息的机制,其实对于面向对象来说很容易误解,毕竟我们一个对象和另一个对象发送消息时,一般都是通过调用对象的方法,而在Actor模型中,消息就是单纯的消息。

我们来看看Kotlin的协程是如何实现Actor模型的,下面是实例代码:

/**
 * 密封类,用于定义向[actor]中发送的消息
 * */
sealed class Msg

/**
 * 增加消息,单例,用于在[actor]被处理
 * */
object AddMsg : Msg()

/**
 * 获取结果的消息,用于获取[actor]中
 * 的结果
 * */
class ResultMsg(
    val result: CompletableDeferred<Int>
) : Msg()

fun main() = runBlocking {

    /**
     * 这里涉及Actor模型,在一个Actor系统中,用的是消息[Msg]来通信,
     * 比如这里可以往[actor]中发送2种消息
     * */
    suspend fun addActor() = actor<Msg> {
        var counter = 0
        //这里的for循环其实在[Channel]中说过,是用来获取[channel]中的
        //数据,是一种简写。
        for (msg in channel) {
            when (msg) {
                is AddMsg -> counter++
                is ResultMsg -> msg.result.complete(counter)
            }
        }
    }

    //函数类型引用
    val actor = addActor()
    val jobs = mutableListOf<Job>()

    repeat(10) {
        //启动10个协程
        val job = launch(Dispatchers.Default) {
            repeat(1000) {
                //在每个协程中,往[actor]发送1000次消息
                actor.send(AddMsg)
            }
        }
        jobs.add(job)
    }

    jobs.joinAll()
    //发送获取结果的消息
    val deferred = CompletableDeferred<Int>()
    actor.send(ResultMsg(deferred))
    //挂起函数,等结果
    val result = deferred.await()
    actor.close()

    println("i = $result")
}

在上面代码中,actor函数就相当于是一个Actor系统,我们可以通过send方法向其中发送AddMsgResultMsg,虽然我们在10个协程中,分别发送了AddMsg消息,但是最终结果还是10000,并没有线程安全问题。

关于actor的更多原理我们不做研究,我们来看一下其函数定义:

public fun <E> CoroutineScope.actor(
    context: CoroutineContext = EmptyCoroutineContext,
    capacity: Int = 0, // todo: Maybe Channel.DEFAULT here?
    start: CoroutineStart = CoroutineStart.DEFAULT,
    onCompletion: CompletionHandler? = null,
    block: suspend ActorScope<E>.() -> Unit
): SendChannel<E> {
    val newContext = newCoroutineContext(context)
    val channel = Channel<E>(capacity)
    val coroutine = if (start.isLazy)
        LazyActorCoroutine(newContext, channel, block) else
        ActorCoroutine(newContext, channel, active = true)
    if (onCompletion != null) coroutine.invokeOnCompletion(handler = onCompletion)
    coroutine.start(start, coroutine, block)
    return coroutine
}

从这里发现,Kotlin的actor模型其实是Channel来实现的,其返回值类型是SendChannel,这也是为什么我们可以调用send方法的原因。

总结

关于协程的并发,我们不仅仅要把协程看成运行在线程上的Task,更要能区分协程是否出现挂起、是否在多个线程上运行,以及是否存在线程共享变量的问题,即线程安全的本质是多线程。

所以这里提供了2个思路,一个是Java的解决方法,比如synchronized关键字,但是Java中的锁会阻塞线程,这个在非阻塞的协程中是不推荐的。

另一个就是协程推荐的方法,比如单线程并发,非阻塞的锁Mutex,以及Channel实现的Actor模型。