如何理解Channel中协程之间点对点消息 + 明确背压/容量控制

148 阅读4分钟

1) Channel 是什么:一对一“邮筒”

  • 点对点:Channel 天然适合 1 生产者 ↔ 1 消费者。也支持多生产/多消费,但不保证公平/顺序(不要依赖)。
  • 冷 vs 热:Channel 是消息队列(创建即存在),和 Flow(冷)互补:可用 receiveAsFlow()/consumeAsFlow() 互转。

2) 核心参数 = “容量 + 背压/丢弃策略”

val ch = Channel<E>(
  capacity = N,                             // 0=RENDEZVOUS, >0=有界, UNLIMITED, CONFLATED, BUFFERED(≈默认合适值)
  onBufferOverflow = BufferOverflow.SUSPEND // or DROP_OLDEST / DROP_LATEST
  // onUndeliveredElement = { e -> ... }    // 元素未能送达时的清理回调(释放资源等)
)
  • capacity(缓冲大小)

    • RENDEZVOUS(0):无缓冲,send 必须等 receive 立刻配对(最强背压)。
    • N > 0:固定有界环形缓冲(常用 16/64/256…)。
    • UNLIMITED:链表无限长(除非 OOM,一般别用在高频)。
    • CONFLATED:仅保“最新” (新值覆盖旧值;接收侧永远拿到最近一次)。
    • BUFFERED:库给的合理默认(≈几十)。常够用。
  • onBufferOverflow(缓冲满时)

    • SUSPEND:默认。不丢,send 挂起,形成背压

    • DROP_OLDEST:丢最老的,再放新值(滑窗语义,适合 UI“只关心最新”)。

    • DROP_LATEST:丢当前发送的新值(保老数据)。

直观选择:

  • 每条必达:capacity=N + SUSPEND。

  • 只要最新:CONFLATED 或 capacity=N + DROP_OLDEST。

  • 吞吐优先但可小丢:capacity=N + DROP_OLDEST。

  • 绝不建议:UNLIMITED(易隐性堆积 → OOM)。


3) send/receive/trySend/tryReceive:阻塞与非阻塞

suspend fun SendChannel<E>.send(e: E)     // 可能挂起(按策略)
fun    SendChannel<E>.trySend(e: E): ChannelResult<Unit> // 不挂起,失败立即返回
suspend fun ReceiveChannel<E>.receive(): E // 可能挂起,队列空则等
fun    ReceiveChannel<E>.tryReceive(): ChannelResult<E> // 不挂起,空则失败
  • 背压点在 send:当容量耗尽且策略 SUSPEND → send 挂起,生产者被“顶住”。
  • 用 trySend 可 不挂起,配合 DROP_OLDEST 实现“满了就丢旧保新”。

4) 关停与异常

ch.close(cause?: Throwable?)  // 关闭发送端;接收侧读完现存数据后结束
ch.cancel(cause)              // 立刻取消,丢弃未读数据(更激进)
ch.isClosedForSend / ch.isClosedForReceive
  • 发送端关闭:trySend 失败,send 抛 ClosedSendChannelException。
  • 接收端完成:for (e in ch) 自然结束;或 receive() 抛 ClosedReceiveChannelException。
  • onUndeliveredElement:元素因取消/关闭未达接收者时回调一次,及时释放资源(文件句柄、Bitmap 等)。

5) 典型范式(点对点 + 明确背压)

(A) 最纯粹点对点:RENDEZVOUS(0)

“一手交钱一手交货”,强背压,永不丢。

val ch = Channel<Task>(capacity = Channel.RENDEZVOUS)

val producer = launch {
  for (i in 0 until 1000) {
    ch.send(Task(i))  // 消费者没收,这里就挂起
  }
  ch.close()
}

val consumer = launch {
  for (t in ch) handle(t) // 逐个处理
}

(B) 有界 + 必达:capacity=N, SUSPEND

允许短暂排队,满了就顶住上游。

val ch = Channel<Event>(capacity = 64, onBufferOverflow = BufferOverflow.SUSPEND)

(C) UI 只要最新:DROP_OLDEST/CONFLATED

// 方案1:滑窗
val ch = Channel<Tick>(capacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST)
// 方案2:永远只保最新
val latest = Channel<Tick>(capacity = Channel.CONFLATED)

producer.launch {
  while (isActive) {
    val t = readSensor()
    latest.trySend(t) // 不阻塞,自动覆盖旧值
  }
}
consumer.launch {
  for (t in latest) render(t) // 始终最新
}

(D) 生产-消费流水线(多阶段背压)

// stage1 -> stage2 -> stage3
val c12 = Channel<Job>(capacity = 64, onBufferOverflow = BufferOverflow.SUSPEND)
val c23 = Channel<Result>(capacity = 32, onBufferOverflow = BufferOverflow.SUSPEND)

launch(Dispatchers.IO) {              // stage1
  source().forEach { c12.send(it) }
  c12.close()
}

repeat(4) {                           // stage2:并行处理
  launch(Dispatchers.Default) {
    for (job in c12) c23.send(process(job))
  }
}

launch(Dispatchers.IO) {              // stage3:落盘/上传
  for (r in c23) sink(r)
  c23.close()
}

每一段容量就是节流阀:c12=64 限制“在处理队列”长度,c23=32 限制输出堆积。

(E) 限速/并发上限(把 Channel 当信号量)

val permits = Channel<Unit>(capacity = 8).apply { repeat(8) { trySend(Unit) } }

suspend fun <T> withPermit(block: suspend () -> T): T {
  permits.receive()         // 拿令牌(若无则挂起)
  return try { block() } finally { permits.trySend(Unit) } // 还令牌
}

6) 与 Flow 的互操作(点对点消息 → 流式处理)

  • 消费端是 Flow:channel.consumeAsFlow() 或 receiveAsFlow(),再接 conflate()/sample() 做 UI 限频。
  • 生产端是 Flow:flow.buffer(capacity, onBufferOverflow) 与 Channel 语义一致;flowOn 在边界处也会插入 BUFFERED 通道解耦。
val uiFlow = ch.receiveAsFlow()
  .conflate()           // UI 只要最新
  .sample(200)          // 每 200ms 刷一次
  .flowOn(Dispatchers.Default)

7) 选择指南(背压/容量小抄)

目标推荐配置
零丢失 + 强背压capacity=N + SUSPEND(或 RENDEZVOUS)
只关心最新CONFLATED 或 capacity=N + DROP_OLDEST + trySend
有限滑窗 + 高吞吐capacity=32/64/128 + DROP_OLDEST
不建议UNLIMITED(隐性堆积→OOM)

8) 常见坑

  1. 生产者阻塞不可见:SUSPEND 下 send 会挂起——请监控队列长度;必要时切换 DROP_OLDEST 或增大容量。
  2. UNLIMITED 伪稳:吞吐看似稳定,实则把压力堆进内存。
  3. 未清理未送达元素:复杂元素(文件句柄/Bitmap)务必提供 onUndeliveredElement 释放资源。
  4. close vs cancel:close() 让接收者把已入队的读完;cancel() 直接丢弃未读。
  5. 多消费者“抢占” :同一 Channel 多消费者读取是竞争关系,不保证均衡/顺序;需要均衡请自建调度。

9) 最小演示:同一套代码,切换三种背压策略

suspend fun demo(overflow: BufferOverflow, cap: Int) = coroutineScope {
  val ch = Channel<Int>(capacity = cap, onBufferOverflow = overflow)
  val p = launch(Dispatchers.Default) {
    repeat(1000) { i ->
      ch.trySend(i).onFailure { ch.send(i) }  // 先尝试不阻塞发送,不成再挂起发送
    }
    ch.close()
  }
  val c = launch {
    for (v in ch) {
      delay(5)  // 模拟慢消费
    }
  }
  joinAll(p, c)
}

runBlocking {
  demo(BufferOverflow.SUSPEND, 32)     // 绝不丢,慢就顶住
  demo(BufferOverflow.DROP_OLDEST, 32) // 滑窗,只保近数据
  demo(BufferOverflow.DROP_LATEST, 32) // 保已有数据,丢新来的
}

结论

  • Channel = 协程间点对点消息邮筒;“容量 + 溢出策略”决定背压
  • 业务要“必达” :用 SUSPEND(或 RENDEZVOUS)形成强背压;业务要“最新优先” :用 CONFLATED 或 DROP_OLDEST。
  • 把 Channel 串成多段流水线,每一段容量都是你的节流阀;必要时用 onUndeliveredElement 做资源清理,用 receiveAsFlow() 接上 Flow 的限频/平滑生态。