目录
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 当中的同步手段,比如 synchronized
、Lock
、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