AlgoMaster Logo

Creating Threads in C#

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.

The Thread Class

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.

Thread Constructors

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.

Key Thread Properties

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).

Thread States

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:

StateDescription
UnstartedThread created but Start() not yet called
RunningThread is executing or ready to execute
WaitSleepJoinBlocked in Sleep(), Join(), Wait(), or waiting for a lock
StoppedThread completed execution (normally or via exception)
AbortRequestedAbort() called but exception not yet delivered (deprecated)
AbortedThread was aborted (deprecated, removed in .NET Core)
SuspendRequestedSuspend() called (deprecated)
SuspendedThread is suspended (deprecated)
BackgroundFlag 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:

Three Ways to Create Threads

C# provides three primary approaches to defining what a thread should execute: using ThreadStart, using ParameterizedThreadStart, and using lambda expressions.

ThreadStart Delegate (No Parameters)

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:

ParameterizedThreadStart Delegate (Object Parameter)

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.

Lambda Expressions (Modern Approach)

The preferred modern approach uses lambda expressions, which provide type safety through closure:

Lambdas are preferred because:

  1. Type safety: Captured variables retain their types. No casting needed.
  2. Multiple parameters: Capture as many variables as you need.
  3. Readability: The code clearly shows what data the thread uses.
  4. Flexibility: Easy to inline simple logic or call methods with any signature.

Comparison: ThreadStart vs ParameterizedThreadStart vs Lambda

AspectThreadStartParameterizedThreadStartLambda
ParametersNoneSingle objectAny via closure
Type safetyN/ALost (object cast)Full
Return valueNoneNoneNone (use shared state)
ReadabilityGood for methodsRequires castBest
When to useSimple tasksLegacy codeMost cases

When to use each:

  • ThreadStart: Simple tasks with no input data
  • ParameterizedThreadStart: Legacy code or when you must match existing APIs
  • Lambda: Almost always the best choice for new code

Thread Configuration

Beyond basic creation, .NET provides several configuration options that affect how threads behave.

Naming Conventions

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

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:

PriorityValueUse Case
Highest4Critical real-time tasks
AboveNormal3Important but not critical
Normal2Default for most work
BelowNormal1Background processing
Lowest0Idle-time tasks

Important caveats:

  1. Priority only matters when there's CPU contention.
  2. High-priority threads can starve lower-priority ones.
  3. Relying on priority for correctness is a design smell.
  4. Different operating systems interpret priorities differently.

Background vs Foreground Threads

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:

  1. Set IsBackground before or after Start() (unlike Java's daemon, this can be changed anytime).
  2. ThreadPool threads are always background threads.
  3. Never use background threads for tasks that must complete (file writes, transactions).
  4. Background threads don't run finally blocks when the app exits.

Apartment State (Windows-Specific)

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.

StateMeaningUse When
STASingle-Threaded ApartmentUI work, Office interop, Clipboard operations
MTAMulti-Threaded ApartmentDefault for non-UI threads
UnknownNot setDefaults to MTA when started

When STA matters:

  • Windows Forms and WPF UI threads must be STA.
  • Microsoft Office interop requires STA.
  • Clipboard operations need STA.
  • Some legacy COM components require STA.

Thread Lifecycle Management

Understanding how to properly start, wait for, and stop threads is essential for correct concurrent programs.

The Start() Method

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.

Join() and Timeouts

The Join() method blocks the calling thread until the target thread terminates:

The overloaded versions:

Thread.Sleep() vs Task.Delay()

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.

Abort() - Deprecated and Removed

In .NET Framework, Thread.Abort() could forcibly terminate a thread by injecting a ThreadAbortException. This was inherently dangerous because:

  1. The exception could be thrown at any point, leaving objects in inconsistent states.
  2. Locks might not be released properly.
  3. Finally blocks would run, but catch blocks could catch and swallow the exception.
  4. It was unreliable for stopping threads in unmanaged code.

Important: Thread.Abort() was removed in .NET Core and .NET 5+. Calling it throws PlatformNotSupportedException. Use cooperative cancellation with CancellationToken instead.

Interrupt()

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.

The Modern Way: CancellationToken

.NET's recommended approach for stopping threads is cooperative cancellation using CancellationToken. This pattern is used throughout the TPL and async/await.

Basic Pattern

CancellationTokenSource

The CancellationTokenSource (CTS) controls the token:

Timeout Cancellation

CTS can automatically cancel after a timeout:

Linking Tokens

You can create a token that cancels when any of several sources cancel:

Registering Callbacks

You can register callbacks that run when cancellation is requested:

Returning Results from Threads

Getting results back from threads is a common requirement. C# provides several approaches.

Shared Variables with Lock

The traditional approach uses shared variables with proper synchronization:

This works but requires careful synchronization and is error-prone.

Thread-Local Storage (ThreadLocal<T>)

When each thread needs its own copy of a value:

TaskCompletionSource Pattern

When you need to return results from thread-based code to Task-based callers:

Why Task<T> is Preferred

For most scenarios, you should skip raw threads entirely and use Task<T>:

ThreadPool Basics

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.

Using the ThreadPool

ThreadPool Properties

ThreadPool vs Raw Threads

AspectThreadPoolRaw Thread
Creation costVery low (reuses threads)High (OS thread creation)
Stack size256KB (default)1MB (default)
IsBackgroundAlways trueFalse by default
Apartment stateAlways MTAConfigurable
ControlLimited (no name, no priority)Full control
Use whenShort-lived tasksLong-running tasks, specific configuration needed

When to use raw threads:

  • You need specific thread names for debugging.
  • You need to set priority or apartment state.
  • The thread runs for the application's lifetime.
  • You need a foreground thread.

When to use ThreadPool:

  • Short-lived tasks.
  • You don't need specific thread configuration.
  • You want maximum efficiency.

In practice, use Task.Run() which uses the ThreadPool internally but provides a richer API.

Common Patterns

Fire-and-Forget Pattern

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.

Worker Thread with Loop Pattern

A long-lived thread that processes items:

Background Cleanup Daemon Pattern

A background thread for periodic maintenance:

Producer-Consumer Setup Pattern

Multiple producers and consumers sharing a queue: