System.Threading.Thread: what it maps to, its cost, its API surface in .NET 10 and the narrow cases where a dedicated thread is the right choice.
A System.Threading.Thread is a managed wrapper over one dedicated operating-system thread with its own stack and scheduling. Creating one is expensive (a stack reservation, a kernel object, scheduler work), so normal application code uses the ThreadPool through Task.Run, async/await or framework hosts. I only create a Thread directly for long-lived, dedicated work: a message pump, a blocking legacy loop, a thread that needs a specific apartment state, name, priority or stack size, or work that must never compete with pool threads.
"A
System.Threading.Threadis a managed wrapper over one dedicated operating-system thread: its own stack, its own scheduling by the OS, its own thread-local state. Creating one is expensive and every thread costs memory and scheduler time, which is why ordinary code uses the ThreadPool, throughasync/await,Task.Runor the framework host. I still create aThreaddirectly only for a small number of long-lived, dedicated jobs: a loop that must block by design (a legacy SDK with only synchronousReceive), a thread that needs STA apartment state for COM or a UI pump, or work that needs its own name, priority or stack size or must keep running when the pool is starved. And I stop those threads cooperatively with aCancellationToken, becauseThread.Abortdoesn't exist on modern .NET."
Thread is| Layer | What exists |
|---|---|
| OS | A kernel thread: scheduled by the OS, owns a stack and register context |
| CLR | A native Thread structure: GC allocation context, execution state, exception state |
| Managed | System.Threading.Thread object: Name, IsBackground, ManagedThreadId, CurrentCulture, etc. |
On Windows, Linux and macOS, .NET maps managed threads 1:1 to OS threads.
| Cost | Detail |
|---|---|
| Stack | Reserved address space per thread (around 1–1.5 MB by default, platform dependent; configurable via the constructor's maxStackSize) |
| Creation/teardown | Kernel calls, CLR setup, thread attach/detach notifications |
| Scheduling | Every runnable thread competes for cores; more threads → more context switches |
| GC | Every managed thread's stack is a root that must be scanned and suspended during GC |
| Diagnostics | More threads make dumps and traces harder to read |
A few dozen threads are fine. Thousands of mostly-blocked threads waste memory and slow GC suspension.
| API | Use |
|---|---|
new Thread(start, maxStackSize) + Start() |
Create and start |
UnsafeStart() (.NET 5+) |
Start without flowing ExecutionContext (avoids leaking AsyncLocal values into a long-lived thread) |
Join() / Join(timeout) |
Wait for completion |
IsBackground |
Don't keep the process alive |
Name |
Visible in dumps, debuggers, dotnet-stack |
Priority |
Rarely; can starve other work |
SetApartmentState(ApartmentState.STA) |
Windows only, before Start |
Thread.Sleep, Yield, SpinWait |
Low-level waiting |
Abort, Suspend, Resume |
Not supported on .NET (Core): obsolete and throw PlatformNotSupportedException |
| Need | Choice | Why |
|---|---|---|
| I/O-bound work | async/await |
No thread is held while waiting |
| Short CPU-bound work off a UI thread | Task.Run |
Reuses pool threads |
| Long-running background job in a service | BackgroundService with async code |
Lifetime tied to the host |
| Infinite blocking loop (sync SDK) | Named background Thread |
Doesn't permanently consume pool threads |
| COM STA object / message loop | Thread + SetApartmentState(STA) |
Pool threads are MTA |
| Must run even if the pool is starved (watchdog, diagnostics) | Dedicated Thread |
Independent of pool injection |
LongRunning is only half a dedicated threadTask.Factory.StartNew(work, TaskCreationOptions.LongRunning) asks the default scheduler for a new thread. That's fine for a synchronous blocking loop. With an async delegate, the thread ends at the first incomplete await and the rest runs on the pool, so you paid for a thread and didn't keep it.
Our [payments API] consumed [eight] vendor feeds through a synchronous TCP SDK; each feed ran while (true) { client.Receive(); ... } inside Task.Run. After we added new feeds, p99 latency rose from [80 ms] to [2 s]. dotnet-counters showed the ThreadPool thread count creeping up while the work-item queue grew, and a dotnet-stack snapshot showed [eight] pool threads permanently inside the vendor's Receive. Those loops had eaten the pool's minimum threads, so requests waited for slow thread injection. I moved each feed loop to a named background Thread started with UnsafeStart, stopped by a CancellationToken from a hosted service's StopAsync and joined with a timeout. p99 went back to [about 80 ms], the threads were obvious by name in dumps, and we added a review rule against infinite blocking loops on the pool.
The interviewer checks that the candidate knows what a thread costs and why the ThreadPool and async exist.
Green flags
Thread.Abort is gone and uses cooperative cancellation.LongRunning plus async trap.Red flags
Abort or Suspend.new Thread to running codenew Thread(start) -> managed Thread object only (no OS thread yet)
thread.Start() -> CLR creates OS thread (CreateThread / pthread_create)
OS thread starts in CLR's thread entry
CLR sets up: allocation context, ExecutionContext (unless UnsafeStart),
culture, then invokes your delegate
delegate returns -> CLR tears down per-thread state; OS thread exits
Join() callers wake up
Start returns once the OS thread exists; it does not wait for your delegate to start running.
By default, Start() captures the current ExecutionContext (which carries AsyncLocal<T> values such as an Activity or a logging scope) and runs the delegate inside it. For a thread that lives for the whole process, that can pin request-specific data forever. UnsafeStart() (or ExecutionContext.SuppressFlow()) starts the thread with a clean context.
var t = new Thread(DeepRecursion, maxStackSize: 16 * 1024 * 1024); // 16 MB
StackOverflowException, which cannot be caught and kills the process.DOTNET_DefaultStackSize, hex), rarely needed.Thread.Abort was removedAbort injected a ThreadAbortException at an arbitrary instruction: inside a lock, halfway through updating shared state, during a static constructor (which then failed permanently for the type). There's no way to write code that's robust to that everywhere. .NET Core removed it; cooperative cancellation is the replacement:
while (!token.IsCancellationRequested)
{
var msg = client.Receive(timeout: TimeSpan.FromMilliseconds(500)); // bounded blocking
if (msg is not null) Handle(msg);
}
If the blocking call has no timeout or cancellation support, closing the underlying resource (socket, connection) from another thread is usually the way to unblock it.
| How it was created | Name in dumps/stacks |
|---|---|
new Thread { Name = "feed-reader" } |
feed-reader |
| ThreadPool worker | .NET TP Worker (recent versions) |
| Thread pool I/O / timer threads | .NET ThreadPool IO, .NET Timer etc. (names vary by version/platform) |
Naming dedicated threads is free and saves time in every incident.
| Version | Change |
|---|---|
| .NET Core 1.0+ | Abort, Suspend, Resume unsupported |
| .NET Core 3.0 | Managed thread pool work begins (ported fully later) |
| .NET 5 | Thread.UnsafeStart |
| .NET 6 | Portable managed ThreadPool default on all platforms |
| .NET 9 | System.Threading.Lock (dedicated lock type) |
| .NET 10 | Thread API unchanged; guidance still "pool + async by default" |
dotnet-stack report -p <pid>; confirm it shows its name.Task.Run and see it become an anonymous pool worker.IsBackground = true, press Enter without cancelling, and observe the process not exiting.reader.Abort() and observe PlatformNotSupportedException.Thread.Sleep(Timeout.Infinite) and compare process memory and GC.GetTotalPauseDuration() before and after a few GC.Collect() calls.AsyncLocal<string> value, start a thread with Start() and another with UnsafeStart(), and print the value in each.using System.Threading;
var cts = new CancellationTokenSource();
// Dedicated, named, background thread for a blocking legacy loop.
var reader = new Thread(() => ReadLoop(cts.Token))
{
Name = "vendor-feed-reader",
IsBackground = true // do not keep the process alive on shutdown
};
reader.Start();
Console.WriteLine("Press Enter to stop");
Console.ReadLine();
cts.Cancel(); // cooperative stop, never Thread.Abort
reader.Join(TimeSpan.FromSeconds(5));
Console.WriteLine($"Reader alive after join: {reader.IsAlive}");
static void ReadLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
// Stand-in for a blocking vendor call such as client.Receive().
Thread.Sleep(200);
Console.WriteLine($"[{Thread.CurrentThread.Name}] message read");
}
}
What happens: new Thread(() => Handle(request)).Start() for every item.
Why it is wrong: creation cost, memory per stack, scheduler load and slower GC suspension.
Do instead: async I/O or Task.Run; keep dedicated threads for a handful of long-lived loops.
Thread.AbortWhat happens: code ported from .NET Framework tries to kill a worker.
Why it is wrong: it throws PlatformNotSupportedException, and was unsafe anyway.
Do instead: cooperative cancellation, bounded blocking calls, then Join with a timeout.
LongRunning with an async delegateWhat happens: Task.Factory.StartNew(async () => { await ...; }, LongRunning).
Why it is wrong: the dedicated thread exits at the first await; you also get a Task<Task>.
Do instead: LongRunning only for synchronous loops; otherwise plain async on the pool.
Task.RunWhat happens: Task.Run(() => { while (true) client.Receive(); }).
Why it is wrong: permanently consumes pool threads and causes starvation under load.
Do instead: a dedicated named background thread, or an async API if one exists.
IsBackgroundWhat happens: a dedicated foreground thread keeps running after Main returns.
Why it is wrong: the process can't exit; deployments hang on shutdown.
Do instead: IsBackground = true and still stop it cleanly via cancellation.
What happens: dumps show dozens of anonymous threads.
Why it is wrong: incident investigation takes longer.
Do instead: always set Name.
Priority to "make it faster"What happens: a worker is set to Highest.
Why it is wrong: it can starve other threads, including GC or request threads, and doesn't make CPU-bound work faster overall.
Do instead: leave priority alone unless a measured latency requirement justifies it.
What happens: code sets Thread.CurrentThread.Name, priority or culture inside a pool work item.
Why it is wrong: the change leaks to whatever runs next on that reused thread.
Do instead: use a dedicated thread, or set culture via proper scoping and restore it.
Start() from a requestWhat happens: the thread captures the request's ExecutionContext (activity, logging scope).
Why it is wrong: request data stays alive and appears in unrelated logs/traces.
Do instead: UnsafeStart() or start such threads at application startup.
Join() without a timeout during shutdownWhat happens: shutdown waits forever on a stuck worker.
Why it is wrong: the host's shutdown timeout is exceeded and the process is killed abruptly.
Do instead: Join(timeout), log if it didn't stop, and design the worker to observe cancellation quickly.
The senior answer is that creating threads is now rare, and every dedicated thread should be justified. I look for blocking loops on Task.Run in code review, because they quietly steal pool capacity, and I name every dedicated thread so it is obvious in dumps and traces.
| Need | My choice | Why |
|---|---|---|
| CPU work, short | Task.Run |
Reuses pool threads |
| Async I/O | async/await |
No thread held while waiting |
| Blocking loop forever | Named background Thread |
Does not starve the pool |
| COM STA or UI pump | Thread + SetApartmentState |
Pool threads are MTA |
| Hosted background job | BackgroundService with async code |
Lifetime tied to the host |
The ThreadPool is a process-wide set of reusable background threads that run queued work items: Task.Run, async continuations, timer callbacks and ASP.NET Core requests. Work goes into a global queue or, when queued from a pool thread, into that thread's local queue, and idle threads steal from others. The pool starts up to minimum threads (default: processor count) immediately on demand; above that it adds threads gradually, using a hill-climbing algorithm that measures throughput plus a starvation detector that injects threads when queued work is not progressing. That gradual injection is why blocking pool threads causes latency spikes.
A thread starts as Unstarted, becomes Running after Start(), moves to WaitSleepJoin whenever it blocks on Sleep, Join, a lock or a wait handle, and returns to Running when woken. It ends as Stopped when its delegate returns or throws. ThreadState is a flags enum (it can include Background), it is only a snapshot, and you cannot restart a stopped thread: calling Start twice throws ThreadStateException. An unhandled exception on any thread terminates the process.
A process is a running program with its own virtual address space, handles (open files, sockets) and security context; processes are isolated from each other. A thread is a path of execution inside a process with its own stack, registers and thread-local storage, while sharing the process's memory with other threads. Sharing makes communication cheap but requires synchronisation. In .NET, a System.Diagnostics.Process represents an OS process, a Thread is one-to-one with an OS thread, the thread pool keeps a reusable set of threads, and a Task is a unit of work scheduled onto them. An await on I/O usually uses no thread at all while it waits.
How to tell it (STAR)
In [a payments API at my last company], latency jumped after we added [new vendor feeds]. I took stack snapshots and saw [eight] pool threads stuck in the vendor's blocking receive. I moved those loops onto dedicated named threads with cooperative cancellation, and p99 went back to [about 80 ms]. We then added a review rule and an alert on pool queue length.
Last updated 31 Oct 2026.