Back
Detailed C# Threading Examples

Detailed C# Threading Examples

C# offers several ways to handle concurrency, from low-level Thread class manipulation to higher-level abstractions like the Task Parallel Library (TPL) and async/await.

Understanding Threading in C#

At its core, threading allows a program to execute multiple parts of its code concurrently. In C#, this means your application can perform a long-running operation (like fetching data from a network or processing a large file) in the background without freezing the user interface or making the application unresponsive.

Key Concepts:

  • Process: An independent execution environment that provides resources like memory space, file handles, etc. A program typically runs as a single process.
  • Thread: The smallest unit of execution within a process. A process can have multiple threads, all sharing the same process resources.
  • Main Thread: The primary thread of execution that starts when your application launches.
  • Worker Thread (or Background Thread): Any thread created by the main thread to perform tasks concurrently.
  • Concurrency vs. Parallelism:
    • Concurrency: Deals with managing multiple tasks at the same time, often by rapidly switching between them (even on a single CPU core).
    • Parallelism: Involves executing multiple tasks simultaneously on multiple CPU cores.
  • Thread Synchronization: When multiple threads access shared resources (like variables or files), you need mechanisms to prevent data corruption and ensure consistency. This is crucial to avoid "race conditions" (where the outcome depends on the unpredictable timing of multiple threads) and "deadlocks" (where two or more threads are blocked indefinitely, waiting for each other to release resources).
  • Thread Pool: A collection of pre-created threads managed by the .NET runtime. It's more efficient to reuse threads from a pool than to create and destroy new ones for every small task.
  • async and await (Asynchronous Programming): A modern C# feature that simplifies asynchronous operations, especially I/O-bound tasks. While not directly about creating new threads for every operation, async/await often leverages the thread pool internally and provides a much cleaner syntax for non-blocking operations.

Detailed Examples in C#

1. Basic Thread Class Usage (System.Threading.Thread)

This is the most fundamental way to create and manage threads.


using System;
using System.Threading;

public class BasicThreadingExample
{
    // Method that will be executed by the new thread
    public static void DoWork()
    {
        Console.WriteLine("Worker thread: Starting work...");
        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine($"Worker thread: Counting {i}");
            Thread.Sleep(500); // Simulate some work
        }
        Console.WriteLine("Worker thread: Work finished.");
    }

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

        // 1. Create a new Thread instance, passing a ThreadStart delegate
        //    which points to the method to execute.
        Thread workerThread = new Thread(DoWork);

        // 2. Start the new thread. This causes DoWork to execute on a separate thread.
        workerThread.Start();

        // The main thread continues its own work concurrently
        for (int i = 0; i < 3; i++)
        {
            Console.WriteLine($"Main thread: Doing its own thing {i}");
            Thread.Sleep(700); // Simulate main thread work
        }

        // 3. Wait for the worker thread to complete using Join().
        //    The main thread will block here until workerThread finishes.
        Console.WriteLine("Main thread: Waiting for worker thread to finish...");
        workerThread.Join();

        Console.WriteLine("Main thread: Worker thread has completed. Program ending.");
        Console.ReadKey(); // Keep console open
    }
}
        
Explanation:
  • We define a DoWork method that represents the task to be performed on a separate thread.
  • In Main, we create an instance of Thread, passing a ThreadStart delegate (or a lambda expression () => DoWork() for simpler cases) that points to DoWork.
  • workerThread.Start() initiates the execution of DoWork on a new thread.
  • The Main thread continues executing its loop immediately after Start(), demonstrating concurrency.
  • workerThread.Join() is crucial for synchronization. It tells the Main thread to pause its execution until workerThread has completed. Without Join(), the Main thread might exit before the workerThread has a chance to finish, especially for short-lived worker tasks.

2. Thread Synchronization (lock keyword)

When multiple threads access shared data, you need to protect that data to prevent race conditions. The lock statement in C# is a common and easy-to-use mechanism for this.


using System;
using System.Threading;

public class ThreadSynchronizationExample
{
    private static int sharedCounter = 0;
    private static readonly object lockObject = new object(); // Object to lock on

    public static void IncrementCounter()
    {
        for (int i = 0; i < 100000; i++)
        {
            // Acquire a lock before accessing sharedCounter
            lock (lockObject)
            {
                sharedCounter++;
            }
            // The lock is released when execution exits the lock block
        }
        Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId}: Finished incrementing.");
    }

    public static void Main(string[] args)
    {
        Console.WriteLine("Main thread: Starting synchronization example.");

        Thread[] threads = new Thread[5];
        for (int i = 0; i < threads.Length; i++)
        {
            threads[i] = new Thread(IncrementCounter);
            threads[i].Start();
        }

        // Wait for all threads to complete
        foreach (Thread t in threads)
        {
            t.Join();
        }

        Console.WriteLine($"Main thread: All threads finished. Final counter value: {sharedCounter}");

        // Without lock, sharedCounter would likely be less than 500000 due to race conditions.
        // Each thread increments sharedCounter 100,000 times. With 5 threads,
        // the expected value is 500,000.
        // If you remove the 'lock' statement, you'll see a different, often lower, result.

        Console.ReadKey();
    }
}
        
Explanation:
  • sharedCounter is a static variable, meaning it's shared across all instances of the class and thus all threads.
  • lockObject is a private, static, read-only object used as the mutual-exclusion lock. Any object can be used for locking, but a dedicated object instance is best practice.
  • The lock (lockObject) { ... } block ensures that only one thread can execute the code inside the curly braces at any given time.
  • When a thread enters the lock block, it acquires a monitor lock on lockObject. If another thread tries to acquire the same lock, it will be blocked until the first thread releases the lock (when it exits the lock block).
  • This guarantees that sharedCounter++ (which is a read-modify-write operation) is atomic, preventing lost updates.

3. Using the Thread Pool (System.Threading.ThreadPool)

For short-lived tasks that don't require their own dedicated thread, the ThreadPool is highly efficient. It manages a pool of threads and reuses them, avoiding the overhead of creating new threads.


using System;
using System.Threading;

public class ThreadPoolExample
{
    // Method to be executed by a thread pool thread
    public static void ProcessTask(object state)
    {
        int taskId = (int)state;
        Console.WriteLine($"ThreadPool thread: Task {taskId} started on thread {Thread.CurrentThread.ManagedThreadId}");
        Thread.Sleep(TimeSpan.FromSeconds(new Random().Next(1, 4))); // Simulate varying work
        Console.WriteLine($"ThreadPool thread: Task {taskId} completed.");
    }

    public static void Main(string[] args)
    {
        Console.WriteLine("Main thread: Queueing tasks to ThreadPool.");

        // Use a CountdownEvent to know when all tasks are complete
        int numberOfTasks = 5;
        CountdownEvent countdown = new CountdownEvent(numberOfTasks);

        for (int i = 0; i < numberOfTasks; i++)
        {
            int taskId = i + 1; // Capture loop variable for lambda
            ThreadPool.QueueUserWorkItem((state) =>
            {
                try
                {
                    ProcessTask(state);
                }
                finally
                {
                    countdown.Signal(); // Decrement the count when task is done
                }
            }, taskId); // Pass taskId as state
        }

        Console.WriteLine("Main thread: All tasks queued. Doing other work...");
        // Main thread can continue doing other things
        Thread.Sleep(1000);

        // Wait for all tasks in the thread pool to complete
        countdown.Wait();
        Console.WriteLine("Main thread: All ThreadPool tasks completed. Program ending.");
        Console.ReadKey();
    }
}
        
Explanation:
  • ThreadPool.QueueUserWorkItem() enqueues a method to be executed by a thread from the thread pool. It's ideal for tasks that don't block for long periods.
  • We use a CountdownEvent for synchronization. It's a convenient way to signal when a certain number of operations have completed. We initialize it with numberOfTasks, and each worker thread calls Signal() when it finishes. The Main thread then Wait()s until the count reaches zero.

4. Task Parallel Library (TPL) - The Modern Approach (System.Threading.Tasks)

The TPL (introduced in .NET Framework 4.0) is the preferred way to write concurrent and parallel code in modern C#. It provides a higher-level abstraction (Task objects) over raw threads and the thread pool, making parallel programming much easier and more robust.


using System;
using System.Threading;
using System.Threading.Tasks; // Important namespace for TPL

public class TPLParallelExample
{
    public static void PerformCalculation(int value)
    {
        Console.WriteLine($"Task {Task.CurrentId} processing value {value} on thread {Thread.CurrentThread.ManagedThreadId}");
        Thread.Sleep(TimeSpan.FromMilliseconds(new Random().Next(200, 1000))); // Simulate work
        Console.WriteLine($"Task {Task.CurrentId} finished processing value {value}");
    }

    public static async Task DownloadFileAsync(string url)
    {
        Console.WriteLine($"Downloading {url} on thread {Thread.CurrentThread.ManagedThreadId} (potentially a ThreadPool thread)");
        // Simulate a network call
        await Task.Delay(2000); // This is an awaitable operation
        Console.WriteLine($"Finished downloading {url} on thread {Thread.CurrentThread.ManagedThreadId}");
    }

    public static void Main(string[] args)
    {
        Console.WriteLine("Main thread: Starting TPL examples.");

        // --- Example 1: Parallel.For for CPU-bound loops ---
        Console.WriteLine("\n--- Parallel.For Example ---");
        int[] data = new int[10];
        for (int i = 0; i < data.Length; i++)
        {
            data[i] = i * 10;
        }

        // Parallel.For automatically partitions the work and uses the ThreadPool
        Parallel.For(0, data.Length, i =>
        {
            PerformCalculation(data[i]);
        });
        Console.WriteLine("--- Parallel.For finished ---");

        // --- Example 2: Task.Run for general background tasks ---
        Console.WriteLine("\n--- Task.Run Example ---");
        Task task1 = Task.Run(() =>
        {
            Console.WriteLine($"Task.Run 1: Starting work on thread {Thread.CurrentThread.ManagedThreadId}");
            Thread.Sleep(1500);
            Console.WriteLine("Task.Run 1: Finished work.");
        });

        Task<int> task2 = Task.Run(() =>
        {
            Console.WriteLine($"Task.Run 2: Calculating result on thread {Thread.CurrentThread.ManagedThreadId}");
            Thread.Sleep(1000);
            return 42; // Return a value
        });

        Console.WriteLine("Main thread: Tasks started. Doing other work...");

        // You can wait for tasks to complete
        task1.Wait();
        Console.WriteLine("Main thread: Task 1 completed.");

        int result = task2.Result; // This will block until task2 is complete
        Console.WriteLine($"Main thread: Task 2 completed with result: {result}");

        // --- Example 3: Async/Await for I/O-bound operations (best practice) ---
        Console.WriteLine("\n--- Async/Await Example ---");
        // We need to run async methods from Main, which isn't directly async.
        // A common pattern is to wrap it in a Wait method or use .GetAwaiter().GetResult().
        // For a real async main, it would be 'static async Task Main(string[] args)' in C# 7.1+
        // But for a simple console app, this works:
        Console.WriteLine("Main thread: Initiating async downloads.");
        Task downloadTask1 = DownloadFileAsync("http://example.com/file1.txt");
        Task downloadTask2 = DownloadFileAsync("http://example.com/file2.txt");

        // Use Task.WhenAll to wait for multiple async tasks concurrently
        Task.WhenAll(downloadTask1, downloadTask2).Wait();
        Console.WriteLine("Main thread: All async downloads completed.");


        Console.WriteLine("\nMain thread: All TPL examples finished. Program ending.");
        Console.ReadKey();
    }
}
        
Explanation:
  • Parallel.For and Parallel.ForEach: These are excellent for parallelizing loops, especially CPU-bound computations. The TPL handles the partitioning of work and thread management automatically.
  • Task.Run(): This is the primary way to offload CPU-bound work to a thread pool thread. It returns a Task object, which you can use to monitor the task's completion or retrieve a result.
  • Task<TResult>: A generic Task that allows you to return a value from the asynchronous operation. task2.Result will block the calling thread until the task completes and its result is available.
  • async and await:
    • async keyword marks a method as asynchronous.
    • await keyword is used inside an async method to pause its execution until the awaited operation completes. While waiting, the thread is returned to the thread pool (or released), allowing it to perform other work. This is the key difference from Thread.Sleep() or Task.Wait(), which block the thread.
    • async/await is especially powerful for I/O-bound operations (like network calls, file I/O, database queries) because it allows your application to remain responsive while waiting for external resources without consuming a thread unnecessarily.
    • In the DownloadFileAsync example, Task.Delay simulates an I/O operation. When await Task.Delay(2000) is hit, the DownloadFileAsync method pauses, and the thread that was executing it is free to do other work. After 2 seconds, the continuation of the DownloadFileAsync method is scheduled on a thread pool thread (not necessarily the same one).
    • Task.WhenAll() allows you to wait for multiple Task objects to complete concurrently.

When to Use What:

  • Thread class:
    • Use when you need fine-grained control over thread properties (e.g., IsBackground, Priority, Name).
    • For very long-running, CPU-bound operations that you want to isolate from the thread pool.
    • Generally less common in modern C# development due to the overhead and complexity.
  • ThreadPool.QueueUserWorkItem:
    • For short, independent tasks that don't need direct thread management.
    • Used internally by TPL. Directly using it is less common now, as Task.Run is more versatile.
  • Task Parallel Library (Task, Parallel.For, Parallel.ForEach, Task.Run):
    • Recommended for CPU-bound parallel operations. It abstracts away thread management and makes parallelizing work much simpler and more efficient.
    • Task.Run is generally preferred over ThreadPool.QueueUserWorkItem as it provides more features (return values, exceptions, continuations).
  • async and await:
    • Recommended for I/O-bound operations. This is the modern and most efficient way to handle operations that involve waiting for external resources (network, disk, database) without blocking threads.
    • It doesn't necessarily create new threads but efficiently manages thread usage by releasing the current thread during await operations.

Important Considerations for Threading:

  • Race Conditions: Multiple threads accessing and modifying shared resources without proper synchronization can lead to unpredictable and incorrect results. Use lock, Monitor, Mutex, Semaphore, ReaderWriterLockSlim, or thread-safe collections (ConcurrentBag, ConcurrentQueue, etc.).
  • Deadlocks: Two or more threads are blocked indefinitely, each waiting for the other to release a resource. Careful design and consistent locking order are essential to prevent deadlocks.
  • Starvation: A thread might repeatedly lose the race for a resource or CPU time, leading to it never completing its task.
  • Exception Handling: Exceptions on worker threads behave differently than on the main thread. With Task, exceptions are captured and re-thrown when you await or access Task.Result.
  • UI Thread Responsiveness: Never perform long-running operations directly on the UI thread (e.g., in a WinForms or WPF application). Always offload such work to background threads/tasks to keep the UI responsive.
  • Cancellation: Provide a way to gracefully cancel long-running operations using CancellationTokenSource and CancellationToken.

How to Run These C# Examples:

To run these C# code examples, you will need a .NET development environment installed on your machine. Here's a general guide:

  1. Install .NET SDK: Download and install the latest .NET SDK from the official Microsoft website (e.g., .NET 8).
  2. Choose an IDE:
    • Visual Studio: A comprehensive IDE for Windows, highly recommended for C# development.
    • Visual Studio Code: A lightweight, cross-platform code editor with C# extensions.
  3. Create a New Console Project:
    • In Visual Studio: Go to File > New > Project, search for "Console App" (C#), and select it.
    • In VS Code (or Command Line):
      dotnet new console -n MyThreadingApp
      cd MyThreadingApp
  4. Replace Program.cs content: Open the Program.cs file in your new project and replace its entire content with one of the C# code examples provided above (e.g., BasicThreadingExample). Make sure the class name in the code matches the main class where your Main method resides.
  5. Run the Project:
    • In Visual Studio: Press F5 or click the "Start" button.
    • In VS Code (or Command Line):
      dotnet run

You will see the output in the console window.

Comments - Beta - WIP

Leave a Comment