kotlin-协程(六)关于channel

1,108 阅读6分钟

一、概述

挂起函数、async,一次都只能返回一个结果,但在某些业务场景下,我们往往需要协程返回多次结果,比如 IM 通道接收的消息,或者是手机定位返回的经纬度坐标需要实时更新。那么,在这些场景下,之前学习的协程知识就无法直接解决了。Kotlin 协程中的 Channel,就是专门用来做这种事情的。

二、Channel管道概念

理解Channel可以结合Rxjava流的概念,Channel是一个管道,管道首尾端可以收发数据,如下图所示:

image.png

结合下面的代码来看看:

fun main() {
    runBlocking {
        printMsg("start")
        //创建管道
        val channel = Channel<Int>()
        //主线程协程
        launch{
            (1..5).forEach {
                printMsg("channel send $it")
                channel.send(it)
            }
        }
        //子线程协程
        launch(Dispatchers.IO) {
            for (i in channel) {
                printMsg("channel receive $i")
            }
        }
        printMsg("end")
    }
}

fun printMsg(msg: String) {
    println("${Thread.currentThread().name} $msg")
}

runBlocking的开始和结束打印了日志,同时通过Channel发送了5条数据,需要注意的是发送数据和接收数据的协程并不相同,并且没有写Thread.sleep的代码防止线程退出,看看日志输出:

main @coroutine#1 start
main @coroutine#1 end
main @coroutine#2 channel send 1
main @coroutine#2 channel send 2
DefaultDispatcher-worker-1 @coroutine#3 channel receive 1
DefaultDispatcher-worker-1 @coroutine#3 channel receive 2
main @coroutine#2 channel send 3
main @coroutine#2 channel send 4
DefaultDispatcher-worker-1 @coroutine#3 channel receive 3
DefaultDispatcher-worker-1 @coroutine#3 channel receive 4
main @coroutine#2 channel send 5
DefaultDispatcher-worker-1 @coroutine#3 channel receive 5

可以看到:

  • runBlocking即使打印了end,整个协程也没有退出,channel还在运行,说明channel不会主动退出。
  • channel可以跨线程和协程传输数据。
  • channel可以在for循环中迭代,这是因为channel实现了ReceiveChannel接口,而ReceiveChanneliterator()迭代的方法,源码如下:
//channel实现了SendChannel、ReceiveChannel接口
public interface Channel<E> : SendChannel<E>, ReceiveChannel<E> {...}

//ReceiveChannel有iterator()方法
public interface ReceiveChannel<out E> {
    ...
    //返回ChannelIterator
    public operator fun iterator(): ChannelIterator<E>
    ...
}

//ChannelIterator源码
public interface ChannelIterator<out E> {
    public suspend operator fun hasNext(): Boolean
    public suspend fun next0(): E {...}
    public operator fun next(): E
}

如果想要关闭Channnel以免浪费协程资源只需要调用close方法即可:

channel.close()

三、Channel源码解析

Channel的源代码如下:

public fun <E> Channel(
    capacity: Int = RENDEZVOUS,
    onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,
    onUndeliveredElement: ((E) -> Unit)? = null
): Channel<E>{...}

当我们调用Channel()的时候,感觉像是在调用一个构造函数,但实际上它却只是一个普通的顶层函数。这个函数带有一个泛型参数 E,另外还有三个参数。

1、capacity

第一个参数capacity,代表了管道的容量。在默认情况下是RENDEZVOUS,代表了默认Channel 的管道容量为 0。

capacity 有以下几种情况:

  • RENDEZVOUS,默认值,代表了容量为 0;
  • UNLIMITED,代表了无限容量;
  • CONFLATED,代表了容量为 1,新的数据会替代旧的数据;
  • BUFFERED,代表了具备一定的缓存容量,默认情况下是 64,具体容量由这个 VM 参数决定 kotlinx.coroutines.channels.defaultBuffer
  • Int 除了上面的几种模式,也可以给任意的Int值来定义管道容量

接下来只改变创建Channel的参数并调用了channel.close()方法,其他代码不变,看下代码和日志:

① RENDEZVOUS

//-------------------------------RENDEZVOUS--------------------------------
val channel = Channel<Int>(capacity = Channel.Factory.RENDEZVOUS)

//日志
main @coroutine#1 start
main @coroutine#1 end
main @coroutine#2 channel send 1
main @coroutine#2 channel send 2
DefaultDispatcher-worker-1 @coroutine#3 channel receive 1
DefaultDispatcher-worker-1 @coroutine#3 channel receive 2
main @coroutine#2 channel send 3
main @coroutine#2 channel send 4
DefaultDispatcher-worker-1 @coroutine#3 channel receive 3
DefaultDispatcher-worker-1 @coroutine#3 channel receive 4
main @coroutine#2 channel send 5
DefaultDispatcher-worker-1 @coroutine#3 channel receive 5

