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

BlockingCollection

Using BlockingCollection of T

Overview

BlockingCollection<T> is the .NET type for the classic producer/consumer pattern: one or more threads put items in, one or more threads take items out. What makes it special is the word blocking: taking from an empty collection makes the caller wait until an item arrives, and adding to a full collection makes the caller wait until a slot becomes free.

The other concurrent collections cannot do this. ConcurrentQueue<T> can only tell you "there is nothing right now"; it cannot tell you whether something will arrive later. That is exactly the gap BlockingCollection<T> fills, and it is why it lives in the same System.Collections.Concurrent namespace.

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

  1. Step 1: creating the collection and using its members on a single thread.
  2. Step 2: two producer tasks adding items at the same time.
  3. Step 3: one producer and two consumer tasks running at the same time.
  4. Step 4: a bad example where a ConcurrentQueue<T> consumer finishes too early, and a good example where the BlockingCollection<T> consumer waits and handles every item.

Features and Design

BlockingCollection<T> is a wrapper, not a storage type of its own. It takes any IProducerConsumerCollection<T> and adds waiting and completion on top of it. By default it uses a ConcurrentQueue<T>, which gives FIFO order; passing a ConcurrentStack<T> or a ConcurrentBag<T> to the constructor changes the ordering behaviour.

Its two key features are:

  1. Bounding. A boundedCapacity limits how many items may wait at once. When the buffer is full, Add() blocks the producer, which automatically slows a fast producer down to the speed of the consumers. This is called back pressure and it protects your process from running out of memory.
  2. Completion. CompleteAdding() says "no more items will ever arrive". Consumers use this signal to stop waiting and exit cleanly, which is impossible with a bare concurrent collection.

The type implements IDisposable, because it uses internal semaphores, so it should be disposed (or declared with using) when you are done with it. GetConsumingEnumerable() is the idiomatic way to consume it: the foreach loop waits while the buffer is empty, gives each item to exactly one consumer, and ends by itself once CompleteAdding() has been called and the buffer has been drained.

Creating a BlockingCollection and Its Main Members

The common ways to create one:

  1. new BlockingCollection<T>() creates an unbounded collection; Add() never waits.
  2. new BlockingCollection<T>(int boundedCapacity) limits how many items may wait at the same time.
  3. new BlockingCollection<T>(IProducerConsumerCollection<T> collection) chooses the underlying storage, for example a ConcurrentStack<T> for LIFO order.

The members you will use most often are:

  1. Add(T item) – adds an item, waiting only while the collection is full.
  2. Take() – removes an item, waiting only while the collection is empty.
  3. TryAdd(item) / TryAdd(item, timeout) – the non-waiting and limited-waiting versions of Add().
  4. TryTake(out item) / TryTake(out item, timeout) – the same idea for Take().
  5. CompleteAdding() – signals that no further items will be added.
  6. GetConsumingEnumerable() – a foreach-friendly consumer loop that waits for work and ends after completion.
  7. BoundedCapacity – the configured limit, or -1 when unbounded.
  8. Count, IsAddingCompleted, IsCompleted, ToArray(), Dispose().

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

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

// Way 1: create a BOUNDED collection. boundedCapacity: 1 means the
// collection can hold at most 1 item at a time. A small capacity is used
// here so the "collection is full" behaviour is easy to trigger below.
// 'using' is used because BlockingCollection<T> implements IDisposable.
using BlockingCollection<int> buffer = new BlockingCollection<int>(boundedCapacity: 1);

// BoundedCapacity: the maximum number of items, or -1 when unbounded.
Console.WriteLine($"BoundedCapacity: {buffer.BoundedCapacity}");
Console.WriteLine($"Count at start: {buffer.Count}");

// Add(item): puts one item in. It only waits when the collection is full,
// and there is a free slot right now, so it returns immediately.
buffer.Add(1);
Console.WriteLine($"Add(1) returned immediately. Count is now: {buffer.Count}");

// Take(): removes and returns one item. It only waits when the collection
// is empty, and there is an item right now, so it returns immediately.
int takenItem = buffer.Take();
Console.WriteLine($"Take() returned: {takenItem}");

// TryAdd(item): the non-waiting version of Add. It returns false instead
// of waiting when the collection is full.
bool firstAddSucceeded = buffer.TryAdd(100);
Console.WriteLine($"TryAdd(100) on an empty collection succeeded: {firstAddSucceeded}");

// TryAdd(item, timeout): waits at most the given time for a free slot.
// The collection is full now (capacity is 1), so after 200 ms it gives up
// and returns false. Without the timeout the call would wait forever.
bool secondAddSucceeded = buffer.TryAdd(200, TimeSpan.FromMilliseconds(200));
Console.WriteLine($"TryAdd(200) on a full collection succeeded (expected False): {secondAddSucceeded}");

// TryTake(out item): the non-waiting version of Take. Returns false
// instead of waiting when the collection is empty.
bool takeSucceeded = buffer.TryTake(out int secondTakenItem);
Console.WriteLine($"TryTake() succeeded: {takeSucceeded}, value: {secondTakenItem}");

// CompleteAdding(): says "no more items will ever be added". After this
// call, Add() throws and consumers know they can stop waiting.
buffer.CompleteAdding();
Console.WriteLine($"IsAddingCompleted after CompleteAdding(): {buffer.IsAddingCompleted}");

// Because the collection is empty AND adding is completed, TryTake gives
// up immediately instead of waiting the full 200 ms for an item that can never arrive.
bool takeAfterComplete = buffer.TryTake(out _, TimeSpan.FromMilliseconds(200));
Console.WriteLine($"TryTake() after CompleteAdding() succeeded (expected False): {takeAfterComplete}");

// IsCompleted: true only when adding is completed AND the collection is empty.
Console.WriteLine($"IsCompleted (adding completed and collection empty): {buffer.IsCompleted}");

// Way 2: create an UNBOUNDED collection by passing no capacity. Add()
// then never waits, which is what you usually want for a logging queue.
using BlockingCollection<string> unboundedBuffer = new BlockingCollection<string>();
unboundedBuffer.Add("Pen");
unboundedBuffer.Add("Notebook");
Console.WriteLine($"Unbounded collection BoundedCapacity: {unboundedBuffer.BoundedCapacity} (-1 means unlimited)");
Console.WriteLine($"Unbounded collection Count: {unboundedBuffer.Count}");
Console.WriteLine();
}

How It Works

This first example runs entirely on the main thread, so nothing about concurrency can distract from the API itself. The capacity of 1 is deliberately tiny, so the collection can be filled with a single Add() and the "full" behaviour of TryAdd() becomes visible: it waits 200 ms and then returns false. Without a timeout argument the equivalent Add() call would have waited forever.

The last part shows the completion signal. After CompleteAdding(), IsAddingCompleted becomes true immediately, and TryTake() no longer waits for its 200 ms timeout, because an item can never arrive again. IsCompleted becomes true only when both conditions hold: adding is finished and the buffer is empty.

Sample Output

=== Step 1: Creating a BlockingCollection<T> and using its members ===
BoundedCapacity: 1
Count at start: 0
Add(1) returned immediately. Count is now: 1
Take() returned: 1
TryAdd(100) on an empty collection succeeded: True
TryAdd(200) on a full collection succeeded (expected False): False
TryTake() succeeded: True, value: 100
IsAddingCompleted after CompleteAdding(): True
TryTake() after CompleteAdding() succeeded (expected False): False
IsCompleted (adding completed and collection empty): True
Unbounded collection BoundedCapacity: -1 (-1 means unlimited)
Unbounded collection Count: 2

Step 2: Code Example – Two Tasks Adding Items

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

// Unbounded, so neither producer ever has to wait for a free slot.
BlockingCollection<string> collection = new BlockingCollection<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 firstProducer = Task.Run(() => AddItems(collection, "Task-1"));
Task secondProducer = Task.Run(() => AddItems(collection, "Task-2"));

// Task.WaitAll blocks the main thread until both producers are finished.
Task.WaitAll(firstProducer, secondProducer);

// Only now is it safe to say "no more items will be added". Calling this
// while a producer is still running would make that producer throw.
collection.CompleteAdding();

// 2 tasks * 3 items each = 6 items, and no item is lost because
// BlockingCollection handles the concurrent Add calls internally.
Console.WriteLine($"Total items after both producers finished: {collection.Count}");

// We sort the snapshot only to make the console output deterministic,
// because the two producers interleave differently on every run.
foreach (string item in collection.ToArray().OrderBy(text => text))
{
Console.WriteLine($" - {item}");
}

collection.Dispose();
Console.WriteLine();
}

// Adds a few items to the shared collection.
// collection: the shared buffer both producers write to.
// taskName: a label so we can see in the output which task added which item.
private static void AddItems(BlockingCollection<string> collection, string taskName)
{
// 3 items keep the output short and easy to read for a beginner.
for (int itemNumber = 1; itemNumber <= 3; itemNumber++)
{
collection.Add($"{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. The collection is unbounded, so neither producer ever waits, and all six items survive without any lock.

The important rule this example teaches is when to call CompleteAdding(): only after every producer has finished. Calling it while a producer is still running would make that producer's next Add() throw an InvalidOperationException. That is why Task.WaitAll() comes first and CompleteAdding() second.

Sample Output

=== Step 2: Two tasks adding items at the same time ===
Total items after both producers 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 Taking Items

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

// BoundedCapacity 2: at most 2 jobs may wait in the buffer. This keeps the
// producer from running far ahead of the consumers, which is exactly how a
// real print spooler or download queue protects its memory.
BlockingCollection<string> jobs = new BlockingCollection<string>(boundedCapacity: 2);

// Two consumers start FIRST, while the buffer is still empty. They simply
// wait, which is the behaviour a plain ConcurrentQueue cannot give you.
Task<int> firstWorker = Task.Run(() => ProcessJobs(jobs, "Worker-1"));
Task<int> secondWorker = Task.Run(() => ProcessJobs(jobs, "Worker-2"));

// The producer adds 6 jobs. Add() waits whenever the buffer already holds
// 2 jobs, so the producer automatically slows down to the consumers' speed.
Task producer = Task.Run(() =>
{
for (int jobNumber = 1; jobNumber <= 6; jobNumber++)
{
jobs.Add($"Job-{jobNumber}");
Console.WriteLine($"Producer added Job-{jobNumber}");
}

// Tells both consumers that no more jobs will arrive, so their
// foreach loops end once the buffer has been drained.
jobs.CompleteAdding();
});

Task.WaitAll(producer, 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 taken 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($"IsCompleted at the end: {jobs.IsCompleted}");

jobs.Dispose();
Console.WriteLine();
}

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

// GetConsumingEnumerable() is the idiomatic way to consume this type:
// the foreach loop WAITS while the buffer is empty, hands each item to
// exactly one consumer, and ends by itself after CompleteAdding() has been
// called and every item has been taken.
foreach (string job in jobs.GetConsumingEnumerable())
{
handledJobCount++;
Console.WriteLine($"{workerName} is handling {job}");

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

return handledJobCount;
}

How It Works

This is the full producer/consumer pattern. The two consumers are started before the producer, so they begin against a completely empty buffer – and instead of finishing immediately, they simply wait inside GetConsumingEnumerable().

The boundedCapacity: 2 argument creates back pressure in the other direction. As soon as two jobs are waiting, the producer's Add() call blocks until a consumer frees a slot, so the producer runs at the consumers' pace. You can see this in the output: the producer does not print all six lines at once, but interleaves with the workers.

CompleteAdding() at the end of the producer is what allows both foreach loops to finish. Without it, the consumers would wait forever and Task.WaitAll() would never return.

Sample Output

=== Step 3: Two tasks taking items at the same time ===
Producer added Job-1
Producer added Job-2
Worker-1 is handling Job-1
Worker-2 is handling Job-2
Producer added Job-3
Producer added Job-4
Worker-1 is handling Job-4
Producer added Job-5
Producer added Job-6
Worker-2 is handling Job-3
Worker-2 is handling Job-5
Worker-1 is handling Job-6
Worker-1 handled 3 jobs.
Worker-2 handled 3 jobs.
Total handled jobs: 6
IsCompleted at the end: True

Step 4: Code Example – ConcurrentQueue Consumer (bad) vs BlockingCollection Consumer (good)

The earlier steps used BlockingCollection<T> without showing what goes wrong without it. Note that this comparison is different from the one in the other articles: both collections here are thread-safe, so nothing crashes and no item is corrupted. The difference is waiting.

public static void RunEarlyExitVersusBlockingCollection()
{
Console.WriteLine("=== Step 4: ConcurrentQueue consumer (bad) vs BlockingCollection consumer (good) ===");

const int itemCount = 5;

// --- BAD EXAMPLE: ConcurrentQueue<T> cannot wait ---
// TryDequeue returns false the moment the queue is empty, and the consumer
// has no way to tell "empty for now" apart from "empty forever". Because
// the consumer starts before the producer has added anything, it exits
// right away and most items are never handled.
ConcurrentQueue<int> queue = new ConcurrentQueue<int>();

Task<int> queueConsumer = Task.Run(() =>
{
int handled = 0;
while (queue.TryDequeue(out int item))
{
handled++;
Console.WriteLine($"[Queue consumer] handled item {item}");
}

return handled;
});

Task queueProducer = Task.Run(() =>
{
for (int item = 1; item <= itemCount; item++)
{
// 30 ms: the producer is a little slow, which is completely normal
// in real code (a network call, a disk read, a database query).
Thread.Sleep(30);
queue.Enqueue(item);
}
});

Task.WaitAll(queueProducer, queueConsumer);
Console.WriteLine($"ConcurrentQueue consumer handled {queueConsumer.Result} of {itemCount} items, " +
$"and {queue.Count} item(s) were left behind.");

// --- GOOD EXAMPLE: BlockingCollection<T> waits for work ---
// The consumer blocks while the buffer is empty instead of giving up, and
// CompleteAdding() is the explicit signal that ends the loop. That is why
// it handles all 5 items even though it starts before the producer.
using BlockingCollection<int> blockingBuffer = new BlockingCollection<int>();

Task<int> blockingConsumer = Task.Run(() =>
{
int handled = 0;
foreach (int item in blockingBuffer.GetConsumingEnumerable())
{
handled++;
Console.WriteLine($"[Blocking consumer] handled item {item}");
}

return handled;
});

Task blockingProducer = Task.Run(() =>
{
for (int item = 1; item <= itemCount; item++)
{
Thread.Sleep(30);
blockingBuffer.Add(item);
}

blockingBuffer.CompleteAdding();
});

Task.WaitAll(blockingProducer, blockingConsumer);
Console.WriteLine($"BlockingCollection consumer handled {blockingConsumer.Result} of {itemCount} items, " +
$"and {blockingBuffer.Count} item(s) were left behind.");
Console.WriteLine();
}

How It Works

Both halves do exactly the same thing: a consumer starts first, then a producer adds five items with a small delay between them. The only difference is the collection type.

In the bad half, TryDequeue() returns false on the very first call because the producer has not added anything yet. The consumer has no way to distinguish "empty for now" from "empty forever", so its while loop ends immediately and it handles zero items. All five items are left behind in the queue. The usual "fix" people reach for – wrapping the loop in while (true) with a Thread.Sleep(1) – burns CPU, adds latency and still gives no clean way to stop.

In the good half, GetConsumingEnumerable() blocks while the buffer is empty, so the consumer patiently waits for each item and handles all five. CompleteAdding() then ends the foreach loop cleanly, with no polling and no guessing.

Sample Output

=== Step 4: ConcurrentQueue consumer (bad) vs BlockingCollection consumer (good) ===
ConcurrentQueue consumer handled 0 of 5 items, and 5 item(s) were left behind.
[Blocking consumer] handled item 1
[Blocking consumer] handled item 2
[Blocking consumer] handled item 3
[Blocking consumer] handled item 4
[Blocking consumer] handled item 5
BlockingCollection consumer handled 5 of 5 items, and 0 item(s) were left behind.

The queue consumer may occasionally handle one or two items if the scheduler happens to start it late, but it practically never handles all five. The blocking consumer always handles exactly five.

When to Use

Use BlockingCollection<T> when:

  1. You have a real producer/consumer pipeline and the consumers must wait for work.
  2. You need back pressure so a fast producer cannot flood memory.
  3. You need a clean shutdown signal through CompleteAdding().
  4. You want a dedicated background worker, for example a single log writer serializing writes from many components.

Prefer another type when:

  1. Consumers can simply stop when the collection happens to be empty – use ConcurrentQueue<T>, ConcurrentStack<T> or ConcurrentBag<T> directly.
  2. You are writing asynchronous code and do not want to block threads – use System.Threading.Channels.Channel<T>, which offers the same pattern with await.
  3. You need key-based lookup – use ConcurrentDictionary<TKey, TValue>.


Share this lesson: