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

ConcurrentBag

Using ConcurrentBag of T

Overview

A bag is a collection data structure used when the order of elements does not matter. Unlike a set, a bag allows duplicates, meaning it can hold multiple instances of the same item. It differs from a list in that it makes no guarantee about the arrangement of its elements. Bags are useful in scenarios where the availability of items matters more than their order or uniqueness.

ConcurrentBag<T> is the thread-safe bag implementation provided by .NET, found in the System.Collections.Concurrent namespace. Multiple threads can add, remove, and access items in a ConcurrentBag<T> at the same time without external locking, making it a convenient building block for scenarios such as shared work lists or object pools.

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

  1. Step 1: creating the bag and using its members on a single thread.
  2. Step 2: two tasks adding items to the same bag at the same time.
  3. Step 3: two tasks taking items from the same bag at the same time.
  4. Step 4: a bad example with a plain List<T> and a good example with ConcurrentBag<T>, showing the problem the type actually solves.

Features and Design

In addition to being thread-safe, the methods on ConcurrentBag<T> are non-blocking, which keeps performance high in multithreaded scenarios. Internally, the bag tries to keep track of which thread added which items, so that a thread taking an item back out often gets one of "its own" items first. This reduces contention between threads and improves scalability. The bag also resizes itself dynamically as elements are added.

ConcurrentBag<T> implements several interfaces:

  1. IEnumerable<T>
  2. IReadOnlyCollection<T>
  3. ICollection
  4. IEnumerable
  5. IProducerConsumerCollection<T>

Because it implements IEnumerable<T>, the contents of a ConcurrentBag<T> can be read with a foreach loop or LINQ. A foreach loop (and LINQ methods) work over a snapshot taken at the moment enumeration begins, so later changes made by other threads will not show up in that enumeration. Similarly, members such as Count or CopyTo() may return inconsistent results if other threads are modifying the bag concurrently. If your program needs a perfectly consistent view while enumerating or copying, you must add your own synchronization, for example with a lock statement around the relevant operations.

Creating a ConcurrentBag and Its Main Members

There are two common ways to create a bag:

  1. new ConcurrentBag<T>() creates an empty bag.
  2. new ConcurrentBag<T>(IEnumerable<T> collection) creates a bag that already contains the items of an existing collection.

The members you will use most often are:

  1. Add(T item) – puts one item into the bag. It never blocks and never fails.
  2. TryTake(out T item) – removes one item and returns true, or returns false when the bag is empty.
  3. TryPeek(out T item) – looks at one item without removing it.
  4. Count – how many items the bag currently holds (it walks every item).
  5. IsEmpty – a cheaper way to ask whether the bag holds at least one item.
  6. ToArray() – copies the current items into a new array (a snapshot).
  7. Clear() – removes every item at once.

Note that TryTake() and TryPeek() give you no ordering guarantee: you cannot predict which item will come out of the bag.

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

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

// Way 1: create an empty bag with the parameterless constructor.
ConcurrentBag<string> bag = new ConcurrentBag<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: {bag.IsEmpty}");

// Add(item): puts one item into the bag.
// The parameter is simply the value you want to store.
bag.Add("Apple");
bag.Add("Banana");
bag.Add("Apple"); // a bag allows duplicates, unlike HashSet<T>

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

// TryPeek(out item): looks at one item 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 bag is empty.
if (bag.TryPeek(out string? peekedItem))
{
Console.WriteLine($"TryPeek looked at (not removed): {peekedItem}");
}

// TryTake(out item): removes one item and gives it back through the out parameter.
// A bag has no ordering guarantee, so you cannot predict which item comes out.
if (bag.TryTake(out string? takenItem))
{
Console.WriteLine($"TryTake removed: {takenItem}");
}

Console.WriteLine($"Count after 1 TryTake call: {bag.Count}");

// ToArray(): copies the current items into a new array (a snapshot).
// Useful when you want a stable list you can sort or print safely.
string[] snapshot = bag.ToArray();
Console.WriteLine($"ToArray snapshot: {string.Join(", ", snapshot)}");

// Way 2: create a bag that already contains items, from any IEnumerable<T>.
ConcurrentBag<string> bagFromList = new ConcurrentBag<string>(new List<string> { "Pen", "Notebook" });

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

// Clear(): removes every item at once.
bagFromList.Clear();
Console.WriteLine($"IsEmpty after Clear: {bagFromList.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. The bag starts empty, three items are added (including a duplicate to show that duplicates are allowed), and then TryPeek() and TryTake() show the difference between reading an item and removing it. ToArray() produces a snapshot, and Clear() empties the second bag that was created from a List<string>.

Sample Output

=== Step 1: Creating a ConcurrentBag<T> and using its members ===
IsEmpty right after creation: True
Count after 3 Add calls: 3
TryPeek looked at (not removed): Apple
TryTake removed: Apple
Count after 1 TryTake call: 2
ToArray snapshot: Banana, Apple
Items of the bag created from a List<string>:
- Notebook
- Pen
IsEmpty after Clear: True

Step 2: Code Example – Two Tasks Adding Items

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

ConcurrentBag<string> bag = new ConcurrentBag<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(() => AddItems(bag, "Task-1"));
Task secondTask = Task.Run(() => AddItems(bag, "Task-2"));

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

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

// We sort the snapshot only to make the console output deterministic,
// because the bag itself does not keep any order.
foreach (string item in bag.ToArray().OrderBy(text => text))
{
Console.WriteLine($" - {item}");
}

Console.WriteLine();
}