本来以为发送二个接收二个是一种偶然现象,但当把发送的个数改为1000,发现还是这样,可以理解为这种模式确实有这个规律。

② UNLIMITED

//-------------------------------UNLIMITED--------------------------------
val channel = Channel<Int>(capacity = Channel.Factory.UNLIMITED)

//日志
main @coroutine#1 start
main @coroutine#1 end
main @coroutine#2 channel send 1
main @coroutine#2 channel send 2
main @coroutine#2 channel send 3
main @coroutine#2 channel send 4
main @coroutine#2 channel send 5
DefaultDispatcher-worker-1 @coroutine#3 channel receive 1
DefaultDispatcher-worker-1 @coroutine#3 channel receive 2
DefaultDispatcher-worker-1 @coroutine#3 channel receive 3
DefaultDispatcher-worker-1 @coroutine#3 channel receive 4
DefaultDispatcher-worker-1 @coroutine#3 channel receive 5

由于 Channel 的容量是UNLIMITED无限大的,所以发送方可以一直往管道当中塞入数据,等数据都塞完以后,接收方才开始接收。这跟之前的交替执行是不一样的。要注意。

③ CONFLATED

//-------------------------------CONFLATED--------------------------------
val channel = Channel<Int>(capacity = Channel.Factory.CONFLATED)

//日志
main @coroutine#1 start
main @coroutine#1 end
main @coroutine#2 channel send 1
main @coroutine#2 channel send 2
main @coroutine#2 channel send 3
main @coroutine#2 channel send 4
main @coroutine#2 channel send 5
DefaultDispatcher-worker-1 @coroutine#3 channel receive 1
DefaultDispatcher-worker-1 @coroutine#3 channel receive 5

等上一次处理完才去接收最新的数据,这种情况会丢失一些发送的数据。

④ BUFFERED

//-------------------------------BUFFERED--------------------------------
val channel = Channel<Int>(capacity = Channel.Factory.BUFFERED)

//日志
main @coroutine#1 start
main @coroutine#1 end
main @coroutine#2 channel send 1
main @coroutine#2 channel send 2
main @coroutine#2 channel send 3
main @coroutine#2 channel send 4
main @coroutine#2 channel send 5
DefaultDispatcher-worker-1 @coroutine#3 channel receive 1
DefaultDispatcher-worker-1 @coroutine#3 channel receive 2
DefaultDispatcher-worker-1 @coroutine#3 channel receive 3
DefaultDispatcher-worker-1 @coroutine#3 channel receive 4
DefaultDispatcher-worker-1 @coroutine#3 channel receive 5

因为BUFFERED默认的缓冲区是64,所以日志的输出肯定是这样的。如果发送的数据大于64,是否等发送到64个后才开始回调接收端呢?以发送1000个数据为例,截取其中的一小段日志如下:

......
main @coroutine#2 channel send 5
main @coroutine#2 channel send 6
DefaultDispatcher-worker-1 @coroutine#3 channel receive 1
main @coroutine#2 channel send 7
DefaultDispatcher-worker-1 @coroutine#3 channel receive 2
main @coroutine#2 channel send 8
......

通过上面的日志片段可以看出,虽然缓冲区为64,但是接收数据并不一定要把缓冲区填满才开始。

2、onBufferOverflow

第二个参数onBufferOverflow是指: 指定第一个参数capacity容量的情况下管道的容量满了,Channel用什么样的策略来应对。

这里,它主要有三种做法:

  • SUSPEND,默认,当管道的容量满了以后,如果发送方还要继续发送,我们就会挂起当前的send() 方法。由于它是一个挂起函数,所以我们可以以非阻塞的方式,将发送方的执行流 程挂起,等管道中有了空闲位置以后再恢复。
  • DROP_OLDEST,顾名思义,就是丢弃最旧的那条数据,然后发送新的数据;
  • DROP_LATEST,丢弃最新的那条数据。这里要注意,这个动作的含义是丢弃当前正准备 发送的那条数据,而管道中的内容将维持不变。

我们可以定义第一个参数capacity为Int值1,第二个参数onBufferOverflowDROP_OLDEST来实现第一个参数capacityCONFLATED的模式,代码如下:

fun main() {
    runBlocking {
        printMsg("start")
        //创建管道
        val channel = Channel<Int>(
            capacity = 1,                   <-------------------变化在这里
            onBufferOverflow = BufferOverflow.DROP_OLDEST    <-------------------变化在这里
        )
        //主线程协程
        launch{
            (1..5).forEach {
                printMsg("channel send $it")
                channel.send(it)
            }
            channel.close()
        }

        //子线程协程
        launch(Dispatchers.IO) {
            for (i in channel) {
                printMsg("channel receive $i")
            }
        }
        printMsg("end")
    }
}

fun printMsg(msg: String) {
    println("${Thread.currentThread().name} $msg")
}

//日志
main @coroutine#1 start
main @coroutine#1 end
main @coroutine#2 channel send 1
main @coroutine#2 channel send 2
main @coroutine#2 channel send 3
main @coroutine#2 channel send 4
main @coroutine#2 channel send 5
DefaultDispatcher-worker-1 @coroutine#3 channel receive 1
DefaultDispatcher-worker-1 @coroutine#3 channel receive 5

3、onUndeliveredElement

先看一段代码:

fun main() {
    runBlocking {
        printMsg("start")
        //创建管道
        val channel = Channel<Int>(
            capacity = Channel.Factory.UNLIMITED,
            onUndeliveredElement = {          <--------------第三个参数onUndeliveredElement为高阶函数
                printMsg("onUndeliveredElement $it")   <--------------打印第三个参数回调的值
            }
        )

        //发送3个数据
        (1..3).forEach {
            printMsg("channel send $it")
            channel.send(it)
        }

        //取出一个,剩下二个
        channel.receive()

        //取消当前的channel
        channel.cancel()

        printMsg("end")
    }
}

fun printMsg(msg: String) {
    println("${Thread.currentThread().name} $msg")
}

//日志
main @coroutine#1 start
main @coroutine#1 channel send 1
main @coroutine#1 channel send 2
main @coroutine#1 channel send 3
main @coroutine#1 onUndeliveredElement 2  //onUndeliveredElement回调
main @coroutine#1 onUndeliveredElement 3  //onUndeliveredElement回调
main @coroutine#1 end

可以看到,onUndeliveredElement 的作用,就是一个回调,当我们发送出去的 Channel 数据无法被接收方处理的时候,就可以通过 onUndeliveredElement 这个回调,来进行监听。

四、Channel 关闭引发的问题

如果我们忘记调用Channelclose(),所以会导致程序一直运行无法终止。这个问题其实是很严重的。有没有办法避免这个问题呢?Kotlin 官方其实还为我们提供了另一种创建 Channel 的方式,也就是 produce{}高阶函数。produce{}会在数据收发完成后关闭通道:

fun main() {
    runBlocking {
        printMsg("start")
        //创建管道
        val channel = produce {
            //发送1个数据
            send(1)
        }

        //取出1个数据
        channel.receiveCatching().also {
            printMsg("channel receive ${it.getOrNull()} ,isClosed=${it.isClosed}")
        }
        //尝试再取出一个数据
        channel.receiveCatching().also {
            printMsg("channel receive ${it.getOrNull()} ,isClosed=${it.isClosed}")
        }

        printMsg("end")
    }
}

fun printMsg(msg: String) {
    println("${Thread.currentThread().name} $msg")
}

//日志
main @coroutine#1 start
main @coroutine#1 channel receive 1 ,isClosed=false
main @coroutine#1 channel receive null ,isClosed=true
main @coroutine#1 end

五、Channel接收数据的方式

1、channel.consumeEach {}

kotlin为我们提供了一个高阶函数以方便我们接收数据,如下:

fun main() {
    runBlocking {
        printMsg("start")
        //创建管道
        val channel = produce {
            (1..5).forEach {
                printMsg("channel send $it")
                //发送数据
                send(it)
            }
        }
        //接收数据
        channel.consumeEach {
            printMsg("channel receive $it,isChannelClose=${channel.isClosedForReceive}")
        }

        printMsg("end")
    }
}

fun printMsg(msg: String) {
    println("${LocalDateTime.now()} ${Thread.currentThread().name} $msg")
}

//日志
main @coroutine#1 start
main @coroutine#2 channel send 1
main @coroutine#2 channel send 2
main @coroutine#1 channel receive 1,isChannelClose=false
main @coroutine#1 channel receive 2,isChannelClose=false
main @coroutine#2 channel send 3
main @coroutine#2 channel send 4
main @coroutine#1 channel receive 3,isChannelClose=false
main @coroutine#1 channel receive 4,isChannelClose=false
main @coroutine#2 channel send 5
main @coroutine#1 channel receive 5,isChannelClose=true     //channel已经关闭
main @coroutine#1 end

2、for循环

fun main() {
    runBlocking {
        printMsg("start")
        //创建管道
        val channel = produce {
            (1..5).forEach {
                printMsg("channel send $it")
                //发送数据
                send(it)
            }
        }

        //接收数据
        for(i in channel){
            printMsg("channel receive $i,isChannelClose=${channel.isClosedForReceive}")
        }

        printMsg("end")
    }
}

fun printMsg(msg: String) {
    println("${Thread.currentThread().name} $msg")
}

//日志
main @coroutine#1 start
main @coroutine#2 channel send 1
main @coroutine#2 channel send 2
main @coroutine#1 channel receive 1,isChannelClose=false
main @coroutine#1 channel receive 2,isChannelClose=false
main @coroutine#2 channel send 3
main @coroutine#2 channel send 4
main @coroutine#1 channel receive 3,isChannelClose=false
main @coroutine#1 channel receive 4,isChannelClose=false
main @coroutine#2 channel send 5
main @coroutine#1 channel receive 5,isChannelClose=true   //channel已经关闭
main @coroutine#1 end

3、receiveCatching

如果channel已经被关闭,那么调用channel.receive()会报错kotlinx.coroutines.channels.ClosedReceiveChannelException: Channel was closed,可以使用receiveCatching,如下:

fun main() {
    runBlocking {
        printMsg("start")
        //创建管道
        val channel = produce {
            printMsg("channel send 1")
            //发送数据
            send(1)
        }

        //接收数据
        channel.receiveCatching().also {
            if (it.isSuccess) {
                val result = it.getOrNull()
                printMsg("channel receive $result,isChannelClose=${it.isClosed}")
            }
        }

        //尝试再次接收数据
        channel.receiveCatching().also {
                printMsg("channel receive again, isSuccess=${it.isSuccess}, isChannelClose=${it.isClosed}")
        }

        printMsg("end")
    }
}

fun printMsg(msg: String) {
    println("${Thread.currentThread().name} $msg")
}

//日志
main @coroutine#1 start
main @coroutine#2 channel send 1
main @coroutine#1 channel receive 1,isChannelClose=false
main @coroutine#1 channel isSuccess=false,isChannelClose=true
main @coroutine#1 end

小结:

当我们想要读取 Channel 当中的数据时,我们一定要使用 for 循环,或者是channel.consumeEach {},千万不要直接调用 channel.receive()。如果在某些特殊场景下,我们必须要自己来调用 channel.receive(),那么可以考虑使用 receiveCatching(),它可以防止异常发生。注意 receiveCatching()只能接收一个数据并且会立刻结束,不能用它接收多组数据。

六、为什么说Channel是热的?

先看下面一段代码

fun main() {
    runBlocking {
        printMsg("start")
        //创建管道
        val channel = produce<Int>(capacity = 10) {
            //发送数据
            (1..3).forEach {
                send(it)
                printMsg("channel send $it")
            }
        }

        //没有接收者

        printMsg("end")
    }
}

fun printMsg(msg: String) {
    println("${Thread.currentThread().name} $msg")
}

//日志
main @coroutine#1 start
main @coroutine#1 end
main @coroutine#2 channel send 1
main @coroutine#2 channel send 2
main @coroutine#2 channel send 3

即使没有接收者,Channel也会发送数据,所以我们说Channel是热的。

七、Channel的缺点

1、可能接收到旧数据,也有可能数据丢失
2、因为是“热”的只管发,会造成资源浪费
3、如果不及时的close,在页面退出、注销监听器等场景下可能会导致内存泄漏。

八、Channel的应用场景

Channel的应用场景一般偏底层,所以在实际开发中直接使用的场景比较少,下面几个例子抛砖引玉。

1、协程间传递数据

val channel = Channel<String>()
//协程一
launch {
    channel.send("Hello World")
}
//协程二
launch {
    channel.receiveCatching().also {
        printMsg(it.getOrElse { "" })
    }
}

2、基于Channel的生产者-消费者模型

fun main() {
    runBlocking {
        val channel = Channel<String>()
        //生产者
        launch {
            channel.send("Hello")
            delay(500)
            channel.send("World")
            delay(500)
            channel.send("Hello")
            delay(500)
            channel.send("Kotlin")
        }
        //消费者
        launch {
            //用循环接收
            while (true){
                channel.receiveCatching().also {
                    printMsg(it.getOrElse { "" })
                }
            }
        }
    }
}

3、做一个倒计时功能

/**
 * Activity倒计时的扩展方法
 */
fun AppCompatActivity.countDownFlow(
    time: Int = 60,
    start: ((scope: CoroutineScope) -> Unit)? = null,
    next: ((describe: String) -> Unit)? = null,
    end: () -> Unit,
    catch: () -> Unit
): Job {
    return lifecycleScope.launch {
        flow {
            (time downTo 0).forEach {
                delay(1000)
                emit(it)
            }
        }.onStart {
            //倒计时开始,这里可以让Button禁止点击状态
            start?.let { it(this@launch) }
        }.onCompletion {
            //倒计时结束,这里可以让Button恢复点击状态
            end()
        }.catch {
            //捕获协程的异常
            catch()
        }.collect {
            //在这里显示每一秒倒计时的UI
            next?.let { n -> n(it.toString()) }
        }
    }
}

不推荐直接使用Channel,推荐使用Flow

参考了以下内容:

Channel:为什么说Channel是"热"的

其他资料

个人学习笔记