• Kotlin 朱涛22 协程 并发 同步 Mutex Actor


    本文地址


    目录

    22 | 并发:协程不需要处理同步吗?

    虽然 Kotlin 的协程仍然是基于线程运行的,但是,经过层层封装以后,Kotlin 协程应对并发问题的处理手段,其实跟 Java 就大不一样了。

    协程与并发

    案例一:一个线程一个协程

    fun main() = runBlocking {
        val startTime = System.currentTimeMillis()
        fun log(text: Any) =
            println("$text - ${Thread.currentThread().name} - ${System.currentTimeMillis() - startTime}")
    
        var i = 0
        val deferred = async(Dispatchers.Default) { // Default 线程池
            repeat(10) { num ->
                log("$num")
                delay(1000)
                repeat(1000) { i++ }
            }
        }
    
        deferred.await()
        log("i = $i") // 总耗时约等于 10 个 delay
    }
    
    0 - DefaultDispatcher-worker-1 @coroutine#2 - 8
    1 - DefaultDispatcher-worker-1 @coroutine#2 - 1022
    2 - DefaultDispatcher-worker-1 @coroutine#2 - 2031
    3 - DefaultDispatcher-worker-1 @coroutine#2 - 3045
    4 - DefaultDispatcher-worker-1 @coroutine#2 - 4052
    5 - DefaultDispatcher-worker-1 @coroutine#2 - 5063
    6 - DefaultDispatcher-worker-1 @coroutine#2 - 6065
    7 - DefaultDispatcher-worker-1 @coroutine#2 - 7068
    8 - DefaultDispatcher-worker-1 @coroutine#2 - 8082
    9 - DefaultDispatcher-worker-1 @coroutine#2 - 9096
    i = 10000 - main @coroutine#1 - 10114
    

    上面的代码中,虽然 Default 线程池内部有多个线程,但压根就没有并发执行的任务,所有对 i 的计算都发生在一个协程中。所以,在这种情况下,就不需要考虑同步的问题。

    案例二:多个线程多个协程

    fun main() = runBlocking {
        var i = 0
        val jobs = mutableListOf<Job>()
        repeat(10) { num ->
            val job = launch(Dispatchers.Default) { // Default 线程池
                log("$num")
                delay(1000) // 可能是因为我电脑性能太好了,如果不加 delay 的话,很难复现问题
                repeat(1000) { i++ }
            }
            jobs.add(job)
        }
        jobs.joinAll()
        log("i = $i") // 总耗时约等于 1 个 delay
    }
    
    0 - DefaultDispatcher-worker-1 @coroutine#2 - 8
    1 - DefaultDispatcher-worker-2 @coroutine#3 - 11
    // ...
    9 - DefaultDispatcher-worker-10 @coroutine#11 - 12
    i = 3788 - main @coroutine#1 - 1025
    

    运行后发现,结果很可能不是 10000。原因也很简单,这 10 个协程分别运行在不同的线程之上,并且这 10 个协程还会以并发的形式对 i 进行修改,所以自然就会产生同步的问题。

    案例三:单个线程多个协程

    fun main() = runBlocking {
        val dispatcher = Executors.newSingleThreadExecutor { Thread(it, "bqt") }.asCoroutineDispatcher()
        var i = 0
        val jobs = mutableListOf<Job>()
        repeat(10) { num ->
            val job = launch(dispatcher) { // 分发到单一线程之上
                log("$num")
                delay(1000)
                repeat(1000) { i++ }
            }
            jobs.add(job)
        }
        jobs.joinAll()
        log("i = $i") // 总耗时约等于 1 个 delay
    }
    
    0 - bqt @coroutine#2 - 37
    1 - bqt @coroutine#3 - 44
    // ...
    9 - bqt @coroutine#11 - 44
    i = 10000 - main @coroutine#1 - 1049
    

    上面这段代码中,我们把所有的协程任务都分发到了单线程的 Dispatcher 中,这样一来,我们就不必担心同步问题了。另外,上面创建的 10 个协程之间,其实仍然是并发执行的。

    案例四:单线程并发

    其实,案例三也是单线程并发,下面是另外一个典型案例。

    fun main() = runBlocking {
        val deferred1: Deferred<String> = async { log("1"); delay(1000L); "Result1" }
        val deferred2: Deferred<String> = async { log("2"); delay(1000L); "Result2" }
        val deferred3: Deferred<String> = async { log("3"); delay(1000L); "Result3" }
    
        val results: List<String> = listOf(deferred1.await(), deferred2.await(), deferred3.await())
        log(results) // 总耗时约等于 1 个 delay
    }
    
    1 - main @coroutine#2 - 11
    2 - main @coroutine#3 - 13
    3 - main @coroutine#4 - 13
    [Result1, Result2, Result3] - main @coroutine#1 - 1024
    

    上面的代码中启动了三个协程,它们之间是并发执行的,而且,这几个协程是运行在同一个线程 main 之上的。

    协程的并发

    由于 Kotlin 协程也是基于 JVM 的,所以,当我们面对并发问题的时候,脑子里第一时间想到的肯定是 Java 当中的同步手段,比如 synchronizedLock、Atomic,等等。

    Java 中最简单的同步方式就是 synchronized 同步了,换到 Kotlin 里,可以使用 @Synchronized 注解来修饰函数,也可以使用 synchronized(){} 高阶函数来实现同步代码块。

    高阶函数 synchronized

    fun main() = runBlocking {
        var i = 0
        val lock = Any()
        val jobs = mutableListOf<Job>()
        repeat(10) { num ->
            val job = launch(Dispatchers.Default) {
                log("$num")
                delay(1000)
                repeat(1000) {
                    synchronized(lock) { i++ } // synchronized 同步代码块,不能调用挂起函数
                }
            }
            jobs.add(job)
        }
        jobs.joinAll()
        log("i = $i") // 总耗时约等于 1 个 delay
    }
    
    0 - DefaultDispatcher-worker-1 @coroutine#2 - 8
    1 - DefaultDispatcher-worker-2 @coroutine#3 - 11
    // ...
    9 - DefaultDispatcher-worker-10 @coroutine#11 - 12
    i = 10000 - main @coroutine#1 - 1030
    

    注意,在 synchronized(){} 中调用挂起函数,编译器会报错:The 'xxx' suspension point is inside a critical section

    原因是:这里的挂起函数会被转换成带有 Continuation 的异步函数,从而就造成了 synchronid 代码块无法正确处理同步。

    非阻塞式锁 Mutex

    Java 中的 Lock 等同步锁阻塞式的,会影响协程的非阻塞式的特性。所以,在 Kotlin 协程中,不推荐直接使用传统的同步锁。甚至,在某些场景下,在协程中使用 Java 的锁也会遇到意想不到的问题。

    为此,Kotlin 提供了非阻塞式的锁 Mutex,其对比 JDK 中的锁,最大的优势就在于支持挂起和恢复。

    public interface Mutex {
        public val isLocked: Boolean
        public suspend fun lock(owner: Any? = null) // 挂起函数
        public fun unlock(owner: Any? = null)       // 非挂起函数
    }
    

    可以看到,Mutex 接口的 lock() 方法是一个挂起函数,这就是实现非阻塞式同步锁的根本原因。

    Mutex 的简单案例

    fun main() = runBlocking {
        var i = 0
        val mutex = Mutex()
        val jobs = mutableListOf<Job>()
        repeat(10) { num ->
            val job = launch(Dispatchers.Default) {
                log("$num")
                delay(1000)
                repeat(1000) {
                    mutex.lock()   // 非阻塞式的锁 Mutex,可以调用挂起函数
                    i++
                    mutex.unlock() // 注意,这种用法有安全隐患,不建议使用
                }
            }
            jobs.add(job)
        }
        jobs.joinAll()
        log("i = $i") // 总耗时约等于 1 个 delay
    }
    
    0 - DefaultDispatcher-worker-1 @coroutine#2 - 10
    1 - DefaultDispatcher-worker-2 @coroutine#3 - 12
    // ...
    9 - DefaultDispatcher-worker-10 @coroutine#11 - 13
    i = 10000 - main @coroutine#1 - 1082
    

    上面的代码中,我们使用 mutex.lock()mutex.unlock() 包裹了需要同步的计算逻辑,这样就可以实现多线程同步了。

    不建议使用 mutex.lock

    上面的代码中,对于 Mutex 的用法并不安全。

    try {
        mutex.lock()
        i++
        i / (i - 100)  // 故意制造异常
        mutex.unlock() // 出现异常时,由于 unlock 无法被调用,会导致程序不会结束
    } catch (e: Exception) {
        log(e)
    }
    
    0 - DefaultDispatcher-worker-1 @coroutine#2 - 10
    1 - DefaultDispatcher-worker-2 @coroutine#3 - 12
    // ...
    9 - DefaultDispatcher-worker-10 @coroutine#11 - 14
    java.lang.ArithmeticException: / by zero - DefaultDispatcher-worker-7 @coroutine#2 - 1021
    

    以上代码会导致 mutex.unlock() 无法被调用。这个时候,整个程序的执行流程就会一直卡住,无法结束

    建议使用 mutex.withLock

    为了避免出现上面的问题,应该使用 Kotlin 提供的扩展函数 mutex.withLock{}

    public suspend inline fun <T> Mutex.withLock(owner: Any? = null, action: () -> T): T {
        lock(owner)
        try {
            return action()
        } finally {          // 注意,这里并没有 catch 代码块,所以不会捕获异常
            unlock(owner)
        }
    }
    

    可以看到,withLock{} 的本质,其实是在 finally{} 中调用了 unlock()。这样一来,我们就再也不必担心因为异常导致 unlock() 无法执行的问题了。

    try {
        mutex.withLock {  // 使用扩展函数 withLock
            i++
            i / (i - 100) // 故意制造异常
        }
    } catch (e: Exception) {
        log(e)
    }
    
    0 - DefaultDispatcher-worker-1 @coroutine#2 - 10
    // ...
    8 - DefaultDispatcher-worker-9 @coroutine#10 - 14
    9 - DefaultDispatcher-worker-10 @coroutine#11 - 14
    java.lang.ArithmeticException: / by zero - DefaultDispatcher-worker-9 @coroutine#11 - 1018
    i = 10000 - main @coroutine#1 - 1071
    

    Actor

    Actor 是在很多编程语言中都存在的一个并发同步模型,在 Kotlin 中,它本质上是基于 Channel 管道消息实现的。

    sealed class Msg      // 密封类,用于定义两种消息类型
    object AddMsg : Msg() // 用于计算 i++
    class ResultMsg(val deferred: CompletableDeferred<Int>) : Msg() // 用于返回计算结果
    
    fun main() = runBlocking {
        val startTime = System.currentTimeMillis()
        fun log(text: Any) =
            println("$text - ${Thread.currentThread().name} - ${System.currentTimeMillis() - startTime}")
    
        val actor: SendChannel<Msg> = actor { // 高阶函数 actor
            var counter = 0
            for (msg in channel) {
                when (msg) {       // 处理密封类的两种消息类型
                    is AddMsg -> counter++ // 计算 i++
                    is ResultMsg -> msg.deferred.complete(counter) // 返回计算结果
                }
            }
        }
    
        val jobs = mutableListOf<Job>()
        repeat(10) { num ->
            val job = launch(Dispatchers.Default) {
                log("$num")
                delay(1000)
                repeat(1000) { actor.send(AddMsg) } // 重复发送 AddMsg 消息
            }
            jobs.add(job)
        }
        jobs.joinAll()
    
        val deferred = CompletableDeferred<Int>()
        actor.send(ResultMsg(deferred)) // 发送 ResultMsg 消息
        val result = deferred.await()   // 取回计算结果
        actor.close()
        log("i = $result") // 总耗时约等于 1 个 delay
    }
    
    4 - DefaultDispatcher-worker-4 @coroutine#7 - 18
    8 - DefaultDispatcher-worker-9 @coroutine#11 - 18
    // ...
    2 - DefaultDispatcher-worker-6 @coroutine#5 - 18
    i = 10000 - main @coroutine#1 - 1115
    

    高阶函数 actor() 的返回值类型是 SendChannel,所以,Actor 其实就是 Channel 的简单封装,Actor 的多线程同步能力都源自于 Channel。

    虽然 AddMsg 消息是在多线程并行发送的,但是 Channel 可以保证接收到的消息可以同步接收并处理

    PS:Kotlin 目前的 Actor 实现还比较简陋,未来官方会对 Actor API 进行重构

    避免共享可变状态

    多线程并发之所以需要考虑同步问题,是因为多线程并发时,往往会有共享的可变状态,而如果可以避免共享可变状态,就不需要考虑同步问题了。

    不共享可变状态

    fun main() = runBlocking {
        val deferreds = mutableListOf<Deferred<Int>>()
        repeat(10) { num ->
            val deferred = async(Dispatchers.Default) { // 每个协程都是独立的计算
                log("$num")
                delay(1000)
                var i = 0            // 局部变量,不再共享可变状态
                repeat(1000) { i++ }
                return@async i       // 每个协程都可以返回计算结果
            }
            deferreds.add(deferred)
        }
        var result = 0
        deferreds.forEach {
            result += it.await() // 将 10 个协程的结果累加起来
        }
        log("i = $result") // 总耗时约等于 1 个 delay
    }
    
    0 - DefaultDispatcher-worker-1 @coroutine#2 - 8
    1 - DefaultDispatcher-worker-2 @coroutine#3 - 10
    // ...
    9 - DefaultDispatcher-worker-10 @coroutine#11 - 11
    i = 10000 - main @coroutine#1 - 1019
    

    上面的代码中,我们不再共享可变状态 i,对应的,在每一个协程中,都有一个局部变量 i,同时将 launch 都改为了 async,让每一个协程都可以返回计算结果

    这样一来,每个协程都可以进行独立的计算(多线程并发计算),然后我们将 10 个协程的结果累加起来。

    使用函数式编程

    上面的思路,其实也是借鉴自函数式编程的思想,因为在函数式编程中,就是追求不变性、无副作用。不过,以上代码其实还是命令式的代码,如果用函数式风格来重构的话,代码会更加简洁。

    fun main() = runBlocking {
        (1..10).map { num ->
            async(Dispatchers.Default) {
                log("$num")
                delay(1000)
                var i = 0
                repeat(1000) { i++ }
                return@async i
            }
        }.awaitAll()
            .sum()
            .also { log("i = $it") }
        log("end")
    }
    
    1 - DefaultDispatcher-worker-1 @coroutine#2 - 10
    2 - DefaultDispatcher-worker-2 @coroutine#3 - 12
    // ...
    10 - DefaultDispatcher-worker-10 @coroutine#11 - 14
    i = 10000 - main @coroutine#1 - 1018
    end - main @coroutine#1 - 1018
    

    小结

    Java 中的同步手段,不能直接照搬到 Kotlin 协程中,其中最大的问题就是,synchronized 不支持挂起函数

    协程并发主要有 4 种方案:

    • 单线程并发,在 Java 世界里,并发往往意味着多线程,但在 Kotlin 协程中,我们可以轻松实现单线程并发,这时候我们就不用担心多线程同步的问题了。
    • 官方提供的协程同步锁 Mutex,由于它的 lock 方法是挂起函数,所以跟 JDK 中的锁不一样,它是非阻塞的。在使用 Mutex 时,应该使用 withLock 而不是 lock/unlock
    • 官方提供的 Actor,这是一种普遍存在的并发模型。在目前的版本中,Actor 只是 Channel 的简单封装,它的 API 会在未来的版本发生改变。
    • 借助函数式思维。当我们借助函数式编程思维,实现无副作用和不变性以后,并发代码也会随之变得安全。

    2017-02-20

  • 相关阅读:
    【SpringFramework】Spring 事务控制
    Mini 学生管理器
    方法的重写override,重载overload。
    方法封装,属性调用以及设置。
    判断hdfs文件是否存在
    模拟(删除/远程拷贝)当前一周的日志文件
    2.上传hdfs系统:将logs目录下的日志文件每隔十分钟上传一次 要求:上传后的文件名修为:2017111513xx.log_copy
    使用定时器:在logs目录,每两分钟产生一个文件
    五个节点的hadoop集群--主要配置文件
    hadoop集群配置文件与功能对应解析
  • 原文地址:https://www.cnblogs.com/baiqiantao/p/6420595.html
Copyright © 2020-2023  润新知