协程 Channel 与 Actor(单播/强背压)

42 阅读5分钟

1) Channel 的语义与定位(单播 + 背压原语)

  • 单播(unicast) :一个元素只会被一个接收者消费。你可以 fan-in(多生产者)或 fan-out(多消费者“抢”同一条管道),但同一条消息不会被多人同时收到

  • 背压(backpressure) :send/receive 是挂起操作;当队列满没人接收时,send 会挂起,形成天然背压

  • **容量(capacity)**决定背压强度:

    • RENDEZVOUS / 0:无缓冲最强背压(发送与接收一一配对)。

    • BUFFERED(n):有限缓冲,吸收突发;满则背压。

    • CONFLATED:只保最新(丢弃旧的)。

    • UNLIMITED:理论无限队列(极易 OOM,不建议)。

    • onBufferOverflow = DROP_OLDEST / DROP_LATEST:结合 BUFFERED 的丢弃策略

常用 API 速记:

  • 发送:send(挂起) / trySend(非挂起,返回 ChannelResult)/ trySendBlocking(阻塞线程;谨慎用于非协程环境)。
  • 接收:receive / tryReceive / 推荐:receiveCatching()(能感知关闭)。
  • 关闭:close(cause?)(优雅关写端,允许读完缓冲) vs cancel(cause?)(立刻失败并丢弃未读)。
  • 状态:isClosedForSend / isClosedForReceive。

2) Actor 是什么?为何适合“单线程串行 + 强背压”

Actor =(邮箱 Channel)+(一个协程的串行事件循环)

  • 把可变状态“圈进”一个协程,所有修改都通过“发消息”(send)排队,内部顺序处理 ⇒ 无需锁、天然线程安全。
  • 强背压:把邮箱开成 RENDEZVOUS 或小容量,上游“超速”就挂在 send,保证 Actor 不会被洪水淹没。
  • 官方 actor{} 构建器在 kotlinx-coroutines 里长期标注 Obsolete/Experimental更推荐Channel + launch 手写 Actor,语义更直观、可控性更好。

3) 两种 Actor 写法

3.1 推荐:Channel + launch 手写

class CounterActor(scope: CoroutineScope) {
    // 1) 定义消息协议(强类型!)
    sealed interface Msg
    data class Inc(val n: Int) : Msg
    data class Get(val reply: CompletableDeferred<Int>) : Msg

    // 2) 强背压邮箱:0 容量 = RENDEZVOUS
    private val mailbox = Channel<Msg>(capacity = 0)

    // 3) 串行事件循环(同一协程/线程内操作可变状态)
    private val job = scope.launch(Dispatchers.Default) {
        var count = 0
        for (m in mailbox) when (m) {
            is Inc -> count += m.n
            is Get -> m.reply.complete(count)
        }
    }

    // 4) 对外暴露“写端”(单播)
    val inbox: SendChannel<Msg> get() = mailbox

    suspend fun closeAndJoin() { mailbox.close(); job.join() }
}

要点:0 容量让 send 与 receive 对接 → 最强背压;Actor 内部的状态更新严格串行

3.2(了解即可)actor{}构建器

@OptIn(ObsoleteCoroutinesApi::class)
fun CoroutineScope.counterActor() = actor<CounterMsg>(capacity = 0) {
    var count = 0
    for (m in channel) when (m) { /* … */ }
}
// 返回的是 SendChannel<CounterMsg>,内部通过 channel 接收

受限于过时/实验标记与可读性,生产更建议用 手写 版本。


4) 典型模板(照抄能用)

4.1 强背压流水线(RENDEZVOUS,避免堆积)

val ch = Channel<Task>(capacity = 0)  // 一手交钱一手交货
val worker = scope.launch {
    for (t in ch) doWork(t)           // 严格串行
}
// 生产处:没有空闲消费者就挂起
scope.launch { tasks.forEach { ch.send(it) }; ch.close() }

适用:GPU/IO 受限、设备资源稀缺(解码、渲染、磁盘),“宁慢勿堆”。

4.2 限流 + 抗抖(小缓冲 + 丢弃旧帧)

