前言
在学习协程最开始的时候,我们说过要脱离线程封装库的思维,把协程看成运行在线程上的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中的同步手段,比如synchronized
、Atomic
、Lock
等,那我们就使用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 Before
对lock
的加锁,所以这里的结果就是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)
}
这个代码是无法编译的:
提示挂起点在一个临界区内,这里为什么不可以呢?
我们可以想象一下挂起函数的本质,这里当调用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
方法向其中发送AddMsg
和ResultMsg
,虽然我们在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
模型。