In this article, we will take a closer look at the .NET collection ConcurrentStack. As software development evolves, multi-threading has become essential for efficient, scalable applications. However, managing concurrent operations, especially due to thread-safety issues, is challenging. .NET offers developers a rich set of data structures and classes to help.

One such data structure is the ConcurrentStack, designed specifically as a thread-safe LIFO (last-in, first-out) collection for concurrent tasks. This article delves deep into its benefits, fundamental operations, and suitable scenarios for use.

To download the source code for this article, you can visit our GitHub repository.

Let’s dive in.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

What is ConcurrentStack?

ConcurrentStack is a dynamic, generic collection class located in the System.Collections.Concurrent namespace. It gives developers a thread-safe, LIFO data structure out of the box. This eliminates the necessity for explicit synchronization code. Thus it provides an edge over the traditional Stack in multi-threaded applications.

The official documentation for ConcurrentStack can be found in the .NET API browser. The System.Collections.Concurrent namespace also houses other concurrent collections. An overview of these collections can be found in our Concurrent Collections in C# article.

To understand how ConcurrentStack can be declared and used, let’s create an empty stack of integers:

var stack = new ConcurrentStack<int>();Ā 

We can also define a ConcurrentStack using its overloaded constructor which initializes it with the elements from an IEnumerable<T>:

var numbers= new List<int> {1, 2, 3, 4};
var stack = new ConcurrentStack<int>(numbers);

Here, we define the list numbers and then pass it as an argument into the constructor of ConcurrentStack<int>. This initializes the stack with the list elements.

Key Features of ConcurrentStack

ConcurrentStack, as its name implies, is a concurrent version of a stack. It ensures thread safetyĀ while allowing multiple threads to perform operations like push and pop concurrently. There is no data corruption or exceptions due to modifications in the collection.

Moreover, it boosts performance by minimizing lock contention. This ensures high throughput and low latency, which is vital in highly concurrent applications. Its methods are atomic and maintain the stackā€™s state, ensuring consistency and integrity, even with simultaneous access by multiple threads.

Basic Operations of ConcurrentStack

Push, pop, and peek are the most basic operations for a stack data structure. ConcurrentStack also provides access to similar operations which are optimized for multi-threaded access. To demonstrate these methods let’s start by defining an empty ConcurrentStack<int> object:

var intStack = new ConcurrentStack<int>();

Push

One of the two ways to add items to a ConcurrentStack is to use the Push()Ā method. This method inserts an item to the top of the stack. Let’s demonstrate this by pushing a few integers to the intStack:

for (int i = 0; i < 4; i++)
{
    intStack.Push(i);
}

Here, calling the Push() method inserts the integers from 0 to 3 into the stack in a LIFO manner. When we view or retrieve an item from the stack, it outputs 3 because it was the last item pushed.

PushRange

We can also use the PushRange() method to push all four integers to stack atomically:

intStack.PushRange(new [] {1, 2, 3, 4});

PushRange() also has an overload that accepts both a startIndex and a count as arguments. startIndex represents a zero-based offset into the input array at which to begin inserting elements to the top of the stack and count that specifies the number of items to push. Let’s use this overloaded version instead of the original PushRange() in our example:

intStack.PushRange(new [] {1, 2, 3, 4}, 0, 4);

Here, the first argument to the PushRange() method is the array from which we are pushing items to the stack. The second argument specifies the start index in the input array from which to begin. The last argument dictates how many items to push; pushing all array items into the stack with a count of 4.

Iterating a ConcurrentStack

Like other collections in C#, we can iterate a ConcurrentStack using a foreach loop. To see how it works, let’s verify the PushRange() operation that we did in the last section by iterating on the stack to see its contents:Ā 

foreach (var item in intStack)
{
    Console.Write(item + " ");
}

The output that we get starts with 4 because that is the topmost item of the stack:

4 3 2 1

Note that using PushRange() is more efficient than calling Push() multiple times when adding more than one item simultaneously, especially when multiple threads access the stack.

TryPeek

Peek is the next important operation we can perform on any stack data structure. It allows us to read the item currently at the top of the stack without popping it. This role is performed by the TryPeek()Ā method in a ConcurrentStack.

