kotlin-协程(八)协程并发

1,126 阅读1分钟

一、协程的并发问题

因为协程是基于线程存在的,线程存在并发的问题,那么协程肯定存在,看如下的代码:

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 的并发思路,使用synchronizedAtomicLock等,下面以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)    <--------最大并发2fun 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的课程: 并发:协程不需要处理同步吗?

学习笔记