High-Performance .NET: Async, Multithreading, and Parallel Programming Concurrent Collections Created: 25 Jul 2026 Updated: 26 Jul 2026

ConcurrentQueue

Using ConcurrentQueue of T

Overview

A queue is a collection that follows the FIFO rule: first in, first out. The item that was added first is the item that comes out first, exactly like people waiting in line at a bakery. This makes a queue the right choice whenever the arrival order of the items must be preserved.

ConcurrentQueue<T> is the thread-safe queue implementation provided by .NET, found in the System.Collections.Concurrent namespace. Multiple threads can add and remove items at the same time without any external locking, and the FIFO order is still guaranteed.

This article follows an easy-to-harder path with four examples:

  1. Step 1: creating the queue and using its members on a single thread.
  2. Step 2: two tasks enqueuing items at the same time.
  3. Step 3: two tasks dequeuing items at the same time.
  4. Step 4: a bad example with a plain Queue<T> and a good example with ConcurrentQueue<T>, showing the problem the type actually solves.

Features and Design

ConcurrentQueue<T> is lock-free for the common operations: it uses atomic compare-and-swap instructions on an internal segment list instead of a lock statement, which keeps it fast even when many threads use it at once. The queue grows automatically as items are added.

It implements IEnumerable<T>, IReadOnlyCollection<T>, ICollection and IProducerConsumerCollection<T>. Because it implements IEnumerable<T>, you can read it with foreach or LINQ; enumeration works over a snapshot taken when the loop starts, so later changes made by other threads do not appear in that loop. For the same reason, Count and ToArray() describe a moment in time and can already be out of date by the time you read them.

The IProducerConsumerCollection<T> interface is what allows a ConcurrentQueue<T> to be used as the storage behind a BlockingCollection<T>.

Creating a ConcurrentQueue and Its Main Members

There are two common ways to create a queue:

  1. new ConcurrentQueue<T>() creates an empty queue.
  2. new ConcurrentQueue<T>(IEnumerable<T> collection) creates a queue that already contains the items of an existing collection, enqueued in enumeration order.

The members you will use most often are:

  1. Enqueue(T item) – adds one item to the end of the queue.
  2. TryDequeue(out T item) – removes the item at the front and returns true, or returns false when the queue is empty.
  3. TryPeek(out T item) – looks at the front item without removing it.
  4. Count – how many items the queue currently holds.
  5. IsEmpty – a cheaper way to ask whether the queue holds at least one item.
  6. ToArray() – copies the current items into a new array, front to back.
  7. Clear() – removes every item at once.

Note that ConcurrentQueue<T> has no bulk-add method: items are always enqueued one at a time, or through the constructor that takes an IEnumerable<T>.

Step 1: Code Example – Creating the Queue and Using Its Members

public static void RunBasics()
{
Console.WriteLine("=== Step 1: Creating a ConcurrentQueue<T> and using its members ===");

// Way 1: create an empty queue with the parameterless constructor.
ConcurrentQueue<string> queue = new ConcurrentQueue<string>();

// IsEmpty: a fast way to ask "is there at least one item?".
// Prefer IsEmpty over "Count == 0" because Count has to walk every item.
Console.WriteLine($"IsEmpty right after creation: {queue.IsEmpty}");

// Enqueue(item): adds one item to the END of the queue.
// The parameter is simply the value you want to store.
queue.Enqueue("First");
queue.Enqueue("Second");
queue.Enqueue("Third");

// Count: how many items are in the queue right now.
Console.WriteLine($"Count after 3 Enqueue calls: {queue.Count}");

// TryPeek(out item): looks at the item at the FRONT without removing it.
// Returns true and fills the out parameter when an item was found,
// returns false and leaves the out parameter null when the queue is empty.
if (queue.TryPeek(out string? peekedItem))
{
Console.WriteLine($"TryPeek looked at the front item (not removed): {peekedItem}");
}

// TryDequeue(out item): removes the item at the FRONT and gives it back
// through the out parameter. Because a queue is FIFO, this is always the
// oldest item, so the result is fully predictable: "First" comes out first.
if (queue.TryDequeue(out string? dequeuedItem))
{
Console.WriteLine($"TryDequeue removed the front item: {dequeuedItem}");
}

Console.WriteLine($"Count after 1 TryDequeue call: {queue.Count}");

// ToArray(): copies the current items into a new array (a snapshot),
// in front-to-back order, so you can print or inspect them safely.
string[] snapshot = queue.ToArray();
Console.WriteLine($"ToArray snapshot (front to back): {string.Join(", ", snapshot)}");

// Way 2: create a queue that already contains items, from any IEnumerable<T>.
// The items are enqueued in the order they are enumerated.
ConcurrentQueue<string> queueFromList = new ConcurrentQueue<string>(new List<string> { "Pen", "Notebook" });

// ConcurrentQueue<T> implements IEnumerable<T>, so foreach works.
// foreach walks a snapshot taken when the loop starts.
Console.WriteLine("Items of the queue created from a List<string>:");
foreach (string item in queueFromList)
{
Console.WriteLine($" - {item}");
}

// Clear(): removes every item at once.
queueFromList.Clear();
Console.WriteLine($"IsEmpty after Clear: {queueFromList.IsEmpty}");
Console.WriteLine();
}

How It Works

This first example runs entirely on the main thread, so nothing about concurrency can distract from the API itself. Three items are enqueued, and then TryPeek() and TryDequeue() both return "First" – the difference is that TryPeek() leaves it in the queue. The FIFO rule makes every result here fully predictable, which is the main practical difference from ConcurrentBag<T>.

Sample Output

=== Step 1: Creating a ConcurrentQueue<T> and using its members ===
IsEmpty right after creation: True
Count after 3 Enqueue calls: 3
TryPeek looked at the front item (not removed): First
TryDequeue removed the front item: First
Count after 1 TryDequeue call: 2
ToArray snapshot (front to back): Second, Third
Items of the queue created from a List<string>:
- Pen
- Notebook
IsEmpty after Clear: True

Step 2: Code Example – Two Tasks Enqueuing Items

public static void RunTwoTasksAdding()
{
Console.WriteLine("=== Step 2: Two tasks enqueuing items at the same time ===");

ConcurrentQueue<string> queue = new ConcurrentQueue<string>();

// Task.Run schedules the work on a thread pool thread.
// It is the modern way of starting background work; you do not have to
// create and manage a Thread object yourself.
Task firstTask = Task.Run(() => EnqueueItems(queue, "Task-1"));
Task secondTask = Task.Run(() => EnqueueItems(queue, "Task-2"));

// Task.WaitAll blocks the main thread until both tasks are finished,
// so the results below are printed only after all items were enqueued.
Task.WaitAll(firstTask, secondTask);

// 2 tasks * 3 items each = 6 items, and no item is lost because
// ConcurrentQueue<T> handles the concurrent Enqueue calls internally.
Console.WriteLine($"Total items after both tasks finished: {queue.Count}");

// We sort the snapshot only to make the console output deterministic.
// Each task keeps its own items in order, but how the two tasks
// interleave with each other changes on every run.
foreach (string item in queue.ToArray().OrderBy(text => text))
{
Console.WriteLine($" - {item}");
}

Console.WriteLine();
}

// Enqueues a few items into the shared queue.
// queue: the shared collection both tasks write to.
// taskName: a label so we can see in the output which task added which item.
private static void EnqueueItems(ConcurrentQueue<string> queue, string taskName)
{
// 3 items keep the output short and easy to read for a beginner.
for (int itemNumber = 1; itemNumber <= 3; itemNumber++)
{
queue.Enqueue($"{taskName}-Item{itemNumber}");
}
}

How It Works

Two background operations are started with Task.Run(), which queues the work on a thread pool thread. Compared with creating a Thread object manually, this is shorter, reuses pooled threads, and gives you a Task you can wait on. Both tasks call Enqueue() on the same queue at the same time, and no lock is used anywhere.

An important detail: FIFO is guaranteed per operation, not per task. Each task's own three items stay in their relative order, but how the two tasks interleave with each other is decided by the scheduler and changes on every run. That is why the snapshot is sorted before printing.

Sample Output

=== Step 2: Two tasks enqueuing items at the same time ===
Total items after both tasks finished: 6
- Task-1-Item1
- Task-1-Item2
- Task-1-Item3
- Task-2-Item1
- Task-2-Item2
- Task-2-Item3

