一、协程的并发问题
因为协程是基于线程存在的,线程存在并发的问题,那么协程肯定存在,看如下的代码:
fun main() {
runBlocking(Dispatchers.IO) { <-------------注意这里非主线程
var i = 0
val jobs = mutableListOf<Job>()
//重复10次
repeat(10) {
val job = launch {
repeat(10000) {
i++
}
}
jobs.add(job)
}
//等待计算完成
jobs.joinAll()
printMsg("i = $i")
}
}
//日志
DefaultDispatcher-worker-1 @coroutine#1 i = 61777
结果并不是10万,所以协程肯定存在并发的问题。
二、如何解决协程并发的问题
1、使用单一线程执行
将上面的IO线程,改为主线程
fun main() {
runBlocking { <-------------注意这里默认就是主线程
var i = 0
val jobs = mutableListOf<Job>()
//重复10次
repeat(10) {
val job = launch {
repeat(10000) {
i++
}
}
jobs.add(job)
}
//等待计算完成
jobs.joinAll()
printMsg("i = $i")
}
}
//日志
main @coroutine#1 i = 100000
使用单一的子线程
fun main() {
runBlocking(Dispatchers.IO) { <-------------注意这里非主线程
val singleDispatcher = Executors.newSingleThreadExecutor {
Thread(it, "SingleThread").apply { isDaemon = true }
}.asCoroutineDispatcher() <-------------创建单一线程的Dispatcher
var i = 0
val jobs = mutableListOf<Job>()
//重复10次
repeat(10) {
val job = launch(singleDispatcher) { <-------------使用singleDispatcher
repeat(10000) {
i++
}
}
jobs.add(job)
}
//等待计算完成
jobs.joinAll()
printMsg("i = $i")
}
}
//日志
DefaultDispatcher-worker-1 @coroutine#1 i = 100000
2、使用线程同步的方法
借鉴 Java 的并发思路,使用synchronized、Atomic、Lock等,下面以synchronized举例:
@OptIn(InternalCoroutinesApi::class)
fun main() {
runBlocking(Dispatchers.IO) {
val lock = Any() <-------------创建锁
var i = 0
val jobs = mutableListOf<Job>()
//重复10次
repeat(10) {
val job = launch {
repeat(10000) {
synchronized(lock) { <-------------使用同步加锁
i++
}
}
}
jobs.add(job)
}
//等待计算完成
jobs.joinAll()
printMsg("i = $i")
}
}
//日志
DefaultDispatcher-worker-8 @coroutine#1 i = 100000
3、Mutex
在 Java 当中,其实还有 Lock 之类的同步锁。但由于 Java 的锁是阻塞式的,会大大影响协程的非阻塞式的特性。所以,在 Kotlin 协程当中,我们也是不推荐直接使用传统的同步锁的,甚至在某些场景下,在协程中使用 Java 的锁也会遇到意想不到的问题。
fun main() {
runBlocking(Dispatchers.IO) {
val mutex = Mutex() <---------创建Mutex
var i = 0
val jobs = mutableListOf<Job>()
//重复10次
repeat(10) {
val job = launch {
repeat(10000) {
mutex.lock() <---------使用锁
i++
mutex.unlock() <---------释放锁
}
}
jobs.add(job)
}
//等待计算完成
jobs.joinAll()
printMsg("i = $i")
}
}
在上面的代码中,使用 mutex.lock()、mutex.unlock() 包裹了需要同步的计算逻辑,这样一来,代码就可以实现多线程同步了
实际上,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(Dispatchers.IO) {
val mutex = Mutex()
var i = 0
val jobs = mutableListOf<Job>()
//重复10次
repeat(10) {
val job = launch {
repeat(10000) {
mutex.lock()
i++
i/0 <---------创建一个异常
mutex.unlock()
}
}
jobs.add(job)
}
//等待计算完成
jobs.joinAll()
printMsg("i = $i")
}
}
//日志
程序报错
以上代码可能会因为异常导致 mutex.unlock() 无法被调用。这个时候,整个程序的执行流程就会一直卡住,无法结束。
所以,为了避免出现这样的问题,我们应该使用 Kotlin 提供的一个扩展函数:mutex.withLock{}:
fun main() {
runBlocking(Dispatchers.IO) {
val mutex = Mutex()
var i = 0
val jobs = mutableListOf<Job>()
//重复10次
repeat(10) {
val job = launch {
repeat(10000) {
mutex.withLock { <---------变化在这里
i++
}
}
}
jobs.add(job)
}
//等待计算完成
jobs.joinAll()
printMsg("i = $i")
}
}
mutex.withLock{}的源码很容易就可以猜到加了try..finally,具体如下:
@OptIn(ExperimentalContracts::class)
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)
}
}
4、Semaphore
在某些情况下,我们可能希望限制同时执行的协程数量,以控制并发的程度或避免资源过度消耗。这时,Semaphore 可以发挥作用。在 Kotlin 中,可以使用 kotlinx.coroutines 包中提供的 Semaphore 实现类,如 kotlinx.coroutines.sync.Semaphore。这个 Semaphore 类与传统的 Semaphore 类类似,提供了 acquire() 和 release() 方法用于获取和释放许可。当使用 Semaphore(信号量)时,我们首先需要创建一个 Semaphore 对象,并指定初始的许可数量。许可数量表示可以同时访问共享资源的线程或协程的数量。看使用Semaphore的例子:
fun main() {
val semaphore = Semaphore(1) <--------创建Semaphore最大信号量为1
runBlocking(Dispatchers.IO) {
var i = 0
val jobs = mutableListOf<Job>()
//重复10次
repeat(10) {
val job = launch {
repeat(10000) {
semaphore.acquire() <--------获取信号,如果获取不到就挂起,对,它是一个挂起函数
i++
semaphore.release() <--------释放信号
}
}
jobs.add(job)
}
//等待计算完成
jobs.joinAll()
printMsg("i = $i")
}
}
//日志
DefaultDispatcher-worker-11 @coroutine#1 i = 100000
与上面的Mutex类型,如果因为异常无法释放信号怎么办呢? Kotlin为我们提供了扩展方法Semaphore.withPermit(),修改上面的代码:
fun main() {
val semaphore = Semaphore(1)
runBlocking(Dispatchers.IO) {
var i = 0
val jobs = mutableListOf<Job>()
//重复10次
repeat(10) {
val job = launch {
repeat(10000) {
semaphore.withPermit { <--------变化在这里
i++
}
}
}
jobs.add(job)
}
//等待计算完成
jobs.joinAll()
printMsg("i = $i")
}
}
这个可以拓展很多场景,比如控制最大的下载/上传数量,批量操作的最大并发数量等。看下面的例子:
val semaphore = Semaphore(2) <--------最大并发2个
fun main() {
runBlocking(Dispatchers.IO) {
(1..5).forEach {
launch {
download(it)
}
}
}
}
suspend fun download(sourceId: Int) {
semaphore.withPermit {
printMsg("downloading source id is $sourceId")
delay(100L * sourceId) <-------模拟不同的下载耗时不一样
printMsg("download finish , id is $sourceId")
}
}
//日志
DefaultDispatcher-worker-3 @coroutine#2 downloading source id is 1
DefaultDispatcher-worker-4 @coroutine#3 downloading source id is 2
DefaultDispatcher-worker-6 @coroutine#2 download finish , id is 1
DefaultDispatcher-worker-6 @coroutine#4 downloading source id is 3
DefaultDispatcher-worker-5 @coroutine#3 download finish , id is 2
DefaultDispatcher-worker-5 @coroutine#5 downloading source id is 4
DefaultDispatcher-worker-9 @coroutine#4 download finish , id is 3
DefaultDispatcher-worker-9 @coroutine#6 downloading source id is 5
DefaultDispatcher-worker-6 @coroutine#5 download finish , id is 4
DefaultDispatcher-worker-6 @coroutine#6 download finish , id is 5
5、Actor
Actor本质上是基于 Channel 管道消息实现的,先看下面的代码,它会与上面的代码有明显不同:
fun main() {
runBlocking(Dispatchers.IO) {
val sendChannel = counterActor()
val jobs = mutableListOf<Job>()
// 重复10次
repeat(10) {
val job = launch {
repeat(1000) {
printMsg("print send event thread")
sendChannel.send("event") <---------随便定义的一个字符串
}
}
jobs.add(job)
}
// 等待计算完成
jobs.joinAll()
sendChannel.close()
}
}
fun counterActor() = GlobalScope.actor<String>(context = Dispatchers.Default) {
var count = 0
for (msg in channel) {
when (msg) {
"event" -> { <---------收这个随便定义的字符串
count++ <---------注意在这里++
}
}
}
printMsg("i = $count") <---------在Actor中输出最终结果
}
fun printMsg(msg: Any) {
println("${Thread.currentThread().name} $msg")
}
//日志
DefaultDispatcher-worker-8 @coroutine#8 print send event thread
DefaultDispatcher-worker-2 @coroutine#7 print send event thread
DefaultDispatcher-worker-16 @coroutine#10 print send event thread
DefaultDispatcher-worker-18 @coroutine#6 print send event thread
DefaultDispatcher-worker-16 @coroutine#10 print send event thread
...略...
DefaultDispatcher-worker-4 @coroutine#2 i = 10000
可以看到Actor只是基于Channel的简单封装,本质上是利用了Channel跨协程的特性,它这种解决并发的思路与上面的几点有本质区别,可以看到10000最终是在Actor中++得到的,而不是利用锁的机制在源头上解决问题。
看了很多例子都是密封类与Actor结合,这确实是一种能拓展到实际开发中的思路,下面是一个模拟机器开关控制的代码:
//密封类
sealed class Control
object Open : Control()
object Close : Control()
fun main() {
runBlocking {
val sendChannel = counterActor()
launch(Dispatchers.IO) { <------子线程
delay(500) <------模拟子线程耗时操作
sendChannel.send(Open) <-------操作完成发送对象Open
}
launch(Dispatchers.IO) {
delay(800)
sendChannel.send(Close) <-------操作完成发送对象Close
}
}
}
fun counterActor() = GlobalScope.actor<Control>{
channel.consumeEach {
when (it) {
Open -> {
printMsg("Open")
}
Close -> {
printMsg("Close")
channel.close() <-------不要忘记关闭channel
}
}
}
}
fun printMsg(msg: Any) {
println("${Thread.currentThread().name} $msg")
}
//日志
DefaultDispatcher-worker-1 @coroutine#2 Open
DefaultDispatcher-worker-2 @coroutine#2 Close
部分内容参考了以下文章
极客时间 朱涛kotlin的课程: 并发:协程不需要处理同步吗?