前言
炼虚初成,你已掌握 Channel 的虚空造物之术。任务队列在你手中如臂使指,生产与消费的节奏完美协调。然而,当你面对更复杂的协程通信场景时,新的困惑悄然浮现:
- 1、我需要同时等待两个
Channel——一个接收网络响应,一个接收超时信号。任何一个先到达就立即处理,另一个忽略。怎么写?- 2、我有一组并发的网络请求,我想竞速——只要最快的一个结果,其他的取消掉。怎么写?
- 3、我有多个协程需要安全地修改同一个状态,但又不想引入重量级的锁(
Mutex)。Channel能解决吗?
这些问题的答案,指向协程并发通信的两个高阶法器:select 表达式 与 actor 模式。
select是一个挂起函数,它同时等待多个挂起操作(如 Channel 的onReceive、onSend,或任意挂起函数的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 模式的优势:
- 无锁:所有状态修改都在同一个协程中串行执行,天然线程安全。
- 简单:代码逻辑像单线程编程一样直观,无需
synchronized或Mutex。 - 可组合:Actor 之间通过发送消息协作,易于构建复杂系统。
Actor vs Mutex:何时用哪个?
| 对比维度 | Actor | Mutex |
|---|---|---|
| 并发模型 | 消息传递,串行处理 | 锁,互斥访问 |
| 复杂度 | 需要定义消息类型 | 只需 withLock 包裹 |
| 适用场景 | 复杂状态机、需要顺序保证 | 简单的临界区保护 |
| 性能 | 消息传递有开销 | 轻量级锁开销较小 |
| 死锁风险 | 无(无锁) | 有(锁顺序不当) |
简单选择:
实战:用 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:Actor 的 Channel 无限增长
如果 Actor 处理速度慢于消息发送速度,且 Actor 的 Mailbox 容量未限制,可能导致内存溢出。
val actor = actor<Msg>(capacity = Channel.BUFFERED) // 有界缓冲区
错误 4:在 Actor 内部调用挂起函数导致消息积压
Actor 的 for (msg in channel) 是串行的,如果处理某个消息时内部 delay 或进行长时间网络请求,后续消息会积压。如需并发处理,应在 Actor 内部 launch 新协程(但需注意状态一致性)。
最佳实践
- 用
select实现超时和竞速:简洁且高效。 - 复杂状态管理优先考虑
actor:比手写锁更安全、更易维护。 - Actor 内部避免耗时挂起:如需 I/O,考虑在内部启动子协程并通过消息回传结果。
- 为 Actor 设置合理的
capacity:防止无限缓冲导致 OOM。 select目前是实验性 API:需标注@OptIn(ExperimentalCoroutinesApi::class),生产环境谨慎使用(API 可能变动)。- 考虑用
Flow替代部分Channel场景:select主要解决多路复用,而数据流转换优先用Flow。
总结与下回预告
恭喜,你已掌握 select 与 actor 的并发艺术,炼虚境中阶修炼完成!
本讲核心收获:
select同时等待多个挂起操作,执行最先完成的分支。onReceive、onSend、onAwait、onTimeout覆盖多路复用主要场景。actor通过消息队列串行处理,实现无锁的并发状态管理。- Actor 模式天然避免竞态条件,代码逻辑清晰。
在下一讲 【炼虚境·后阶】 中,我们将深入 Channel 的底层实现:AbstractChannel 的内部结构、send/receive 的挂起与恢复机制、以及 BroadcastChannel 为何被废弃。届时你会明白:
- Channel 的缓冲区是如何用链表实现的?
- 挂起函数
send的 Continuation 是如何被存储和恢复的? ConflatedChannel的合并逻辑在源码中如何体现?
【当前境界修为面板】
| 当前境界 | 修炼技能 | 修炼进度 | 修炼心得 |
|---|---|---|---|
| 炼虚境 · 中阶 | 1、select 多路复用诀2、 actor 无锁并发术3、消息驱动架构心法 | 当前进度:70%修为: 700/1000下一突破: [炼虚境 · 后阶] (需领悟:Channel 底层源码、AbstractChannel 实现、挂起恢复机制) | select同时等待多个挂起点,哪个先完成就执行哪个。Actor不共享状态,只通信。 |
【本讲思考题】
-
表象题:以下
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 } } -
场景题:你需要实现一个“抢购”功能:100 个用户同时请求,但只有前 10 个能成功。如何用
actor实现这个限量逻辑?写出核心代码。 -
原理题:
select表达式是如何实现“同时等待多个挂起操作,并只执行最快的一个”的?请从SelectBuilder和selectInternal的角度简述其内部机制。
道友,炼虚境的最后一道关隘已在眼前。看穿 Channel 的底层实现,你对协程通信的理解将臻至化境。炼虚境·后阶见。
欢迎一键四连(
关注+点赞+收藏+评论)