Updated on
ConcurrentQueue<T> is a first-in-first-out collection that any number of threads can add to and take from at the same time, without a single lock in our code.
It lives in System.Collections.Concurrent, it exposes three methods that matter (Enqueue(), TryDequeue() and TryPeek()), and it is built from linked segments rather than one array, which is why it stays lock-free while several threads hammer it.
What Is ConcurrentQueue in C#?
ConcurrentQueue<T> is a thread-safe, first-in-first-out collection in the System.Collections.Concurrent namespace. Several threads can enqueue and dequeue at the same time without us writing a single lock.
Three methods carry almost all of its use. Enqueue() adds an item to the back. TryDequeue() removes the item at the front. TryPeek() reads the front item without removing it.
The Try prefix is the whole design, not a naming habit. Another thread can empty the queue between our check and our read, so there is no Dequeue() that throws on empty and no safe way to guard one with if (queue.Count > 0). We ask for an item and we handle the answer we get.
It is also not a drop-in replacement for Queue<T>. There is no Contains(), no indexer, and no way to pull an item out of the middle. What we get in exchange is a queue that several threads can share safely.
Queue Concept in Data Processing
Before we get to the specifics of the ConcurrentQueue class, it helps to know why queues matter in data processing.
From the data structures perspective, we can talk about two ordering principles – first-in, first-out (FIFO) and last-in, first-out (LIFO).
A queue is a fundamental data structure that operates on the principle of first in first out (FIFO). This means that the first element added to the queue is the first one to be removed:
As we can see above, the incoming requests form a queue and await processing by appropriate handlers.
On the other hand, a stack is the mirror image of a queue, operating last-in, first-out (LIFO). In this case, it is the last element added to the stack that will be removed in the first place:
As we can see, incoming messages are stacked one on another and only the top element can be retrieved for processing.
While both stacks and queues are types of data structures, it is important to emphasize that they are fundamentally distinct. Stacks and queues serve unique purposes and exhibit different behaviors. They can both be considered arrays or collections of items, but it is crucial that we recognize them as separate and independent first-class data structures.
As we can see, queues and stacks play a crucial role in managing data in scenarios where the order of processing matters. They find applications in tasks like managing print jobs in a printer queue, handling requests in web servers, and more.
ConcurrentQueue and Concurrency
Now that we’ve grasped the significance of queues, let’s introduce the star of our article: the ConcurrentQueue class.
The ConcurrentQueue class is part of the System.Collections.Concurrent namespace in C#, which offers a collection of thread-safe data structures designed for use in multi-threaded applications.
The ConcurrentQueue class primarily facilitates data management across multiple threads, eliminating the need for explicit synchronization mechanisms. ConcurrentQueue ensures safe enqueueing and dequeuing operations from different threads, preventing data corruption and race conditions.
This makes it an indispensable tool when building applications that require efficient parallelism and synchronization.
In addition to the ConcurrentQueue class, the System.Collections.Concurrent namespace provides the other thread-safe collections in the same namespace: ConcurrentDictionary for key-value data, ConcurrentBag, and ConcurrentStack. When order does not matter, ConcurrentBag is the unordered member of the same family.
How Do We Use ConcurrentQueue in C#?
Everything starts with an instance. new ConcurrentQueue<int>() gives us an empty queue, and passing an IEnumerable<T> to the constructor seeds it with items already in order.
Adding happens one item at a time. Enqueue() takes a single value and there is no EnqueueRange(), so a bulk load is either a loop or the seeding constructor.
Reading is always a Try call. TryDequeue(out var item) removes and returns the front item, and TryPeek(out var item) leaves it in place. Both return false on an empty queue rather than throwing, and the out value means nothing when they do.
To ask whether the queue is empty, reach for IsEmpty rather than Count == 0. The runtime’s own documentation recommends it, and Count is the more expensive of the two.
Clear() empties the queue. A foreach loop walks a point-in-time snapshot, not the live queue.
Instantiating ConcurrentQueue
First, before we even start using a ConcurrentQueue class, we need to create an instance of it.
We can create an empty queue like so:
ConcurrentQueue<int> myQueue = new();
Alternatively, in case we already have a list of elements that we would like to add to the queue we can use a constructor that accepts a collection of elements:
ConcurrentQueue<int> myQueue = new(itemsList);
This will create a new ConcurrentQueue instance and add all elements from the itemsList instance to it.
Enqueueing Items
Afterwards, we can use the Enqueue method to add elements to our queue in a safe and orderly manner:
myQueue.Enqueue(item);
As simple as that, we have added an item instance to our queue.
Now, what if we want to enqueue multiple items at once? Unfortunately, by design, we are limited to adding our items one by one to the queue:
for (int i = 0; i < 10000; i++)
{
myQueue.Enqueue(i);
}
However, as we mentioned previously, we can leverage a constructor to transform a list of our items into a new ConcurrentQueue instance.
Dequeueing Items
Equally important is dequeuing items from our queue, which coincidentally is just as straightforward as enqueuing them.
We can use the TryDequeue method to safely retrieve and remove an item from the queue:
if (myQueue.TryDequeue(out var item))
{
// Process item
}
The TryDequeue method not only dequeues an item but also indicates whether the operation was successful.
For example, when we try to dequeue a message from an empty queue, instead of throwing an exception the method would simply return false as a result. For that reason, it is crucial that we check the return value and handle potential failure conditions appropriately.
Iterating a ConcurrentQueue
Iterating through the contents of a queue is a relatively uncommon operation when processing data and it might contradict the purpose of the queue.
Surprisingly, C# allows for such an operation. What’s more, it is constructed in such a way that it fits into the principle of multithreading and parallelism.
As we can read in the official documentation – the GetEnumerator method creates a snapshot of our queue allowing us to safely iterate through its content.
Therefore, it is important to remember that this snapshot won’t reflect any changes to the queue that we make after invoking the GetEnumerator method.
We can iterate over a queue by using a foreach loop:
foreach (var item in myQueue)
{
// Process item
}
This allows us to process each item in the queue separately from our handlers.
TryPeek Method
Next, the TryPeek method is a valuable addition to the ConcurrentQueue class that allows us to examine the item at the front of the queue without removing it.
This is particularly useful when we want to inspect the next item to be processed without altering the queue’s state:
if (myQueue.TryPeek(out var item))
{
// Process item (without removing it)
}
Additionally, TryPeek method gracefully handles the scenario in which the queue is empty, preventing exceptions and ensuring smooth operation. For that reason, we need to validate the return value by the same token as for the TryDequeue method.
Count, IsEmpty, and Clear
Lastly, managing the state of a queue often involves asking how many items it holds, whether it holds any at all, and clearing it when necessary.
The ConcurrentQueue class provides us with three members for these tasks:
var itemCount = myQueue.Count; var isQueueEmpty = myQueue.IsEmpty; myQueue.Clear();
The Count property tells us how many items are currently in the queue. It is not the way to ask whether the queue is empty: Microsoft’s reference page for IsEmpty says “use of this property is recommended rather than retrieving the number of items from the Count property and comparing it to 0”, and the implementation backs the advice, because IsEmpty peeks without marking anything and Count may have to walk the queue’s internal segments.
The Clear method efficiently removes all items from the queue, leaving it empty and ready for new data. This can be handy when we need to reset the queue’s state or perform periodic maintenance.
| Member | What it does | Cost and empty-queue behaviour |
|---|---|---|
Enqueue(item) | Adds one item to the back of the queue | Lock-free inside the current segment; takes the internal cross-segment lock only when a new segment has to be linked in |
TryDequeue(out item) | Removes and returns the item at the front | Returns false on an empty queue instead of throwing; lock-free inside a segment |
TryPeek(out item) | Returns the front item and leaves it in the queue | Returns false on an empty queue instead of throwing; pins the segment it read, the same snapshot cost as ToArray() |
IsEmpty | Whether the queue currently holds anything | The recommended emptiness check; cheaper than Count and, unlike TryPeek(), it pins nothing |
Count | How many items the queue holds right now | Walks the segments; past two segments it takes the cross-segment lock |
Clear() | Removes every item | Always takes the cross-segment lock |
ToArray() | Copies a point-in-time snapshot into a new array | Freezes the segments it copies, so a later enqueue starts a fresh segment |
CopyTo(array, index) | Copies a snapshot into an array we already own | Same snapshot cost as ToArray() |
foreach / GetEnumerator() | Iterates a point-in-time snapshot | Changes made after the call are invisible to the loop |
Contains() | Does not exist on this type | Use LINQ Any(), and remember it runs over a snapshot |
How Does ConcurrentQueue Stay Thread-Safe Without a Lock?
ConcurrentQueue<T> is not one array. It is a linked list of fixed-size segments, and the queue keeps a reference to the head segment it dequeues from and the tail segment it enqueues into.
Inside a segment there is no lock at all. Every slot carries a sequence number, and a thread claims a slot with an interlocked compare-and-swap on the segment’s head or tail index. When two threads race for the same slot, one wins and the other simply retries at the next index.
A lock appears only at the seams. Linking a new segment on the tail, retiring a drained segment at the head, and Clear() all take one private cross-segment lock, because those operations move the head and tail references themselves.
So the accurate description is lock-free on the common path. Enqueueing and dequeueing inside a segment never blocks, and crossing a segment boundary occasionally does.
Challenges of Multi-Threaded Data Management
Multi-threaded data management introduces several challenges that can lead to data access issues, race conditions, and even data corruption. These challenges arise when multiple threads attempt to read, write, or modify data simultaneously.
Some common issues include:
- Race conditions that occur when two or more threads access shared data concurrently; the final state of the data may depend on the order of execution, leading to unpredictable results
- Data corruption as a result of simultaneous writes to shared data without proper synchronization
- Deadlock scenarios in which threads get stuck waiting for resources held by other threads, causing the application to freeze
Understanding these challenges is crucial for appreciating the value of thread-safe data structures like these specialized queues for mitigating these problems.
Inside the Segmented Queue
A ConcurrentQueue instance is not one growing array. It is a linked list of fixed-size segments, and every enqueue and dequeue happens inside one of them.
Enqueue and dequeue are lock-free inside a segment; only the links between segments are locked.
Inside a segment there is no lock. Each slot carries a sequence number, and a thread takes a slot by running an interlocked compare-and-swap on the segment’s head or tail index: it reads the index, and swaps in the next value only if nobody else has moved it meanwhile. A thread that loses the race retries at the following index rather than waiting.
The one lock in the type sits on the links between segments. A single private field guards the three operations that move the head and tail references themselves: linking a fresh segment on the tail when the current one fills, retiring a drained segment at the head, and Clear(). The Count property joins them once the queue holds more than two segments.
The sizes are concrete: the first segment holds 32 items, and each new segment doubles the last up to a maximum of 1,048,576 items.
How Do We Build a Producer-Consumer Queue in C#?
Lastly, we’ll explore how we can leverage the ConcurrentQueue class to implement the producer-consumer pattern, a common scenario in multi-threaded applications where one or more threads produce data, and one or more threads independently consume and process that data at the same time.
Understanding the Producer-Consumer Pattern
Before diving into the implementation details, let’s flesh out the producer-consumer pattern a bit.
It is a synchronization design pattern where producers generate data, and consumers consume and process that data:
In this diagram, we can see an example implementation of the producer-consumer pattern. In this case, the Checkout service acts as a producer, and the Fulfillment service acts as a consumer. Multiple instances of each of those services are connected by a single message queue which transports requests between them.
This pattern is essential for building parallel and efficient systems, and it often requires robust synchronization mechanisms.
Implementing Queue
Now, what would the implementation of such a pattern look like?
It requires implementing three components: consumer, producer, and message bus.
First, let’s implement a message bus:
public class OrderMessageBus
{
private readonly ConcurrentQueue<Order> _queue = new();
public int Count => _queue.Count;
public void Add(Order? order)
{
ArgumentNullException.ThrowIfNull(order);
_queue.Enqueue(order);
}
public bool Fetch(out Order? order) => _queue.TryDequeue(out order);
}
In our example, the OrderMessageBus class is a simple wrapper over ConcurrentQueue<Order> that exposes Add and Fetch methods.
The Add method allows us to call the Enqueue method of our private _queue field. Additionally, it provides us with a guardrail against adding null objects.
In a similar fashion, the Fetch method allows us to use the TryDequeue method of our internal queue, and hands its bool result straight back to the caller.
Implementing Producer
Next, let’s implement a producer that will feed messages to our queue:
public class Producer(OrderMessageBus messageBus, int numberOfMessages)
{
public Task Produce()
{
return Task.Run(() =>
{
for (int i = 0; i < numberOfMessages; i++)
{
messageBus.Add(new Order { Id = Guid.NewGuid().ToString() });
}
});
}
}
As we can see above, our Producer class takes an OrderMessageBus instance and the number of messages to send as primary constructor parameters, and Produce() hands the whole loop to Task.Run() so several producers can fill the queue at once.
Implementing Consumer
Lastly, we need to create our consumer class:
public class Consumer(OrderMessageBus messageBus)
{
public Task Process(CancellationToken token)
{
return Task.Run(async () =>
{
while (!token.IsCancellationRequested)
{
if (messageBus.Fetch(out var order))
{
Console.WriteLine($"ProcessId {Task.CurrentId} | Processing order {order!.Id}");
Thread.Sleep(200);
}
else
{
await Task.Delay(50, CancellationToken.None);
}
}
}, CancellationToken.None);
}
}
The Consumer class is responsible for consuming and processing orders from a message bus.
The Process method does not start a thread of its own. It hands the loop to Task.Run(), which schedules it on a thread-pool thread, and returns the resulting Task immediately, so several consumers can drain the same queue side by side.
The wait on the empty path is not optional, and it is the interesting part of this class. Fetch() returns false the instant the queue is empty, so a loop with nothing in it would spin a processor core at full speed for as long as the producers are slow. The await Task.Delay() is the cheapest honest answer available with this type, and the cancellation token is what ends the loop once the producers are done. The next section explains what to reach for when a guess about the delay is not good enough.
When Should We Not Use ConcurrentQueue?
ConcurrentQueue<T> has no blocking read and no upper bound. Those two gaps decide almost every case where something else fits better.
A consumer with nothing to do cannot wait. TryDequeue() returns false straight away, so a consumer loop either spins the processor or sleeps on a guess about when work will arrive. When consumers genuinely need to wait for work, BlockingCollection<T> and System.Threading.Channels both give us a real wait instead of a poll.
A producer that outruns its consumers is never told to slow down. The queue simply grows until the process runs out of memory. Where that back-pressure matters, a bounded channel is the answer the platform ships.
And a single thread needs none of this. Queue<T> does the same job without the interlocked slot claims and the segment bookkeeping that make ConcurrentQueue<T> safe to share.
ConcurrentQueue<T> is the right choice when several threads share a queue and nobody has to wait on it.
Each of those alternatives has a page of its own on this site. For the single-threaded case, our article on how a plain Queue<T> behaves on a single thread covers the type this one replaces, and for the back-pressure case we walk through building the same pipeline with .NET Channels end to end.
What Does ConcurrentQueue Cost?
Lastly, when utilizing the ConcurrentQueue class in code that already juggles the difference between asynchronous programming and multithreading, it is essential that we consider a couple of costs the API does not advertise.
The Cost of a Snapshot
Iterating a ConcurrentQueue instance, calling ToArray(), calling CopyTo() or even calling TryPeek() all take a point-in-time snapshot, and a snapshot is not free.
The mechanism is concrete: the queue marks every segment the snapshot touches as preserved for observation. A preserved segment cannot recycle the slots we have already dequeued, so the memory stays committed for as long as the segment is alive. It also changes how the queue grows: instead of doubling, the next segment the queue allocates starts back at the initial 32 slots.
That is the memory cost and the throughput cost in one sentence, and it is why IsEmpty exists as a separate member. It peeks without preserving anything, which is exactly what TryPeek() and Count == 0 cannot promise.
Unbounded Growth and Batching
In producer-consumer scenarios, an imbalance between the two sides leads to indefinite growth of the queue and high memory usage, because nothing in the type pushes back on a fast producer. Where that matters, the "When Should We Not Use ConcurrentQueue?" section above names the types that do.
The other thing the class does not do is batching. There is no EnqueueRange() and no batched dequeue, so a bulk load is a loop, or the constructor that takes an IEnumerable<T>. If our application needs batching on the way out, we write it ourselves, and we should benchmark it rather than assume it helps.
Conclusion
In this article we have unpacked the ConcurrentQueue class in C#, a thread-safe collection for concurrent programming. We’ve learned how the ConcurrentQueue class delivers efficient and non-blocking solutions for handling data in multi-threaded environments, and where its two gaps, no blocking read and no bound, send us somewhere else.
With ConcurrentQueue, we can confidently build robust and high-performance multi-threaded software, harnessing the full potential of modern parallelism while ensuring data integrity.
Tested with .NET 10.





“we can talk about two types of queues – first-in, last-out (FIFO) and last-in, first-out (LIFO).”
Should be “first-in, first-out (FIFO)”, isn’t it?
That’s right Mathew, that was a typo. Thanks for letting us know. We’ve corrected it.