val updates = Channel<Update>(
    capacity = 64,
    onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val actor = scope.launch(Dispatchers.Default) {
    for (u in updates) applyUpdate(u) // 只保住“最新的 64 条”
}

适用:UI 状态/遥测上报,只关心最新,不需处理全部历史。

4.3 “只要最新”工作(取消旧任务 → mapLatest 的手写 Actor 版)

sealed interface Msg { data class Start(val job: suspend () -> Unit) : Msg }
val mbox = Channel<Msg>(capacity = 64)
scope.launch {
    var running: Job? = null
    for (m in mbox) when (m) {
        is Msg.Start -> {
            running?.cancel()
            running = launch { m.job() }   // 最新的生效
        }
    }
}

4.4 请求-应答(Ask 模式)

sealed interface Msg
data class Put(val k: String, val v: Int) : Msg
data class Get(val k: String, val reply: CompletableDeferred<Int?>) : Msg

val mbox = Channel<Msg>(capacity = 0)
val kvActor = scope.launch {
    val map = HashMap<String, Int>()
    for (m in mbox) when (m) {
        is Put -> map[m.k] = m.v
        is Get -> m.reply.complete(map[m.k])
    }
}
// 调用方
suspend fun get(k: String): Int? {
    val reply = CompletableDeferred<Int?>()
    mbox.send(Get(k, reply))
    return reply.await()
}

要点:通过 CompletableDeferred 往回“回信”,仍保持单播与串行


5) 关闭与清理(谁关、何时关、怎么关)

  • 谁生产谁 close() :关闭写端,允许消费者把缓冲读完后自然退出 for (e in ch)。
  • 消费者不想再读:cancel(cause),会立刻让挂起中的 send/receive 失败并丢弃未读。
  • Actor 收尾:常用 close(); job.join() 或 cancelAndJoin();在 finally 中用 withContext(NonCancellable) 做资源释放。
  • 错误传播:Actor 内未捕获异常会取消其 Job 并使 channel 失败;上游 send 会收到异常(或 trySend 失败)。在 Actor 内部 try/catch,按需上报并决定是否 close()。

6) Channel/Actor vs Flow/SharedFlow(怎么选)

  • Channel/Actor:点对点单播、需要明确背压/容量控制、需要命令式消息协议(如状态机/命令处理)。
  • SharedFlow/StateFlow广播、多订阅者、无背压(SharedFlow 只提供缓冲 + 重放而非挂起背压),状态/事件分发优选。
  • 互转:ReceiveChannel.consumeAsFlow();flow.produceIn(scope)。

7) 性能与调度小抄

  • 容量小即强背压,避免无界内存增长;多数业务用 0/1/64 足矣。
  • CPU 密集型 Actor 绑到 Dispatchers.Default;UI Actor 绑 Main/Main.immediate。
  • 不要在 Actor 内阻塞线程(sleep/IO 阻塞),用挂起 API;确需阻塞改用 withContext(IO) 包起来。
  • 高并发场景可建固定数量 worker(多个消费者 fan-out 同一 Channel);需要顺序一致就单 Actor。

8) 高频坑与排错

  1. 双向等待死锁:两个 Actor 用 RENDEZVOUS 互相 send → 都在等对方 receive。解决:引入缓冲或一侧改为 trySend + 重试/协商。
  2. 不小心广播:想“一条消息给所有人”却用 Channel,导致只有一人收到。需要广播请用 SharedFlow 或多播设计。
  3. UNLIMITED 内存暴涨:见过把 Bitmap 往 UNLIMITED 里塞到 OOM。一定用小容量+背压/丢弃策略。
  4. 多处 close() :close 只能一次;重复 send 到已关闭通道会失败。明确“关闭权责”(通常生产者)。
  5. 消耗不干净:for (e in ch) 之外别忘了在 finally 里清理资源;使用 receiveCatching() 感知关闭。
  6. 混用阻塞发送:trySendBlocking 在 UI 线程会卡死;仅在非协程环境或确知可以阻塞时用。
  7. 吞异常:Actor 内 catch 了但不告知外部,通道持续背压。应上报/打点,并在致命错误后 close(cause)。

9) 设计指北(三步走)

  1. 写出消息协议(sealed class Msg)——把“能做的事”类型化。

  2. 选邮箱容量策略

    • 实时一致性/不能堆积 → 0(强背压);
    • 可吸突发 → 小 BUFFERED(n);
    • 只要最新 → BUFFERED + DROP_OLDEST 或 CONFLATED。
  3. 确定并发度

    • 必须顺序 → 单 Actor;
    • 可并发处理 → 固定 worker 数量 repeat(k) { launch { for (t in ch) … } }。

一句话总结

  • Channel = 单播消息队列 + 背压Actor = Channel + 串行处理的协程

  • 强背压选 RENDEZVOUS/小缓冲;只要最新用 DROP_OLDEST/CONFLATED。

  • 谁生产谁 close;消费者不再读用 cancel。

  • 想广播请用 SharedFlow;需要可变状态串行执行业务逻辑,Actor 最省心。