Using ConcurrentStack of T
Overview
A stack is a collection that follows the LIFO rule: last in, first out. The item that was added most recently is the item that comes out first, exactly like a pile of plates in a cafeteria. This makes a stack the right choice for undo histories, back-navigation and any other "most recent first" behaviour.
ConcurrentStack<T> is the thread-safe stack implementation provided by .NET, found in the System.Collections.Concurrent namespace. Multiple threads can push and pop items at the same time without any external locking, and the LIFO order is still guaranteed.
This article follows an easy-to-harder path with four examples:
- Step 1: creating the stack and using its members on a single thread.
- Step 2: two tasks pushing items at the same time.
- Step 3: two tasks popping items at the same time.
- Step 4: a bad example with a plain
Stack<T> and a good example with ConcurrentStack<T>, showing the problem the type actually solves.
Features and Design
ConcurrentStack<T> is lock-free: internally it is a singly linked list whose head is swapped with an atomic compare-and-swap instruction, so no lock statement is needed. Unlike the queue and the bag, it also offers real bulk operations, PushRange() and TryPopRange(), which move several items in one atomic step and therefore cost much less synchronization work than calling Push() or TryPop() in a loop.
It implements IEnumerable<T>, IReadOnlyCollection<T>, ICollection and IProducerConsumerCollection<T>. Enumeration with foreach or LINQ works over a snapshot taken when the loop starts, ordered from top to bottom, 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 only.
Creating a ConcurrentStack and Its Main Members
There are two common ways to create a stack:
new ConcurrentStack<T>() creates an empty stack.new ConcurrentStack<T>(IEnumerable<T> collection) pushes the items of an existing collection in enumeration order, so the last item ends up on top.
The members you will use most often are:
Push(T item) – puts one item on top of the stack.PushRange(T[] items) – puts several items on the stack in one operation.TryPop(out T item) – removes the top item and returns true, or returns false when the stack is empty.TryPopRange(T[] buffer) – removes several items at once and returns how many were really removed.TryPeek(out T item) – looks at the top item without removing it.Count – how many items the stack currently holds.IsEmpty – a cheaper way to ask whether the stack holds at least one item.ToArray() – copies the current items into a new array, top to bottom.Clear() – removes every item at once.
Step 1: Code Example – Creating the Stack and Using Its Members
public static void RunBasics()
{
Console.WriteLine("=== Step 1: Creating a ConcurrentStack<T> and using its members ===");
// Way 1: create an empty stack with the parameterless constructor.
ConcurrentStack<string> stack = new ConcurrentStack<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: {stack.IsEmpty}");
// Push(item): puts one item on TOP of the stack.
// The parameter is simply the value you want to store.
stack.Push("First");
stack.Push("Second");
// PushRange(array): puts several items on the stack in ONE operation.
// This costs less synchronization work than calling Push once per item,
// so it is the better choice when you already have a batch of items.
stack.PushRange(new[] { "Third", "Fourth" });
// Count: how many items are in the stack right now.
Console.WriteLine($"Count after 2 Push calls and 1 PushRange call: {stack.Count}");
// TryPeek(out item): looks at the item on TOP 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 stack is empty.
if (stack.TryPeek(out string? peekedItem))
{
Console.WriteLine($"TryPeek looked at the top item (not removed): {peekedItem}");
}
// TryPop(out item): removes the item on TOP and gives it back through
// the out parameter. Because a stack is LIFO, this is always the newest
// item, so the result is fully predictable: "Fourth" comes out first.
if (stack.TryPop(out string? poppedItem))
{
Console.WriteLine($"TryPop removed the top item: {poppedItem}");
}
Console.WriteLine($"Count after 1 TryPop call: {stack.Count}");
// ToArray(): copies the current items into a new array (a snapshot),
// ordered from top to bottom, which is a handy way to see the LIFO order.
string[] snapshot = stack.ToArray();
Console.WriteLine($"ToArray snapshot (top to bottom): {string.Join(", ", snapshot)}");
// TryPopRange(buffer): removes several items in ONE operation and copies
// them into the buffer. The return value is how many items were really
// removed, which can be smaller than the buffer length.
string[] buffer = new string[10];
int poppedCount = stack.TryPopRange(buffer);
Console.WriteLine($"TryPopRange removed {poppedCount} item(s): {string.Join(", ", buffer.Take(poppedCount))}");
// Way 2: create a stack that already contains items, from any IEnumerable<T>.
// The items are pushed in the order they are enumerated, so the LAST one
// ("Notebook") ends up on top.
ConcurrentStack<string> stackFromList = new ConcurrentStack<string>(new List<string> { "Pen", "Notebook" });
// ConcurrentStack<T> implements IEnumerable<T>, so foreach works.
// foreach walks a snapshot taken when the loop starts, from top to bottom.
Console.WriteLine("Items of the stack created from a List<string> (top to bottom):");
foreach (string item in stackFromList)
{
Console.WriteLine($" - {item}");
}
// Clear(): removes every item at once.
stackFromList.Clear();
Console.WriteLine($"IsEmpty after Clear: {stackFromList.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. Four items go in, and both TryPeek() and TryPop() return "Fourth", the newest one – the difference is that TryPeek() leaves it on the stack. ToArray() prints the items from top to bottom, which shows the LIFO order directly, and TryPopRange() empties the rest in a single operation.
Sample Output
=== Step 1: Creating a ConcurrentStack<T> and using its members ===
IsEmpty right after creation: True
Count after 2 Push calls and 1 PushRange call: 4
TryPeek looked at the top item (not removed): Fourth
TryPop removed the top item: Fourth
Count after 1 TryPop call: 3
ToArray snapshot (top to bottom): Third, Second, First
TryPopRange removed 3 item(s): Third, Second, First
Items of the stack created from a List<string> (top to bottom):
- Notebook
- Pen
IsEmpty after Clear: True
Step 2: Code Example – Two Tasks Pushing Items
public static void RunTwoTasksAdding()
{
Console.WriteLine("=== Step 2: Two tasks pushing items at the same time ===");
ConcurrentStack<string> stack = new ConcurrentStack<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(() => PushItems(stack, "Task-1"));
Task secondTask = Task.Run(() => PushItems(stack, "Task-2"));
// Task.WaitAll blocks the main thread until both tasks are finished,
// so the results below are printed only after all items were pushed.
Task.WaitAll(firstTask, secondTask);
// 2 tasks * 3 items each = 6 items, and no item is lost because
// ConcurrentStack<T> handles the concurrent Push calls internally.
Console.WriteLine($"Total items after both tasks finished: {stack.Count}");
// We sort the snapshot only to make the console output deterministic,
// because which task reached the top of the stack last changes per run.
foreach (string item in stack.ToArray().OrderBy(text => text))
{
Console.WriteLine($" - {item}");
}
Console.WriteLine();
}
// Pushes a few items onto the shared stack.
// stack: 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 PushItems(ConcurrentStack<string> stack, string taskName)
{
// 3 items keep the output short and easy to read for a beginner.
for (int itemNumber = 1; itemNumber <= 3; itemNumber++)
{
stack.Push($"{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 Push() on the same stack at the same time, and no lock is used anywhere.
Every Push() replaces the head of the internal linked list with an atomic swap, so two tasks can never overwrite each other. All six items are present, but which task ended up on top is decided by the scheduler and changes on every run, which is why the snapshot is sorted before printing.
Sample Output
=== Step 2: Two tasks pushing 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 Popping Items
public static void RunTwoTasksTaking()
{
Console.WriteLine("=== Step 3: Two tasks popping items at the same time ===");
// The stack starts with 6 jobs. Job-6 is on top, so it is handled first.
ConcurrentStack<string> jobs = new ConcurrentStack<string>();
for (int jobNumber = 1; jobNumber <= 6; jobNumber++)
{
jobs.Push($"Job-{jobNumber}");
}
Console.WriteLine($"Jobs waiting at start: {jobs.Count}");
// Each task keeps popping jobs until the stack 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 popped 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 stack: {jobs.Count}");
Console.WriteLine();
}
// Pops jobs from the shared stack 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(ConcurrentStack<string> jobs, string workerName)
{
int handledJobCount = 0;
// TryPop returns false as soon as the stack 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.TryPop(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 pushed, and two tasks started with Task.Run() share the work. Each worker loops with while (jobs.TryPop(out string? job)), which is the standard pattern for draining a concurrent collection: the loop ends automatically when TryPop() returns false because the stack is empty.
Checking Count > 0 first and then calling TryPop() would be a bug, because the other worker could take the last job in between the two calls. TryPop() 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 come out in descending order (Job-6, Job-5, ...), because the stack is LIFO. This is the opposite of the queue example and is the main reason to choose one type over the other.
Sample Output
=== Step 3: Two tasks popping items at the same time ===
Jobs waiting at start: 6
Worker-1 is handling Job-6
Worker-2 is handling Job-5
Worker-1 is handling Job-4
Worker-2 is handling Job-3
Worker-1 is handling Job-2
Worker-2 is handling Job-1
Worker-1 handled 3 jobs.
Worker-2 handled 3 jobs.
Total handled jobs: 6
Jobs left in the stack: 0
Step 4: Code Example – Plain Stack (bad) vs ConcurrentStack (good)
The first three steps used ConcurrentStack<T> without showing what goes wrong without it. This last example runs exactly the same work twice: once with a plain Stack<string> and once with a ConcurrentStack<string>.
public static void RunUnsafeStackVersusConcurrentStack()
{
Console.WriteLine("=== Step 4: Plain Stack<T> (bad) vs ConcurrentStack<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: Stack<T> is NOT thread-safe ---
// Stack<T> keeps an internal array and a size field. Push() writes the
// value at the current size and then increases the size. When two tasks
// run these steps at the same time, both can write into the same slot, so
// items disappear. During a resize the collection can even throw.
Stack<string> unsafeStack = new Stack<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++)
{
unsafeStack.Push("item"); // not safe from multiple tasks
}
});
}
Task.WaitAll(unsafeTasks);
unsafeResult = $"{unsafeStack.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 Stack<T>.
unsafeResult = $"crashed with {aggregateException.InnerExceptions[0].GetType().Name}";
}
Console.WriteLine($"Stack<string> result: {unsafeResult}");
// --- GOOD EXAMPLE: ConcurrentStack<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.
ConcurrentStack<string> safeStack = new ConcurrentStack<string>();
Task[] safeTasks = new Task[taskCount];
for (int taskIndex = 0; taskIndex < taskCount; taskIndex++)
{
safeTasks[taskIndex] = Task.Run(() =>
{
for (int itemNumber = 0; itemNumber < itemsPerTask; itemNumber++)
{
safeStack.Push("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($"ConcurrentStack<string> result: {safeStack.Count} items (expected {expectedItemCount})");
Console.WriteLine();
}
How It Works
Stack<T>.Push() is not one single, indivisible operation. It writes the value into the internal array at the current size and then increases that size. When several tasks perform these steps at the same time, two of them can read the same size and write into the same slot, so one value overwrites the other and the final count is too low. If the collision happens while the stack is growing its internal array, it can throw an IndexOutOfRangeException 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 ConcurrentStack<string>. Each push swaps the head of the internal linked list atomically, so no lock statement is needed in our code and the result is always exactly 40000 items.
Sample Output
=== Step 4: Plain Stack<T> (bad) vs ConcurrentStack<T> (good) ===
Stack<string> result: crashed with IndexOutOfRangeException
ConcurrentStack<string> result: 40000 items (expected 40000)
The Stack<string> line changes from run to run: sometimes an exception name, sometimes a number that is lower than 40000. The ConcurrentStack<string> line never changes.
When to Use
Use ConcurrentStack<T> when:
- Multiple threads or tasks share one collection.
- The newest item should be processed first (undo history, back navigation, retry of the most recent work).
- You add or remove items in batches and can benefit from
PushRange() and TryPopRange().
Prefer another type when:
- Items must be processed in arrival order – use
ConcurrentQueue<T>. - Order does not matter at all – use
ConcurrentBag<T>. - You need key-based lookup – use
ConcurrentDictionary<TKey, TValue>. - Consumers must wait until an item becomes available – use
BlockingCollection<T>. - Only a single thread touches the collection – a plain
Stack<T> is faster.