【Kotlin 协程修仙录 · 炼虚境 · 中阶】 | 多路复用:select 表达式与 Actor 模式的并发艺术

51 阅读7分钟

image_11.png

前言

炼虚初成,你已掌握 Channel 的虚空造物之术。任务队列在你手中如臂使指,生产与消费的节奏完美协调。然而,当你面对更复杂的协程通信场景时,新的困惑悄然浮现:

  • 1、我需要同时等待两个 Channel——一个接收网络响应,一个接收超时信号。任何一个先到达就立即处理,另一个忽略。怎么写?
  • 2、我有一组并发的网络请求,我想竞速——只要最快的一个结果,其他的取消掉。怎么写?
  • 3、我有多个协程需要安全地修改同一个状态,但又不想引入重量级的锁(Mutex)。Channel 能解决吗?

这些问题的答案,指向协程并发通信的两个高阶法器:select 表达式actor 模式

select 是一个挂起函数,它同时等待多个挂起操作(如 Channel 的 onReceiveonSend,或任意挂起函数的 onAwait),并执行最先完成的那一个。它是协程世界的“多路复用器”。

actor 是一个协程构建器,它创建一个专属的 Channel 并启动一个消费者协程,串行处理所有发送给它的消息。所有对共享状态的访问都通过向 Actor 发送消息来完成,从而天然避免竞态条件

本讲是炼虚境的中阶修炼。你将:

  • 掌握 select 表达式的语法与核心应用场景。
  • 学会用 select 实现超时、竞速、多 Channel 监听。
  • 理解 actor 模式的设计哲学,以及它如何取代显式锁。
  • Android 中用 actor 构建线程安全的状态管理器。

准备好掌握多路复用与无锁并发的艺术了吗?我们开始。

千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意


select 表达式:同时等待多个挂起操作

什么是 select

select 是一个实验性的挂起函数(需 @OptIn(ExperimentalCoroutinesApi::class)),它允许你同时等待多个挂起操作的完成,并只执行最先完成的那一个。其他未完成的操作会被取消(对于 onReceive 等)或忽略

它的语法类似于 Kotlin 的 when 表达式,但每个分支是一个挂起操作的子句

select<ResponseType> {
    channel1.onReceive { value ->
        // 如果 channel1 先收到数据,执行此分支
        value.toResponse()
    }
    channel2.onReceive { value ->
        // 如果 channel2 先收到数据,执行此分支
        value.toResponse()
    }
    onTimeout(1000) {
        // 如果 1 秒内都没有收到数据,执行此分支
        Response.Timeout
    }
}
flowchart LR
    subgraph Select[select 表达式]
        direction TB
        S[同时等待]
        C1[channel1.onReceive]
        C2[channel2.onReceive]
        T[onTimeout]
    end
    
    S --> C1
    S --> C2
    S --> T
    
    C1 -->|先到达| R1[执行分支1<br>取消其他等待]
    C2 -->|先到达| R2[执行分支2<br>取消其他等待]
    T -->|超时| R3[执行超时分支]
    
    style Select fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style C1 fill:#c8e6c9,stroke:#2e7d32
    style C2 fill:#c8e6c9,stroke:#2e7d32
    style T fill:#ffcdd2,stroke:#b71c1c
    style R1 fill:#a5d6a7
    style R2 fill:#a5d6a7
    style R3 fill:#ef9a9a

select 的核心子句类型

子句作用典型场景
onReceive等待从 Channel 接收数据多 Channel 监听
onSend等待向 Channel 发送数据背压场景下的选择性发送
onAwait等待 Deferred 完成多网络请求竞速
onTimeout等待指定的时间超时控制
onJoin等待 Job 完成多协程竞速完成

实战:用 select 实现超时与竞速

场景一:网络请求超时控制

suspend fun fetchWithTimeout(): String = coroutineScope {
    val dataChannel = Channel<String>()
    val timeoutChannel = Channel<Unit>()
    
    // 模拟网络请求
    launch {
        delay(Random.nextLong(500, 2000))
        dataChannel.send("用户数据")
    }
    
    // 超时协程
    launch {
        delay(1000)
        timeoutChannel.send(Unit)
    }
    
    select<String> {
        dataChannel.onReceive { data ->
            "成功:$data"
        }
        timeoutChannel.onReceive {
            "超时:请求超过 1 秒"
        }
    }
}

场景二:多个网络请求竞速(取最快响应)

suspend fun fetchFastest(): String = coroutineScope {
    val deferred1 = async { fetchFromServer("主服务器") }
    val deferred2 = async { fetchFromServer("备用服务器") }
    
    select<String> {
        deferred1.onAwait { result -> "主服务器响应:$result" }
        deferred2.onAwait { result -> "备用服务器响应:$result" }
    }.also {
        // 取消未完成的请求
        deferred1.cancel()
        deferred2.cancel()
    }
}
sequenceDiagram
    participant Select as select 表达式
    participant D1 as deferred1
    participant D2 as deferred2
    participant Caller as 调用方

    Select->>D1: onAwait 等待
    Select->>D2: onAwait 等待
    
    D2-->>Select: 先完成!
    Select->>Caller: 返回 D2 的结果
    Select->>D1: 取消等待
    Caller->>D1: cancel()
    Caller->>D2: cancel()

select 的高级应用:多 Channel 监听与背压处理

同时监听多个 Channel

假设你有一个下载管理器,同时接收来自网络的数据块和来自用户的中止指令。你需要任何一个 Channel 有数据就立即响应

suspend fun downloadWithCancellation(
    dataChannel: ReceiveChannel<ByteArray>,
    cancelChannel: ReceiveChannel<Unit>
): List<ByteArray> = buildList {
    while (true) {
        select<Unit> {
            dataChannel.onReceive { chunk ->
                add(chunk)
                // 继续循环
            }
            cancelChannel.onReceive {
                println("下载被用户取消")
                return@buildList // 退出函数
            }
        }
    }
}

onSend:选择性发送

当 Channel 的缓冲区可能已满时,你可以在 select 中使用 onSend,如果发送不成功则走其他分支。

select<Unit> {
    channel.onSend(data) {
        println("数据已发送")
    }
    onTimeout(500) {
        println("发送超时,丢弃数据")
    }
}

select 的循环模式

select 经常与 while 循环结合,持续监听多个源。注意:select 本身只执行一次,需要循环来持续监听。

while (isActive) {
    select<Unit> {
        channel1.onReceive { handle1(it) }
        channel2.onReceive { handle2(it) }
    }
}
stateDiagram-v2
    [*] --> 等待
    等待 --> 分支1 : channel1 收到数据
    等待 --> 分支2 : channel2 收到数据
    分支1 --> 处理1
    分支2 --> 处理2
    处理1 --> 等待
    处理2 --> 等待
    等待 --> [*] : 循环条件为 false

Actor 模式:无锁的并发状态管理

什么是 Actor

actor 是一个协程构建器,它创建一个专属的 Mailbox Channel 并启动一个消费者协程,串行处理所有发送给它的消息。Actor 对外暴露一个 SendChannel,调用方通过 send 发送消息来与 Actor 交互,而 Actor 内部可以安全地修改私有状态,无需任何显式锁。

Actor 模型的核心思想是:不要共享内存,而要通信。状态被封装在 Actor 内部,外部只能通过发送消息来间接修改状态。

flowchart LR
    subgraph Callers[调用方]
        C1[协程1]
        C2[协程2]
        C3[协程3]
    end
    
    subgraph Actor[Actor 协程]
        direction TB
        MB[Mailbox Channel]
        Loop[消息循环]
        State[私有状态]
    end
    
    C1 -->|send 消息| MB
    C2 -->|send 消息| MB
    C3 -->|send 消息| MB
    MB --> Loop
    Loop --> State
    State --> Loop
    
    style Callers fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Actor fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
    style MB fill:#ffb74d
    style Loop fill:#a5d6a7
    style State fill:#81c784

创建 Actor

import kotlinx.coroutines.channels.actor
import kotlinx.coroutines.channels.SendChannel

sealed class CounterMsg
object Inc : CounterMsg()
class GetCounter(val reply: CompletableDeferred<Int>) : CounterMsg()

fun CoroutineScope.counterActor() = actor<CounterMsg> {
    var counter = 0 // Actor 私有状态
    for (msg in channel) { // 串行处理所有消息
        when (msg) {
            is Inc -> counter++
            is GetCounter -> msg.reply.complete(counter)
        }
    }
}

使用 Actor

val actor = counterActor()

// 多个协程并发发送消息,但 Actor 内部串行处理,无需锁
repeat(1000) {
    launch {
        actor.send(Inc)
    }
}

// 获取当前值
val reply = CompletableDeferred<Int>()
actor.send(GetCounter(reply))
println("计数:${reply.await()}")

actor.close()

Actor 模式的优势

  • 无锁:所有状态修改都在同一个协程中串行执行,天然线程安全。
  • 简单:代码逻辑像单线程编程一样直观,无需 synchronizedMutex
  • 可组合:Actor 之间通过发送消息协作,易于构建复杂系统。

Actor vs Mutex:何时用哪个?

对比维度ActorMutex
并发模型消息传递,串行处理锁,互斥访问
复杂度需要定义消息类型只需 withLock 包裹
适用场景复杂状态机、需要顺序保证简单的临界区保护
性能消息传递有开销轻量级锁开销较小
死锁风险无(无锁)有(锁顺序不当)

简单选择

deepseek_mermaid_20260825_e1daa1.png

实战:用 Actor 实现线程安全的下载状态管理

场景:多个下载任务并发执行,需要安全地汇总总进度、成功/失败数量。

import kotlinx.coroutines.channels.actor
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.CompletableDeferred

class DownloadManager {
    
    sealed class DownloadMsg {
        data class Progress(val taskId: String, val percent: Int) : DownloadMsg()
        data class Complete(val taskId: String, val success: Boolean) : DownloadMsg()
        object GetState : DownloadMsg()
    }
    
    data class DownloadState(
        val totalProgress: Int = 0,
        val completed: Int = 0,
        val failed: Int = 0
    )
    
    private fun CoroutineScope.stateActor() = actor<DownloadMsg> {
        var state = DownloadState()
        var pendingReplies = mutableListOf<CompletableDeferred<DownloadState>>()
        
        for (msg in channel) {
            when (msg) {
                is DownloadMsg.Progress -> {
                    // 更新进度(简化:取平均值)
                    // 实际逻辑可能更复杂
                }
                is DownloadMsg.Complete -> {
                    state = if (msg.success) {
                        state.copy(completed = state.completed + 1)
                    } else {
                        state.copy(failed = state.failed + 1)
                    }
                }
                is DownloadMsg.GetState -> {
                    // 无法直接回复,需要通过消息中的 CompletableDeferred
                    // 这里演示另一种方式:发送 GetState 时附带 CompletableDeferred
                }
            }
            
            // 回复所有等待的 GetState 请求(简化处理)
            // 实际应在 GetState 消息中携带 CompletableDeferred
        }
    }
}

更好的实践:使用 SendChannel 配合 CompletableDeferred 实现请求-响应模式。

sealed class CounterMsg {
    object Increment : CounterMsg()
    class GetValue(val response: CompletableDeferred<Int>) : CounterMsg()
}

fun CoroutineScope.counterActor() = actor<CounterMsg> {
    var count = 0
    for (msg in channel) {
        when (msg) {
            is CounterMsg.Increment -> count++
            is CounterMsg.GetValue -> msg.response.complete(count)
        }
    }
}

// 使用
val actor = counterActor()
actor.send(CounterMsg.Increment)
val response = CompletableDeferred<Int>()
actor.send(CounterMsg.GetValue(response))
println("当前值:${response.await()}")

常见错误与避坑指南

错误 1:在 select 分支中执行耗时操作

