Channel 的基本用法
Channel 是 Kotlin 协程之间进行安全通信的工具,本质上就是一个并发安全的队列,以下是其基本用法:
kotlin
fun main() = runBlocking {
val channel = Channel<Int>()
val producer = launch {
while (isActive) {
delay(timeMillis = 1000L)
// 发送数据
channel.send(Random.nextInt())
}
}
val consumer = launch {
while (isActive) {
// 获取数据
val element = channel.receive()
println("The received element is $element")
}
}
producer.join()
consumer.join()
}
当队列为空时,接收端获取数据会挂起等待直到新元素到来,并不会阻塞线程;发送端同样如此,当队列塞满后,发送数据也会挂起直到有元素被取走。
容量与缓冲区策略
在阻塞队列(BlockingQueue)中,如果队列空间不足,还往其中添加元素,此时会出现两种情况:
-
阻塞等待,直到队列腾出空间。
-
抛异常,拒绝此次添加。
Channel 队列也是有着缓冲区,我们来看看它的设置:
kotlin
public fun <E> Channel(
capacity: Int = RENDEZVOUS,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,
onUndeliveredElement: ((E) -> Unit)? = null
): Channel<E> =
when (capacity) {
RENDEZVOUS -> {
if (onBufferOverflow == BufferOverflow.SUSPEND)
BufferedChannel(RENDEZVOUS, onUndeliveredElement) // an efficient implementation of rendezvous channel
else
ConflatedBufferedChannel(1, onBufferOverflow, onUndeliveredElement) // support buffer overflow with buffered channel
}
CONFLATED -> {
require(onBufferOverflow == BufferOverflow.SUSPEND) {
"CONFLATED capacity cannot be used with non-default onBufferOverflow"
}
ConflatedBufferedChannel(1, BufferOverflow.DROP_OLDEST, onUndeliveredElement)
}
UNLIMITED -> BufferedChannel(UNLIMITED, onUndeliveredElement) // ignores onBufferOverflow: it has buffer, but it never overflows
BUFFERED -> { // uses default capacity with SUSPEND
if (onBufferOverflow == BufferOverflow.SUSPEND) BufferedChannel(CHANNEL_DEFAULT_CAPACITY, onUndeliveredElement)
else ConflatedBufferedChannel(1, onBufferOverflow, onUndeliveredElement)
}
else -> {
if (onBufferOverflow === BufferOverflow.SUSPEND) BufferedChannel(capacity, onUndeliveredElement)
else ConflatedBufferedChannel(capacity, onBufferOverflow, onUndeliveredElement)
}
}
在 Channel 的快捷构造函数(也称为 "工厂函数")中,会根据指定的容量(capacity)、溢出策略(onBufferOverflow)来决定缓存区的创建。
第三个参数
onUndeliveredElement是未分发回调,用于指定元素已经发送,但未传递给消费者的处理逻辑。这些元素是由于 Channel 的关闭和溢出策略导致丢弃的,我们可以在这释放资源和打印日志。
我们来解释一下每个小点,首先是 RENDEZVOUS,它的值是 0,本义是 "会合"。表示无缓冲区:发送端不发送数据,接收端就会挂起一直等待;接收端不获取数据,发送端也会挂起等待。
想到这,我脑海中突然涌现了这个画面:

是不是非常贴切啊?两个人需要同时伸手才能够到。
为了更好地观察到这个现象,我们改造一下之前的代码:
kotlin
fun main() = runBlocking {
val channel = Channel<Int>()
val producer = launch {
while (isActive) {
val randomInt = Random.nextInt()
delay(timeMillis = 3000L)
println("before send $randomInt")
channel.send(randomInt)
println("after send $randomInt")
}
}
val consumer = launch {
while (isActive) {
delay(timeMillis = 5000L)
val element = channel.receive()
println("received: $element")
}
}
producer.join()
consumer.join()
}
可以看到 producer 发送后会挂起,等到 consumer 接收后才会往下执行,打印 "after send ..."。
UNLIMITED 的值是 Int.MAX_VALUE,表示无限制的,缓冲区永远不会溢出,它的元素数量受到了可用内存的限制。
CONFLATED 的字面意思是 "合并、结合",发送的元素会不断被替换,例如同时发送了 3 个元素,最后只会保留一个元素。它实际上就是一个容量为 1 的缓冲区,新元素会替换掉旧元素。
BUFFERED 表示默认缓冲,将使用默认的容量 64。
再就是缓冲区溢出策略,默认值 SUSPEND,表示缓冲区满了,生产者会挂起。DROP_LATEST 表示缓冲区满时,会丢弃最新发送的元素(保留旧数据);DROP_OLDEST 表示缓冲区满时,会丢弃缓冲区里最旧的元素(保留最新数据)。
注意:只有 ConflatedBufferedChannel 支持
DROP_OLDEST/DROP_LATEST,BufferedChannel 只支持SUSPEND,所有非SUSPEND策略,最终都会强制创建 ConflatedBufferedChannel。
深入底层实现:BufferedChannel
接着我们来说说最重要的两个底层实现类,分别是 BufferedChannel 和 ConflatedBufferedChannel,Channel 的工厂函数最终只会创建这两种通道的实例。
在 Kotlin 协程 1.7 之后,Channel 的内部结构经过了一次重构,底层从原本繁琐的各个实现类,改为了只由 BufferedChannel 和 ConflatedBufferedChannel 接管。
而 ConflatedBufferedChannel 只是在 BufferedChannel 的基础上添加了特定丢弃策略的处理,所以我们只需了解 BufferedChannel 即可。Channel 的高性能和线程安全,都源自于其内部的 BufferedChannel。
它的物理结构是一个由固定大小的数组分段(Segment)组成的链表,又称为块状链表(Unrolled Linked List),它同时具备了数组(缓存友好、随机访问快)和链表(插入删除灵活)的优点。啥意思呢?其实就是固定大小的数组以链表的形式连在一起了而已,就像这样:
虽然是链表,但在逻辑上,被当作了一个无限大的数组来使用。
css
Segment Node 1 Segment Node 2 Segment Node 3
[ A, B, C, D ] ---> [ E, F, G, ] ---> [ H, I, , ]
同时它采用了非常高效的 FAA(Fetch-And-Add)无锁算法,维护了三个非常核心的原子计数器:
sendersCounter: 记录了Channel.send()的调用次数。receiversCounter: 记录了Channel.receive()的调用次数。bufferEndCounter: 标记当前缓冲区允许发送者不挂起就能写入的最高索引。
有了这些前置知识后,我们就来说说其核心流程:
-
首先不管是获取还是发送数据,都会利用原子的 FAA 操作将自己的计数器 +1,这是为了在逻辑无限数组中占用(预定)一个单元格,这个格子只属于自己,不会存在两个发送者拿到同一个格子。
-
如果是
send():会去看分配到的格子有没有正在挂起的receive,如果有的话,会把数据交给并唤醒对方,也就是两者握手。如果没有接收者,并且当前的索引小于bufferEndCounter,说明缓冲区还有空位,直接把数据存放进去,然后返回(不挂起);如果超出了缓冲区边界,就会将自己(协程实例)存入格子并挂起等待。 -
如果是
receive():如果对应的格子里已经有了数据(send之前存入的),会直接取走并清空格子;如果是一个挂起的send,那么拿走数据的同时会唤醒发送者;如果是空的,会挂起等待发送者存入数据。
这种设计在无竞争时指令极少,在高并发竞争时也没有线程会被锁阻塞,所以 Channel 的缓冲区采用了这种架构。
我们再用这个流程来解释一下 Channel 的所有容量类型:
-
Channel.RENDEZVOUS(容量 0): 缓冲区边界为 0,send只要拿到坑位(发现永远越界),要是没有现成的receive,就一定会挂起。 -
Channel.UNLIMITED: 缓冲区边界为无穷大,此时send永远不会越界,所以总是直接把数据塞进数组然后返回,绝对不会挂起。 -
Channel.BUFFERED(指定容量 N): 缓冲区边界初始为 N,由于前 N 个send操作在边界内,会直接存入数组;第 N+1 个操作发现越界后,会开始挂起。当有receive消费了数据时,才会把bufferEndCounter推着往前移,从而释放出新的缓冲区空间。
Channel 的迭代与消费
迭代 Channel 不需要像上面那样,使用 while(isActive) 循环,我们可以直接获取一个 Channel 的迭代器:
kotlin
runBlocking {
val consumer = launch {
val iterator = channel.iterator()
while (iterator.hasNext()) { // 挂起点
val element = iterator.next()
println(element)
}
}
}
hasNext() 是一个挂起函数,它会去 Channel 中读取元素来判断是否有下一个元素。
判断过程中,会让对应的协程恢复执行,直到挂起或完成,hasNext 才会结束挂起。因此,你能看到这个不符合直觉的现象:输出结果中,B 比 Got 1 还早输出,同样 Done 比 Got 早输出。
kotlin
runBlocking {
// 为了赶在第一次调用 hasNext 前,让协程在 send(1) 挂起点,我们使用了 Unconfined 调度器,它会让协程在当前线程同步执行直到挂起
val channel = produce(Dispatchers.Unconfined) {
println("A")
send(1)
println("B")
send(2)
println("Done")
}
for (item in channel) {
println("Got $item")
}
}
这个写法可以简化为 for ... in:
kotlin
runBlocking {
val consumer = launch {
for (element in this) {
println(element)
}
}
}
协程构造器:produce 与 actor
快速构造生产者和消费者协程,可以使用 produce 和 actor 方法:
kotlin
runBlocking {
val receiveChannel: ReceiveChannel<Int> = produce {
// 构造生产者协程
repeat(10) {
delay(timeMillis = 1000L)
send(it)
}
}
launch {
// 通过返回的 Channel 获取数据
for (element in receiveChannel) { // 这是一个挂起操作
println(element)
}
}
// ============================
val sendChannel: SendChannel<Int> = actor {
// 构造消费者协程
for (element in this) {
println(element)
}
}
// 通过返回的 Channel 发送数据
repeat(5) {
delay(timeMillis = 500L)
sendChannel.send(-it)
}
}
ReceiveChannel 和 SendChannel 是 Channel 的父接口,因此 Channel 才既能发也能收。
produce 和 actor 也是协程构造器,只是它们专用于 Channel,当协程结束后,对应的 Channel 也会关闭。
以生产者协程为例:
kotlin
private class ProducerCoroutine<E>(
parentContext: CoroutineContext, channel: Channel<E>
) : ChannelCoroutine<E>(parentContext, channel, true, active = true), ProducerScope<E> {
override val isActive: Boolean
get() = super.isActive
override fun onCompleted(value: Unit) {
_channel.close() // 在完成时关闭 _channel
}
override fun onCancelled(cause: Throwable, handled: Boolean) {
// 在取消时,同样关闭 _channel
val processed = _channel.close(cause)
if (!processed && !handled) handleCoroutineException(context, cause)
}
}
注意:actor 目前已经被官方标记为了 @ObsoleteCoroutinesApi (即过时 API)。
Channel 的关闭
前面我们多次提到了 Channel 的关闭,只需调用它的 close 方法即可。
关闭后,SendChannel 会立即停止发送新元素,对应的 isClosedForSend 标志会返回 true;此时缓冲区还有未消费的数据,而 isClosedForReceive 标志只有在缓冲区为空时,才会返回 true。
因此,关闭通道后将无法再发送数据:此时如果继续调用 send() 会立即抛出 ClosedSendChannelException 异常。当缓冲区里的数据被全部取完后,如果还想继续调用 receive(),会抛出 ClosedReceiveChannelException 异常。
关于 Channel 的取消 (
cancel)可以看我的这篇博客:协程间的通信管道 ------ Kotlin Channel 详解
那关闭有什么意义呢?
一是前面提到的释放资源,二就是在没有数据要发送后,让接收端停止挂起等待。
Channel 的关闭表示没有更多数据要发送了,所以 close 通常由生产者来调用。