
A Beginner's Guide to Threads, Processes and Async/Await in C#
A user reports that the app freezes when they export a large report. You open the code and find three different attempts at fixing it: a Task.Run, a fresh Thread, and a helper process launched with Process.Start. All three can be reasonable. They also mean completely different things.
Processes, threads and async workflows are three answers to one question: how do I get more than one thing done at once? Picking the wrong level accounts for a good share of the concurrency bugs in C# codebases. Here is what each one actually is, and when it earns its place.
Processes: the walls around your code
A process is the operating system's unit of isolation. Your console app is a process. So is the ASP.NET Core application serving this page. Each has its own virtual address space, its own file handles and its own copy of the runtime. One process cannot reach into another's objects, and if it crashes, its neighbour usually carries on.
That isolation is not free. Starting a process takes milliseconds and megabytes, and moving data between them means serialising it through pipes, sockets or files:
var start = new ProcessStartInfo("ffmpeg", "-i input.mp4 out.mp4") { RedirectStandardOutput = true };
Reach for a separate process when you want a hard boundary. Running untrusted or third-party code, hosting a plugin that might take the whole application down with it, targeting a different runtime version, or keeping a heavy batch job from stealing memory and CPU from your web app. The boundary is the point, not the overhead.
Threads: several workers in one house
A thread is a unit of execution inside a process. Threads share the same heap, so they see the same objects. That is fast, and it is also where the trouble starts. Each thread gets its own stack and its own notion of where it is in the code, and the OS schedules them across your cores.
var worker = new Thread(() => CrunchNumbers()); worker.Start();
Because memory is shared, two threads writing to the same field is a race. The compiler, the CPU and the runtime are all allowed to reorder operations you assumed were ordered. Guard shared state with lock, Interlocked or a concurrent collection, and prefer passing immutable data between threads over sharing mutable data.
Threads are comparatively expensive. Each reserves a stack, and switching between them costs time, so creating thousands is a bad plan. The thread pool exists precisely so you do not have to: Task.Run and Parallel.For use it for you.
Threads earn their keep on CPU-bound work: resizing images, parsing large files, running calculations. They also suit genuinely long-running loops. What they are bad at is waiting. A thread parked on Thread.Sleep or a synchronous read is a thread doing nothing useful, and inside a server that is capacity thrown away.
Async and await: waiting without occupying a thread
Async is about latency, not parallelism. When your code awaits a database query or an HTTP response, the real work is happening elsewhere: on a network card, a disk, a database server. Awaiting says "I have nothing to do until this finishes, so release this thread".
public async Task<string> GetTitleAsync(HttpClient client, string url)
{
var response = await client.GetAsync(url);
return await response.Content.ReadAsStringAsync();
}
No thread sits parked on that await. The method's state is captured, and when the I/O completes, a continuation runs on a thread pool thread. That is why one server with a modest number of threads can handle hundreds of slow requests at once.
The habit worth building is async all the way down. If a method is async, its callers should be too. Blocking on a task with .Result or .Wait() in a UI app or in ASP.NET can deadlock, and even when it does not, it occupies a thread pool thread waiting for something that never needed one.
Concurrency without extra threads
Task.WhenAll lets you start several operations and await them together: var tasks = urls.Select(u => client.GetStringAsync(u)); var pages = await Task.WhenAll(tasks); On a four-core machine that can mean two hundred I/O operations in flight with almost no additional threads.
How the three fit together
Picture a web request. The browser talks to a process: your application. Kestrel hands the request to a thread pool thread, which runs your handler. Your handler awaits the database and frees the thread; later it continues on another. Meanwhile a separate worker process runs the nightly import, and inside it a Parallel.For spreads the number crunching across cores. One request, three levels, each doing what it is good at.
Choosing the right tool
- Waiting on I/O — HTTP calls, database queries, files, message queues: use async/await.
- CPU-bound work you want off the current thread — Task.Run, Parallel.For, PLINQ.
- Untrusted code, crash isolation, a different runtime, heavy batch jobs — a separate process.
- Long-running background work — a hosted service or dedicated thread is usually enough; move to a process when a crash must not take the app down with it.
- A responsive user interface — async for I/O, offload anything computationally heavy.
Where beginners trip up
- Blocking on async. .Result, .Wait() and .GetAwaiter().GetResult() can deadlock and starve the thread pool. Make the caller async instead.
- Thread.Sleep in async code. It blocks a real thread. Use await Task.Delay.
- Fire-and-forget tasks. Calling an async method and dropping the task loses the exception. Keep the task, await it, or handle errors inside.
- Assuming Task.Run speeds up I/O. It moves blocking work to another thread; it does not shorten the wait.
- Unbounded fan-out. Starting ten thousand tasks at once can exhaust connections or memory. Cap it with Parallel.ForEachAsync or a SemaphoreSlim.
A practical starting point
When something feels slow or frozen, ask what the code is actually waiting on. A short routine helps:
- Is the wait I/O? Make the whole call chain async and await it.
- Is it CPU work on a UI thread or inside a request? Measure first, then move it with Task.Run or Parallel.For.
- Is it a long-running background job? A hosted service or dedicated thread is usually enough, and a separate process is the answer when isolation matters.
- Are you blocking a thread pool thread? Search the codebase for .Result, .Wait(), Thread.Sleep and synchronous I/O on hot paths.
- Is mutable state shared between threads? Lock it properly, or stop sharing it.
Keep those five questions in mind and the distinction stops being academic. Threads and processes describe where work runs; async describes what happens while you wait. Most real C# systems need both, applied in the right places.
Photo: Ron Lach / Pexels
Designing Backpressure into Event-Driven Systems
Queues, rate limits and load shedding are the three levers that stop a busy producer overwhelming a slower...
Debugging Race Conditions with ThreadSanitizer: A Practical Walkthrough
ThreadSanitizer catches data races that only appear under load. Here is how to build with it, read its...




