C#
ConcurrentQueue
asynchronous programming
threading
data structures

Is there a data structure in C like a ConcurrentQueue which allows me to await an empty queue until an item is added?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

ConcurrentQueue<T> is thread-safe, but it is not awaitable. If you want consumer code to pause asynchronously until a new item arrives, you need a queue plus a signaling mechanism, or a higher-level abstraction that already combines both ideas.

Why ConcurrentQueue<T> Is Not Enough

ConcurrentQueue<T> solves safe enqueue and dequeue operations across threads. What it does not provide is an async wait primitive that says, "resume me when the queue becomes non-empty."

That means code like this is a bad pattern:

csharp
1while (true)
2{
3    if (queue.TryDequeue(out var item))
4    {
5        await ProcessAsync(item);
6    }
7}

This loop either spins wastefully or forces you to insert artificial delays. Neither is a good consumer design.

The Modern Answer: Channel<T>

In modern .NET, System.Threading.Channels.Channel<T> is usually the best fit for an awaitable producer-consumer queue.

csharp
1using System;
2using System.Threading.Channels;
3using System.Threading.Tasks;
4
5class Program
6{
7    static async Task Main()
8    {
9        var channel = Channel.CreateUnbounded<string>();
10
11        _ = Task.Run(async () =>
12        {
13            await channel.Writer.WriteAsync("first");
14            await channel.Writer.WriteAsync("second");
15            channel.Writer.Complete();
16        });
17
18        await foreach (var item in channel.Reader.ReadAllAsync())
19        {
20            Console.WriteLine(item);
21        }
22    }
23}

The reader waits asynchronously when there is no item and resumes when data arrives. That is exactly the behavior people often wish ConcurrentQueue<T> provided.

A Custom Awaitable Queue With SemaphoreSlim

If you need to build your own abstraction, the usual pattern is ConcurrentQueue<T> plus SemaphoreSlim.

csharp
1using System.Threading;
2using System.Threading.Tasks;
3using System.Collections.Concurrent;
4
5public sealed class AsyncQueue<T>
6{
7    private readonly ConcurrentQueue<T> _queue = new();
8    private readonly SemaphoreSlim _signal = new(0);
9
10    public void Enqueue(T item)
11    {
12        _queue.Enqueue(item);
13        _signal.Release();
14    }
15
16    public async Task<T> DequeueAsync(CancellationToken cancellationToken = default)
17    {
18        await _signal.WaitAsync(cancellationToken);
19        _queue.TryDequeue(out var item);
20        return item!;
21    }
22}

This works because:

  • the queue stores the data safely
  • the semaphore tracks how many items are available
  • 'WaitAsync suspends the consumer without busy-waiting'

For many applications, this is perfectly adequate. Channel<T> is still preferable when available because it handles more edge cases and completion semantics for you.

What About BlockingCollection<T>?

BlockingCollection<T> can block until an item is available, but it is built around synchronous blocking rather than await. It is useful in thread-based code, but it is not the best choice when you want fully asynchronous consumers.

So the rough decision rule is:

  • synchronous worker threads: BlockingCollection<T> can be fine
  • async producer-consumer code: prefer Channel<T>
  • custom minimal wrapper: ConcurrentQueue<T> plus SemaphoreSlim

Cancellation and Shutdown Matter Too

Real queues usually need more than item delivery. Consumers may need to stop cleanly when the application shuts down. Channel<T> has built-in completion semantics, while a custom queue should usually accept a CancellationToken and define clearly what happens when no more items will arrive.

Common Pitfalls

The biggest pitfall is confusing thread-safe access with awaitable consumption. A collection can be safe for multiple threads and still offer no efficient way to wait asynchronously for new data.

Another common mistake is writing a polling loop with Task.Delay. That works in demos but adds latency, wastes wakeups, and becomes awkward under load.

Developers also forget completion semantics. A real queue often needs to signal not only "an item arrived" but also "no more items will ever arrive." Channel<T> handles that much more cleanly than most homegrown wrappers.

Summary

  • 'ConcurrentQueue<T> is thread-safe, but it does not let consumers await the arrival of new items.'
  • 'Channel<T> is the modern await-friendly queue abstraction in .NET.'
  • A custom queue can be built from ConcurrentQueue<T> plus SemaphoreSlim.
  • 'BlockingCollection<T> blocks threads rather than supporting await.'
  • If your consumer is asynchronous, use a data structure that models signaling as well as storage.

Course illustration
Course illustration

All Rights Reserved.