How to use structured concurrency in C#

Structured concurrency offers a more organized and more intuitive way of managing the lifetimes of asynchronous tasks. Here’s how to take advantage of it in C#.

women spinning plates asynchronous programming synchrony multi tasking by graemenicholson getty ima
graemenicholson / Getty Images

Modern programming languages such as C# facilitate the efficient use of resources by allowing multiple operations to be executed concurrently. Here concurrently often means asynchronously, when multiple processes must share the same CPU core. However, managing concurrent execution paths (using threads or tasks) can quickly become challenging due to the overhead of juggling asynchronous tasks.

“Structured concurrency” is a programming paradigm that was introduced to address this. Structured concurrency promotes a more disciplined and organized way to manage concurrency. In this article, we will delve into structured concurrency and see how it can be implemented in C#. 

Create a console application project in Visual Studio

First off, let’s create a .NET Core console application project in Visual Studio. Assuming Visual Studio 2022 is installed in your system, follow the steps outlined below to create a new .NET Core console application project.

  1. Launch the Visual Studio IDE.
  2. Click on “Create new project.”
  3. In the “Create new project” window, select “Console App (.NET Core)” from the list of templates displayed.
  4. Click Next.
  5. In the “Configure your new project” window shown next, specify the name and location for the new project.
  6. Click Next.
  7. In the “Additional information” window, choose “.NET 7.0 (Standard Term Support)” as the Framework version you would like to use.
  8. Click Create.

We’ll use this .NET 7 console application project to work with structured concurrency in the subsequent sections of this article.

What is asynchronous programming?

Asynchronous programming allows applications to perform resource-intensive operations concurrently, without having to block on the main or the executing thread of the application. Traditional approachеs to async programming, likе using callback functions or thе Task Parallеl Library (TPL), oftеn fall short whеn it comеs to managing concurrеncy and controlling thе lifеtimе of asynchronous opеrations. Structurеd concurrеncy offеrs a morе structurеd and intuitive way of managing asynchronous programming.

What is structured concurrency?

Structured concurrency is a strategy for handling concurrent operations in asynchronous programming. It relies on task scopes and proper resource cleanup to provide several benefits including cleaner code, simpler error handling, and prevention of resource leaks. Structured concurrency emphasizes the idea that all asynchronous tasks should be structured within a specific context, allowing developers to effectively compose and control the flow of these tasks.

To better manage the execution of async operations, structured concurrency introduces the concept of task scopes. Task scopes provide a logical unit that sets boundaries for concurrent tasks. All tasks executed within a task scope are closely monitored and their lifecycle is carefully managed. If any task within the scope encounters failure or cancellation, all other tasks within that scope are automatically canceled as well. This ensures proper cleanup and prevents re­source leaks.

Benefits of structured concurrency

Here are some of the key benefits of structured concurrency:

  • Cleaner code: Structured concurrency helps you write clean and maintainable code by keeping asynchronous operations logically organized within defined scopes. With structured concurrency, tasks have a clear scope. When the scope exits, we can be sure that all tasks within that scope were completed.
  • Efficient resource cleanup: Structured concurrency provides an automatic resource cleanup mechanism. It ensures that all tasks within a scope are canceled if any task encounters an error or is canceled, effectively managing the cleanup of resources.
  • Better error handling: With structured concurrency, handling errors and preventing error propagation are made easier by canceling all tasks within a scope. This promotes better control flow and reduces complexity.
  • Eliminates resource leaks: In traditional approaches to asynchronous programming, there is a risk of resource leaks when an operation fails or gets canceled. Structured concurrency helps address this problem by ensuring that correct cleanup is enforced.

Structured concurrency example in C#

In C#, we can implement structured concurrency by using the features available in the System.Threading.Tasks.Channels namespace. This namespace offers helpful constructs like Channel and ChannelReader that make implementing structured concurrency easier.

Take a look at the following example code, which I will explain below.

using System;
using System.Threading.Channels;
using System.Threading.Tasks;
async Task MyAsyncOperation (ChannelWriter channelWrite­r)
{
    await channelWriter.WriteAsync(100);
}
async Task Main()
{
    var channel = Channel.CreateUnbounde­d();
    var channelWriter = channel.Writer;
    await using (channel.Reader.EnterAsync())
    {
        var taskA = MyAsyncOperation (channelWriter);
        var taskB = MyAsyncOperation (channelWriter);
        await Task.WhenAll(taskA, taskB);
    }
    channelWriter.Complete();
    await channel.Reader.Completion;
}

In the example code above, we establish a channel to enable communication between the main thread and concurrent tasks. The MyAsyncOperation method represents the asynchronous operation that will be executed concurrently. By using the EnterAsync method of the ChannelReader object, we enter a task scope. Here multiple tasks are scheduled and waited using a call to the the Task.WhenAll method. If any task fails or is canceled, then all other tasks within that scope also will be canceled. Lastly, the Writer instance is closed and we await the completion of the Reader.

Install the Nito.StructuredConcurrency NuGet package

You can implement structured concurrency using the Nito.StructuredConcurrency NuGet package. Before we dive into using this package, let’s install it into the console application project we created earlier. To do this, select the project in the Solution Explorer window and right-click and select “Manage NuGet Packages.” In the NuGet Package Manager window, search for the Nito.StructuredConcurrency package and install it.

Alternatively, you can install the Nito.StructuredConcurrency package via the NuGet Package Manager console by entering the line of code below.

PM> Install-Package Nito.StructuredConcurrency

Use the TaskGroup class to create task scopes

You can create a task scope in which you can define the work to be done by your asynchronous tasks. To do this, you can use the RunGroupAsync method of the TaskGroup class as shown in the code snippet given below.

var group = TaskGroup.RunGroupAsync(default, group =>
{
    group.Run(async token => await Task.Delay(TimeSpan.FromSeconds(1), token));
    group.Run(async token => await Task.Delay(TimeSpan.FromSeconds(2), token));
    group.Run(async token => await Task.Delay(TimeSpan.FromSeconds(3), token));
});
await group;

In the preceeding code snippet, note how the delegate is passed to the RunGroupAsync method. This delegate represents the first work item. The subsequent work items are added to the same task group. When all the work items have completed their execution, the control exits the task group and the group is closed.

In the event of an exception, the task group enters a faulted state.

var group = TaskGroup.RunGroupAsync(default, group =>
{
    group.Run(async token =>
    {
        await Task.Delay(TimeSpan.FromSeconds(1), token);
        throw new Exception("This is a test exception.");
    });
});
await group;

Task groups ignore tasks that have been cancelled, i.e., they catch and ignore any occurrences of OperationCanceledException. Additionally, task groups can own resources, which are disposed when the execution of the task group is complete.

var groupTask = TaskGroup.RunGroupAsync(default, async group =>
{
    await group.AddResourceAsync(myResource);
    group.Run(async token => await myResource.PerformWork(token));
});
await groupTask;

Structurеd concurrеncy offеrs a disciplinеd approach to handling concurrеnt tasks by еnsuring that tasks havе a clеar scopе and lifеtimе. It offers significant benefits in terms of code maintainability, error handling, and resource management in asynchronous programming scenarios. By following this approach, dеvеlopеrs can rеducе thе risk of common pitfalls associatеd with concurrеncy, such as dangling tasks or unhandlеd еxcеptions, lеading to morе maintainablе and rеliablе codе.

Copyright © 2023 IDG Communications, Inc.