select<Unit> {
    channel.onReceive { value ->
        // 错误:这里执行耗时操作会阻塞 select 的返回
        Thread.sleep(1000)
        process(value)
    }
}

正确select 分支应尽快返回结果,耗时操作在分支执行完后处理。

错误 2:忘记取消 select 中未完成的操作

select 会自动取消其他挂起等待(如 onReceive 的等待),但如果你等待的是 Deferred,且未在分支中显式取消,该 Deferred 仍会在后台执行。建议在 select 后取消未完成的 Deferred

错误 3:ActorChannel 无限增长

如果 Actor 处理速度慢于消息发送速度,且 Actor 的 Mailbox 容量未限制,可能导致内存溢出。

val actor = actor<Msg>(capacity = Channel.BUFFERED) // 有界缓冲区

错误 4:在 Actor 内部调用挂起函数导致消息积压

Actor 的 for (msg in channel) 是串行的,如果处理某个消息时内部 delay 或进行长时间网络请求,后续消息会积压。如需并发处理,应在 Actor 内部 launch 新协程(但需注意状态一致性)。


最佳实践

  1. select 实现超时和竞速:简洁且高效。
  2. 复杂状态管理优先考虑 actor:比手写锁更安全、更易维护。
  3. Actor 内部避免耗时挂起:如需 I/O,考虑在内部启动子协程并通过消息回传结果。
  4. 为 Actor 设置合理的 capacity:防止无限缓冲导致 OOM。
  5. select 目前是实验性 API:需标注 @OptIn(ExperimentalCoroutinesApi::class),生产环境谨慎使用(API 可能变动)。
  6. 考虑用 Flow 替代部分 Channel 场景select 主要解决多路复用,而数据流转换优先用 Flow

总结与下回预告

恭喜,你已掌握 selectactor 的并发艺术,炼虚境中阶修炼完成!

本讲核心收获

  • select 同时等待多个挂起操作,执行最先完成的分支。
  • onReceiveonSendonAwaitonTimeout 覆盖多路复用主要场景。
  • actor 通过消息队列串行处理,实现无锁的并发状态管理。
  • Actor 模式天然避免竞态条件,代码逻辑清晰。

在下一讲 【炼虚境·后阶】 中,我们将深入 Channel 的底层实现:AbstractChannel 的内部结构、send/receive 的挂起与恢复机制、以及 BroadcastChannel 为何被废弃。届时你会明白:

  • Channel 的缓冲区是如何用链表实现的?
  • 挂起函数 send 的 Continuation 是如何被存储和恢复的?
  • ConflatedChannel 的合并逻辑在源码中如何体现?

【当前境界修为面板】

当前境界修炼技能修炼进度修炼心得
炼虚境 · 中阶1、select 多路复用诀
2、actor 无锁并发术
3、消息驱动架构心法
当前进度70%
修为700/1000
下一突破[炼虚境 · 后阶] (需领悟:Channel 底层源码、AbstractChannel 实现、挂起恢复机制)
select同时等待多个挂起点,哪个先完成就执行哪个。Actor不共享状态,只通信。

【本讲思考题】

  1. 表象题:以下 select 表达式会输出什么?

    val c1 = Channel<Int>()
    val c2 = Channel<Int>()
    launch { delay(100); c1.send(1) }
    launch { delay(50); c2.send(2) }
    select<Int> {
        c1.onReceive { it }
        c2.onReceive { it }
    }
    
  2. 场景题:你需要实现一个“抢购”功能:100 个用户同时请求,但只有前 10 个能成功。如何用 actor 实现这个限量逻辑?写出核心代码。

  3. 原理题select 表达式是如何实现“同时等待多个挂起操作,并只执行最快的一个”的?请从 SelectBuilderselectInternal 的角度简述其内部机制。


道友,炼虚境的最后一道关隘已在眼前。看穿 Channel 的底层实现,你对协程通信的理解将臻至化境。炼虚境·后阶见。

欢迎一键四连关注 + 点赞 + 收藏 + 评论