Channel<T> in System.Threading.Channels is an in-memory queue for the Producer-Consumer pattern: producers hand items to consumers inside one process, and both ends wait asynchronously instead of blocking a thread. Reach for it when work arrives faster than it is processed and the buffer between them needs a limit — an endpoint that returns 202 while a BackgroundService renders the upload, or one stage of an ingestion pipeline feeding the next.

How It Works

Channel.CreateBounded<T>(capacity) gives a fixed buffer; Channel.CreateUnbounded<T>() one that grows without limit. The channel exposes two façades, channel.Writer and channel.Reader, so each end can be handed to a different component.

  • WriteAsync follows the configured full-buffer policy. In the default Wait mode, it completes after the item is accepted and suspends the producer on a full channel until space frees up, without parking a thread. In DropWrite mode, completion does not prove acceptance: a full buffer can discard the incoming item. Waiting in Wait mode is the backpressure.
  • TryWrite returns false immediately instead of waiting; WaitToWriteAsync awaits capacity, returning false once the channel is completed. Looping the two avoids per-item await machinery on hot paths.
  • reader.ReadAllAsync() returns an IAsyncEnumerable<T>, so a consumer is an await foreach. Items come out FIFO — the ordering SemaphoreSlim does not guarantee.
  • writer.Complete() says no more items: the reader drains the buffer, ReadAllAsync ends the loop, reader.Completion completes. Without it the reader cannot know the stream ended.

BoundedChannelFullMode, fixed at construction, is the entire backpressure decision:

ModeWhen the buffer is full
Wait (default)Producer awaits; pressure propagates upstream
DropWriteThe incoming item is discarded
DropOldestThe oldest buffered item is evicted
DropNewestThe newest buffered item is evicted

Either you slow the producer or you throw data away; choosing a capacity is choosing which. SingleReader/SingleWriter are promises about how many threads touch each end — the channel takes a cheaper path when you make them, and breaks when you lie.

Blocking, lock-free, starvation-free, and wait-free progress

API waiting and implementation progress answer different questions. On a full bounded channel, WriteAsync deliberately suspends until capacity exists: that is backpressure, even though no thread is blocked. TryWrite reports the admission decision immediately, but false means only not accepted now; it does not define the channel as formally non-blocking.

A lock-free queue can update its head or tail with compare-and-swap (CAS). When two threads race, one wins and the other retries. Lock-free means the system as a whole keeps completing operations despite those retries; it does not guarantee that a particular thread wins. Starvation-free means every contender eventually makes progress. Wait-free is stronger again: every operation finishes within a bounded number of its own steps.

Channel<T> exposes waiting and drop semantics, not a wait-free guarantee. Its progress properties depend on the runtime implementation and the options used. Treat bounded capacity as an overload contract, and use the formal progress terms only when the chosen data structure documents them.

Example

builder.Services.AddSingleton(_ => Channel.CreateBounded<ThumbnailJob>(
    new BoundedChannelOptions(capacity: 100)
    {
        FullMode = BoundedChannelFullMode.Wait,
        SingleReader = true,   // one BackgroundService drains it
        SingleWriter = false   // many concurrent requests write
    }));
builder.Services.AddHostedService<ThumbnailWorker>();
 
app.MapPost("/thumbnails", async (
    ThumbnailJob job, Channel<ThumbnailJob> channel, CancellationToken ct) =>
{
    // With 100 buffered, this waits asynchronously until the worker takes one out.
    // The request gets slower; the queue does not grow.
    await channel.Writer.WriteAsync(job, ct);
    return Results.Accepted();
});
public sealed class ThumbnailWorker(
    Channel<ThumbnailJob> channel,
    ILogger<ThumbnailWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var job in channel.Reader.ReadAllAsync(stoppingToken))
        {
            try
            {
                await RenderAsync(job, stoppingToken);
            }
            catch (Exception ex) // an escaping exception kills the pump
            {
                logger.LogError(ex, "Thumbnail failed for {Path}", job.BlobPath);
            }
        }
    }
}

Pitfalls

  • Unbounded is a memory leak with extra stepsCreateUnbounded applies no backpressure; writes always succeed. A consumer that falls behind grows the buffer until the process OOMs. Use it only when something else already rate-limits the producer.
  • No writer.Complete(), no endReadAllAsync waits for an item or for completion, so the consumer loop and any shutdown awaiting reader.Completion hang forever. Complete the writer in StopAsync, or a finally around production.
  • BlockingCollection<T> parks a ThreadPool threadTake()/Add() block the caller, so in async code the thread sits doing nothing. Enough of them and thread-pool starvation shows up as latency on every endpoint, not just this one.
  • Drop modes lose data silently — under DropOldest/DropWrite, TryWrite still returns true. Nothing throws, nothing logs. Fine for sampled telemetry; wrong for payment events. Pick one on purpose, and count the drops.

Tradeoffs

OptionFull bufferBlocks the callerAsync APIFIFO
lock + Queue<T>Grows unboundedInside the lockNoYes, but you build the waiting
ConcurrentQueue<T>Grows unboundedNoNo — consumers pollYes
BlockingCollection<T>Producer blocks the threadYesNoYes
Channel<T>Producer awaits, or a drop policy firesNo — it awaitsYesYes

Use a bounded Channel<T> for in-process producer-consumer in async code: it alone has an asynchronous wait, an explicit full-buffer policy, and no parked thread.

What flips it: ConcurrentQueue<T> when nobody waits — the consumer runs on a timer and an empty queue just means nothing to do. BlockingCollection<T> when the consumer is a dedicated long-running Thread and blocking it is the point. lock + Queue<T> when you must inspect or mutate queued items (dedupe, reprioritise), because Channel<T> never exposes its buffer. If the work must survive a crash, none of the four qualify: the buffer dies with the process, so use a durable broker.

Questions

References