Step 3: Code Example – Two Tasks Dequeuing Items

public static void RunTwoTasksTaking()
{
Console.WriteLine("=== Step 3: Two tasks dequeuing items at the same time ===");

// The queue starts with 6 jobs, in order. Two tasks will share this work.
ConcurrentQueue<string> jobs = new ConcurrentQueue<string>();
for (int jobNumber = 1; jobNumber <= 6; jobNumber++)
{
jobs.Enqueue($"Job-{jobNumber}");
}

Console.WriteLine($"Jobs waiting at start: {jobs.Count}");

// Each task keeps dequeuing jobs until the queue is empty.
// Task.Run returns a Task<int> here, so we can read how many jobs each task handled.
Task<int> firstWorker = Task.Run(() => ProcessJobs(jobs, "Worker-1"));
Task<int> secondWorker = Task.Run(() => ProcessJobs(jobs, "Worker-2"));

Task.WaitAll(firstWorker, secondWorker);

// The split between the two workers can change on every run (that is normal),
// but the total is always 6 because each job is dequeued only once.
Console.WriteLine($"Worker-1 handled {firstWorker.Result} jobs.");
Console.WriteLine($"Worker-2 handled {secondWorker.Result} jobs.");
Console.WriteLine($"Total handled jobs: {firstWorker.Result + secondWorker.Result}");
Console.WriteLine($"Jobs left in the queue: {jobs.Count}");
Console.WriteLine();
}

// Dequeues jobs from the shared queue until it is empty and returns how many were handled.
// jobs: the shared collection both workers read from.
// workerName: a label used in the console output.
private static int ProcessJobs(ConcurrentQueue<string> jobs, string workerName)
{
int handledJobCount = 0;

// TryDequeue returns false as soon as the queue is empty, which ends the loop.
// This is safer than checking Count first, because the other task could
// take the last item between the check and the removal.
while (jobs.TryDequeue(out string? job))
{
handledJobCount++;
Console.WriteLine($"{workerName} is handling {job}");

// A short 50 ms delay simulates real work and gives the other task
// enough time to take jobs too, so the work is really shared.
Thread.Sleep(50);
}

return handledJobCount;
}

How It Works

Six jobs are enqueued, and two tasks started with Task.Run() share the work. Each worker loops with while (jobs.TryDequeue(out string? job)), which is the standard pattern for draining a concurrent collection: the loop ends automatically when TryDequeue() returns false because the queue is empty.

Checking Count > 0 first and then calling TryDequeue() would be a bug, because the other worker could take the last job in between the two calls. TryDequeue() performs the check and the removal as one atomic operation, so a job is handed to exactly one worker and can never be processed twice.

Notice that the jobs are handled in ascending order (Job-1, Job-2, ...), because the queue is FIFO. Which worker gets each job still changes on every run.

Sample Output

=== Step 3: Two tasks dequeuing items at the same time ===
Jobs waiting at start: 6
Worker-2 is handling Job-1
Worker-1 is handling Job-2
Worker-1 is handling Job-3
Worker-2 is handling Job-4
Worker-1 is handling Job-5
Worker-2 is handling Job-6
Worker-1 handled 3 jobs.
Worker-2 handled 3 jobs.
Total handled jobs: 6
Jobs left in the queue: 0

Step 4: Code Example – Plain Queue (bad) vs ConcurrentQueue (good)

The first three steps used ConcurrentQueue<T> without showing what goes wrong without it. This last example runs exactly the same work twice: once with a plain Queue<string> and once with a ConcurrentQueue<string>.