// Adds a few items to the shared bag.
// bag: 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 AddItems(ConcurrentBag<string> bag, string taskName)
{
// 3 items keep the output short and easy to read for a beginner.
for (int itemNumber = 1; itemNumber <= 3; itemNumber++)
{
bag.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. Both tasks call Add() on the same bag at the same time, and no lock is used anywhere.

Task.WaitAll() blocks the main thread until both tasks have finished, which guarantees that all six items are present before the results are printed. The final count is always 6: the bag never loses an item, even though both tasks wrote to it concurrently. The snapshot is sorted before printing only so that the output is deterministic for the reader – the bag itself keeps no order.

Sample Output

=== Step 2: Two tasks adding 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 Taking Items

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

// The bag starts with 6 jobs. Two tasks will share this work.
ConcurrentBag<string> jobs = new ConcurrentBag<string>();
for (int jobNumber = 1; jobNumber <= 6; jobNumber++)
{
jobs.Add($"Job-{jobNumber}");
}

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

// Each task keeps taking jobs until the bag 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 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($"Jobs left in the bag: {jobs.Count}");
Console.WriteLine();
}

// Takes jobs from the shared bag 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(ConcurrentBag<string> jobs, string workerName)
{
int handledJobCount = 0;

// TryTake returns false as soon as the bag 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.TryTake(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 placed in the bag, and two tasks started with Task.Run() share the work. Each worker loops with while (jobs.TryTake(out string? job)), which is the standard pattern for draining a concurrent collection: the loop ends automatically when TryTake() returns false because the bag is empty.

Checking Count > 0 first and then calling TryTake() would be a bug, because the other worker could take the last job in between the two calls. TryTake() 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.

Here Task.Run() returns Task<int>, so the number of jobs handled by each worker can be read from the Result property after Task.WaitAll() completes. The 50 ms delay makes the shared work visible; without it, the first worker would often finish everything before the second one even started.

Sample Output

=== Step 3: Two tasks taking 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-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
Jobs left in the bag: 0

Which worker picks up which job, and how the six jobs are divided, can change on every run. Only the totals are guaranteed: six jobs handled, zero left in the bag.

Step 4: Code Example – Plain List (bad) vs ConcurrentBag (good)

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

public static void RunUnsafeListVersusConcurrentBag()
{
Console.WriteLine("=== Step 4: Plain List<T> (bad) vs ConcurrentBag<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: List<T> is NOT thread-safe ---
// List<T> keeps an internal array and a count. Add() reads the count,
// writes into the array and increases the count. When two tasks run these
// steps at the same time, they can overwrite each other's slot, so items
// disappear. In the worst case List<T> can even throw an exception while
// it is resizing its internal array.
List<string> unsafeList = new List<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++)
{
unsafeList.Add("item"); // not safe from multiple tasks
}
});
}

Task.WaitAll(unsafeTasks);
unsafeResult = $"{unsafeList.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 List<T>.
unsafeResult = $"crashed with {aggregateException.InnerExceptions[0].GetType().Name}";
}

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

// --- GOOD EXAMPLE: ConcurrentBag<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.
ConcurrentBag<string> safeBag = new ConcurrentBag<string>();
Task[] safeTasks = new Task[taskCount];
for (int taskIndex = 0; taskIndex < taskCount; taskIndex++)
{
safeTasks[taskIndex] = Task.Run(() =>
{
for (int itemNumber = 0; itemNumber < itemsPerTask; itemNumber++)
{
safeBag.Add("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($"ConcurrentBag<string> result: {safeBag.Count} items (expected {expectedItemCount})");
Console.WriteLine();
}

How It Works

List<T>.Add() is not one single, indivisible operation. It roughly does three things: read the current item count, write the new value into the internal array at that position, and increase the count. When two tasks perform these three steps at the same time, both can read the same count and write into the same slot, so one value overwrites the other and the final count is lower than expected. If the collision happens while the list is growing its internal array, List<T> can even throw an IndexOutOfRangeException or an ArgumentException, 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 ConcurrentBag<string>. The bag protects its own internal state, so Add() 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. This is the whole point of the concurrent collections: they move the synchronization work into the collection so that you cannot forget it.

Sample Output

=== Step 4: Plain List<T> (bad) vs ConcurrentBag<T> (good) ===
List<string> result: 16761 items (expected 40000)
ConcurrentBag<string> result: 40000 items (expected 40000)

The number printed for List<string> is different on every run (16761, 18766, or an exception name), which is exactly what makes race conditions so hard to debug. The ConcurrentBag<string> line never changes.

When to Use

Use ConcurrentBag<T> when:

  1. Multiple threads or tasks add to and take from the same collection.
  2. The order of items does not matter.
  3. Duplicates are acceptable.
  4. The same threads tend to both add and take items, for example in an object pool.

Prefer another type when:

  1. You need first-in-first-out order – use ConcurrentQueue<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 List<T> is faster.


Share this lesson: