Kotlin的Channel

129 阅读11分钟

Channel 就是管道

挂起函数、async,它们一次都只能返回一个结果。但在某些业务场景下,我们往往需要协程返回多个结果,而Channel,就是专门用来做这种事情的。Channel 这个管道的其中一端,是发送方;管道的另一端是接收方。而管道本身,则可以用来传输数据。

用法:

// 代码段1

fun main() = runBlocking {
    // 1,创建管道
    val channel = Channel<Int>()

    launch {
        // 2,在一个单独的协程当中发送管道消息
        (1..3).forEach {
            channel.send(it) // 挂起函数
            logX("Send: $it")
        }
        
        channel.close()
    }

    launch {
        // 3,在一个单独的协程当中接收管道消息
        for (i in channel) {  // 挂起函数
            logX("Receive: $i")
        }
    }

    logX("end")
}

/*
================================
end
Thread:main @coroutine#1
================================
================================
Receive: 1
Thread:main @coroutine#3
================================
================================
Send: 1
Thread:main @coroutine#2
================================
================================
Send: 2
Thread:main @coroutine#2
================================
================================
Receive: 2
Thread:main @coroutine#3
================================
================================
Receive: 3
Thread:main @coroutine#3
================================
================================
Send: 3
Thread:main @coroutine#2
================================
// 4,如果没写channel.close()程序不会退出
*/

Channel 可以跨越不同的协程进行通信。我们是在“coroutine#1”当中创建的 Channel,然后分别在 coroutine#2、coroutine#3 当中使用 Channel 来传递数据。coroutine#2、coroutine#3,这两个协程是交替执行的。

注释 1,我们通过“Channel()”这样的方式,就可以创建一个管道。其中传入的泛型 Int,就代表了这个管道里面传递的数据类型。也就是说这里创建的 Channel,就是用于传递 Int 数据的。

注释 2,我们创建了一个新的协程,然后在协程当中调用了 send() 方法,发送数据到管道里。其中的 send() 方法是一个挂起函数。

注释 3,在另一个协程当中,我们通过遍历 channel,将管道当中的数据都取了出来。这里,我们使用的是 for 循环。

注释 4,channel 其实也是一种协程资源,在用完 channel 以后,如果我们不去主动关闭它的话,是会造成不必要的资源浪费的。在上面的案例中,如果我们忘记调用“channel.close()”,程序将永远不会停下来。

看看创建 Channel 的源代码。

// 代码段3

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

第一个参数,capacity,代表了管道的容量。这个也很好理解,我们日常生活中的管道,自身也是有容量的,即使接收方不将数据取走,管道本身也可以存储一些数据。而 Kotlin 的 Channel,在默认情况下是“RENDEZVOUS”,也就代表了 Channel 的容量为 0。capacity 还有其他几种情况,比如:

UNLIMITED,代表了无限容量;

CONFLATED,代表了容量为 1,新的数据会替代旧的数据;

BUFFERED,代表了具备一定的缓存容量,默认情况下是 64,具体容量由这个 VM 参数决定 "kotlinx.coroutines.channels.defaultBuffer"。

第二个参数,onBufferOverflow,也就是指当我们指定了 capacity 的容量,等管道的容量满了时,Channel 的应对策略是怎么样的。这里,它主要有三种做法:

SUSPEND,当管道的容量满了以后,如果发送方还要继续发送,我们就会挂起当前的 send() 方法。由于它是一个挂起函数,所以我们可以以非阻塞的方式,将发送方的执行流程挂起,等管道中有了空闲位置以后再恢复。

DROP_OLDEST,顾名思义,就是丢弃最旧的那条数据,然后发送新的数据;

DROP_LATEST,丢弃最新的那条数据。这里要注意,这个动作的含义是丢弃当前正准备发送的那条数据,而管道中的内容将维持不变。

第三个参数,onUndeliveredElement,它其实相当于一个异常处理回调。当管道中的某些数据没有被成功接收的时候,这个回调就会被调用。通过 onUndeliveredElement 这个回调,来进行监听。它的使用场景一般都是用于“接收方对数据是否被消费特别关心的场景”。比如说,我发送出去的消息,接收方是不是真的收到了?对于接收方没收到的信息,发送方就可以灵活处理了,比如针对这些没收到的消息,发送方可以先记录下来,等下次重新发送。

举例:

// 代码段8

fun main() = runBlocking {
    // 无限容量的管道
    val channel = Channel<Int>(Channel.UNLIMITED) {
        println("onUndeliveredElement = $it")
    }

    // 等价这种写法
//    val channel = Channel<Int>(Channel.UNLIMITED, onUndeliveredElement = { println("onUndeliveredElement = $it") })

    // 放入三个数据
    (1..3).forEach {
        channel.send(it)
    }

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

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

/*
输出结果:
onUndeliveredElement = 2
onUndeliveredElement = 3
*/

Channel 关闭引发的问题

如果忘记调用channel的 close(),会导致程序一直运行无法终止。这个问题其实是很严重。Kotlin 官方其实还为我们提供了另一种创建 Channel 的方式,也就是 produce{} 高阶函数

用法:

// 代码段9

fun main() = runBlocking {
    // 变化在这里
    val channel: ReceiveChannel<Int> = produce {
        (1..3).forEach {
            send(it)
            logX("Send: $it")
        }
    }

    launch {
        // 3,接收数据
        for (i in channel) {
            logX("Receive: $i")
        }
    }

    logX("end")
}

使用 produce{} 以后,就不用再去调用 close() 方法了,因为 produce{} 会自动帮我们去调用 close() 方法。

验证:

// 代码段10

fun main() = runBlocking {
    // 1,创建管道
    val channel: ReceiveChannel<Int> = produce {
        // 发送3条数据
        (1..3).forEach {
            send(it)
        }
    }

    // 调用4次receive()
    channel.receive() // 1
    channel.receive() // 2
    channel.receive() // 3
    channel.receive() // 异常

    logX("end")
}

/*
输出结果:
ClosedReceiveChannelException: Channel was closed
*/

channel 还有一个 receive() 方法,它是与 send(it) 对应的。在上面代码中,我们只调用了 3 次 send(),却调用 4 次 receive()。当我们第 4 次调用 receive() 的时候,代码会抛出异常“ClosedReceiveChannelException”,这同时说明了两个问题。

Channel 已经被关闭,说明produce {}确实会帮我们调用 close() 方法;否则,第 4 次 receive() 会被挂起,而不是抛出异常。

直接使用 receive() 很容易出问题。

Channel 其实还有两个属性:isClosedForReceive、isClosedForSend。这两个属性,就可以用来判断当前的 Channel 是否已经被关闭。由于 Channel 分为发送方和接收方,所以这两个参数也是针对这两者的。也就是说,对于发送方,我们可以使用“isClosedForSend”来判断当前的 Channel 是否关闭;对于接收方来说,我们可以用“isClosedForReceive”来判断当前的 Channel 是否关闭。

判断管道是否关闭:

// 代码段12

fun main() = runBlocking {
    // 1,创建管道
    val channel: ReceiveChannel<Int> = produce {
        // 发送3条数据
        (1..3).forEach {
            send(it)
            println("Send $it")
        }
    }

    // 使用while循环判断isClosedForReceive
    while (!channel.isClosedForReceive) {
        val i = channel.receive()
        println("Receive $i")
    }

    println("end")
}

/*
输出结果
Send 1
Receive 1
Receive 2
Send 2
Send 3
Receive 3
end
*/

看起来是可以正常工作了。但仍然不建议用这种方式。因为,当管道指定了 capacity 以后,以上的判断方式将会变得不可靠!

// 代码段13

fun main() = runBlocking {
    // 变化在这里
    val channel: ReceiveChannel<Int> = produce(capacity = 3) {
        // 变化在这里
        (1..300).forEach {
            send(it)
            println("Send $it")
        }
    }



    while (!channel.isClosedForReceive) {
        val i = channel.receive()
        println("Receive $i")
    }

    logX("end")
}

/*
输出结果
// 省略部分
Receive 300
Send 300
ClosedReceiveChannelException: Channel was closed
*/

所以,最好不要用 channel.receive()。即使配合 isClosedForReceive 这个判断条件,我们直接调用 channel.receive() 仍然是一件非常危险的事情!

除了可以使用 for 循环以外,还可以使用 Kotlin 为我们提供的另一个高阶函数:channel.consumeEach {}

// 代码段14

fun main() = runBlocking {
    val channel: ReceiveChannel<Int> = produce(capacity = 3) {
        (1..300).forEach {
            send(it)
            println("Send $it")
        }
    }

    // 变化在这里
    channel.consumeEach {
        println("Receive $it")
    }

    logX("end")
}

/*
输出结果:

正常
*/

所以,当我们想要读取 Channel 当中的数据时,我们一定要使用 for 循环,或者是 channel.consumeEach {},千万不要直接调用 channel.receive()。

注:在某些特殊场景下,如果我们必须要自己来调用 channel.receive(),那么可以考虑使用 receiveCatching(),它可以防止异常发生。

为什么说 Channel 是“热”的?

Channel 其实就是用来传递“数据流”的。注意,这里的数据流,指的是多个数据组合形成的流。此前的挂起函数、async 返回的数据,就像是水滴一样,而 Channel 则像是自来水管当中的水流一样。在业界一直有一种说法:Channel 是“热”的。也是因为这句话,在 Kotlin 当中,我们也经常把 Channel 称为“热数据流”。

不管有没有接收方,发送方都会工作”的模式,是我们将其认定为“热”的原因。

这有点像是一个热心的饭店服务员,不管你有没有提要求,服务员都会给你端茶送水,把茶水摆在你的饭桌上。当你想要喝水的时候,就可以直接从饭桌上拿了(当你想要数据的时候,就可以直接从管道里取出来了)。

又或者,按水龙头的思维模型去思考,Channel 的发送方,其实就像是“自来水厂”,不管你是不是要用水,自来水厂都会把水送到你家门口的管道当中来。这样当你想要用水的时候,打开水龙头就会马上有水了。

image.png

极端情况,如果设置成“capacity = 0”,Channel 的发送方仍然是会工作的。

// 代码段16

fun main() = runBlocking {
    val channel = produce<Int>(capacity = 0) {
        (1..3).forEach {
            println("Before send $it")
            send(it)
            println("Send $it")
        }
    }

    println("end")
}

/*
输出结果:
end
Before send 1
程序将无法退出
*/

调用 send() 方法的时候,由于接收方还未就绪,且管道容量为 0,所以它会被挂起。所以,它仍然还是有在工作的。最直接的证据就是:这个程序将无法退出,一直运行下去。

不管接收方是否存在,Channel 的发送方一定会工作。对应的,你可以想象成:虽然你的饭桌已经没有空间了,但服务员还是端来了茶水站在了你旁边,只是没有把茶水放在你桌上,等饭桌有了空间,或者你想喝水了,你就能马上喝到。至于自来水的那个场景,可以想象成,你家就在自来水厂的门口,你们之间的管道容量为 0,但这并不意味着自来水厂没有工作。

Channel 的源代码表示Channel 本身只是一个接口。

// 代码段17

public interface Channel<E> : SendChannel<E>, ReceiveChannel<E> {}

Channel 本身并没有什么方法和属性,它其实只是 SendChannel、ReceiveChannel 这两个接口的组合。也就是说,Channel 的所有能力,都是来自于 SendChannel、ReceiveChannel 这两个接口。

// 代码段18

public interface SendChannel<in E> 
    public val isClosedForSend: Boolean

    public suspend fun send(element: E)

    // 1,select相关
    public val onSend: SelectClause2<E, SendChannel<E>>

    // 2,非挂起函数的接收
    public fun trySend(element: E): ChannelResult<Unit>

    public fun close(cause: Throwable? = null): Boolean

    public fun invokeOnClose(handler: (cause: Throwable?) -> Unit)

}

public interface ReceiveChannel<out E> {

    public val isClosedForReceive: Boolean

    public val isEmpty: Boolean

    public suspend fun receive(): E

    public suspend fun receiveCatching(): ChannelResult<E>
    // 3,select相关
    public val onReceive: SelectClause1<E>
    // 4,select相关
    public val onReceiveCatching: SelectClause1<ChannelResult<E>>

    // 5,非挂起函数的接收
    public fun tryReceive(): ChannelResult<E>

    public operator fun iterator(): ChannelIterator<E>

    public fun cancel(cause: CancellationException? = null)
}

大部分情况下,我们应该优先使用挂起函数版本的 API。

如果说 Channel 是一个管道,那么 SendChannel、ReceiveChannel 就是组成这个管道的两个零件。

image.png

对于 Channel 来说,也可以实现对外暴露不变性集合的特性:

// 代码段19

class ChannelModel {
    // 对外只提供读取功能
    val channel: ReceiveChannel<Int> by ::_channel
    private val _channel: Channel<Int> = Channel()

    suspend fun init() {
        (1..3).forEach {
            _channel.send(it)
        }
    }
}

fun main() = runBlocking {
    val model = ChannelModel()
    launch {
        model.init()
    }

    model.channel.consumeEach {
        println(it)
    }
}

对于 Channel 来说,它的 send() 就相当于集合的写入 API,所以我们可以做到“对写入封闭,对读取开放”。

image.png

这一切,都得益于 Channel 的能力是通过“组合”得来的。

小结:

Channel 是一个管道,当我们想要用协程传递多个数据组成的流的话,就没办法通过挂起函数、async 来实现了。这时候,Channel 是一个不错的选择。

我们可以通过 Channel() 这个顶层函数来创建 Channel 管道。在创建 Channel 的时候,有三个重要参数:capacity 代表了容量;onBufferOverflow 代表容量满了以后的应对策略;onUndeliveredElement 则是一个异常回调。在某些场景下,比如“发送方对于数据是否被接收方十分关心”的情况下,可以注册这个回调。

Channel 有两个关键的方法:send()、receive(),前者用于发送管道数据,后者用于接收管道数据。但是,由于 Channel 是存在关闭状态的,如果我们直接使用 receive(),就会导致各种问题。因此,对于管道数据的接收方来说,我们应该尽可能地使用 for 循环、consumeEach {}。

Channel 是“热”的。这是因为“不管有没有接收方,发送方都会工作”。

通过分析 Channel 的源码定义,发现它其实是 SendChannel、ReceiveChannel 这两个接口的组合。而我们也可以借助它的这个特点,实现“对读取开放,对写入封闭”的设计。

最后,Channel 是“热”的,这一特点有什么坏处?

  1. 可能会导致数据的丢失。 2. 浪费不必要的程序资源,类似于非懒加载的情况。 3. 如果未及时 close 的话,可能会导致内存泄露。

常规业务开发其实很少会需要用到Channel,Channel的使用场景其实是比较偏底层的,比如IM消息通道、股票行情实时刷新,等等。