Concurrency and Parallelism in .NET

Introduction to Concurrency and Parallelism

Concurrency and parallelism are fundamental concepts in modern software development, especially for building responsive and high-performance applications. While often used interchangeably, they represent distinct ideas:

  • Concurrency: Dealing with multiple tasks that are making progress over the same period. This doesn't necessarily mean they are executing at the exact same instant. Think of juggling multiple balls – you're managing all of them, but you're only actively touching one at a time.
  • Parallelism: Executing multiple tasks simultaneously. This requires multiple processing units (e.g., CPU cores) to achieve true simultaneous execution. Think of a team of workers each performing a different task at the same time.

In .NET, you can achieve both concurrency and parallelism through various mechanisms, primarily involving threads and the Task Parallel Library (TPL).

Threads in .NET

The most basic unit of execution in .NET is a thread. Managed code runs within threads. Applications can create and manage multiple threads to perform tasks concurrently.

Creating and Managing Threads

You can create a new thread using the System.Threading.Thread class:


using System;
using System.Threading;

public class ThreadExample
{
    public static void WorkerMethod()
    {
        Console.WriteLine("Worker thread started.");
        Thread.Sleep(2000); // Simulate work
        Console.WriteLine("Worker thread finished.");
    }

    public static void Main(string[] args)
    {
        Console.WriteLine("Main thread started.");

        Thread workerThread = new Thread(WorkerMethod);
        workerThread.Start(); // Start the thread

        Console.WriteLine("Main thread continues execution.");
        Thread.Sleep(1000); // Main thread does some work

        workerThread.Join(); // Wait for the worker thread to complete

        Console.WriteLine("Main thread finished.");
    }
}
                

Key concepts related to threads include:

  • Thread Creation: Instantiating Thread and passing a delegate (like a method) to its constructor.
  • Starting a Thread: Using the Start() method.
  • Thread Synchronization: Mechanisms like lock, Monitor, and semaphores are crucial for preventing race conditions when multiple threads access shared resources.
  • Thread.Sleep(): Pauses the current thread for a specified duration.
  • Thread.Join(): Blocks the calling thread until the thread whose Join() method is called terminates.

Task Parallel Library (TPL)

The Task Parallel Library (TPL) provides a higher-level abstraction for writing parallel code, simplifying many of the complexities associated with direct thread management. It's built around the concept of Task objects.

Introduction to Tasks

A Task represents an asynchronous operation. TPL manages the underlying threads, making it easier to write scalable and efficient parallel code.


using System;
using System.Threading;
using System.Threading.Tasks;

public class TplExample
{
    public static void ProcessItem(int item)
    {
        Console.WriteLine($"Processing item {item} on thread {Thread.CurrentThread.ManagedThreadId}");
        Thread.Sleep(500); // Simulate work
    }

    public static async Task Main(string[] args)
    {
        Console.WriteLine("Starting parallel processing.");

        // Using Parallel.For for a parallel loop
        Parallel.For(0, 10, i =>
        {
            ProcessItem(i);
        });

        Console.WriteLine("Parallel.For completed.");

        // Using Task.Run for asynchronous operations
        Task task1 = Task.Run(() => ProcessItem(100));
        Task task2 = Task.Run(() => ProcessItem(200));

        await Task.WhenAll(task1, task2); // Wait for both tasks to complete

        Console.WriteLine("Asynchronous tasks completed.");
    }
}
                

Key TPL Components:

  • Task and Task<TResult>: Represent operations that can be run asynchronously.
  • Task.Run(): Schedules a delegate to run on a thread pool thread.
  • Parallel.For() and Parallel.ForEach(): Provide easy ways to parallelize loops. TPL partitions the work and executes iterations concurrently.
  • async and await: Keywords that, when used with TPL, enable asynchronous programming patterns, allowing UI threads to remain responsive and I/O-bound operations to not block worker threads.
  • CancellationToken: Used to signal cancellation requests to asynchronous operations.
  • Dataflow (TPL Dataflow): A library for building concurrent applications by messaging, providing building blocks like ActionBlock, TransformBlock, and BufferBlock.

Synchronization Primitives

When multiple threads or tasks access shared data, it's essential to prevent race conditions and ensure data integrity. .NET provides several synchronization primitives:

  • lock statement: The simplest way to ensure that only one thread can execute a block of code at a time.
  • System.Threading.Monitor: Provides more granular control over locking than the lock statement.
  • System.Threading.Semaphore / SemaphoreSlim: Limits the number of concurrent threads that can access a resource or pool.
  • System.Threading.Mutex: Similar to a lock but can be used across different processes.
  • System.Threading.ReaderWriterLockSlim: Allows multiple threads to read a resource concurrently, but only one thread to write at a time.
  • System.Collections.Concurrent namespace: Contains thread-safe collection types like ConcurrentDictionary and ConcurrentQueue, which handle synchronization internally.

Asynchronous Programming Patterns (async/await)

The async and await keywords revolutionized asynchronous programming in C#. They allow you to write non-blocking code that looks synchronous, making it much easier to handle I/O-bound operations (like network requests or file access) without tying up threads.

An async method typically returns a Task or Task<TResult> and can contain one or more await expressions.


using System;
using System.Net.Http;
using System.Threading.Tasks;

public class AsyncExample
{
    public static async Task<string> DownloadStringAsync(string url)
    {
        using (HttpClient client = new HttpClient())
        {
            Console.WriteLine($"Starting download from {url}...");
            string content = await client.GetStringAsync(url); // await relinquishes control until GetStringAsync completes
            Console.WriteLine($"Finished download from {url}.");
            return content;
        }
    }

    public static async Task Main(string[] args)
    {
        Console.WriteLine("App started.");
        Task<string> downloadTask = DownloadStringAsync("https://www.microsoft.com");

        Console.WriteLine("Doing other work while download happens...");
        await Task.Delay(1000); // Simulate other work

        string result = await downloadTask; // Wait for the download to complete and get the result
        Console.WriteLine($"Downloaded {result.Length} characters.");
        Console.WriteLine("App finished.");
    }
}
                

Using async and await is crucial for:

  • Keeping UI applications responsive.
  • Improving scalability of server-side applications by freeing up threads for other requests.
  • Efficiently handling I/O-bound operations.

Best Practices and Considerations

  • Prefer TPL over direct thread management: TPL simplifies resource management and thread pooling.
  • Use async/await for I/O-bound operations: Prevents blocking threads.
  • Be mindful of thread safety: Always synchronize access to shared mutable state.
  • Avoid thread starvation: Ensure that long-running operations don't prevent shorter, higher-priority tasks from executing.
  • Handle exceptions properly: Use try-catch blocks around thread operations and aggregate exceptions from tasks.
  • Consider cancellation: Implement cancellation tokens for long-running operations.
  • Understand overhead: Creating threads and managing tasks has a cost; choose the right tool for the job.
  • Use concurrent collections: Leverage the System.Collections.Concurrent namespace when possible.