What a process owns (address space, handles, security context) versus what a thread owns (stack, registers, thread-local storage), how threads share memory, how .NET threads map to OS threads, how the thread pool and Task build on them, and when to use multiple processes instead of threads.
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.
| Process | Thread | |
|---|---|---|
| Memory | Own virtual address space | Shares the process's address space |
| Stack | One per thread | Own stack (~1–1.5 MB reserved) |
| CPU state | n/a | Own registers and instruction pointer |
| Handles and file descriptors | Owns them | Uses the process's |
| Security context | Own user and token | Usually inherits the process's |
| Scheduling | Not scheduled itself | The unit the OS schedules |
| Failure impact | Isolated from other processes | An unhandled exception on any thread usually terminates the whole process |
| Creation cost | Higher (new address space, loading) | Lower, but still microseconds and a stack |
lock, SemaphoreSlim, Interlocked, concurrent collections, Channel<T>.Task / async-await units of work, continuations
|
ThreadPool reusable worker threads + I/O completion handling
|
System.Threading.Thread one managed thread = one OS thread
|
OS thread scheduled by the kernel on a core
|
Process dotnet.exe / your apphost, one CLR instance
new Thread(...) creates a dedicated OS thread. Use it rarely (long-running loops with special needs).ThreadPool and Task.Run reuse threads, avoiding creation costs. ASP.NET Core requests run on thread-pool threads.async/await on I/O releases the thread while the operation is in progress; the continuation runs later on a pool thread. That's why one server can handle thousands of concurrent requests with a few dozen threads.Task.Factory.StartNew(..., TaskCreationOptions.LongRunning) hints that a dedicated thread is appropriate.AssemblyLoadContext for loading and unloading plug-ins, which isn't a security boundary).| Use threads (within one process) | Use separate processes or containers |
|---|---|
| Parallel CPU work on shared data | Fault isolation: a crash shouldn't take everything down |
| Concurrent I/O in one service | Different scaling needs (API versus background worker) |
| Low-latency communication | Different security contexts or least-privilege boundaries |
| Untrusted plug-ins or native code that might corrupt memory |
A reporting service ran heavy PDF generation on the same process as its API. A native PDF library bug occasionally crashed the process, taking down all API requests too. Moving rendering into a separate worker process fed by a queue isolated the crashes: the worker restarted and retried, and the API kept serving.
A fundamentals question that reveals whether the candidate understands concurrency in .NET.
Green flags: lists what each owns, explains sharing and isolation, maps Thread, thread pool and Task correctly, knows async I/O doesn't hold a thread, gives good reasons for separate processes.
Red flags: "a thread is a lightweight process" with nothing more; equates tasks with threads; suggests creating threads per request.
Process 4312 (MyApi)
+---------------------------------------------------------------+
| virtual address space |
| code (JIT + native) | GC heap | native heap | mapped files |
| |
| thread 1 stack | thread 2 stack | thread 3 stack |
| |
| handle table: files, sockets, events, threads |
| security token / uid |
+---------------------------------------------------------------+
thread 1: registers, IP thread 2: registers, IP thread 3: ...
all three see the same GC heap and statics
clone). Tools like top -H and ps -L show threads.Environment.CurrentManagedThreadId) and an OS thread ID; debuggers and dotnet-dump show both..Result, .Wait(), synchronous I/O) cause thread pool starvation: requests queue while the pool slowly grows.ThreadPool.GetAvailableThreads, dotnet-counters (threadpool-thread-count, threadpool-queue-length) show the state.await httpClient.GetAsync(url)
1. start the request (a few microseconds on a pool thread)
2. return to the caller; the pool thread is free
3. ... network round trip: NO thread is waiting ...
4. the OS signals completion (IOCP / epoll); a pool thread runs the continuation
Task.Run(() => CpuWork()) does use a pool thread for the duration of the work. Know which kind of task you have.
| Concern | Process-level tool |
|---|---|
| Isolation and restarts | Kubernetes pods, Windows services with recovery, App Service instances |
| Resource limits | cgroups (CPU, memory), Windows job objects |
| Health | Liveness and readiness probes |
| Graceful shutdown | IHostApplicationLifetime, handling SIGTERM within the termination grace period |
Threads printing their managed and OS thread IDs (Environment.CurrentManagedThreadId and, on Linux, /proc/thread-self or top -H), then compare with three Task.Run calls.Task.Delay(1000) calls and watch the thread count in dotnet-counters: it barely moves.Thread.Sleep(1000) inside Task.Run and watch the thread pool grow slowly, then explain why.Process.Start and communicate with it over standard input and output.namespace ProcessesAndThreads;
using System.Diagnostics;
public static class Program
{
public static async Task Main()
{
using var current = Process.GetCurrentProcess();
Console.WriteLine($"Process {current.Id} has {current.Threads.Count} OS threads");
// Dedicated OS thread (rarely needed)
var worker = new Thread(() => Console.WriteLine($"Dedicated thread {Environment.CurrentManagedThreadId}"))
{
IsBackground = true,
Name = "dedicated-worker",
};
worker.Start();
worker.Join();
// Thread pool work (CPU-bound)
int result = await Task.Run(() => Enumerable.Range(0, 1_000_000).Sum(i => i % 7));
Console.WriteLine($"CPU work result {result}");
// 1,000 concurrent waits use almost no threads
var sw = Stopwatch.StartNew();
await Task.WhenAll(Enumerable.Range(0, 1_000).Select(_ => Task.Delay(1_000)));
current.Refresh();
Console.WriteLine($"1,000 delays in {sw.ElapsedMilliseconds} ms with {current.Threads.Count} OS threads");
// A separate process for isolation
var info = new ProcessStartInfo("dotnet", "--version") { RedirectStandardOutput = true };
using var child = Process.Start(info)!;
string version = await child.StandardOutput.ReadToEndAsync();
await child.WaitForExitAsync();
Console.WriteLine($"Child process {child.Id} reported SDK {version.Trim()}");
}
}
Mistake: saying a Task is a thread.
Many tasks (async I/O, timers) use no thread while waiting, and many tasks share a few pool threads.
Instead: describe a task as a unit of work or a promise of a result, scheduled onto threads when there's code to run.
Mistake: creating a new Thread for each piece of work.
Thread creation costs time and a stack each, and thousands of threads cause heavy context switching and memory use.
Instead: use the thread pool, Task.Run for CPU work, and async I/O.
Mistake: blocking pool threads with .Result or .Wait().
Blocked threads can't serve other work, the pool grows slowly, and requests queue up (thread pool starvation).
Instead: use async all the way, and bound genuinely blocking work with dedicated threads or a limited scheduler.
Mistake: assuming threads are isolated from each other. They share memory, so unsynchronised access corrupts state, and an unhandled exception on any thread usually ends the whole process. Instead: synchronise shared state, prefer immutable data and message passing, and handle exceptions in background work.
Mistake: running risky native work in the main service process. A native crash or memory corruption takes the whole service down. Instead: isolate risky or heavy work in a separate process or container with a queue between them.
Seniors think about processes as the unit of isolation, deployment and scaling, and threads as an implementation detail of concurrency inside a process. In modern .NET services, that means almost never creating threads directly: async I/O for concurrency, the thread pool for CPU work, bounded concurrency with SemaphoreSlim or Channel<T>, and separate processes or containers when failure isolation or independent scaling matters. They watch thread count and pool queue length in production because blocking code is the most common way to break this model.
A context switch is the OS stopping one thread and starting another on a core: it saves the registers, instruction pointer and stack pointer of the old thread and restores those of the new one. Switching between processes also swaps the address space, which is more expensive. The direct cost is a few microseconds, but the indirect cost is often bigger: the new thread finds caches and TLBs cold. Switches are voluntary when a thread blocks, and involuntary when its time slice ends. Too many threads in a .NET service, usually caused by blocking code that makes the thread pool inject more threads, means more switching, more cache misses, more memory for stacks and more lock contention, so throughput falls and latency rises. The fix is async I/O, bounded parallelism around the core count, and less lock contention.
The scheduler keeps a queue of runnable threads per core and gives each a time slice. Scheduling is preemptive: when the slice ends, or a higher-priority thread becomes ready, the running thread is interrupted and another runs. Threads that wait for I/O, locks or timers are blocked and don't use CPU. Windows uses priority-based round-robin with temporary boosts; Linux uses a fair scheduler (CFS, and EEVDF from kernel 6.6). For a .NET service the practical points are: don't play with thread priorities; blocking threads wastes them; and in containers, a CPU limit is enforced as a quota per 100 ms period, so a burst of work can use the quota early and get throttled, causing latency spikes. Environment.ProcessorCount follows the limit and sizes the thread pool and GC.
When it hangs, I press Break All so every thread stops where it is. In the Threads window I look for threads sitting in Monitor.Enter, Lock.Enter or Task.Wait, and switch to each one to read its call stack. Parallel Stacks shows all thread stacks merged into one graph, so two threads each holding a lock and waiting for the other's lock stand out immediately. That's the classic opposite-order deadlock. In production I can't attach, so I take a dump with dotnet-dump and run clrstack -all and syncblk. The fix is a consistent lock order, smaller locks, or removing sync-over-async.
Last updated 23 Oct 2026.