Channel 的 4 类常用并发/架构模式讲透:管线(pipeline)、扇入(fan-in)、扇出(fan-out)、Actor(消息驱动)

230 阅读5分钟

一些通用基石(先记住)

  • 容量×策略:Channel(capacity = N, onBufferOverflow = SUSPEND | DROP_OLDEST | DROP_LATEST)

    • “必达”流:SUSPEND;“最新优先/可丢旧”:DROP_OLDEST 或 CONFLATED。
  • 非阻塞发送:trySend()(失败立即返回);“先 try 失败再挂起”的万能封装:

suspend fun <E> SendChannel<E>.offerOrSend(e: E) { if (!trySend(e).isSuccess) send(e) }
  • 优雅收尾:生产结束用 close()(让消费把队列读干净),紧急终止用 cancel()。

  • 资源清理:为持资源元素配置 onUndeliveredElement = { release(it) },避免丢弃/取消路径泄漏。

  • 监控压力:自己维护 AtomicInteger 统计“入队-出队”长度,打点 p95/p99。


1) 管线(pipeline):阶段化处理,用多个通道串起来

何时用:典型 ETL/媒体/图像/音视频/日志处理,多阶段串联;每段可独立限速与并行。

核心要点****

  • 每一段(Stage)只干一件事;段与段之间用 Channel 解耦。

  • 每段的容量就是节流阀:队列小=低延迟;队列大=高吞吐但更“滞后”。

  • 中间重活段可开 N 个 worker 并行消费。

代码范式

data class Job(val id: Int)
data class Mid(val id: Int, val data: ByteArray)
data class Out(val id: Int, val ok: Boolean)

val c12 = Channel<Job>(64, BufferOverflow.SUSPEND)   // Stage1→2:必达
val c23 = Channel<Mid>(64, BufferOverflow.SUSPEND)   // Stage2→3:必达

val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

// Stage1:拉取/预处理
scope.launch(Dispatchers.IO) {
  try {
    source().forEach { c12.send(it) }
  } finally { c12.close() }
}

// Stage2:CPU 重,开 4 个 worker
repeat(4) {
  scope.launch(Dispatchers.Default) {
    for (j in c12) {
      val mid = transform(j)           // 重计算
      c23.send(mid)
    }
  }
}

// Stage3:落地/写盘/上传
scope.launch(Dispatchers.IO) {
  try {
    for (m in c23) sink(store(m))
  } finally { c23.close() }
}

调优****

  • Stage2 爆满:加大 c12 容量或 worker 数;若允丢旧,可改 DROP_OLDEST。

  • 端到端延迟大:先减小容量再评估并行度。

  • 对“只要最新的展示”型中间态,可在 stage 边界前加 CONFLATED/DROP_OLDEST。

易坑****

  • 无限容量/UNLIMITED 伪稳 → OOM。
  • 只 cancel 生产者不 close 通道 → 消费者 for(e in ch) 不退出。

2) 扇入(fan-in):多个生产者 → 一个通道

何时用:多来源合流(多 socket/目录/传感器/任务队列)。

核心要点****

  • 多个 launch 并发写同一 Channel;错误隔离用 SupervisorJob 或每个生产者内部 try/catch。

  • 收尾:所有生产者完成后再 close 汇总通道(可用计数/joinAll)。

代码范式

val inbox = Channel<Event>(capacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

// 多个生产者
val producers = listOf(srcA(), srcB(), srcC())
producers.forEach { src ->
  scope.launch {
    try {
      src.collect { e -> inbox.trySend(e) }   // 高频:不阻塞生产者
    } catch (t: Throwable) { /* log */ }
  }
}

// 汇总消费者
scope.launch(Dispatchers.Default) {
  try {
    for (e in inbox) handle(e)
  } finally { /* flush */ }
}

// 全部结束后关闭
scope.launch {
  // 等待所有生产者结束(自行保存 Job 列表并 joinAll)
  delay( /* …或 joinAll(producerJobs) … */ )
  inbox.close()
}

调优****

  • 高频场景(行情/日志)建议 DROP_OLDEST 或 CONFLATED;低频则 SUSPEND。

  • 单消费者吃不动:在下一段做 pipeline + 多 worker。

易坑****

  • 公平性不保证:快源可能“挤占”吞吐;必要时用**路由器(见扇出)**或为每源分独立通道再合并。

3) 扇出(fan-out):一个生产者 → 多个消费者(单播)

何时用:同一任务源,交给多 worker 并行处理(单播:一条消息只被其中一个消费)。

核心要点****

  • 单个 Channel + N 个消费者:天然“竞争消费”(类似工作池),不保证均匀

  • 真正的“广播一条给所有人”→ 不用 Channel,请用 SharedFlow(或多通道复制)。

代码范式(工作池:竞争消费)

val jobs = Channel<Job>(capacity = 128, onBufferOverflow = BufferOverflow.SUSPEND)

// producer
launch(Dispatchers.IO) {
  try { sourceJobs().forEach { jobs.send(it) } }
  finally { jobs.close() }
}

// N workers(竞争消费)
repeat(8) {
  launch(Dispatchers.Default) {
    for (j in jobs) process(j)     // 每个 j 只会被一个 worker 处理
  }
}

均衡分发(Round-Robin 路由器)

val workerChs = List(4) { Channel<Job>(32, BufferOverflow.SUSPEND) }
val router = Channel<Job>(128, BufferOverflow.SUSPEND)

// 路由器:轮询分发
launch {
  var idx = 0
  for (j in router) {
    workerChs[idx].send(j)
    idx = (idx + 1) % workerChs.size
  }
  workerChs.forEach { it.close() }
}

// Worker
workerChs.forEach { ch ->
  launch(Dispatchers.Default) { for (j in ch) process(j) }
}

调优****

  • 源头高频但处理重:源→DROP_OLDEST,worker 通道 SUSPEND;或在 worker 前做限速/节流。

  • 想要广播(每个消费者都要)→ 用 MutableSharedFlow(replay=0, extraBufferCapacity=...),不要用一个 Channel 给多人读。

易坑****

  • 把 Channel 当广播:会丢给大部分消费者(因为单播)。
  • 竞争消费下任务“偏食” :必要时加路由/按 key 分桶。

4) Actor(消息驱动):把共享可变状态封进单线程协程

何时用:需要一个**“串行的关键区”**来管理共享状态(计数器、缓存、会话、路由表),避免锁。

核心要点****

  • 单协程 + 邮箱 Channel;所有状态变更都通过发送消息进入该协程。

  • 严格串行(可选:单线程调度)→ 无锁。

  • 请求-响应用 CompletableDeferred 回传。

代码范式

sealed interface Msg {
  data class Inc(val n: Int) : Msg
  data class Get(val reply: CompletableDeferred<Int>) : Msg
  data object Stop : Msg
}

class CounterActor(scope: CoroutineScope) {
  private val mailbox = Channel<Msg>(capacity = 64, onBufferOverflow = BufferOverflow.SUSPEND)
  private val job = scope.launch(Dispatchers.Default.limitedParallelism(1)) { // 串行
    var count = 0
    for (m in mailbox) when (m) {
      is Msg.Inc -> count += m.n
      is Msg.Get -> m.reply.complete(count)
      Msg.Stop   -> { mailbox.close(); break }
    }
  }
  suspend fun inc(n: Int) = mailbox.send(Msg.Inc(n))
  suspend fun get(): Int = CompletableDeferred<Int>().also { mailbox.send(Msg.Get(it)) }.await()
  suspend fun stop() { mailbox.send(Msg.Stop); job.join() }
}

调优****

  • 邮箱容量要配合策略:状态必达 → SUSPEND;若外界可能“狂点”,可改 DROP_OLDEST 以自保。

  • 长任务不要在 actor 内跑(会堵消息),应把耗时踢到外部并以结果消息回投。

易坑****

  • 在 actor 内做阻塞/重 IO → 饿死后续消息。
  • 忘了 Stop/close → 协程泄漏。

组合技:管线 × 扇入 × 扇出 × Actor

  • 现实系统通常是:外界扇入到入口 → Actor 做路由/限流/状态校验 → 按业务 管线多段处理 → 中间重活段 扇出到工作池 → 汇总落地。
  • 每个“边界”都明确:容量、策略、线程、错误处理、关停顺序

调优清单(速查)

  • 明确每段业务语义:必达/可丢?只要最新/滑窗?
  • 容量从 64 起步,观察队列长度与延迟再调;高频展示段优先 DROP_OLDEST/CONFLATED。
  • 重计算移出 Main;IO/CPU 段用 Dispatchers.IO/Default,必要时 limitedParallelism(k)。
  • 生产者用 trySend,失败时再 send,既保证吞吐又能背压兜底。
  • 统一关停:先停上游→close 中间→等消费干净;紧急停用 cancel()。
  • 为“未投递元素”配 onUndeliveredElement 做资源清理。