Channels give us a way to communicate between concurrent (async) operations in
.NET. Channel<T> was included in .NET Core 3.0 (prior to that, it was available as a separate NuGet package). I first encountered channels in Go (golang)
several years ago, so I was really interested when I found out that they are
available to use in C#.
Channels give us a way to communicate between concurrent (async)
operations in .NET.
We can think of a channel as a sort of thread-safe queue. This means that we
can add items to a channel in one concurrent operation and read those items in
another. (We can also have multiple readers and multiple writers.)
As an introduction to channels, we'll look at an example that mirrors some
code from the Go programming language (golang). This will give us an idea of
the syntax and how channels work. In a future article, we'll look at a more
real-world example.
What is Channel<T>?
The purpose of a channel is to let us communicate between 2 (or more)
concurrent operations. Like a thread-safe queue, we can add an item to the
queue, and we can remove an item from the queue. Since channels are
thread-safe, we can have multiple processes writing to and reading from the
channel without worrying about missed writes or duplicate reads.
An Example
Let's imagine that we need to transform 1,000 records and save the resulting
data. Due to the nature of the operation, the transformation takes several
seconds to complete. In this scenario, we can set up several concurrent
processes that transform records. When a transformation is complete, it puts
the record onto the channel.
In a separate operation, we have a process that listens to that channel. When
an item is available, it takes it off of the channel and saves it to the data
store. Since this operation is faster (only taking a few milliseconds), we can
have a single operation that is responsible for this.
This is an implementation of the Observer pattern -- at its core, it is a
publish-subscribe relationship. The channel takes results from the publisher
(the transformation process) and provides them to the subscriber (the saving
process).
Channel Operations
There are a number of operations available with Channel<T>. The primary
operations that we'll look at today include the following:
- Creating a channel
- Writing to a channel
- Closing a channel when we are done writing to it
- Reading from a channel
Channel<T> gives us quite a few ways of accomplishing each of these
functions. What we'll see today will give us a taste of what is available, but
there is more to explore.
Parallel Operations
The sample project is available on GitHub:
https://github.com/jeremybytes/csharp-channels. This project has a service with several end points. One end point provides
a single "Person" record based on an ID. This operation has a built-in 1
second delay (to simulate a bit of lag).
Our code is in a console application that gets data -- the goal is to pull out
9 records from the service. If we do this sequentially, then the operation
takes 9 seconds. But if we do this concurrently (i.e., in parallel), the the
operation takes a little over 1 second.
The concurrent operations that call the service put their results onto a
channel. Then we have a listener that reads values off of the channel and
displays them in the console.
Creating a Channel
To create a channel, we use static methods on the "Channel" class. The two
methods we can choose from are "CreateBoundedChannel" and
"CreateUnboundedChannel". A bounded channel has a constraint on how many items
the channel can hold at one time. If a channel is full, a writer can wait
until there is space available in the channel.
Whether we have a bounded or an unbounded channel, we must also specify a type
(using a generic type parameter). A channel can only hold one type of object.
Here is the code from our sample (line 61 in the
Program.cs file
in the project mentioned above):
C#
var channel = Channel.CreateBounded<Person>(10);
The above code creates a bounded channel that can hold up to 10 "Person"
objects. Our sample data only has 9 items, so we will not need to worry about
the channel reaching capacity for this example.
Writing to a Channel
A channel has 2 primary properties: a Reader and a Writer. For our channel,
the Writer property is a ChannelWriter<Person>. A channel writer has
several methods; we'll use the "WriteAsync" method.
C#
await channel.Writer.WriteAsync(person);
Like many things in C#, this method is asynchronous. The "await" will pause
the operation until it is complete.
Let's look at the "WriteAsync" method in context. Here is the enclosing
section (lines 64 to 81 in the
Program.cs file):
C#
// Write person objects to channel asynchronously
foreach (var id in ids)
{
async Task func(int id)
{
try
{
var person = await GetPersonAsync(id);
await channel.Writer.WriteAsync(person);
}
catch (HttpRequestException ex)
{
Console.WriteLine($"ERROR: ID {id}:
{ex.Message}");
}
}
asyncCalls.Add(func(id));
}
The "foreach" loops through a collection of "ids" (the "id" property of the
"Person" object).
Inside the "foreach" loop, we have a local function (named "func" -- not a
great name, but it makes the code similar to the anonymous function in the Go
example).
The "func" function calls "GetPersonAsync". "GetPersonAsync" calls the service
and returns the resulting "person".
After getting the "person", we put that object onto the channel using
"Writer.WriteAsync".
The catch block is looking for exceptions that might be thrown from the
service -- for example if the service is not available or the service returns
an empty object.
The last line of the "foreach" loop does a couple of things. It calls the
local "func" function using the "id" value from the loop. "func" is
asynchronous and returns a Task.
"asyncCalls" is a "List<Task>". By calling "asyncCalls.Add()", we add
the Task from the "func" call to the list. We'll see how this is used later.
There are no blocking operations inside the "foreach" loop. Instead, each
iteration of the loop kicks off a concurrent (async) operation. This means
that the loop will complete very quickly, and we will have 9 operations
running concurrently.
Closing a Channel
When we are done writing to a channel, we should close it. This tells anyone
reading from the channel that there will be no more values, and they can stop
reading. We'll see why this is important when we look at reading from a
channel.
One issue that we have to deal with in our code is that we do not want to
close the channel until all 9 of the concurrent operations are complete. This
is why we have the "asyncCalls" list. It has a list of all of the operations
(Tasks), and we can wait until they are complete.
C#
// Wait for the async tasks to complete & close the
channel
_ = Task.Run(async () =>
{
await Task.WhenAll(asyncCalls);
channel.Writer.Complete();
});
"await Task.WhenAll(asyncCalls)" will pause the operation of this section of
code until all of the concurrent tasks are complete. When the "await" is done,
we know that it is safe to close the channel.
The next line -- "channel.Writer.Complete()" -- closes the channel. This
indicates that no new items will be written to the channel.
These 2 lines of code are wrapped in a task (using "Task.Run"). The reason for
this is that we want the application to continue without blocking at this
point. The "Task.WhenAll" will block, but it will block inside a
separate concurrent (asynchronous) operation.
This means that the read operation (in the next section) can start running
before all of the concurrent tasks are complete.
If running this section asynchronously is a bit hard to grasp, that's okay. It
took me a while to wrap my head around it. For our limited sample (with only 9
records), it does not make any visible difference. But it's a good pattern to
get used to for larger, more complex scenarios.
Reading from a Channel
Our last step is to read from a channel -- this uses the "Reader" property
from the channel. For our example, "Reader" is a
"ChannelReader<Person>".
There are multiple ways that we can read from the channel. The sample code has
an "Option 1" and "Option 2" listed (with one of the options commented out).
We'll look at "Option 2" first.
Option: WaitToReadAsync / TryRead
"Option 2" uses a couple of "while" loops to read from the channel. Here is
the code (lines 97 to 105 in the
Program.cs file):
C#
// Read person objects from the channel until the channel is
closed
// OPTION 2: Using WaitToReadAsync / TryRead
while (await channel.Reader.WaitToReadAsync())
{
while (channel.Reader.TryRead(out Person person))
{
Console.WriteLine($"{person.ID}: {person}");
}
}
The first "while" loop makes a call to "WaitToReadAsync" on the channel
"Reader" property. When we "await" the "WaitToReadAsync" method, the code will
pause until an item is available to read from the channel *or* until the
channel is closed.
When an item is available to read, "WaitToReadAsync" returns "true" (so the
"while" loop will continue). If the channel is closed, then "WaitToReadAsync"
returns "false", and the "while" loop will exit.
So far, this only tells us whether there is an item available to read. We have
not done any actual reading. For that, we have the next "while" loop.
The inner "while" loop makes a call to "TryRead" on the channel "Reader"
property. As with most "Try" methods, this uses an "out" parameter to hold the
value. If the read is successful, then the "person" variable is populated, and
the value is written to the console.
"TryRead" will return "false" if there are no items available. If that's the
case, then it will exit the inner "while" loop and return to the outer "while"
loop to wait for the next available item (or the closing of the channel).
Note: After the channel is closed (meaning the Writer is marked Complete), the Reader will continue to read from the channel until the channel is empty. Once a closed channel is empty, the "WaitToReadAsync" will return false and the loop will exit.
Option: ReadAllAsync
Another option ("Option 1" in the code) is to use "ReadAllAsync". This code is
nice because it has fewer steps. But it also uses an IAsyncEnumerable. If you
are not familiar with IAsyncEnumerable, then it can be a bit difficult to
understand. That's why both options are included.
Let's look at the code that uses "ReadAllAsync" (lines 90 to 95 in the
Program.cs file):
C#
// Read person objects from the channel until the channel is
closed
// OPTION 1: Using ReadAllAsync (IAsyncEnumerable)
await foreach (Person person in channel.Reader.ReadAllAsync())
{
Console.WriteLine($"{person.ID}: {person}");
}
"ReadAllAsync" is a method on the channel "Reader" property. This returns an
"IAsyncEnumerable<Person>". This combines the power of enumeration
("foreach") with the concurrency of "async".
The "foreach" loop condition looks pretty normal -- we iterate on the result
of "ReadAllAsync" and use the "Person" object from that iteration.
What is a little different is that there is an "await" before the "foreach".
This means that the "foreach" loop may pause between iterations if it needs to
wait for an item to be populated in the channel.
The effect is that the "foreach" will continue to provide values until the
channel is closed and empty. Inside the loop, we write the value to the console.
At a high level, this code is a bit easier to understand (since it does not
have nested loops and uses a familiar "foreach" loop). But when we look more
closely, we see that we "await" the loop, and what returns from "ReadAllAsync"
may not be a complete set of data -- our loop may wait for items to be
populated in the channel.
I would recommend learning more and getting comfortable with IAsyncEnumerable
(which was added with C# 8). It's pretty powerful and, as we've seen here, it
can simplify our code. But it only simplifies the code if we understand what
it's doing.
Application Output
To run the application, first start the service in the "net-people-service"
folder. Navigate to the folder from the command line (I'm using PowerShell here), and type "dotnet run".
This starts the service and hosts it at "http://localhost:9874".
Console
PS C:\Channels\net-people-service> dotnet run
info: Microsoft.Hosting.Lifetime[0]
Now listening on: http://localhost:9874
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path:
C:\Channels\net-people-service
To run the application, navigate to the "async" folder (from a new command line)
and type "dotnet run".
Console
PS C:\Channels\async> dotnet run
[1 2 3 4 5 6 7 8 9]
9: Isaac Gampu
3: Turanga Leela
7: John Sheridan
4: John Crichton
6: Laura Roslin
5: Dave Lister
2: Dylan Hunt
1: John Koenig
8: Dante Montana
Total Time: 00:00:01.3324302
PS C:\Channels\async>
There are a couple of things to note about this output. First, notice the "Total Time" at the bottom. This takes a bit over 1 second to complete. As mentioned earlier, each call to the service takes at least 1 second (due to a delay in the service). This shows us that all 9 of these service calls happen concurrently.
Next, notice the order of the output. It is non-deterministic. Even though we have a "foreach" loop that goes through the ids in sequence (1, 2, 3, 4, 5, 6, 7, 8, 9), the output is not in the same order. This is one of the joys of running things in parallel -- the order things complete is not the same as the order things were started.
If we run this application multiple times, the items will be in a different order each time. (Okay, not different *each* time since we are only dealing with 9 values, but we cannot guarantee the order of the results.)
There's Much More
In this article, we've looked at some of the functions of Channel<T>,
including creating, writing to, closing, and reading from channels. But
Channel<T> has much more functionality.
When we create a channel (using "CreateBounded" or "CreateUnbounded"), we can
also include a set of options. These include "SingleReader" and "SingleWriter"
to indicate that we will only have one read or write operation at a time. This
allows the compiler to optimize the channel. In addition, there is an
"AllowSynchronousContinuations" option. This can increase throughput by
reducing parallelism.
ChannelWriter<T> has several methods in addition to "WriteAsync". These
include "WaitToWriteAsync" and "TryWrite" which we may need in scenarios where
we have bounded channels and we exceed the capacity. There is also a
"TryComplete" method that tries to close the channel.
ChannelReader<T> has many methods as well. Today, we saw "ReadAllAsync",
"WaitToReadAsync", and "TryRead". But there is also a "ReadAsync". We can mix
and match these methods in different ways depending on what our needs are.
Wrap Up
Channel<T> is really interesting to me. When I started looking at Go
(golang) a few years back, I found channels to be clean and easy to use. I
wondered if it would make sense to have something similar in C#. Apparently, I
was not the only one. Channel<T> uses many of the same concepts as
channels in Go, but with a bit more power and functionality. This adds
complexity as well (but I've learned to accept complexity in the C# world).
In today's example, we saw how Channel<T> lets us communicate between
concurrent operations. In our case, we have 9 concurrent operations that all
write their results to a channel. Then we have a reader that pulls the results
from the channel and displays them on the console.
This allowed us to see some basics of creating, writing to, closing, and
reading from channels. But the real world can get a bit more complicated.
I have been experimenting with channels in my digit recognition project (
https://github.com/jeremybytes/digit-display). This uses machine learning to recognize hand-written digits from bitmap
data. These operations are computationally expensive, so I've been using tasks
with continuations to run them in parallel. But I've also created versions
that uses channels. This simplifies some things (I don't need to be as
concerned about getting back to the UI thread in the desktop application). But
it creates issues in others (using a Parallel.Foreach can end up starving the
main application thread). I've got a bit more experimenting to do. The
solution that I have right now works, but it doesn't feel quite right.
As always, there is still more to learn.
Happy Coding!