20、Channel:协程间的“数据管道”

聊到协程,我们总绕不开一个核心问题:多个协程之间怎么安全地传数据?

你可能会想到共享变量,再加个锁。嗯,这确实是个办法。但我在项目中用过几次之后,发现锁这东西,用好了是利器,用不好就是噩梦——死锁、竞态、调试困难……说白了,就是心智负担太重。

那有没有更优雅的方式?Channel 就是 Kotlin 给出的答案。

我个人习惯把 Channel 想象成一根水管。一头往里灌水,另一头接水。水管本身是线程安全的,你只管往里扔数据,另一头只管取,中间的事 Channel 帮你搞定。

Channel 的概念

Channel 本质上是一个并发安全的队列。它连接了生产者(发送方)和消费者(接收方)。

你可以把它理解成 Java 里的 BlockingQueue,但有一个关键区别:Channel 是挂起的,不是阻塞的

什么意思呢?

当队列满了,你还要往里发数据——BlockingQueue 会阻塞线程,而 Channel 会挂起协程。挂起不占线程,阻塞却会。这在高并发场景下,性能差距非常明显。

我记得有一次优化一个实时数据推送服务,原来用 LinkedBlockingQueue,压测时线程池被打满。改成 Channel 后,同样的吞吐量,线程数直接降了一半。嗯,这就是挂起 vs 阻塞的威力。

Channel 的类型

Kotlin 提供了几种内置的 Channel 类型。别被名字吓到,其实就四种,很好记。

类型 行为 典型场景
RendezvousChannel 容量为 0,发送和接收必须同时发生 一对一握手,数据不缓存
BufferedChannel 有固定容量,发送方可以提前发 生产速度 > 消费速度时缓冲
ConflatedChannel 容量为 1,新数据会覆盖旧数据 只关心最新值,比如 UI 状态
UnlimitedChannel 容量无限,但小心 OOM 生产远快于消费,且数据不能丢

你可能会问:实际开发中怎么选?

我个人经验是:默认用 BufferedChannel。容量设多少?看你的业务容忍度。比如你希望生产者最多等 10 个数据,那就设 10。Rendezvous 适合“必须实时对接”的场景,比如两个协程做乒乓通信。Conflated 我常用于 UI 层——比如用户连续点击按钮,我只处理最后一次点击事件。

核心要点:Channel 的类型本质上决定了它的背压策略。选对了类型,你的系统就成功了一半。

Channel 的发送与接收

发送用 send(),接收用 receive()。就这么简单?嗯,但细节里藏着魔鬼。

先看一个最基础的例子:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
    val channel = Channel<Int>(capacity = 5)

    launch {
        for (i in 1..10) {
            channel.send(i)
            println("发送: $i")
        }
        channel.close()
    }

    launch {
        for (value in channel) {
            println("接收: $value")
        }
    }
}

这段代码里,for (value in channel) 是 Channel 的迭代用法。它会一直取数据,直到 Channel 被关闭。我个人非常喜欢这种写法——简洁、安全,不会漏数据。

但要注意:send() 和 receive() 都是挂起函数。如果你在非协程环境里调用,会报错。我曾经见过一个同事在 Callback 里直接调 channel.send(),结果编译过了,运行时直接崩。嗯,这个坑我踩过,所以提醒你一下。

小技巧:如果你需要在非协程环境里发送数据,可以用 trySend()tryReceive()。它们是非挂起的,返回 Booleannull 表示成功或失败。

Channel 的关闭与迭代

Channel 用完了要关吗?要关。不关的话,接收方会一直挂起等待,造成协程泄漏。

关闭很简单:

channel.close()

但关闭之后,接收方还能取到之前已经发送但还没取走的数据。这个行为很关键——close() 不是清空,而是“不再接受新数据”

迭代 Channel 有两种方式:

  1. for 循环:自动处理关闭,推荐使用。
  2. while + receive():需要手动捕获 ClosedReceiveException

看个对比:

// 方式一:for 循环(推荐)
launch {
    for (msg in channel) {
        println(msg)
    }
}

// 方式二:while 循环(不推荐,但理解原理)
launch {
    while (true) {
        try {
            val msg = channel.receive()
            println(msg)
        } catch (e: ClosedReceiveException) {
            break
        }
    }
}

我个人只用方式一。为什么?少写代码,少犯错。你想想看,手动捕获异常这种事,写多了难免会漏掉某个分支。

警告:千万不要在多个协程里同时对一个 Channel 调用 close()。这会导致 CloseForbiddenException。如果你有多个生产者,建议用 Channel(UNLIMITED) 配合 consumeEach 来管理生命周期。

知识体系总览

下面这张图,是我为你整理的 Channel 核心知识结构。你可以把它当作一张“地图”,学完本章后对照着回顾一下:

Channel 概念:并发安全的挂起队列 类型:Rendezvous | Buffered | Conflated | Unlimited 操作:send() / receive() / trySend() / tryReceive() 关闭与迭代:close() / for循环 / consumeEach 核心:选对类型 + 正确关闭

你看,Channel 的知识点其实就这么多。概念清晰,类型明确,操作简单。但真正用好它,需要你在项目中多练、多踩坑。

我曾经在一个高并发日志收集系统里,因为选错了 Channel 类型,导致生产者频繁挂起,吞吐量上不去。后来换成 BufferedChannel 并调整了容量,问题迎刃而解。嗯,这种经验,光看书是学不来的。

希望你能把 Channel 当作协程编程的“标配”来掌握。它不复杂,但很强大。


公众号:蓝海资料掘金营,微信deep3321