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:
- Install .NET SDK: Download and install the latest .NET SDK from the official Microsoft website (e.g., .NET 8).
- 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.
- Create a New Console Project:
- 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.
- 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