A channel is a data structure that’s used to store produced data for a consumer to retrieve, and an appropriate synchronization to enable that to happen safely, while also enabling appropriate notifications in both directions.
A Toy Channel
public sealed class Channel<T> {
// Use a thread-safe data store to free us from locking semantics for any
// number of producers and consumers.
private readonly ConcurrentQueue<T> _items = new ConcurrentQueue<T>();
// A mechanism for coordinating between producers and consumers.
private readonly SemaphoreSlim _semaphore = new Semaphore(0);
public void Write(T item)
{
_items.Enqueue(item); // Store the data.
_semaphore.Release(); // Notify any consumers that more data is available.
}
public ValueTask<T> ReadAsync(CancellationToken ct = default)
{
await _semaphore.WaitAsync(ct).ConfigureAwait(false); // Wait for data to be available.
bool gotOne = _items.TryDequeue(out T item);
Debug.Assert(gotOne);
return item;
}
}
Creating a Channel<T>
A Channel<TWrite, TRead> supports writing elements of type TWrite and
reading elements of type TRead. There are implicit casts and properties
available to get its writable half, ChannelWriter<TWrite>, and its readable
half, ChannelReader<TRead>. Channel<T> is a Channel<T, T>, i.e., supports
reading and writing elements of type T.
The base Channel<TWrite, TRead> abstract class is available for niche
implementations where a channel may itself transform written data into a
different type for consumption. Channel<T> suffices for the vast majority of
use cases.
ChannelOptions provide information that the Channel can use to optimize
certain operations. All of the options default to false, the conservative
option.
ChannelOptions.SingleReader. Set if readers from the channel guarantee that there will only ever be at most one read operation at a time.ChannelOptions.SingleWriter. Set if writers from the channel guarantee that there will only ever be at most one write operation at a time.ChannelOptions.AllowSynchronousContinuations. Set if operations performed in a channel may synchronously invoke continuations subscribed to notifications of pending async operations.
… e.g., SingleReader == true allows an implementation that avoids locks and
interlocked operations when reading.
Consider a case where a consumer reads asynchronously before any data is
available, effectively hooking up a callback. Typically, when a producer writes
data, it queues the invocation of consumer’s callback, for that callback to be
invoked asynchronously. With AllowSynchronousContinuations == true, writing to
the channel can invoke the pending callback instead of queuing its invocation,
cutting down on overhead. However, this might end up invoking the callback while
holding a lock, leading to broken invariants that said lock was trying to
maintain.
I don’t quite grok AllowSynchronousContinuations. “If I’m not using locks,
then I’m fine” doesn’t sound convincing. I need examples of broken invariants.
Channel.CreateBounded<T>(...) creates a channel that call hold at most \(N\)
items. It supports BoundedChannelOptions, an extension of ChannelOptions.
BoundedChannelOptions.FullMode specifies the behavior to use when writing to a
bounded channel that is already full. BoundedChannelFullMode.Wait waits for
space to be available. Various BoundedChannelFullMode.Drop* make room by
dropping an item, e.g., DropNewest, DropOldest, and DropWrite.
BoundedChannelFullMode.Wait is the default. DropWrite means that writing
will return success, but the item added will immediately be removed.
Channel.CreateBounded<T>(...) supports supplying a delegate that will be
called when an item is being dropped from the channel.
Channel.CreateUnbounded<T>(...) creates an unbounded channel, with
UnboundedChannelOptions that have the same options as ChannelOptions.
Channel.CreateUnboundedPrioritized<T>(...) creates an unbounded channel, but
where the next item read from the channel will be the element available in the
channel with the lowest priority value. UnboundedPrioritizedChannelOptions<T>,
an extension of ChannelOptions, allows us to specify the IComparer<T>.
Writing to a Channel
ChannelWriter<T>.WaitToWriteAsync(CancellationToken) returns a
ValueTask<bool> that will complete with true when space is available to
write an item, or with false when no further writing is permitted . WaitToWriteAsync is useful when it’s
undesirable to produce a value immediately, e.g., when producing a value is
expensive .
Calling ChannelWriter<T>.WaitToWriteAsync on a bounded channel created with
BoundedChannelFullMode.Wait will never complete if there are no read
operations on the other end of the channel.
ChannelWriter<T>.WriteAsync(T, CancellationToken) returns a ValueTask that
represents the asynchronous write operation . ChannelWriter<T>.TryWrite(T) returns true
if the item was written to the channel, and false otherwise .
WriteAsync is virtual. The base type’s implementation is basically:
public async ValueTask WriteAsync(T item, CancellationToken cancellationToken)
{
while (await WaitToWriteAsync(cancellationToken).ConfigureAwait(false))
if (TryWrite(item))
return;
throw new ChannelCompletedException();
}
… where looping on WaitToWriteAsync is important in the case of multiple
producers being told yes, only for TryWrite to fail on the ones that lose the
race condition after the channel becomes full. When one producer marks the
channel as complete, the other producers will have WaitToWriteAsync and
TryWrite return false, and WriteAsync throws a
ChannelCompletedException.
ChannelWriter<T>.TryComplete(Exception?) attempts to mark the channel as
completed, i.e., no more data will be written to it. false is returned if the
channel couldn’t be marked for completion, e.g., due to having already been
marked as such, or due to not supporting completion. Set Exception if the
reason for completion was a failure. ChannelWriter<T>.Complete(Exception?) throws an InvalidOperationException if
the channel has already been marked as complete.
How can a channel not support completion? I haven’t run into APIs that would get us into such a state.
Reading from a Channel
ChannelReader<T>.Completion gets a Task that completes when no more data
will ever be available to be read from this channel . Rephrased, completes after either
ChannelWriter<T>.TryComplete(Exception?) or
ChannelWriter<T>.Complete(Exception?) succeeds.
ChannelReader<T>.WaitToReadAsync(CancellationToken) returns a
ValueTask<bool>. There will be a true result when data is available to read.
There will be a false result when no further data will ever be available to be
read due to the channel completely successfully. If the channel was completed
with an Exception, then the ValueTask<bool> will also complete with an
Exception.
ChannelReader<T>.Count gets the current number of items available from this
channel reader, throwing a NotSupportedException if counting is not supported.
ChannelReader<T>.CanCount is true when counting is supported.
How can a channel not support counting? I haven’t run into APIs that would get us into such a state.
ChannelReader<T>.TryPeek(T) returns true if an item was peeked and written
to T, and false if no item could be peeked. ChannelReader<T>.CanPeek is
true if the channel instance supports peeking.
ChannelReader<T>.ReadAsync(CancellationToken) returns a ValueTask<T>
representing the asynchronous read operation . ChannelReader<T>.TryRead(T) returns true
if it read an item and wrote it into T; otherwise returns false .
ChannelReader<T>.ReadAllAsync(CancellationToken) creates an
IAsyncEnumerable<T> that enables reading all of the data from the channel.
Each IAsyncEnumerator<T>.MoveNextAsync call that returns true will read the
next item out of the channel. IAsyncEnumerator<T>.MoveNextAsync returns
false once no more data is or will ever be available to read.
ReadAsync and ReadAllAsync are virtual. The base class implementation of
ReadAllAsync is of the form:
public virtual async IAsyncEnumerable<T> ReadAllAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
while (await WaitToReadAsync(cancellationToken).ConfigureAwait(false))
while (TryRead(out T item)) // Optimization to avoid WaitToReadAsync overhead in the happy case.
yield return item;
}
… enabling all of the data to be read from a channel like so:
await foreach (T item in channelReader.ReadAllAsync())
Use(item);
Performance Considerations
Async writes and reads from channels return a ValueTask and not a Task so
that the result can be allocation-free when it completely synchronously. For
example, a channel with enough room for a write has WriteAsync return
synchronously. Furthermore, the System.Threading.Channels implementation uses
IValueTaskSource<T> to avoid allocations even when the various methods
complete asynchronously and need to return tasks.
Consider an unbounded Channel<int> where we pass 10M integers through. If we
write then read, the reads will complete synchronously as there’ll always be a
int available to read. This benchmark completes in 527.8ms without extra
allocations. If we read then write, the reads will complete asynchronously. This
benchmark completes in 881.2ms, but even then, there are no extra allocations.
References
- Channel<TWrite,TRead> Class (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- Channel<T> Class (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- ChannelOptions Class (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- Channel.CreateBounded Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- Chaining tasks using continuation tasks - .NET | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- BoundedChannelFullMode Enum (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- Channel.CreateUnbounded Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- Channel.CreateUnboundedPrioritized Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- ChannelWriter<T>.WriteAsync(T, CancellationToken) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- ChannelWriter<T>.WaitToWriteAsync(CancellationToken) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- ChannelWriter<T>.TryWrite(T) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- ChannelWriter<T>.TryComplete(Exception) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- ChannelWriter<T>.Complete(Exception) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- ChannelReader<T>.Count Property (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- ChannelReader<T>.CanCount Property (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- ChannelReader<T>.Completion Property (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- ChannelReader<T>.TryPeek(T) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- ChannelReader<T>.CanPeek Property (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- ChannelReader<T>.TryRead(T) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- ChannelReader<T>.WaitToReadAsync(CancellationToken) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- ChannelReader<T>.ReadAsync(CancellationToken) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- ChannelReader<T>.ReadAllAsync(CancellationToken) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
- An Introduction to System.Threading.Channels - .NET Blog. Stephen Toub. devblogs.microsoft.com . Dec 11, 2019. Accessed Aug 1, 2026.
A continuation task is an asynchronous task that’s invoked by another task, known as the antecedent, when the antecedent finishes, e.g.,