Last Updated: February 1, 2026
C# and .NET have evolved significantly in how they handle threading. The early days of .NET 1.0 gave us System.Threading.Thread, a direct wrapper around OS threads. .NET 2.0 added ThreadPool for efficient thread reuse. Then .NET 4.0 introduced the Task Parallel Library (TPL), which abstracted away much of the manual thread management. And .NET 4.5 brought async/await, making asynchronous code read almost like synchronous code.
Despite all these abstractions, understanding raw threads remains essential. You'll encounter legacy code using them, need them for specific scenarios, and understanding threads helps you reason about what those higher-level abstractions actually do.
This chapter dives deep into C#-specific thread creation. We'll explore the full Thread API, all the ways to create threads, configuration options, lifecycle management, and patterns you'll encounter in production code and interviews.
At the heart of .NET's threading model is System.Threading.Thread. Every managed thread in a .NET application is represented by an instance of this class.
The Thread class provides four public constructors:
The maxStackSize parameter specifies the stack size in bytes. The default on 64-bit systems is 1MB for the main thread and 256KB for threads created via the ThreadPool. You might reduce it when creating many threads to conserve memory, or increase it for deeply recursive algorithms.
Unlike Java, C# doesn't have constructors that accept both a delegate and a thread name. You set the name separately via the Name property.
Every thread exposes several properties that affect its behavior and help with debugging:
Name: A human-readable identifier invaluable for debugging. If not set, Name returns null.
ManagedThreadId: A unique integer identifying the thread within the CLR. Unlike names, IDs are always unique and automatically assigned.
Priority: A ThreadPriority enum value that hints to the scheduler. Higher priority threads may get more CPU time, but this is OS-dependent.
IsBackground: Determines whether the thread prevents application shutdown. Background threads are terminated when all foreground threads complete.
IsAlive: Returns true if the thread has started and hasn't terminated yet.
ThreadState: Returns the current state of the thread as a flags enum (can have multiple values combined).
A .NET thread can be in several states, defined by the ThreadState enum. Unlike Java's simple enum, .NET uses flags that can be combined:
| State | Description |
|---|---|
| Unstarted | Thread created but Start() not yet called |
| Running | Thread is executing or ready to execute |
| WaitSleepJoin | Blocked in Sleep(), Join(), Wait(), or waiting for a lock |
| Stopped | Thread completed execution (normally or via exception) |
| AbortRequested | Abort() called but exception not yet delivered (deprecated) |
| Aborted | Thread was aborted (deprecated, removed in .NET Core) |
| SuspendRequested | Suspend() called (deprecated) |
| Suspended | Thread is suspended (deprecated) |
| Background | Flag indicating background thread (combinable with other states) |
You can query a thread's state at any time:
ThreadState is a flags enum, so a background thread that's waiting might have ThreadState.WaitSleepJoin | ThreadState.Background. Use bitwise operations or HasFlag() to check specific states:
C# provides three primary approaches to defining what a thread should execute: using ThreadStart, using ParameterizedThreadStart, and using lambda expressions.
The simplest approach uses a delegate with no parameters and no return value:
This works well when your thread doesn't need any input data. The ThreadStart delegate is defined simply as:
When you need to pass data to a thread, use ParameterizedThreadStart:
The delegate signature is:
The downside here is obvious: the parameter is always object, so you lose compile-time type safety. You must cast inside the method, and mistakes only surface at runtime.
The preferred modern approach uses lambda expressions, which provide type safety through closure:
Lambdas are preferred because:
| Aspect | ThreadStart | ParameterizedThreadStart | Lambda |
|---|---|---|---|
| Parameters | None | Single object | Any via closure |
| Type safety | N/A | Lost (object cast) | Full |
| Return value | None | None | None (use shared state) |
| Readability | Good for methods | Requires cast | Best |
| When to use | Simple tasks | Legacy code | Most cases |
When to use each:
Beyond basic creation, .NET provides several configuration options that affect how threads behave.
Good thread names are invaluable when debugging. When analyzing a thread dump from a production server with hundreds of threads, meaningful names make the difference between finding the problem quickly versus spending hours searching.
Common naming patterns:
{Component}-{Function}-{Number}: "PaymentService-Processor-1"{Pool}-{Number}: "HTTP-Worker-5"{Feature}-{ID}: "UserSession-abc123"Important: Thread names can only be set once. Attempting to change an already-named thread throws InvalidOperationException.
Thread priority hints to the OS scheduler how important a thread is. The mapping between .NET's five priority levels and OS priorities varies by platform:
| Priority | Value | Use Case |
|---|---|---|
| Highest | 4 | Critical real-time tasks |
| AboveNormal | 3 | Important but not critical |
| Normal | 2 | Default for most work |
| BelowNormal | 1 | Background processing |
| Lowest | 0 | Idle-time tasks |
Important caveats:
This distinction is crucial in .NET. A foreground thread keeps the application alive, while background threads are terminated when all foreground threads complete.
Rules for background threads:
IsBackground before or after Start() (unlike Java's daemon, this can be changed anytime).Apartment state is a COM interop concept unique to Windows. It controls how COM objects interact with threads. Most .NET developers never need to worry about it, but it becomes critical when working with certain Windows APIs.
| State | Meaning | Use When |
|---|---|---|
| STA | Single-Threaded Apartment | UI work, Office interop, Clipboard operations |
| MTA | Multi-Threaded Apartment | Default for non-UI threads |
| Unknown | Not set | Defaults to MTA when started |
When STA matters:
Understanding how to properly start, wait for, and stop threads is essential for correct concurrent programs.
Calling Start() tells the CLR to create a new native thread and invoke the thread's delegate on it:
Critical: You can only call Start() once. Calling it again throws ThreadStateException:
If you need to run the same task again, create a new Thread instance.
The Join() method blocks the calling thread until the target thread terminates:
The overloaded versions:
Thread.Sleep() pauses the current thread for the specified duration:
In modern async code, prefer Task.Delay() as it doesn't block a thread:
Thread.Sleep(0) yields the current thread's time slice to other threads of equal priority. Thread.Yield() is similar but only yields to threads on the same processor.
In .NET Framework, Thread.Abort() could forcibly terminate a thread by injecting a ThreadAbortException. This was inherently dangerous because:
Important: Thread.Abort() was removed in .NET Core and .NET 5+. Calling it throws PlatformNotSupportedException. Use cooperative cancellation with CancellationToken instead.
Thread.Interrupt() is less aggressive than Abort(). It causes a ThreadInterruptedException only when the thread is in a waiting state (Sleep, Join, Wait):
Unlike Abort(), the thread must be in a blocked state for the exception to be thrown. If the thread is running CPU-bound code, the interrupt waits until the next blocking call.
.NET's recommended approach for stopping threads is cooperative cancellation using CancellationToken. This pattern is used throughout the TPL and async/await.
The CancellationTokenSource (CTS) controls the token:
CTS can automatically cancel after a timeout:
You can create a token that cancels when any of several sources cancel:
You can register callbacks that run when cancellation is requested:
Getting results back from threads is a common requirement. C# provides several approaches.
The traditional approach uses shared variables with proper synchronization:
This works but requires careful synchronization and is error-prone.
When each thread needs its own copy of a value:
When you need to return results from thread-based code to Task-based callers:
For most scenarios, you should skip raw threads entirely and use Task<T>:
Creating a new thread is expensive: it involves OS calls, memory allocation for the stack, and scheduling overhead. The ThreadPool maintains a pool of worker threads that can be reused for multiple tasks.
| Aspect | ThreadPool | Raw Thread |
|---|---|---|
| Creation cost | Very low (reuses threads) | High (OS thread creation) |
| Stack size | 256KB (default) | 1MB (default) |
| IsBackground | Always true | False by default |
| Apartment state | Always MTA | Configurable |
| Control | Limited (no name, no priority) | Full control |
| Use when | Short-lived tasks | Long-running tasks, specific configuration needed |
When to use raw threads:
When to use ThreadPool:
In practice, use Task.Run() which uses the ThreadPool internally but provides a richer API.
For tasks where you don't need the result:
Caveat: Fire-and-forget with background threads means the work might not complete if the app exits. For important work, use foreground threads or proper async patterns with graceful shutdown.
A long-lived thread that processes items:
A background thread for periodic maintenance:
Multiple producers and consumers sharing a queue: