深入理解 Kotlin 协程 (九):互通有无,解构 Channel 缓冲策略与底层无锁机制

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)中,如果队列空间不足,还往其中添加元素,此时会出现两种情况:

  1. 阻塞等待,直到队列腾出空间。

  2. 抛异常,拒绝此次添加。

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

接着我们来说说最重要的两个底层实现类,分别是 BufferedChannelConflatedBufferedChannel,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

快速构造生产者和消费者协程,可以使用 produceactor 方法:

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)
    }

}

ReceiveChannelSendChannel 是 Channel 的父接口,因此 Channel 才既能发也能收。

produceactor 也是协程构造器,只是它们专用于 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 通常由生产者来调用。

相关推荐
灯塔@kuaidao3 小时前
平台交叉编译名词解释与基础流程
android
__Witheart__4 小时前
3568 Android otg模式下adb热拔插不识别
android·adb·rockchip
明天…ling5 小时前
Upload-Labs (Pass1-Pass21) 完整通关思路与源码分析
android·网络安全·渗透测试·burpsuite·upload-labs·文件上传绕过·靶场复现
__Witheart__5 小时前
3568 Android ntp校时使用
android·rockchip
wddptwd286 小时前
android studio 报错怎么处理 java.lang.NullPointerException
android·java·android studio
美狐美颜SDK开放平台7 小时前
直播APP开发,美颜SDK和相机SDK有什么区别?
android·深度学习·数码相机·ios·直播美颜sdk·视频美颜sdk
唐诺8 小时前
Android Open Accessory (AOA) 协议完全解析
android·usb·aoa
我命由我1234510 小时前
Android 开发 - 广播组件(标准广播、有序广播、静态注册广播、分钟到达广播、网络变更广播...)
android·java·开发语言·网络·java-ee·android studio·android-studio
A富得流油的咸鸭蛋10 小时前
安卓鸿蒙面试
android·面试·harmonyos