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.
Let’s dive in.
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 theCouncurrentStack
instanceCopyTo()
: Copies theConcurrentStack
elements to an existing one-dimensional Array, starting at the specified array indexGetEnumerator()
: Returns an enumerator that iterates through theConcurrentStack
ToArray()
: Copies the items stored in theConcurrentStack
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.