What really are Channels in Kotlin?
While many other programming languages rely exclusively on the concept of threads, Kotlin takes advantage of Coroutines to provide an intuitive, less error-prone and efficient set of tools to perform asynchronous work. Unsurprisingly, Kotlin applications often use many coroutines that need to communicate with each other, and Channels provide an elegant solution for this. This article introduces you to Channels by diving into helpful examples. Let’s get started!
Prerequisites
This article assumes you understand the concepts to create and use Coroutines. If you do not, I’d recommend to take a look at the docs beforehand.
Basic Concepts
Conceptually, Channels are communication pathways that allow you to pass a stream of values from one Coroutine to another. The elements are processed in the same order as they arrive in, which is one of the similarities between Channels and BlockingQueues. In a nutshell,
One coroutine, the producer, can send some data to a channel, while the other coroutine, the receiver, can receive this data from it. While a channel can have multiple receivers, each element sent through the channel is handled only once by the receivers.
In practice, channels are implemented using the generic Channel
interface to allow it to transfer any type of data. Channel
also implements the SendChannel
and ReceiveChannel
interfaces, which separate the exposed functionality to avoid errors. For example, only SendChannels can send elements and close the channel altogether and only ReceiveChannels can receive elements and check whether the channel is empty.
To use this encapsulation, channels are cast as SendChannel
when used in producer objects and as ReceiveChannel
when used in receiver objects.
Before going further, let’s make matters more concrete by taking a look at a simple example that sends Animals
through a channel. In this and the other examples in this article, we declare variables explicitly for clarity, even when we don’t need to do so.
As expected, Animals.RABBIT
is sent through the channel, gets received and its value is printed in the console.
Channel Capacity
Before diving into how Channels work under the hood, it’s crucial to understand the different types of Channel available. In a nutshell,
Channels differ in how many elements they can store internally until they are properly received. This buffer size affects how
send
operations are performed, since we cannot simply send elements to a Channel that’s already full.
Behind the scenes, there are 4 capacity options with which we can initialize a channel. We explore each of them in detail below.
Unlimited Channel
An unlimited channel can indefinitely buffer elements that were sent to it.
Building from the Animals
example above, this means you can call send
in as many Animals
you’d like and the channel will store the elements until you decide to start calling receiver.receive()
. In other words, the send
operation never gets suspended.
Note that in the definition of the
SendChannel
interface,send
is a suspend function, meaning that, in simple terms, it can be paused and resumed later. Although this is not the case in Unlimited channels, in the cases we will analyze belowsend
may get suspended in case the channel cannot support sending more elements.
Even though unlimited channels have an unlimited buffer size, if no memory is available and you try to send more elements to it, you will eventually get an OutOfMemoryException
.
Buffered Channel
A buffered channel can internally buffer at most the number of elements that is passed to it in the channel’s constructor.
Because of this buffer limit, if we send
an element while the channel is full, this call is suspended until more space is freed up. The buffer can be freed up by calling receive
for example.
If this example is still a bit abstract, don’t worry, as we will cover this suspension mechanism in more detail later in the article.
Rendezvous Channel
A Rendezvous channel is a Buffered channel with capacity 0.
This means that a send
operation gets suspended until the corresponding receive
operation is called. By default, channels are Rendezvous, meaning that to initialize a Rendezvous channel it suffices to call the constructor with no arguments:
Conflated Channel
A Conflated channel buffers exactly one element and in turn overwrites the previously sent elements if they have not yet been received.
For example, in the image above, if the channel sends the blue ball, and before it is received, it sends the yellow ball, the yellow ball overwrites the blue ball, meaning that the call to receive
now fetches the yellow ball.
In this case, the send
operation never gets suspended, since subsequent calls to it will simply overwrite unreceived values.
What about Receiving values?
We’ve discussed how different channels send values and in which cases the send
operation is suspended, and now we will take a closer look at how channels receive elements.
Note that receive
is also a suspend function, as declared in the ReceiveChannel
interface, meaning that it can be suspended just like the send
operation. In a nutshell, for any channel type:
If a channel is not empty, a call to
receive
retrieves an element, otherwise it suspends execution until an element in the channel is available.
To solidify this knowledge, let’s take a look at some examples.
Rabbits, Elephants and Sloths
We will build upon the Animals
example in the beginning of this article to explore how each channel type behaves under the same condition.
The shared code is quite simple, we initialize a channel, launch a coroutine that sends Animals.RABBIT
, then Animals.ELEPHANT
. In another coroutine, we send Animals.SLOTH
and in yet another coroutine, we receive all three Animals
. The sections below explain the outputs for when the channel
is each of the 4 possible channel types.
Rendezvous. In this case, the channel has no buffer, so any send
operation will be suspended until the corresponding receive
is called. And that’s exactly what we observe by following the diagram below.
producerA
from coroutine #1 sendsRABBIT
, but gets suspended since there are no corresponding receivers, yet;producerB
from coroutine #2 sendsSLOTH
, but gets suspended for the same reason;- Upon execution of coroutine #3, both suspended values are received and printed. And coroutines #1 and #2 are freed up, in this order. But there’s no third value to be received, yet, so coroutine #3 also gets suspended;
producerA
from coroutine #1 sendsELEPHANT
but does not suspend since there’s already a correspondingreceiver
from coroutine #3 suspended. In fact, since the channel is no longer empty, coroutine #3 is now free as well;- Coroutine #1 proceeds to finish execution, and since there are no more operations in coroutine #2, it also finishes. Finally, coroutine #3 receives
ELEPHANT
and finishes executing as well.
Note that “freed” here means that the coroutines no longer need to remain suspended and will be scheduled to be executed once the current coroutine and any other coroutines scheduled beforehand are done executing. Remember we are operating on a single thread, so only one coroutine can execute at a given moment.
The output prints the following:
RABBIT
SLOTH
coroutine 1 done
coroutine 2 done
ELEPHANT
coroutine 3 doneProcess finished with exit code 0
Unlimited. In this case, the channel’s buffer size is not bound by any value, so send
operation won’t ever be suspended. You can check out how the code executes in the diagram below.
The output prints the following:
coroutine 1 done
coroutine 2 done
RABBIT
ELEPHANT
SLOTH
coroutine 3 doneProcess finished with exit code 0
Now a quick note. For this and the next examples, we will abstract the written explanation since the diagrams should be able to show all the steps with clarity. If you have any questions, don’t hesitate to leave a comment in the article!
Buffered (More specifically, a buffer with capacity 2). In this case, the send
operations get suspended when when the buffer capacity is at 2 and we try to send an additional element. The diagram below depicts the remaining of the execution flow.
The output prints the following:
coroutine 1 done
RABBIT
ELEPHANT
SLOTH
coroutine 3 done
coroutine 2 doneProcess finished with exit code 0
Conflated. This is an interesting case since the completion of the receiver coroutine actually never finishes. Due to the nature of conflated channels, the send
operations overwrite each other, but when the actual receive
operations are called, the channel only contains 1 element in it, so the second receive
call gets suspended and the coroutine stays idle and does not finish executing, as shown in the diagram below.
The output prints the following (notice that exit code 0 is not shown as the process is still running):
coroutine 1 done
coroutine 2 done
SLOTH
Thanks for reading! Hope you enjoyed reading this article as much as I enjoyed writing it. If you like the content, don’t forget to leave a clap and follow for more!