📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is a Thread in .NET, and when would you still create one directly instead of using Task.Run or the ThreadPool?

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.

Mid-Level Theory Intermediate concurrency thread threading threadpool

🎯 Key points

  • A Thread wraps one dedicated OS thread with its own stack
  • Creation is expensive, so normal code uses the ThreadPool
  • Dedicated threads suit long-lived blocking loops, STA/COM, named or prioritised work
  • Thread.Abort, Suspend and Resume are unsupported on .NET (Core); use CancellationToken
  • LongRunning gives a new thread only until the first await
  • Set IsBackground and Name on dedicated threads and Join with a timeout

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"A System.Threading.Thread is 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, through async/await, Task.Run or the framework host. I still create a Thread directly only for a small number of long-lived, dedicated jobs: a loop that must block by design (a legacy SDK with only synchronous Receive), 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 a CancellationToken, because Thread.Abort doesn't exist on modern .NET."


1. What a 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.

2. What a thread costs

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.

3. The API still worth knowing in .NET 10

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

4. Decision table

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

5. LongRunning is only half a dedicated thread

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

Production example

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.

🔍 What the interviewer is checking

The interviewer checks that the candidate knows what a thread costs and why the ThreadPool and async exist.

Green flags

  • Explains the 1:1 mapping to OS threads and the stack cost.
  • Names concrete cases for a dedicated thread (blocking loop, STA, priority).
  • Knows Thread.Abort is gone and uses cooperative cancellation.
  • Knows the LongRunning plus async trap.

Red flags

  • Creates a thread per request.
  • Thinks threads are free or that async creates threads.
  • Suggests Abort or Suspend.

📚 Learn in depth

1. From new Thread to running code

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

2. ExecutionContext flow

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.

3. Stack size

var t = new Thread(DeepRecursion, maxStackSize: 16 * 1024 * 1024); // 16 MB
  • Reserved, not committed: memory is only touched as the stack grows.
  • Exceeding the stack causes a StackOverflowException, which cannot be caught and kills the process.
  • The default can also be changed process-wide with runtime configuration (DOTNET_DefaultStackSize, hex), rarely needed.

4. Why Thread.Abort was removed

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

5. Threads vs pool threads under a debugger

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.

6. Version notes

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"

7. Try it yourself

  1. Run the code example and find the thread with dotnet-stack report -p <pid>; confirm it shows its name.
  2. Change it to Task.Run and see it become an anonymous pool worker.
  3. Remove IsBackground = true, press Enter without cancelling, and observe the process not exiting.
  4. Call reader.Abort() and observe PlatformNotSupportedException.
  5. Start 2,000 threads that each Thread.Sleep(Timeout.Infinite) and compare process memory and GC.GetTotalPauseDuration() before and after a few GC.Collect() calls.
  6. Set an AsyncLocal<string> value, start a thread with Start() and another with UnsafeStart(), and print the value in each.

💻 Code

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");
    }
}

⚠ Common mistakes

1. Creating a thread per request or per small job

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.

2. Calling Thread.Abort

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

3. Using LongRunning with an async delegate

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

4. Infinite blocking loops on Task.Run

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

5. Forgetting IsBackground

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

6. Not naming dedicated threads

What happens: dumps show dozens of anonymous threads. Why it is wrong: incident investigation takes longer. Do instead: always set Name.

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

8. Changing state on pool threads

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.

9. Starting long-lived threads with Start() from a request

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

10. Join() without a timeout during shutdown

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

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says normal code should use the ThreadPool Why: Checks they understand how the pool sizes itself
    How to answer

    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.

  2. When: Candidate mentions Join or cooperative stopping Why: Probes thread states and how threads end
    How to answer

    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.

  3. When: Candidate says a Thread maps to an OS thread Why: Connects to OS process and thread fundamentals
    How to answer

    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.

🛠 Real-world problems: how you handle them

Blocking vendor loops on Task.Run starving the ThreadPool
Situation
A payments API consumed a vendor feed through a synchronous TCP SDK. Each feed ran a `while (true)` loop inside `Task.Run`.
Signals
p99 latency rose from [80 ms] to [2 s] after adding new feeds; `dotnet-counters` showed the ThreadPool thread count climbing slowly while the queue length grew.
Investigation
A `dotnet-stack` snapshot showed [8] pool threads permanently inside the vendor `Receive` call. The pool's minimum was the core count, so the remaining threads could not serve requests until injection caught up.
Resolution
Moved each feed loop to a named background `Thread` with a `CancellationToken`, and stopped them in `StopAsync` of a hosted service.
Prevention
Added a code-review rule: no infinite blocking loops on the pool; alert on ThreadPool queue length.

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.

↩ Usually asked after

More in Threads

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me