共享可变状态和并发
可以使用Dispatchers.Default等多线程调度程序并行执行协程。 它产生了所有常见的并发问题。 主要问题是同步访问共享可变状态。 在协程域中解决这个问题的一些解决方案类似于多线程世界中的解决方案,但其他解决方案却是独一无二的。
问题
让我们开启一百个协程,它们都做了一千次相同的动作。 我们还将测量完成时间以进行进一步比较:
suspend fun CoroutineScope.massiveRun(action: suspend () -> Unit) {
val n = 100 // 启动的协程数量
val k = 1000 // 每个协程中操作的重复次数
val time = measureTimeMillis {
val jobs = List(n) {
launch {
repeat(k) { action() }
}
}
jobs.forEach { it.join() }
}
println("Completed ${n * k} actions in $time ms")
}
我们从一个非常简单的操作开始,该操作使用GlobalScope中使用的多线程Dispatchers.Default来增加共享的可变变量。
import kotlinx.coroutines.*
import kotlin.system.*
suspend fun CoroutineScope.massiveRun(action: suspend () -> Unit) {
val n = 100 // 启动的协程数量
val k = 1000 // 每个协程中操作的重复次数
val time = measureTimeMillis {
val jobs = List(n) {
launch {
repeat(k) { action() }
}
}
jobs.forEach { it.join() }
}
println("Completed ${n * k} actions in $time ms")
}
var counter = 0
fun main() = runBlocking<Unit> {
//例子开始
GlobalScope.massiveRun {
counter++
}
println("Counter = $counter")
//例子结束
}
在这里获取完整代码
最后打印了什么? 它不太可能打印“Counter = 100000”,因为一千个协程从多个线程同时递增计数器而没有任何同步。
注意:如果您的旧系统具有2个或更少的CPU,那么您将始终看到100000,因为在这种情况下,线程池仅在一个线程中运行。 要重现此问题,您需要进行以下更改:
import kotlinx.coroutines.*
import kotlin.system.*
suspend fun CoroutineScope.massiveRun(action: suspend () -> Unit) {
val n = 100 // 启动的协程数量
val k = 1000 // 每个协程中操作的重复次数
val time = measureTimeMillis {
val jobs = List(n) {
launch {
repeat(k) { action() }
}
}
jobs.forEach { it.join() }
}
println("Completed ${n * k} actions in $time ms")
}
val mtContext = newFixedThreadPoolContext(2, "mtPool") // 显式地定义两个线程的上下文
var counter = 0
fun main() = runBlocking<Unit> {
//例子开始
CoroutineScope(mtContext).massiveRun { // 在这个例子和以下例子中使用这个而不是Dispatchers.Default
counter++
}
println("Counter = $counter")
//例子结束
}
在这里获取完整代码
Volatile并没有作用
有一种常见的误解,即让变量volatile可以解决并发问题。 让我们试一试:
import kotlinx.coroutines.*
import kotlin.system.*
suspend fun CoroutineScope.massiveRun(action: suspend () -> Unit) {
val n = 100 // 启动的协程数量
val k = 1000 // 每个协程中操作的重复次数
val time = measureTimeMillis {
val jobs = List(n) {
launch {
repeat(k) { action() }
}
}
jobs.forEach { it.join() }
}
println("Completed ${n * k} actions in $time ms")
}
@Volatile // 在Kotlin中 `volatile`是一个注解
var counter = 0
fun main() = runBlocking<Unit> {
GlobalScope.massiveRun {
counter++
}
println("Counter = $counter")
}
在这里获取完整代码
这段代码运行得更慢,但我们仍然没有得到“Counter = 100000”,因为volatile变量保证可线性化(这是“原子”的技术术语)读取和写入相应的变量,但不提供更大操作的原子性 (在我们的例子中是增加)。
线程安全的数据结构
适用于线程和协程的通用解决方案是使用线程安全(也称为同步,可线性化或原子)数据结构,该数据结构为需要在共享状态上执行的相应操作提供所有必需的同步。 在简单计数器的情况下,我们可以使用具有原子操作incrementAndGet的AtomicInteger类:
import kotlinx.coroutines.*
import java.util.concurrent.atomic.*
import kotlin.system.*
suspend fun CoroutineScope.massiveRun(action: suspend () -> Unit) {
val n = 100 // 启动的协程数量
val k = 1000 // 每个协程中操作的重复次数
val time = measureTimeMillis {
val jobs = List(n) {
launch {
repeat(k) { action() }
}
}
jobs.forEach { it.join() }
}
println("Completed ${n * k} actions in $time ms")
}
var counter = AtomicInteger()
fun main() = runBlocking<Unit> {
//例子开始
GlobalScope.massiveRun {
counter.incrementAndGet()
}
println("Counter = ${counter.get()}")
//例子结束
}
在这里获取完整代码
这是针对此特定问题的最快解决方案。 它适用于普通计数器、集合、队列和其他标准数据结构以及它们的基本操作。 但是,它不容易扩展到复杂状态或复杂操作,这个复杂操作没有现成的线程安全实现的复杂操作。
细粒度的线程限定
线程限定是解决共享可变状态问题的方法,其中对特定共享状态的所有访问都限于单个线程。 它通常用于UI应用程序,其中所有UI状态仅限于事件派发/应用程序单个线程。 通过使用单线程上下文,很容易应用协程。
import kotlinx.coroutines.*
import kotlin.system.*
suspend fun CoroutineScope.massiveRun(action: suspend () -> Unit) {
val n = 100 // 启动的协程数量
val k = 1000 // 每个协程中操作的重复次数
val time = measureTimeMillis {
val jobs = List(n) {
launch {
repeat(k) { action() }
}
}
jobs.forEach { it.join() }
}
println("Completed ${n * k} actions in $time ms")
}
val counterContext = newSingleThreadContext("CounterContext")
var counter = 0
fun main() = runBlocking<Unit> {
//例子开始
GlobalScope.massiveRun { // 用DefaultDispathcer运行每个协程
withContext(counterContext) { // 但是限定每个增加操作到单个线程
counter++
}
}
println("Counter = $counter")
//例子结束
}
在这里获取完整代码
这段代码运行非常慢,因为它进行细粒度的线程限制。 每个增量都使用withContext代码块从多线程Dispatchers.Default上下文切换到单线程上下文。
粗粒度的线程限定
在实践中,线程限定是在大块代码中执行的,例如, 业务逻辑的大量状态更新仅限于单个线程。 下面的示例就像这样,在单线程上下文中运行每个协程开始。 这里我们使用CoroutineScope()函数将协程上下文引用转换为CoroutineScope:
import kotlinx.coroutines.*
import kotlin.system.*
suspend fun CoroutineScope.massiveRun(action: suspend () -> Unit) {
val n = 100 // 启动的协程数量
val k = 1000 // 每个协程中操作的重复次数
val time = measureTimeMillis {
val jobs = List(n) {
launch {
repeat(k) { action() }
}
}
jobs.forEach { it.join() }
}
println("Completed ${n * k} actions in $time ms")
}
val counterContext = newSingleThreadContext("CounterContext")
var counter = 0
fun main() = runBlocking<Unit> {
//例子开始
CoroutineScope(counterContext).massiveRun { // 在单线程环境中运行每个协程
counter++
}
println("Counter = $counter")
//例子结束
}
在这里获取完整代码
现在这可以更快地运行,并产生正确的结果。
互斥
该问题的互斥解决方案,是使用永远不会同时执行的临界区,来保护共享状态的所有修改。 在阻塞世界中,您通常会使用synchronized或ReentrantLock。 Coroutine的替代品叫做Mutex。 它具有lock和unlock功能,可以分隔临界区。 关键的区别在于Mutex.lock()是一个挂起函数。 它不会阻塞线程。
还有withLock扩展函数,表示mutex.lock(); try { … } finally { mutex.unlock() }简便模式:
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.*
import kotlin.system.*
suspend fun CoroutineScope.massiveRun(action: suspend () -> Unit) {
val n = 100 // 启动的协程数量
val k = 1000 // 每个协程中操作的重复次数
val time = measureTimeMillis {
val jobs = List(n) {
launch {
repeat(k) { action() }
}
}
jobs.forEach { it.join() }
}
println("Completed ${n * k} actions in $time ms")
}
val mutex = Mutex()
var counter = 0
fun main() = runBlocking<Unit> {
//例子开始
GlobalScope.massiveRun {
mutex.withLock {
counter++
}
}
println("Counter = $counter")
//例子结束
}
在这里获取完整代码
此示例中的锁定是细粒度的,因此它付出了代价。 但是,对于某些必须定期修改某些共享状态的情况,它是一个不错的选择,但是没有天然的线程可以限制此状态。
Actor
actor是一个实体,由协程的组合体构成,包括被限定和封装到该协程中的状态,以及与其他协程通信的通道。一个简单的actor可以写成一个函数,但对于具有复杂状态的actor,一个类更适合。
有一个actor协程构建器,它可以方便地将actor的邮箱通道组合到其作用域中,以便从发送通道接收消息,并将其组合到最终的job对象中,从而可以将对actor的单个引用作为其句柄进行携带。
使用actor的第一步,是定义一个actor要处理的消息类。 Kotlin的密封类(sealed class)非常适合这个目的。我们用IncCounter消息以增加计数器和GetCounter消息以获取其值,来定义CounterMsg密封类。后者需要发送回复。 CompletableDeferred通信原语此处用于此目的,表示将来已知(已经通信)的单个值。
// counterActor的消息类型
sealed class CounterMsg
object IncCounter : CounterMsg() // 增加计数器的单向消息
class GetCounter(val response: CompletableDeferred<Int>) : CounterMsg() // 有回复的请求
然后我们定义一个函数,使用actor协程构建器启动一个actor:
// 这个函数启动了新的一个计数actor
fun CoroutineScope.counterActor() = actor<CounterMsg> {
var counter = 0 // actor状态
for (msg in channel) { // 迭代传入的消息
when (msg) {
is IncCounter -> counter++
is GetCounter -> msg.response.complete(counter)
}
}
}
主要代码很简单:
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
import kotlin.system.*
suspend fun CoroutineScope.massiveRun(action: suspend () -> Unit) {
val n = 100 // 启动的协程数量
val k = 1000 // 每个协程中操作的重复次数
val time = measureTimeMillis {
val jobs = List(n) {
launch {
repeat(k) { action() }
}
}
jobs.forEach { it.join() }
}
println("Completed ${n * k} actions in $time ms")
}
// counterActor的消息类型
sealed class CounterMsg
object IncCounter : CounterMsg() // 增加计数器的单向消息
class GetCounter(val response: CompletableDeferred<Int>) : CounterMsg() // 有回复的请求
// 这个函数启动了新的一个计数actor
fun CoroutineScope.counterActor() = actor<CounterMsg> {
var counter = 0 // actor状态
for (msg in channel) { // 迭代传入的消息
when (msg) {
is IncCounter -> counter++
is GetCounter -> msg.response.complete(counter)
}
}
}
fun main() = runBlocking<Unit> {
//例子开始
val counter = counterActor() // 创建actor
GlobalScope.massiveRun {
counter.send(IncCounter)
}
// 发生一个消息从actor获取一个计数值
val response = CompletableDeferred<Int>()
counter.send(GetCounter(response))
println("Counter = ${response.await()}")
counter.close() // 关闭actor
//例子结束
}
在这里获取完整代码
actor本身在哪个上下文执行并不重要(为了正确性)。一个actor是一个协程,一个协程是按顺序执行的,因此将状态限定到特定的协程可以解决共享可变状态的问题。 实际上,actor可以修改自己的私有状态,但只能通过消息相互影响(避免任何锁定)。
在负载上actor比锁定更有效,因为在这种情况下它总是有工作要做,而且根本不需要切换到不同的上下文。
注意,actor协程构建器是produce协程构建器的双重构件。 一个actor与它接收消息的通道相关联,而一个生产者与它发送元素的通道相关联。