To demonstrate its usage let’s use this method on intStack:

bool peekSucceded = intStack.TryPeek(out int topItem);
if (peekSucceded)
{
    Console.WriteLine("Peek Succeeded");
    Console.WriteLine($"Value of topItem: {topItem}");
}
else
{
    Console.WriteLine("Peek Failed");
}

Here, the result of the TryPeek() operation is stored in the peekSucceded boolean variable that indicates whether the operation has succeeded or failed. In case the operation succeeds the value of peekSucceded would be true and the topItem would contain the integer that was last inserted into the stack. If the operation fails then peekSucceded would be false and topItem would be 0 which is its default value.

For the example above the TryPeek() operation succeeds:

Peek Succeeded
Value of topItem: 4

TryPop

The other important method of any stack data structure is pop. We can use this operation to remove items from the stack in a LIFO manner. Let’s understand this by trying to remove the integers that we just inserted into intStack:

bool isPopSuccessful = intStack.TryPop(out var item);
if (isPopSuccessful)
{
    Console.WriteLine($"Popped Item : {item}");
}
else
{
    Console.WriteLine("Failed to pop");
}

Here, we can see that calling the TryPop() method returns a boolean value that indicates the success or failure of the pop operation. It also stores the popped item in the item variable. When the pop operation succeeds, we print the popped item and exit. If it fails, we print a failure message to the console. In our case, a single thread accesses the stack, ensuring the pop operation succeeds and displays the popped item:

Popped Item : 4

TryPopRange

This method offers a way to pop multiple items from the stack simultaneously, unlike TryPop() which pops one at a time.

The TryPopRange() method has an overloaded version:

publicĀ intĀ TryPopRangeĀ (T[]Ā items);
public int TryPopRange (T[] items, int startIndex, int count);

Both return an integer indicating the number of items popped. They require an array to store the popped items. The overloaded version additionally takes a starting index and a count of items to pop.

let’s use it in a quick demo:

var numbers = new List<int> { 1, 2, 3, 4, 5 };
var numbersStack = new ConcurrentStack<int>(numbers);
var outputArr = new int[3];

intĀ poppedItemsCountĀ =Ā numbersStack.TryPopRange(outputArr);
if (poppedItemsCount > 0)
{
    Console.WriteLine($"Number of items popped: {poppedItemsCount}");
    Console.WriteLine($"Popped Items : {string.Join(" ", outputArr)}");
    return;
}

Console.WriteLine("Failed to pop");

Here, after defining and populating the stack, we also define an array of integers called outputArr of size 3. Then we call the TryPopRange() method. It takes theĀ outputArr as its argument. The output is then stored in the poppedItemsCount variable. If the variable’s value is > 0, we consider the pop operation successful. Otherwise, we deem it a failure.

Output:

Number of items popped: 3
Popped Items : 5 4 3 

We can see the method call has popped only three items. This is because the outputArr we passed as an argument to the TryPopRange() method has a length of 3.

Note that using the TryPopRange() method can be more efficient than calling TryPop() multiple times to pop more than one item at the same time from the stack in scenarios where multiple threads are accessing the stack.

Some Other Methods of ConcurrentStack

ConcurrentStack also provides a few other methods that are worth mentioning:

  • Clear(): Removes all objects from the CouncurrentStack instance
  • CopyTo(): Copies the ConcurrentStack elements to an existing one-dimensional Array, starting at the specified array index
  • GetEnumerator(): Returns an enumerator that iterates through the ConcurrentStack
  • ToArray(): Copies the items stored in the ConcurrentStack to a new array

When to Use ConcurrentStack

We can use ConcurrentStack effectively in scenarios that demand concurrent, non-blocking stack manipulations. This includes producer-consumer problems, recursion simulations, and backtracking algorithms. It also shines in apps that need thread-safe history management such as collaborative tools where users concurrently perform and undo actions.

Let’s explore a simple example of using ConcurrentStack in a drawing tool. Let’s imagine that multiple users can simultaneously perform and undo one or more actions. For the sake of simplicity, we will assume that anybody can undo any actions taken regardless of who performed them:

public class DrawingTool
{
    private readonly ConcurrentStack<string> _actionHistory = new();

    public string PerformAction(string action)
    {
        _actionHistory.Push(action);

        return $"Performed action: {action}";
    }

    public string UndoLastAction()
    {
        if (!_actionHistory.TryPop(out var lastAction))
            return "No action to undo";

        return $"Undid action: {lastAction}";
    }
}

Here, we represent user actions as strings. We also have a private field of type ConcurrentStack<string> that holds recent user actions. These functions act on this concurrent stack to execute or reverse one action at a time using the Push() and TryPop() methods.

Next, let’s add methods to this class to allow the users to perform and undo multiple actions:

public string PerfromMultipleActions(params string[] actions)
{
    _actionHistory.PushRange(actions);
    return $"Performed actions: {string.Join(", ", actions.ToArray())}";
}

public IEnumerable<string> UndoLastNActions(int numberOfActionsToUndo)
{
    var lastActions = new string[numberOfActionsToUndo];
    var actionsUndoneCount = _actionHistory.TryPopRange(lastActions);
    if (actionsUndoneCount == 0)
    {
        yield return "Failed to undo actions";
    }

    for (int i = 0; i < actionsUndoneCount; i++)
    {
        yield return $"Undid action: {lastActions[i]}";
    }
}

Here, we use the PushRange() and TryPopRange() functions to achieve the effect of performing and undoing multiple actions simultaneously.

Now, to demonstrate the usage of the tool, let’s create an instance of this class and simulate multiple users interacting with it:

public class Program
{
    public static async Task Main()
    {
        await ConsoleWriteLineAsync("Simulating usage of Drawing tool...");
        await UseDrawingTool();
    }

    public static async Task UseDrawingTool()
    {
        DrawingTool tool = new();

        var task1 = Task.Run(async () =>
        {
            var performedAction = tool.PerformAction("Draw Circle");
            await ConsoleWriteLineAsync(performedAction);
            
            var actionUndone = tool.UndoLastAction();
            await ConsoleWriteLineAsync(actionUndone);
        });

        var task2 = Task.Run(async () =>
        {
            var performedActions = tool.PerfromMultipleActions(
                "Draw Square", 
                "Draw Triangle", 
                "Draw Parallel Lines", 
                "Draw Hexagon");
            await ConsoleWriteLineAsync(performedActions);
            
            foreach (var action in tool.UndoLastNActions(4))
            {
                await ConsoleWriteLineAsync(action);
            }
        });

        var task3 = Task.Run(async () =>
        {
            var performedAction = tool.PerformAction("Color Circle Red");
            await ConsoleWriteLineAsync(performedAction);
            
            var actionUndone = tool.UndoLastAction();
            await ConsoleWriteLineAsync(actionUndone);
        });

        await Task.WhenAll(task1, task2, task3);
    }

    private static async Task ConsoleWriteLineAsync(string message)
    {
        await Task.Run(() => Console.WriteLine(message));
    }
}

Here, we use multiple Task.Run() methods to simulate the concurrent operations on the tool instance of DrawingTool class. The output of each operation is written to the console using ConsoleWriteLineAsync(). To ensure that writing to the console is non-blocking the ConsoleWriteLineAsync() method spins up a new Task that in turn calls Console.WriteLine(message).Ā 

The output to the console is:

Simulating usage of Drawing tool...
Performed action: Color Circle Red
Performed action: Draw Circle
Undid action: Draw Circle
Undid action: Color Circle Red
Performed actions: Draw Square, Draw Triangle, Draw Parallel Lines, Draw Hexagon
Undid action: Draw Hexagon
Undid action: Draw Parallel Lines
Undid action: Draw Triangle
Undid action: Draw Square

Note that the order in which we see the messages written in the output might vary between runs due to multiple threads operating on the ConcurrentStack.

Conclusion

In this article, we delved into .NET’s ConcurrentStack, a data structure designed for more straightforward multi-threaded programming. Through its thread-safe features, it eliminates synchronization challenges. By examining various methods of ConcurrentStack and real-life applications, we’ve underscored the importance of thoroughly understanding its functions. This deep knowledge is vital for harnessing its full potential and ensuring robust and efficient software development.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!