Cracking Kotlin Coroutines (9) - Channel

insert image description here

1. Get to know Channel

Channel is actually a queue, and it is safe for concurrency. It can be used to connect coroutines and realize communication between different coroutines. Not much nonsense, just look at the example:

suspend fun main() {
    
    
    val channel = Channel<Int>()

    val producer = GlobalScope.launch {
    
    
        var i = 0
        while (true){
    
    
            channel.send(i++)
            delay(1000)
        }
    }

    val consumer = GlobalScope.launch {
    
    
        while(true){
    
    
            val element = channel.receive()
            Logger.debug(element)
        }
    }

    producer.join()
    consumer.join()
}

We constructed two coroutines, called them producer and consumer respectively. We did not specify the scheduler explicitly, so their schedulers are default. On the Java virtual machine, it is the thread pool that everyone is familiar with: they can Running on different threads, of course, can also run on the same thread.

The operation mechanism of the example is that the producer sends a number Channelto , and the consumer is always reading the Channel to get the number and print it. We can find that the sending end is slower than the receiving end. If there is no value When it can be read, receive is suspended until a new element is sent - so you know that receive is a suspended function, so what about send?

2. Channel capacity

If you go to the IDE and write this code yourself, you will find that send is also a suspending function. Um, why does the server hang? Think about what we know before BlockingQueue, when we add elements to it, the elements actually take up space in the queue. If the queue space is insufficient, then there are two situations when we add elements to it: 1. Blocking, waiting Make room in the queue; 2. Throw an exception and refuse to add elements. Send will also face the same problem. We said that Channel is actually a queue. Shouldn’t the queue have a buffer? Then once the buffer is full and no one calls receive to take away the elements, send will hang wake up. Then let's look Channelat the definition of the buffer:

public fun <E> Channel(capacity: Int = RENDEZVOUS): Channel<E> =
    when (capacity) {
    
    
        RENDEZVOUS -> RendezvousChannel()
        UNLIMITED -> LinkedListChannel()
        CONFLATED -> ConflatedChannel()
        else -> ArrayChannel(capacity)
    }

When we Channelconstructed Channelwe called a function called hmm, which is really not its constructor. In Kotlin, we can define a top-level function to pretend to be a constructor just like some class names, which is essentially a factory method.

Similar to String, if you don’t believe me, try it

It has a parameter called capacity, which specifies the capacity of the buffer, and the default value RENDEZVOUSis 0. This word is intended to describe the scene of "see or leave", so if you don't come to receive, my send will just hang here and wait. In other words, in our example at the beginning, if the consumer does not receive, the first send in the producer will hang:

val producer = GlobalScope.launch {
    
    
    var i = 0
    while (true){
    
    
        i++ //为了方便输出日志,我们将自增放到前面
        Logger.debug("before send $i")
        channel.send(i)
        Logger.debug("before after $i")
        delay(1000)
    }
}

val consumer = GlobalScope.launch {
    
    
    while(true){
    
    
        delay(2000) //receive 之前延迟 2s
        val element = channel.receive()
        Logger.debug(element)
    }
}

We deliberately slow down the rhythm of the receiving end, and you will find that the send will always hang, and will not continue to execute until after the receive:

07:11:23:119 [DefaultDispatcher-worker-2 @coroutine#1]  before send 1
07:11:24:845 [DefaultDispatcher-worker-2 @coroutine#2]  1
07:11:24:846 [DefaultDispatcher-worker-2 @coroutine#1]  before after 1
07:11:25:849 [DefaultDispatcher-worker-4 @coroutine#1]  before send 2
07:11:26:850 [DefaultDispatcher-worker-2 @coroutine#2]  2
07:11:26:850 [DefaultDispatcher-worker-3 @coroutine#1]  before after 2

UNLIMITEDIt is easier to understand, and all comers are welcome. LinkedListChannelJudging , this point is also LinkedBlockingQueuesimilar to ours.

CONFLATED, this word means to merge, it is the same root as inflate, the con- prefix means reverse, does that mean that if I send 1, 2, 3, 4, 5, I will receive one when I receive it? What about the set [1,2,3,4,5]? After all, the literal meaning is to merge. But in fact, the effect of this is to keep only the last element, instead of merging, it should be replaced. In other words, this type of Channel has an element-sized buffer, but every time a new element comes, it will replace the old one with the new one. Yes, that is to say, if I send 1, 2, 3, 4, 5 and then receive it, I can only receive 5.

All that's left ArrayChannelis , which accepts a value as the size of the buffer capacity, which is similar to ArrayBlockingQueue.

3. Iterate Channels

We used it when sending and Channelreading while(true), because we want to continuously read and write operations, and Channelit is actually a bit like a sequence, which can be read one by one, so we can also directly obtain one when reading Channeliterator :

val consumer = GlobalScope.launch {
    
    
    val iterator = channel.iterator()
    while(iterator.hasNext()){
    
     // 挂起点
        val element = iterator.next()
        Logger.debug(element)
        delay(2000)
    }
}

Then at this time, iterator.hasNext() is a suspending function, and actually needs to read elements Channelfrom it .

This way of writing can naturally be simplified to for each:

val consumer = GlobalScope.launch {
    
    
    for (element in channel) {
    
    
        Logger.debug(element)
        delay(2000)
    }
}

4. produce and actor

Earlier we defined it outside the coroutine Channeland accessed it in the coroutine to implement a simple producer-consumer example, so is there a convenient way to construct producers and consumers?

val receiveChannel: ReceiveChannel<Int> = GlobalScope.produce {
    
    
    while(true){
    
    
        delay(1000)
        send(2)
    }
}

We can use producethis method to start a producer coroutine and return one ReceiveChannel, and other coroutines can use this Channelto receive data. In turn, we can actorstart a consumer coroutine with:

val sendChannel: SendChannel<Int> = GlobalScope.actor<Int> {
    
    
    while(true){
    
    
        val element = receive()
    }
}

Both ReceiveChannel and SendChannel are the parent interface of Channel, the former defines receive, and the latter defines send, so Channel can both receive and send.

produceactorLike and launchare both called "coroutine launchers". The coroutines started by the starters of these two coroutines are also naturally Channelbound , so Channelthe closing of will also be automatically completed when the coroutine ends. Taking producefor example , it constructs an ProducerCoroutineobject of :

internal open class ProducerCoroutine<E>(
    parentContext: CoroutineContext, channel: Channel<E>
) : ChannelCoroutine<E>(parentContext, channel, active = true), ProducerScope<E> {
    
    
    ...
    override fun onCompleted(value: Unit) {
    
    
        _channel.close() // 协程完成时
    }

    override fun onCancelled(cause: Throwable, handled: Boolean) {
    
    
        val processed = _channel.close(cause) // 协程取消时
        if (!processed && !handled) handleCoroutineException(context, cause)
    }
}

Note that in the method call of the coroutine completion and cancellation, the corresponding _channelwill be closed.

This still looks quite useful. But so far, neither API norproduce is stable. The former is still marked as , while the latter is marked as . The discussion of the issue mentioned in the documentation also shows that compared to the Actor model-based concurrency framework, the API provided by Kotlin coroutines is nothing more than a return value. Of course, the person in charge of the coroutine also has the idea of ​​implementing a more complex set of Actors, but the superiority of this period of time is obviously-this product has been in public beta since v1.2 of the coroutine framework, and it has been stable since v1.3 of the coroutine , is really fast, we will introduce it later in the article.actorExperimentalCoroutinesApiObsoleteCoroutinesApiactoractorSendChannelFlow

Although produceis not marked as ObsoleteCoroutinesApi, obviously it actoris the other half of , and it is impossible to convert it to a positive one. My suggestion for these two APIs is to have a look.

5. Closing of Channel

We mentioned earlier that produceand actorreturned Channelwill be closed when the corresponding coroutine is executed. Oh, it turns out that Channelthere is also a concept of closure.

ChannelFlowUnlike what we will discuss in the following articles , it is online and a hot data source. In other words, if someone wants to receive data, someone must send it to him on the opposite side, just like sending WeChat. In this case, it is inevitable that the song will come to an end. For one Channel, if we call it close, it will stop accepting new elements immediately, that is to say, its isClosedForSendwill true, and due to the existence of Channelthe buffer , it may still be Some elements have not been processed, so it isClosedForReceivewill not true.

val channel = Channel<Int>(3)

val producer = GlobalScope.launch {
    
    
    List(5){
    
    
        channel.send(it)
        Logger.debug("send $it")
    }
    channel.close()
    Logger.debug("close channel. ClosedForSend = ${channel.isClosedForSend} ClosedForReceive = ${channel.isClosedForReceive}")
}

val consumer = GlobalScope.launch {
    
    
    for (element in channel) {
    
    
        Logger.debug("receive: $element")
        delay(1000)
    }

    Logger.debug("After Consuming. ClosedForSend = ${channel.isClosedForSend} ClosedForReceive = ${channel.isClosedForReceive}")
}

Let's modify the example a little, open a buffer size of 3 Channel, quickly send elements out in the producer coroutine, send 5 and then close Channel, and read one per second in the consumer coroutine, the result is as follows:

11:05:20:678 [DefaultDispatcher-worker-1]  send 0
11:05:20:678 [DefaultDispatcher-worker-3]  receive: 0
11:05:20:678 [DefaultDispatcher-worker-1]  send 1
11:05:20:678 [DefaultDispatcher-worker-1]  send 2
11:05:20:678 [DefaultDispatcher-worker-1]  send 3
11:05:21:688 [DefaultDispatcher-worker-3]  receive: 1
11:05:21:688 [DefaultDispatcher-worker-3]  send 4
11:05:21:689 [DefaultDispatcher-worker-3]  close channel. ClosedForSend =true ClosedForReceive = false
11:05:22:693 [DefaultDispatcher-worker-3]  receive: 2
11:05:23:694 [DefaultDispatcher-worker-4]  receive: 3
11:05:24:698 [DefaultDispatcher-worker-4]  receive: 4
11:05:25:700 [DefaultDispatcher-worker-4]  After Consuming. ClosedForSend =true ClosedForReceive = true

Next, let's discuss the meaning of Channelclosure .

When it comes to closing, we tend to think of IO. If it is not closed, it may cause resource leakage, so what is the concept Channelof closing? As we mentioned earlier, Channelthe internal resource is actually a buffer. This thing is essentially a linear table, which is a piece of memory, so if we open one Channeland not close it, it will not cause any resource leaks. If the sender has already finished sending, it can ignore Channelthis . Well, it looks like there's nothing wrong with it, right?

But, it’s embarrassing at the receiving end at this time, it doesn’t know if there will be data sent, if Channelit’s WeChat, then the receiving end may always see “the other party is typing” when opening the WeChat window, and then it keeps That's it, forever alone. So the closure here is more like a convention:

Female: We have nothing to do, so don't wait around. Man: Oh. (Your message was not sent successfully)

So who should handle Channelthe shutdown? Normal communication, if it is one-way, is like a leader speaking. He will say "I have finished speaking" after he finishes speaking. You cannot say "I have listened to you" before the leader has finished speaking, so the situation of one-way communication is relatively It is recommended to be closed by the originator; for two-way communication, negotiation must be considered. Technically, both ends of two-way communication are equal, but usually not in business scenarios. It is recommended that the leading party handle the close.

There are also some complicated situations. The examples we have seen above are all one-to-one sending and receiving. In fact, there are also one-to-many and many-to-many situations. In this case, there is still a dominant party, and the life cycle is best controlled by the dominant party Channel. maintain. This is actually the case for fan-in and fan-out given in official documents.

The concept of fan-in and fan-out may not be very familiar to everyone. The Internet is not very popular. Everyone imagines that it is a folding fan. The side of the folding fan shoots towards the center of the circle, which is fan-in. In this case, if the center of the circle is the end of the communication, then It's the receiver, and if it's a function, it's the callee. The larger the fan-in, the higher the degree of reuse of the module. Taking functions as an example, the more times a function is called, the higher the degree of reuse. Fan-out is the opposite. It describes a highly complex situation, such as a Model, which is responsible for calling many modules such as network modules, databases , and files.

6. BroadcastChannel

The one-to-many situation was mentioned earlier. From the perspective of data processing itself, although there are multiple receivers, the same element will only be read by one receiver. Broadcasting is not the case, there is no mutually exclusive behavior among multiple receivers.

broadcastChannelThe method of creating directly Channeldoesn't seem to be much different from ordinary :

val broadcastChannel = broadcastChannel<Int>(5)

To subscribe, just call:

val receiveChannel = broadcastChannel.openSubscription()

In this way, we get one ReceiveChannel, get the subscribed message, and only need to call it receive.

Let's look at a more complete example. In the example, we send 1 - 5 at the originator, and start 3 coroutines to receive broadcasts at the same time:

val producer = GlobalScope.launch {
    
    
    List(5) {
    
    
        broadcastChannel.send(it)
        Logger.debug("send $it")
    }
    channel.close()
}

List(3) {
    
     index ->
    GlobalScope.launch {
    
    
        val receiveChannel = broadcast.openSubscription()
        for (element in receiveChannel) {
    
    
            Logger.debug("[$index] receive: $element")
            delay(1000)
        }
    }
}.forEach {
    
     it.join() }

producer.join()

The output is as follows:

12:34:59:656 [DefaultDispatcher-worker-6]  [2] receive: 0
12:34:59:656 [DefaultDispatcher-worker-3]  [1] receive: 0
12:34:59:656 [DefaultDispatcher-worker-5]  [0] receive: 0
12:34:59:656 [DefaultDispatcher-worker-7]  send 0
12:34:59:657 [DefaultDispatcher-worker-7]  send 1
12:34:59:658 [DefaultDispatcher-worker-7]  send 2
12:35:00:664 [DefaultDispatcher-worker-3]  [0] receive: 1
12:35:00:664 [DefaultDispatcher-worker-5]  [1] receive: 1
12:35:00:664 [DefaultDispatcher-worker-6]  [2] receive: 1
12:35:00:664 [DefaultDispatcher-worker-8]  send 3
12:35:01:669 [DefaultDispatcher-worker-8]  [0] receive: 2
12:35:01:669 [DefaultDispatcher-worker-3]  [1] receive: 2
12:35:01:669 [DefaultDispatcher-worker-6]  [2] receive: 2
12:35:01:669 [DefaultDispatcher-worker-8]  send 4
12:35:02:674 [DefaultDispatcher-worker-8]  [0] receive: 3
12:35:02:674 [DefaultDispatcher-worker-7]  [1] receive: 3
12:35:02:675 [DefaultDispatcher-worker-3]  [2] receive: 3
12:35:03:678 [DefaultDispatcher-worker-8]  [1] receive: 4
12:35:03:678 [DefaultDispatcher-worker-3]  [0] receive: 4
12:35:03:678 [DefaultDispatcher-worker-1]  [2] receive: 4

Here, please pay attention to the fact that every receiving coroutine can read every element.

The log sequence cannot reflect the read and write sequence of data very intuitively. If you run it again by yourself, the sequence may also be different.

In addition to direct creation, we can also directly use the previously defined ordinary Channelto make a conversion:

val channel = Channel<Int>()
val broadcast = channel.broadcast(3)

Among them, the parameter indicates the size of the buffer.

In fact, the obtained here broadcastChannelcan be considered as Channela cascading relationship with the original , and the source code of this extension method actually shows us this clearly:

fun <E> ReceiveChannel<E>.broadcast(
    capacity: Int = 1,
    start: CoroutineStart = CoroutineStart.LAZY
): broadcastChannel<E> =
    GlobalScope.broadcast(Dispatchers.Unconfined, capacity = capacity, start = start, onCompletion = consumes()) {
    
    
        for (e in this@broadcast) {
    
      //这实际上就是在读取原 Channel
            send(e)
        }
    }

Oh~ It turns out that BroadcastChannelthe official also produceprovides actormethods similar to and , we can directly start a coroutine CoroutineScope.broadcastthrough and return one BroadcastChannel.

It should be noted that Channelthe conversion to BroadcastChannelis actually a read operation on the Channeloriginal . If there are other coroutines that are also reading the original Channel, then there will be a BroadcastChannelmutually .

In addition, BroadcastChannelmost of the related APIs are marked as ExperimentalCoroutinesApi, and there may be adjustments in the future.

7. Channel version of the sequence generator

As we mentioned in the previous article Sequence, its generator is implemented based on the coroutine API of the standard library. In fact, Channelit can also be used to generate sequences, for example:

val channel = GlobalScope.produce(Dispatchers.Unconfined) {
    
    
    Logger.debug("A")
    send(1)
    Logger.debug("B")
    send(2)
    Logger.debug("Done")
}

for (item in channel) {
    
    
    Logger.debug("Got $item")
}

With the previous foundation, this is easy to understand. produceThe created coroutine returns a buffer size of 0. ChannelIn order to make it easier to describe the problem, we pass in a Dispatchers.Unconfinedscheduler , which means that the coroutine will immediately run in the current The coroutine executes to the first suspending point, so it will output immediately Aand send(1)suspend at until the first value is read by the subsequent for loop, which is channelactually the call of the methoditerator of of , which will check whether there is a next element, is a suspend function, in the process of checking, it will let the previously started coroutine continue to execute from the suspended position, so you will see the log output, and then suspend here , at this time end the suspend, for The loop finally outputs the first element, and so on. The output is as follows:hasNexthasNextsend(1)Bsend(2)hasNext

22:33:56:073 [main @coroutine#1]  A
22:33:56:172 [main @coroutine#1]  B
22:33:56:173 [main]  Got 1
22:33:56:173 [main @coroutine#1]  Done
22:33:56:176 [main]  Got 2

We see that is Bactually Got1output before , and similarly, it Doneis also output Got2before . This seems unintuitive, but the execution order of suspend and resume is indeed the same. The key point is that hasNextthe method we mentioned above will suspend and trigger the coroutine Operations that internally resume execution from the point of suspension. If you choose other schedulers, of course there will be other reasonable results output. Anyway, we have a taste of using Channelthe simulation sequence. If similar code were replaced sequence, it would look like this:

val sequence = sequence {
    
    
    Logger.debug("A")
    yield(1)
    Logger.debug("B")
    yield(2)
    Logger.debug("Done")
}

Logger.debug("before sequence")

for (item in sequence) {
    
    
    Logger.debug("Got $item")
}

sequenceThe execution order of is much more intuitive. It has no concept of a scheduler, sequenceand iteratorneither of hasNextnor of nextis a suspend function hasNext. It will also trigger the search of elements at the time of and trigger sequencethe execution of the internal logic at this time, so this time actually Only when is triggered hasNextwill A be output, yieldand 1 is passed out as the first element sequenceof , so that there will be an output like Got 1, and the complete output is as follows:

22:33:55:600 [main]  A
22:33:55:603 [main]  Got 1
22:33:55:604 [main]  B
22:33:55:604 [main]  Got 2
22:33:55:604 [main]  Done

sequenceIn essence, it is implemented based on the coroutine API of the standard library, without the scope of the upper coroutine framework and the concept of Job.

So we can switch between different schedulers to generate elements Channelin the example, for example:

val channel = GlobalScope.produce(Dispatchers.Unconfined) {
    
    
    Logger.debug(1)
    send(1)
    withContext(Dispatchers.IO){
    
    
        Logger.debug(2)
        send(2)
    }
    Logger.debug("Done")
}

sequence will not work.

Of course, simply using Channelit as a sequence generator is a bit of an overkill, here is more to tell you that there is such a possibility, and you can use it flexibly when you encounter a suitable scene in the future.

8. The internal structure of Channel

We mentioned earlier that sequenceyou can't enjoy various capabilities under the concept of a higher-level coroutine framework, and there is another point sequencethat is obviously not thread-safe, but Channelcan be used in concurrent scenarios.

ChannelFor the internal structure, we mainly say that the buffer is the version of the linked list and the array. The definition of the linked list version is mainly AbstractSendChannelin :

internal abstract class AbstractSendChannel<E> : SendChannel<E> {
    
    
    protected val queue = LockFreeLinkedListHead()
    ...    
}

LockFreeLinkedListHeadIt is actually a node of a doubly-linked list. In fact, it Channelis connected end to end to form a circular linked list, and this quequeis a sentinel node. When a new element is added, it is inserted in front queueof , which is actually equivalent to inserting an element at the end of the entire queue.

Its so-called LockFreeon the Java virtual machine is actually realized through atomic reading and writing. For the linked list, what needs to be modified is nothing more than the reference of the front and back nodes:

public actual open class LockFreeLinkedListNode {
    
    
    private val _next = atomic<Any>(this) // Node | Removed | OpDescriptor
    private val _prev = atomic<Any>(this) // Node | Removed
    ...   
}

Its implementation is based on the implementation of the lock-free linked list mentioned in a paper. Since the CAS atomic operation can usually only modify one reference, it is not applicable to the situation that requires atomic modification of the front and rear node references at the same time. Modify two references, which are the next of the previous node of the operation node and its own next, that is, Head -> A -> B -> C. When inserting X between A and B, you need to modify X -> B first and then modify A -> X, if A is deleted during this process, then the possible result is that X is also deleted, and the resulting linked list is Head -> B -> C.

The implementation of this lock-free linked list helps solve this problem by introducing prev, that is, when the problem of A being deleted occurs, we can actually achieve X.next = B and X.prev = A. At this time, it is judged that if A It has been removed, so B.prev was originally A, but it turned into Head. At this time, you can assign X.prev to B.prev again to fix it. Of course, this process is a little complicated, and interested students can also You can refer LockFreeLinkedListNodeto the implementation on Jvm.

As for the array version, ArrayChannelit is relatively rough, and the inside is an array:

//如果缓冲区大小大于 8,会先分配大小为 8 的数组,在后续进行扩容
private var buffer: Array<Any?> = arrayOfNulls<Any?>(min(capacity, 8))

When reading and writing this array, a lock is directly ReentrantLockused .

Is there room for optimization here? In fact, for the elements of the array, we can also perform CAS reading and writing. If you are interested, you can refer to the implementation ConcurrentHashMapbelow In the implementation of JDK 7 UnSafe, the CAS reading and writing of the segment array is adopted. JDK 1.8 directly eliminates the segmentation , CAS is also UnSafeused .

The implementation of coroutines on Js and Native is much simpler, because their coroutines only run on a single thread, and there is basically no need to deal with concurrency issues.

9. Summary

ChannelThe emergence of , it should be said that it has injected soul into the coroutine. Each independent coroutine is no longer a lonely individual, Channelallowing them to collaborate more conveniently. In fact Channel, the concept of is not original in Kotlin, not to mention in Golang channel, but also in Java ChannelNIO . In fact, at this time, it is easy for everyone to think of multiplexing. When multiplexing, we still have Can it be hung as simple as before? Or what should we do if it doesn't hang? Let's see the breakdown next time.

Kotlin coroutine learning materials can be obtained for free by scanning the QR code below!

"The most detailed Android version of kotlin coroutine entry advanced combat in history"

Chapter 1 Introduction to the Basics of Kotlin Coroutines

            ● 协程是什么

            ● 什么是Job 、Deferred 、协程作用域

            ● Kotlin协程的基础用法

img

Chapter 2 Preliminary Explanation of Key Knowledge Points of Kotlin Coroutine

            ● 协程调度器

            ● 协程上下文

            ● 协程启动模式

            ● 协程作用域

            ● 挂起函数

img

Chapter 3 Exception Handling of Kotlin Coroutines

            ● 协程异常的产生流程

            ● 协程的异常处理

img

Chapter 4 Basic application of kotlin coroutines in Android

            ● Android使用kotlin协程

            ● 在Activity与Framgent中使用协程

            ● ViewModel中使用协程

            ● 其他环境下使用协程

img

Chapter 5 Network request encapsulation of kotlin coroutine

            ● 协程的常用环境

            ● 协程在网络请求下的封装及使用

            ● 高阶函数方式

            ● 多状态函数返回值方式

Guess you like

Origin blog.csdn.net/Android_XG/article/details/130558006