Passing Data Asynchronously Between Producers and Consumers (.NET)

Dated Aug 1, 2026; last modified on Sat, 01 Aug 2026

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.

A continuation task is an asynchronous task that’s invoked by another task, known as the antecedent, when the antecedent finishes, e.g.,

// Declare, assign, and start the antecedent task.
Task<DayOfWeek> taskA = Task.Run(() => DateTime.Today.DayOfWeek);

// Execute the continuation when the antecedent finishes.
await taskA.ContinueWith(antecedent => Console.WriteLine($"Today is {antecedent.Result}"));

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

  1. Channel<TWrite,TRead> Class (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  2. Channel<T> Class (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  3. ChannelOptions Class (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  4. Channel.CreateBounded Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  5. Chaining tasks using continuation tasks - .NET | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  6. BoundedChannelFullMode Enum (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  7. Channel.CreateUnbounded Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  8. Channel.CreateUnboundedPrioritized Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  9. ChannelWriter<T>.WriteAsync(T, CancellationToken) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  10. ChannelWriter<T>.WaitToWriteAsync(CancellationToken) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  11. ChannelWriter<T>.TryWrite(T) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  12. ChannelWriter<T>.TryComplete(Exception) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  13. ChannelWriter<T>.Complete(Exception) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  14. ChannelReader<T>.Count Property (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  15. ChannelReader<T>.CanCount Property (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  16. ChannelReader<T>.Completion Property (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  17. ChannelReader<T>.TryPeek(T) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  18. ChannelReader<T>.CanPeek Property (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  19. ChannelReader<T>.TryRead(T) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  20. ChannelReader<T>.WaitToReadAsync(CancellationToken) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  21. ChannelReader<T>.ReadAsync(CancellationToken) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  22. ChannelReader<T>.ReadAllAsync(CancellationToken) Method (System.Threading.Channels) | Microsoft Learn. learn.microsoft.com . Accessed Aug 1, 2026.
  23. An Introduction to System.Threading.Channels - .NET Blog. Stephen Toub. devblogs.microsoft.com . Dec 11, 2019. Accessed Aug 1, 2026.