public static void RunUnsafeQueueVersusConcurrentQueue()
{
Console.WriteLine("=== Step 4: Plain Queue<T> (bad) vs ConcurrentQueue<T> (good) ===");

// 4 tasks * 10000 items = 40000 expected items.
// The numbers are large enough that the tasks really collide inside the
// collection; with only a few items the problem may not show up at all.
const int taskCount = 4;
const int itemsPerTask = 10_000;
const int expectedItemCount = taskCount * itemsPerTask;

// --- BAD EXAMPLE: Queue<T> is NOT thread-safe ---
// Queue<T> keeps an internal array plus head, tail and size fields.
// Enqueue() writes the value and then updates those fields. When two
// tasks run these steps at the same time, they can write into the same
// slot and produce a wrong size, so items disappear. During a resize the
// collection can even throw an exception.
Queue<string> unsafeQueue = new Queue<string>();
string unsafeResult;

try
{
Task[] unsafeTasks = new Task[taskCount];
for (int taskIndex = 0; taskIndex < taskCount; taskIndex++)
{
unsafeTasks[taskIndex] = Task.Run(() =>
{
for (int itemNumber = 0; itemNumber < itemsPerTask; itemNumber++)
{
unsafeQueue.Enqueue("item"); // not safe from multiple tasks
}
});
}

Task.WaitAll(unsafeTasks);
unsafeResult = $"{unsafeQueue.Count} items (expected {expectedItemCount})";
}
catch (AggregateException aggregateException)
{
// Losing items is only one possible result; a crash is the other one.
// Task.WaitAll wraps errors coming from the tasks, so we unwrap the
// first one to show what really went wrong inside Queue<T>.
unsafeResult = $"crashed with {aggregateException.InnerExceptions[0].GetType().Name}";
}

Console.WriteLine($"Queue<string> result: {unsafeResult}");

// --- GOOD EXAMPLE: ConcurrentQueue<T> IS thread-safe ---
// The exact same work, but the collection itself protects its internal
// state, so no lock is needed in our code and no item is ever lost.
ConcurrentQueue<string> safeQueue = new ConcurrentQueue<string>();
Task[] safeTasks = new Task[taskCount];
for (int taskIndex = 0; taskIndex < taskCount; taskIndex++)
{
safeTasks[taskIndex] = Task.Run(() =>
{
for (int itemNumber = 0; itemNumber < itemsPerTask; itemNumber++)
{
safeQueue.Enqueue("item"); // safe from any number of tasks
}
});
}

Task.WaitAll(safeTasks);

// This line always prints 40000, no matter how many times you run the program.
Console.WriteLine($"ConcurrentQueue<string> result: {safeQueue.Count} items (expected {expectedItemCount})");
Console.WriteLine();
}

How It Works

Queue<T>.Enqueue() is not one single, indivisible operation. It writes the value into the internal array at the tail position and then updates the tail index and the size field. When several tasks perform these steps at the same time, two of them can pick the same tail position, so one value overwrites the other and the final count is too low. If the collision happens while the queue is growing its internal array, it can throw an IndexOutOfRangeException or an ArgumentException instead, which is why the bad example is wrapped in a try/catch block. Because Task.WaitAll() wraps every task error in an AggregateException, the catch block unwraps the first inner exception to show the real problem.

This kind of bug is called a race condition. It is dangerous exactly because it is not reproducible: with few items or a single task the code looks perfectly fine, and the failure only appears under real load. The 4 tasks and 10000 items per task were chosen so that the collision is very likely to happen on every run.

The second half runs the same loops against a ConcurrentQueue<string>. The queue protects its own internal state, so Enqueue() is atomic from the caller's point of view. No lock statement appears anywhere in our code, and the result is always exactly 40000 items.

Sample Output

=== Step 4: Plain Queue<T> (bad) vs ConcurrentQueue<T> (good) ===
Queue<string> result: crashed with IndexOutOfRangeException
ConcurrentQueue<string> result: 40000 items (expected 40000)

The Queue<string> line changes from run to run: sometimes an exception name, sometimes a number that is lower than 40000. The ConcurrentQueue<string> line never changes.

When to Use

Use ConcurrentQueue<T> when:

  1. Multiple threads or tasks share one collection.
  2. The items must be processed in the order they arrived (logs, audit trails, work items).
  3. You want a simple, lock-free producer/consumer buffer.

Prefer another type when:

  1. Order does not matter and items are often added and taken by the same thread – use ConcurrentBag<T>.
  2. You need last-in-first-out order – use ConcurrentStack<T>.
  3. You need key-based lookup – use ConcurrentDictionary<TKey, TValue>.
  4. Consumers must wait until an item becomes available – use BlockingCollection<T>.
  5. Only a single thread touches the collection – a plain Queue<T> is faster.


Share this lesson: