Lesson 1 of 5
Threads, Tasks and What async Does
"If I want to mix a glass of sugar and water, I must, willy nilly, wait until the sugar melts." — Henri Bergson, Creative Evolution (1907)
Bergson is making a point about time that turns out to be the whole of asynchronous programming. Some things take as long as they take. No amount of effort dissolves the sugar faster, and the only question left is what you do with yourself while it happens — stand over the glass, or go and do something else and come back.
That is the distinction this Part is built on, and it is the one almost every newcomer gets wrong, because async reads like a performance feature and is not one. Marking a method async makes nothing faster and starts no thread. It changes where your code is allowed to pause. Whether anything else happens during that pause, and on which thread the work resumes, are separate questions with separate answers — and this chapter measures all of them rather than asserting any.
Six jobs, each of which takes 150ms of waiting. Run one after another they take about 900ms; run concurrently, about 150ms. But there are two ways to get that concurrency, and one of them occupies a thread per job while the other occupies none. Which is which, and why does the difference decide whether a server survives its traffic?
Which thread am I on?
The first thing to establish is that this is a question you can simply ask, at any point, and that the answer is often not the one you assumed.
Click Run to compile and execute the code.
Three different mechanisms, three different kinds of thread. Task.Run borrows a thread from the pool and reports itself as a pool thread. new Thread creates a dedicated operating system thread that is not a pool thread and that dies when its delegate returns. TaskCreationOptions.LongRunning is the bridge between the two: it looks like a task, but it asks for a dedicated thread rather than a pool one, which is exactly what you want for something that will occupy a thread for minutes and would otherwise starve the pool.
Do not memorise the thread numbers themselves. They are assigned by the runtime, they differ between runs, and the only thing that matters is which ones are the same and which differ.
The last group is the one worth dwelling on. After each await, this code resumes on the thread it started on. That is not a universal truth — it is this environment's answer, and the final line says why: there is a SynchronizationContext, so continuations are posted back to the original thread. A desktop UI application behaves the same way, which is what lets you touch a control after an await. A console application or an ASP.NET Core request has no such context, and continuations resume on a pool thread instead, which may well be a different one each time. The next chapter takes that machinery apart; for now, the lesson is that "which thread resumes my code" has an environment-dependent answer and should never be assumed.
Think of a restaurant kitchen. A thread is a cook: hiring one is expensive, you can only afford a certain number, and a cook who stands watching a pot is a cook who is cooking nothing else. A task is an order ticket: a scrap of paper describing work to be done, costing almost nothing to write, and meaningless on its own. Awaiting is putting the ticket aside while the oven does its work and picking up the next one; blocking is the cook standing in front of the oven for twenty minutes. The kitchen's capacity is set by cooks, not tickets — which is why a restaurant can accept a thousand orders with six cooks, and why six cooks all staring into ovens means nothing comes out at all.
async does not mean "on another thread"
Here is the misconception stated plainly, and then dismantled by printing the order in which things actually happen.
Click Run to compile and execute the code.
The numbered sequence is the heart of it. Step 2 prints before step 3, on the caller's thread. Calling an async method does not schedule anything — it runs the body immediately, synchronously, like any other method, right up until it reaches an await that actually needs to wait. Only then does it return to its caller, handing back a Task that represents the unfinished remainder.
AlreadyDoneAsync makes the same point from the other side. It has no await in it at all, so it never pauses — and in a normal build the compiler says so, with warning CS1998: this async method lacks 'await' operators and will run synchronously. That warning is not noise. It is the compiler telling you that the async keyword is doing nothing here except wrapping your return value in a Task and adding overhead, and the fix is to drop async and return Task.FromResult(7) instead.
The third group shows the same behaviour for a task that is already finished. await on a completed task does not yield, does not post a continuation, and does not change threads; it reads the result and carries straight on. This is the single most important optimisation in the whole async machinery, because in real code a great many awaits are on things that turn out to be already done — a cache hit, a buffered read — and they cost almost nothing.
And then the last group: Task.Run is what moved the work. Not async, not await. If you want code to run on a different thread, that is the call that does it.
The syntax arrived in C# 5 in 2012, but the hard part had been settled earlier. Before it, asynchronous .NET code meant one of two awkward patterns: the Asynchronous Programming Model, with its matched pairs of BeginRead and EndRead and an IAsyncResult to thread between them, or the Event-based Asynchronous Pattern, where you subscribed to a Completed event and hoped to keep the state straight. Both scattered a single logical operation across several methods, and both made ordinary control flow — a loop, a try/finally, an early return — extraordinarily painful. The Task Parallel Library in .NET 4 supplied the missing piece by making the result a first-class value, a Task you could pass around and compose. async/await then added the compiler transformation that let you write the composition as straight-line code. The design drew heavily on F#'s asynchronous workflows, and the pattern has since been copied into JavaScript, Python, Rust and others — one of the clearer cases of a language feature escaping the language it was designed for.
What they cost
A thread and a task are not comparable quantities, and the measurements say why.
Click Run to compile and execute the code.
Task.CompletedTask costs 0 bytes — it is a single cached instance handed out to everyone, which is why returning it from a method with nothing to await is the right thing to do. Task.FromResult(42) allocates a small object to carry the value.
The middle pair is the case worth knowing. An async Task<int> that completes without ever pausing still allocates a Task to hand back — measured here at 32 bytes. The ValueTask<int> version costs 24, because a ValueTask is a struct that can carry the result directly and only falls back to allocating when the operation genuinely has to pause. The saving is small in absolute terms and the point is not the eight bytes; it is that on a path called millions of times where the answer is usually already available, ValueTask lets the common case allocate nothing extra at all. That is why the framework's buffered-stream and channel APIs return it.
Then the contrast: the method that really does pause costs 207 bytes, because the compiler-generated state machine — holding the locals, the resume point, and the awaiter — must move to the heap so it can survive the gap. That number is the price of a genuine suspension, and the next chapter is about what exactly got allocated.
The last group is a caution about this particular environment. The pool here reports the same number for minimum and maximum, so it does not grow — a consequence of running inside a browser, where threads are web workers allocated up front. On a server the maximum is in the thousands and the pool injects new threads slowly when the existing ones are busy. That slow injection is precisely what makes the next section's failure mode so damaging.
While measuring that last figure I wrote the bug this Part exists to warn about, which was humbling enough to be worth reporting. My first version read var t = RealAsync(); sink = t.GetAwaiter().GetResult(); — blocking until the task finished. But RealAsync awaits, and in this environment its continuation is posted back to the same thread I was blocking on, so the continuation could never run and the task could never complete. That is sync-over-async deadlock, the subject of a later chapter in this Part, written by accident into a measurement harness. It did not hang politely either: it took the entire runtime down, and I spent a while blaming the playground before reading my own code. The fix was to stop blocking — measure the allocation of starting the task and let it finish on its own. The general lesson is the one this book keeps arriving at from different directions: when something fails, the cheapest hypothesis to test is that the error is yours.
Waiting is not blocking
This is the section that matters most in production, and the whole of it is in one measurement.
Click Run to compile and execute the code.
The first pair is unsurprising and still worth seeing: six sequential awaits take about 950ms, the same six started together and awaited as a group take about 187ms. await in a loop is sequential. Concurrency comes from starting the work before you await it, which is what Task.WhenAll over a set of already-started tasks expresses.
The second pair is the one to remember. Both achieve the same concurrency. The awaiting version finishes in roughly the same time while occupying a single thread, because Task.Delay holds no thread at all — it registers a timer and the thread goes back to doing other things. The blocking version uses a separate pool thread for every job, because Thread.Sleep means a thread sitting there doing nothing. Run it yourself and read the counts the block prints rather than trusting numbers in prose; they depend on the pool and on the environment, and the exact figures vary between runs.
It is worth being concrete about what "holds no thread" means, because it sounds like a trick. For a timer, the runtime registers the due time with the operating system and returns; nothing is watching it. For real I/O — a socket read, a file read — the request is handed to the operating system, which already knows how to notify a program when a device has data, and the thread goes back to the pool. In neither case is there a thread parked somewhere waiting. When the timer fires or the data arrives, the runtime takes the continuation it saved and schedules it onto a thread then, for the microseconds it takes to run. So a server can have tens of thousands of requests outstanding on a dozen threads, because an outstanding request is a small object, not a thread. Blocking throws that away: Thread.Sleep and .Result ask a thread to stand there and do nothing until the answer comes back.
Scale that up and it becomes the classic production failure. Each blocked request holds a pool thread; the pool grows only slowly; incoming requests queue for threads that are all asleep. Throughput collapses while the processors sit idle, which is the diagnostic signature — a server at nearly zero CPU serving almost nothing. The cause is almost always blocking calls on an async path: .Result, .Wait(), Thread.Sleep, or a synchronous file or network API used where an asynchronous one exists.
Thread pool starvation has a recognisable shape worth carrying into an incident. CPU is low, memory is fine, the database is healthy and responsive, and yet request latency climbs until things time out — because the bottleneck is neither the processors nor the data store but the number of threads, all of which are parked inside a blocking call. The tell in the code is usually a single synchronous bridge: a library without an async API, a constructor or a property that cannot be awaited and so calls .Result, or an event handler someone made async void and then blocked on. It tends to appear under load rather than in testing, because with a handful of concurrent users there are threads to spare. The defensive habits are simple and unglamorous: make async code async all the way down, never block on a task you are also responsible for completing, and treat every .Result and .Wait() in a request path as something to justify rather than something to tidy later.
Tasks as values
A Task is an ordinary object you can store, inspect, pass around and combine. That is what makes composition possible.
Click Run to compile and execute the code.
A task's Status moves from WaitingForActivation to RanToCompletion or Faulted. Crucially, a failing async method does not throw when you call it — it returns a task that holds the exception, and the throw happens when you await. Build the task and nothing happens; await it and the original exception comes back out with its stack intact.
The WhenAll behaviour is a genuine trap. Awaiting a faulted WhenAll rethrows only the first exception, so a catch block sees one failure where there were two. The others are not lost — they are on the task's Exception property, an AggregateException whose InnerExceptions holds all of them — but you have to go and look. If you need to report every failure, inspect the aggregate rather than relying on what await rethrows.
WhenAny returns the first task to finish, which is the basis of every timeout and every "race the replicas" pattern. Note that the losing task is still running; it is not cancelled by having lost, and if nobody ever awaits it, any exception it throws goes unobserved. Cancellation is a separate mechanism with its own chapter later in this Part.
Finally, WhenAll returns results in the order of the tasks you passed, not the order they completed — a,b,c even though b finished first. That ordering guarantee is what makes it safe to zip the results back against the inputs.
Common Mistakes
Believing
asyncstarts a thread. It does not. The method body runs synchronously on the caller's thread until the firstawaitthat genuinely has to wait.Using
Task.Runfor work that is already asynchronous. Wrapping an async I/O call inTask.Runoccupies a pool thread to wait for something that needed no thread at all.awaiting inside a loop when the work is independent. That is sequential by construction. Start the tasks first, thenawait Task.WhenAll.Blocking with
.Resultor.Wait()on an async path. It holds a thread, and where a synchronisation context is present it can deadlock outright, because the continuation needs the thread you are blocking.Using
Thread.Sleepin asynchronous code. It blocks a pool thread for the duration.await Task.Delayholds none.Catching only what
awaitrethrows fromWhenAll. You get the first exception; the rest are on the task'sAggregateExceptionand will be missed if you do not look.Assuming
WhenAnycancels the losers. The other tasks keep running, and an exception from one nobody awaits is never observed.Writing
async void. Outside an event handler it is nearly always wrong: the caller cannot await it, cannot know when it finished, and cannot catch its exceptions, which instead tear down the process.Marking a method
asyncwith nothing to await. The compiler warns (CS1998) and you pay the state-machine overhead for nothing. ReturnTask.CompletedTaskorTask.FromResultinstead.Assuming code resumes on the thread it started on. That depends on the synchronisation context: true in a UI app, false in a console app or an ASP.NET Core request.
Interview Corner
async make it run on another thread?async only allows the method to contain await, and the body runs synchronously on the calling thread right up to the first await that actually has to wait — if there is none, it runs start to finish without pausing at all. What await does is release the current thread while the operation is outstanding, so that thread can do something else, and arrange for the rest of the method to run when the operation completes. If you genuinely want work on a different thread, that is Task.Run, which hands it to the thread pool. The distinction matters because the two solve different problems: Task.Run is for CPU-bound work you want off the current thread, and async/await is for I/O-bound waiting where no thread should be involved at all..Result, .Wait(), Thread.Sleep, or a synchronous I/O API on a request path. Each blocked request holds a pool thread; the pool only injects new threads slowly, so once the existing ones are parked, incoming requests queue for threads that are all asleep and throughput collapses while the processors idle. It tends to show up only under load, because with light traffic there are spare threads. I would confirm it by looking at the thread count and what the threads are doing in a dump, and the fix is to make the path async all the way down rather than adding threads, since raising the minimum thread count only buys time. I would also check for a synchronous bridge somewhere a method could not be made async — a constructor or a property — because that is usually where the blocking call hides.Task.Run the right tool, and when is it actively harmful?Task.Run does not remove the blocking, it just moves it onto a pool thread; you have spent a thread to wait for something that needed none, and under load that is starvation with extra steps. It is also usually wrong inside an ASP.NET Core request, where there is no UI to keep responsive and you are simply moving work from one pool thread to another, paying scheduling for nothing. The question I would ask is whether the work is burning CPU or waiting: burning, Task.Run is reasonable; waiting, find the real async API, and if the library has none, be honest that you are blocking a thread and size for it. For work that occupies a thread for a long time, TaskCreationOptions.LongRunning asks for a dedicated thread instead of a pool one.async creates either.await inside a loop, a .Result on a request path, or an async void.Hands-on
Write the three judgements this chapter has been making: whether a construct moves work to another thread, how long a set of jobs takes under each strategy, and how many threads each strategy ties up. The harness scores ten checks, each pairing a case that must be true with one that must be false. A correct solution prints score 10/10.
Click Run to compile and execute the code.
Run it unchanged first: ten failures and score 0/10. A stub that scores above zero means a check is not testing what it claims.
Summary
asyncstarts no thread. The body runs synchronously on the caller's thread until the firstawaitthat actually has to wait, which the numbered trace in this chapter shows directly.awaiton an already-completed task does not pause, post a continuation, or change threads — it reads the result and continues, which is why the common case is cheap.Task.Runis what moves work to a thread, borrowing one from the pool;new Threadcreates a dedicated one;TaskCreationOptions.LongRunningasks for a dedicated thread behind a task-shaped API.- Where code resumes after an
awaitdepends on the synchronisation context — the same thread in a UI app or this runtime, a pool thread in a console app or an ASP.NET Core request. - A task is an object, a thread is an operating system resource.
Task.CompletedTaskcosts nothing, a synchronously-completingasync Task<int>about 32 bytes, and one that genuinely pauses about 207 for its heap-allocated state machine. ValueTaskexists for the path that usually completes immediately, avoiding theTaskobject in that case and falling back to allocation only when it really has to wait.awaitin a loop is sequential. Concurrency comes from starting the work first and awaiting the group withTask.WhenAll.- Awaiting holds no thread; blocking holds one per job. Six concurrent awaits ran on a single thread where six blocking jobs took one pool thread each.
- Thread pool starvation is the production failure this prevents — low CPU, climbing latency, every thread parked in a blocking call.
- A faulted
WhenAllrethrows only its first exception onawait; the rest are on the task'sAggregateException, andWhenAnyneither cancels nor observes the losers.
In the next chapter, What await Compiles Into, we open the state machine: the struct the compiler generates from your method, where your local variables actually live, what an awaiter is, and why the 207 bytes above is exactly the price of being able to pause.
It is worth noticing that nothing in this chapter required understanding how await works. Every claim here was settled by printing a thread id, a byte count or a millisecond figure — and the sync-over-async deadlock in the notebook was found the same way, by reading output instead of defending an assumption. That is deliberate, and it is the order this book prefers: establish what the thing does, from observations anyone can repeat, before opening it up to see how. The temptation with concurrency is the reverse, because the machinery is genuinely interesting and the folklore is thick. But a mental model built on measurements survives contact with a production incident, and one built on a half-remembered diagram of the state machine does not.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.