Skip to content

C# - Multithreading

C# - Modern Concurrency and Multithreading

Section titled “C# - Modern Concurrency and Multithreading”

In modern software development, concurrency is about executing multiple tasks seemingly at the same time. A thread is the fundamental unit of execution within a process. By using multiple threads, an application can perform several jobs simultaneously, leading to better responsiveness and performance, especially for applications with a user interface (UI) or those performing time-consuming I/O operations (like network requests or file access).

While C# has supported low-level thread management since its inception, the modern approach revolves around higher-level abstractions provided by the Task Parallel Library (TPL). Using constructs like Task, async, and await simplifies concurrent programming, reduces boilerplate code, and helps avoid common pitfalls like race conditions and deadlocks.

Directly managing System.Threading.Thread objects is now considered a low-level operation, reserved for specific scenarios. For most application-level concurrency, you should use the Task class. A Task represents an asynchronous operation that may or may not run on a separate thread. The .NET runtime manages the underlying threads efficiently using a thread pool.

The life cycle of a Task is more abstract than a raw thread and is managed by its status:

  • Created: The task has been created but not yet scheduled to run.
  • WaitingToRun: The task is scheduled for execution but is waiting for the scheduler to allocate resources.
  • Running: The task is currently executing.
  • RanToCompletion: The task completed its execution successfully.
  • Faulted: The task terminated due to an unhandled exception.
  • Canceled: The task was canceled before it could complete.

Every C# application starts with a main thread. With modern C# (7.1 and later), the Main method can be marked as async, allowing you to use await at the top level of your application. This is the standard way to write modern console applications that perform asynchronous operations.

The following program demonstrates an async Main method and how to start a new task.

using System;
using System.Threading.Tasks;
namespace ModernConcurrencyApp {
class Program {
// Main method is now async and returns a Task
static async Task Main(string[] args) {
Console.WriteLine("Main thread started.");
// Start a background task using Task.Run
Task backgroundTask = Task.Run(() => {
Console.WriteLine("Background task starting...");
// Simulate a 5-second workload
Task.Delay(5000).Wait();
Console.WriteLine("Background task finished.");
});
Console.WriteLine("Main thread continues execution while the background task runs.");
// Asynchronously wait for the background task to complete
await backgroundTask;
Console.WriteLine("Main thread finished after background task completed.");
}
}
}

When the above code is compiled and executed, the output will demonstrate non-blocking execution:

Main thread started.
Main thread continues execution while the background task runs.
Background task starting...
(after 5 seconds)
Background task finished.
Main thread finished after background task completed.

Here are some of the most common methods used with the Task class:

MethodDescription
Task.Run(Action)Queues work to run on the thread pool and returns a Task handle for that work.
Task.Delay(int)Creates a task that completes after a specified time interval. A non-blocking version of Thread.Sleep.
Task.Wait()Blocks the calling thread until the task completes. Use with caution as it can cause deadlocks in UI or ASP.NET Core contexts. Prefer await.
await myTaskThe await operator suspends execution of the current method until the awaited task completes. It does not block the thread.
Task.WhenAll(...)Creates a task that completes when all of the supplied tasks have completed.
Task.WhenAny(...)Creates a task that completes when any of the supplied tasks have completed.

Using lambda expressions is the standard way to pass parameters to a task. To return a value, use Task<TResult>, where TResult is the type of the value you wish to return. The result can be retrieved by awaiting the task.

Example: Task with Parameters and Return Value

Section titled “Example: Task with Parameters and Return Value”

This example demonstrates a task that accepts a parameter and returns a result.

using System;
using System.Threading.Tasks;
class Program {
static async Task Main() {
Console.WriteLine("Main method started.");
// Use a lambda to pass a parameter and run the task.
// The task returns a string, so we use Task<string>.
Task<string> fetchTask = Task.Run(() => FetchDataFromServer("user-123"));
Console.WriteLine("Waiting for data to be fetched...");
// Await the task to get the result without blocking the thread.
string result = await fetchTask;
Console.WriteLine($"Data received: {result}");
Console.WriteLine("Main method finished.");
}
static string FetchDataFromServer(string userId) {
// Simulate a network request
Task.Delay(3000).Wait();
return $"{{'name': 'John Doe', 'id': '{userId}'}}"; // Returns a JSON-like string
}
}

The output will show a 3-second delay before the result is printed:

Main method started.
Waiting for data to be fetched...
(after 3 seconds)
Data received: {'name': 'John Doe', 'id': 'user-123'}
Main method finished.

Cooperative Cancellation: The Modern Way to Stop Tasks

Section titled “Cooperative Cancellation: The Modern Way to Stop Tasks”

The old Thread.Abort() method was dangerous because it could terminate a thread at any point, potentially leaving application state corrupted. It is now obsolete. The modern, safe way to handle cancellation is cooperative cancellation using a CancellationToken.

The process involves:

  1. Creating a CancellationTokenSource.
  2. Passing its CancellationToken to the task.
  3. Inside the task, periodically checking if cancellation has been requested via token.IsCancellationRequested.
  4. The calling code requests cancellation by calling cancellationTokenSource.Cancel().
using System;
using System.Threading;
using System.Threading.Tasks;
class Program {
static async Task Main() {
var cts = new CancellationTokenSource();
Console.WriteLine("Starting a long-running task...");
Console.WriteLine("Press 'c' to cancel.");
// Start the cancellable task
Task longRunningTask = CountToInfinity(cts.Token);
// Listen for user input to cancel
if (Console.ReadKey().KeyChar == 'c') {
cts.Cancel();
Console.WriteLine("\nCancellation requested.");
}
try {
// Wait for the task to complete or be cancelled
await longRunningTask;
} catch (OperationCanceledException) {
Console.WriteLine("Task was successfully canceled.");
}
Console.WriteLine("Program finished.");
}
static async Task CountToInfinity(CancellationToken token) {
long i = 0;
while (true) {
// Check for cancellation request at each iteration
token.ThrowIfCancellationRequested();
Console.Write($"\rCounting: {i++}");
// Await a small delay to keep the loop from blocking and to be responsive
await Task.Delay(100, token);
}
}
}

When you run this program, it will start counting. If you press the ‘c’ key, it will stop and print the cancellation messages.

This was a brief introduction to modern concurrency in C#. For real-world applications, you should also explore:

  • IProgress<T>: For reporting progress from a background task to the UI thread.
  • Data Synchronization: lock, SemaphoreSlim, and Concurrent collections for safely accessing shared data from multiple threads.
  • Parallel LINQ (PLINQ): For easily parallelizing data-processing queries.