Kotlin协程并发控制:多线程环境下的顺序执行

问题背景

在多线程编程中,保证并发任务的顺序执行是一个常见且重要的需求。本文将探讨几种在Kotlin协程中实现多线程顺序执行的方案。

考虑这样一个场景:有5个线程同时提交任务,每个任务都需要进行耗时操作,要求这些任务的回调必须按照特定顺序执行

kotlin 复制代码
private const val THREAD_NUM = 5
private var mCalculateNum = 0

几种解决方式

1、Dispatchers.IO.limitedParallelism(1) 受限并行度的协程调度器

kotlin 复制代码
@OptIn(ExperimentalCoroutinesApi::class)
private val mSequentScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1))

private fun processData(callback: (Int) -> Unit) {
    mSequentScope.launch {
        delay((500..1500).random().toLong())
        val result = ++mCalculateNum

        withContext(Dispatchers.Main) {
            callback.invoke(result)
            if (result == THREAD_NUM) mSequentScope.cancel()
        }
    }
}

//调用它:
repeat(THREAD_NUM) { index ->
    Thread {
        //Dispatchers.IO.limitedParallelism(1)方式
        processData { result -> log("线程$index, 回调结果: $result") }

        //3、Mutex加锁方式
        processWithLock { result -> log("线程$index, 回调结果: $result") }
    }.start()
}

执行结果:

makefile 复制代码
16:41:38.017  开始执行
16:41:38.582  线程4, 回调结果: 1
16:41:38.750  线程0, 回调结果: 2
16:41:38.779  线程2, 回调结果: 3
16:41:39.112  线程3, 回调结果: 4
16:41:39.231  线程1, 回调结果: 5

Dispatchers.IO.limitedParallelism(1) 创建了一个最大并行度为1的协程调度器,在主线程中多次调用processData()后,启动的多个线程会顺序执行。

2、Mutex互斥锁保护临界区

kotlin 复制代码
private val mSyncLock = Mutex()
private val mScope = CoroutineScope(Dispatchers.Main)

private fun processWithLock(callback: (Int) -> Unit) {
    mScope.launch {
        mSyncLock.withLock {
            val result = withContext(Dispatchers.IO) {
                delay((500..1500).random().toLong())
                ++mCalculateNum
            }
            callback.invoke(result)
        }
    }
}

//调用它:
repeat(THREAD_NUM) { index ->
    Thread {
        //Mutex加锁方式
        processWithLock { result -> log("线程$index, 回调结果: $result") }
    }.start()
}

执行结果:

makefile 复制代码
16:47:40.126  开始执行
16:47:40.735  线程2, 回调结果: 1
16:47:42.034  线程1, 回调结果: 2
16:47:43.311  线程4, 回调结果: 3
16:47:44.452  线程0, 回调结果: 4
16:47:45.576  线程3, 回调结果: 5

使用Mutex互斥锁保护共享资源mCalculateNum的访问, withLock扩展函数确保同一时间只有一个协程能够执行临界区代码。耗时操作在IO线程执行,回调在主线程执行。

3、Channel队列处理器

kotlin 复制代码
/**
 * 使用Channel来处理
 */
class EventHelper {

    private val channel = Channel<Event>(Channel.UNLIMITED)
    private val mChannelScope = CoroutineScope(Dispatchers.IO)
    private val isActive = AtomicBoolean(true)
    
    //接收事件
    fun startProcess() {
        mChannelScope.launch {
            /**
             * Channel实现了ReceiveChannel(提供了iterator方法)接口,而这个接口通过迭代器模式提供了协程化的遍历能力。
             * 迭代器的hasNext()和next()都是挂起函数,能在没有元素时自动挂起协程,for循环会持续从Channel中接收元素,直到Channel被关闭。
             */
            for (event in channel) {
                if (!isActive.get()) break //停止读取

                delay((500..1500).random().toLong()) //模拟在子线程的耗时处理
                withContext(Dispatchers.Main) {
                    //在主线程展示数据
                    if (isActive.get()) {
                        log("接收event: $event")
                    }
                }
            }
        }
    }

    /**
     * 发送事件,注意这里不能使用trySend。
     *
     * 1、trySend():非阻塞尝试发送,立即返回结果,成功返回Success,失败返回Closed或队列已满,适用于非关键事件,允许丢失,避免协程挂起。
     * 2、send():挂起发送,如果Channel已满,挂起协程直到有空间。适用于需要确保事件一定被发送,不丢失数据。
     */
    suspend fun sendEvent(event: Event) {
        if (isActive.get()) {
            channel.send(event)
            log("发送event:$event")
        }
    }

    fun stop() {
        isActive.set(false)
        mChannelScope.cancel()
        channel.close()
    }
}

data class Event(val data: String, val index: Int)

调用处:

kotlin 复制代码
private val mScope = CoroutineScope(Dispatchers.Main)

mScope.launch {
    val channelHelper = EventHelper()

    //尝试接收数据,如果数据为空会挂起
    channelHelper.startProcess()

    //发送数据
    repeat(THREAD_NUM) { index ->
        channelHelper.sendEvent(Event("data$index", index))
    }

    //监听生命周期,页面关闭时停止发送&接收
    activity.lifecycle.addObserver(object : DefaultLifecycleObserver {
        override fun onDestroy(owner: LifecycleOwner) {
            channelHelper.stop()
        }
    })
}

执行结果:

ini 复制代码
21:02:33.352  E  发送event:Event(data=data0, index=0)
21:02:33.352  E  发送event:Event(data=data1, index=1)
21:02:33.352  E  发送event:Event(data=data2, index=2)
21:02:33.352  E  发送event:Event(data=data3, index=3)
21:02:33.352  E  发送event:Event(data=data4, index=4)
21:02:33.865  E  接收event: Event(data=data0, index=0)
21:02:35.331  E  接收event: Event(data=data1, index=1)
21:02:36.426  E  接收event: Event(data=data2, index=2)
21:02:37.719  E  接收event: Event(data=data3, index=3)
21:02:38.771  E  接收event: Event(data=data4, index=4)

以上也能实现对应效果,关于Channel的用法扩展一下,其构造方法中:

kotlin 复制代码
public fun <E> Channel(
    capacity: Int = RENDEZVOUS,
    onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,
    onUndeliveredElement: ((E) -> Unit)? = null
): Channel<E> {
  //......
}

capacity表示容量策略,通常有以下几种选择:

容量类型 发送行为 接收行为 适用场景
RENDEZVOUS,默认值0 无缓冲区时挂起 无数据时挂起 严格同步的生产者消费者
CONFLATED ,值=-1 永不挂起,覆盖旧值 正常接收 状态更新,只关心最新值
BUFFERED ,值=-2 缓冲区满时挂起 正常接收 一般事件处理,应对突发
UNLIMITED,值=Int.MAX_VALUE 永不挂起 正常接收 绝对不能丢失数据的场景
固定数值 缓冲区满时挂起 正常接收 需要精确控制内存的场景

示例代码中选择的是Channel.UNLIMITED,保证了发送的数据不会丢失。Channel通过单消费者队列模型实现多线程串行执行:多个生产者线程并发发送的事件会在Channel的内部队列中缓冲排队,由单个消费者协程通过for (event in channel)循环按FIFO顺序逐个处理。这种"多线程并发发送、单线程顺序消费"的机制,结合Channel内部的线程安全保证,使得所有事件最终都能被串行化处理,从而实现了多线程环境下的顺序执行。

总结

  • Dispatchers.IO.limitedParallelism(1) 受限并行度适合任务量已知的场景
  • Mutex 互斥锁适合需要精细控制的并发访问
  • Channel队列 适合生产者消费者模式,异步事件流处理
相关推荐
_小马快跑_2 小时前
Kotlin协程异常捕获陷阱:try-catch捕获异常失败了?
android
_小马快跑_2 小时前
Android | 权限申请与前置说明弹窗同时展示的优雅方案
android
_小马快跑_2 小时前
Android | Channel 与 Flow的异同点
android
_小马快跑_2 小时前
Android | 文本测量:从 Paint.measureText 到 StaticLayout 的替换
android
树獭非懒3 小时前
告别繁琐多端开发:DivKit 带你玩转 Server-Driven UI!
android·前端·人工智能
三少爷的鞋4 小时前
为什么应该先在 IntelliJ 中学习 Kotlin 与协程,而不是直接上 Android Studio
android
不爱说话郭德纲19 小时前
告别漫长的HbuilderX云打包排队!uni-app x 安卓本地打包保姆级教程(附白屏、包体积过大排坑指南)
android·前端·uni-app
Sinclair1 天前
简单几步,安卓手机秒变服务器,安装 CMS 程序
android·服务器
雮尘1 天前
手把手带你玩转Android gRPC:一篇搞定原理、配置与客户端开发
android·前端·grpc