End

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

posted @ 2017-02-20 17:39  白乾涛  阅读(6048)  评论(0编辑  收藏  举报