This is the feature most C# developers use daily and fewest can explain. The usual mental model, “async makes it run on another thread”, is wrong, and every classic async bug comes from believing it. This post builds the right model from the ground up. It leans on the iterators post, because async/await uses the same compiler trick as yield return: a method rewritten into an object that remembers where it got to.
The problem: threads waiting on nothing
Here is a synchronous web API action:
public Book Get(int id)
{
var book = _http.GetFromJson<Book>($"/books/{id}"); // 200ms of network wait
return book;
}
During those 200 milliseconds, the thread handling this request does nothing. It is not computing. It is parked, holding about 1 MB of stack, waiting for a network card to raise an interrupt.
That is fine for one request. A web server has a thread pool of maybe a few hundred threads. At 500 concurrent requests every thread is parked on I/O, new requests queue with nothing to serve them, and the server times out, while the CPU sits at 3%. It is not out of computing power; it is out of threads to park.
The insight behind async is that waiting does not need a thread. The operating system already tells you when the network response arrives. You do not need somebody standing there.
public async Task<Book> Get(int id)
{
var book = await _http.GetFromJsonAsync<Book>($"/books/{id}");
return book;
}
Now, when the request hits the network, the thread is returned to the pool and serves other requests. When the response arrives, the rest of this method is scheduled onto some available thread. Same 200ms of latency for this caller; a server that handles thousands of concurrent requests on a handful of threads.
Async is about not wasting threads on waiting. It is not about doing work faster, and it is not about creating threads. That sentence is the whole topic.
What Task actually is
A Task is a promise of a future result, not a thread.
Task // "something that will finish, with no result"
Task<Book> // "something that will finish and produce a Book"
A task has a state (running, completed, faulted, cancelled) and carries either the result or the exception. Crucially, a task does not imply a thread. An I/O task is nothing but a callback registration with the OS; there is no thread anywhere assigned to it while it waits. This is the point people miss most.
Two ways tasks come into existence, and they are genuinely different:
await _http.GetAsync(url); // I/O-bound: no thread while waiting
await Task.Run(() => HashPassword()); // CPU-bound: really does use a pool thread
Task.Run exists to move CPU work off the current thread. Use it for genuine computation, and specifically not to make a synchronous I/O call “async”: wrapping File.ReadAllText in Task.Run just burns a pool thread instead of the caller’s, which on a server is strictly worse. Use the real async API (File.ReadAllTextAsync) when one exists.
What await actually does
await is not “wait here”. It is:
Return to my caller now, and resume the rest of this method when the task completes.
The compiler rewrites the method into a state machine, literally the same technique as yield return. Everything before the first await runs synchronously on the calling thread. At the await, if the task is not yet complete, the method returns an incomplete Task to its caller and registers a continuation. When the awaited task finishes, the continuation runs the remainder.
public async Task<string> DescribeAsync(int id)
{
Console.WriteLine("A"); // runs synchronously, on the caller's thread
var book = await _repo.GetAsync(id); // returns to caller here
Console.WriteLine("B"); // runs later, possibly on a different thread
return $"{book.Title} - {book.Price}";
}
Three consequences follow:
asyncalone does nothing. It is a compiler instruction meaning “this method contains awaits, rewrite it”. A method markedasyncwith noawaitruns entirely synchronously and produces a compiler warning.- The code after an await may run on a different thread. Never assume thread affinity across an
await. In a UI application the synchronisation context puts you back on the UI thread; in ASP.NET Core, there is no such context and any pool thread may continue. - Async is viral. To await, you must be
async, so your caller must await, and so on to the top. That is not a defect; it is the type system telling the truth about how the call behaves.
The rules that prevent the classic bugs
Four rules cover nearly every async bug in production.
1. Never block on async code
var book = GetBookAsync(id).Result; // deadlock risk, and wraps exceptions
GetBookAsync(id).Wait(); // same
In any environment with a synchronisation context (WinForms, WPF, older ASP.NET), this deadlocks. The sequence: the UI thread blocks on .Result; the async method finishes its I/O and tries to resume on the captured context, which is the UI thread; the UI thread is blocked waiting for the method to finish. Neither can proceed, and the application freezes with no exception.
ASP.NET Core removed the synchronisation context, so it usually does not deadlock, but blocking still consumes a pool thread doing nothing, which is the exact problem async solved. It also wraps any exception in an AggregateException, as the exceptions post noted.
Async all the way down. If a synchronous entry point is genuinely unavoidable (a Main, a legacy interface implementation) isolate it to that one place, and know it is a compromise.
2. Never use async void
public async void SaveAsync() // wrong
public async Task SaveAsync() // right
async void returns nothing to await, so the caller cannot know when it finished or whether it failed. An exception inside it has no task to be stored on, so it is raised on the synchronisation context and typically terminates the process. It also cannot be tested, because there is nothing to await.
The one legitimate use is an event handler, whose signature must return void. Even there, wrap the body in try/catch.
3. Do not await sequentially when you can run in parallel
var books = await GetBooksAsync(); // 200ms
var authors = await GetAuthorsAsync(); // 200ms, total 400ms
Those two are independent, so the second is waiting for no reason:
var booksTask = GetBooksAsync(); // both start immediately
var authorsTask = GetAuthorsAsync();
await Task.WhenAll(booksTask, authorsTask); // total 200ms
var books = await booksTask;
Calling an async method starts it; await is only where you collect the result. Moving the awaits later is often the single biggest latency win available.
Note the difference between Task.WhenAll (asynchronously waits for all, correct) and Task.WaitAll (blocks a thread, rule 1).
4. Pass a CancellationToken
Long-running work should be abandonable when the caller gives up, a browser tab closing, a timeout, a shutdown signal:
public async Task<Book> GetAsync(int id, CancellationToken ct = default)
{
var response = await _http.GetAsync($"/books/{id}", ct);
return await response.Content.ReadFromJsonAsync<Book>(ct);
}
ASP.NET Core supplies HttpContext.RequestAborted to any action that accepts a CancellationToken parameter. Accept and forward it everywhere; it is nearly free and it stops your service doing work for requests nobody is waiting on. Cancellation surfaces as OperationCanceledException, which is expected rather than an error.
ConfigureAwait(false)
You will see this in library code:
var book = await _repo.GetAsync(id).ConfigureAwait(false);
It says “do not bother resuming on the captured synchronisation context; any thread will do”. It matters in two places:
- In libraries, always. You do not know whether your consumer is a UI app, and resuming on a captured context is both slower and the ingredient that makes
.Resultdeadlock. - In UI applications, when the continuation does not touch UI elements.
In ASP.NET Core application code it is unnecessary, because there is no synchronisation context to capture. That is why you see it all over NuGet packages and rarely in a controller. Answering that distinction correctly is a strong signal in an interview.
A few things worth knowing
ValueTask<T> avoids allocating a Task when the result is usually already available, a cache hit, a buffered read. Use it in hot paths in libraries; do not use it casually, because it may only be awaited once.
IAsyncEnumerable<T> is the iterators post crossed with this one: a sequence produced asynchronously, consumed with await foreach. It is the right shape for streaming database rows or paged API results.
await foreach (var book in _repo.StreamAsync(ct))
Console.WriteLine(book.Title);
Task.FromResult returns an already-completed task, for implementing an async interface synchronously. Task.CompletedTask is the void version.
The same idea inside JavaScript, Python and Go
C#‘s async/await was the first mainstream implementation of this syntax, in C# 5 (2012), and it has been copied nearly verbatim.
JavaScript (Meta, every browser and Node service). Promises are Tasks and the keywords are identical: TC39 explicitly took the design from C#:
const book = await fetch(`/books/${id}`).then(r => r.json());
const [a, b] = await Promise.all([getBooks(), getAuthors()]);
Promise.all is Task.WhenAll. JavaScript’s single-threaded event loop makes the “async is not multithreading” point unavoidable, which is arguably why JS developers internalise it faster than C# developers do.
Python. async def, await, and asyncio.gather as Task.WhenAll. Same keywords, same event loop model, same viral colouring of functions.
Rust. async fn and .await again, with the notable difference that a Rust future does nothing until polled, whereas a .NET Task returned from an async method is already running. That difference is worth knowing: in C#, calling an async method starts the work.
Go (Google). The dissenting design. Goroutines are so cheap that Go simply writes blocking code and lets the scheduler park goroutines on I/O:
book, err := repo.Get(id) // looks blocking, is not, at the OS level
There is no async keyword and no function colouring, which many people consider the nicer model. C# could not do this without changing its threading model, and Go pays for it with a heavier runtime.
Kotlin (Google, Android). Coroutines with suspend functions, structured concurrency and explicit scopes, arguably the most refined version, and the one C# is borrowing from with recent work on cancellation and scoped lifetimes.
Summary
- Async exists so a thread is not wasted parked on I/O. It is about scalability, not speed, and it is not multithreading.
- A
Taskis a promise of a future result. An I/O-bound task occupies no thread while it waits; onlyTask.Runputs work on a pool thread. awaitmeans “return to my caller now and resume the rest when this completes”. The compiler rewrites the method into a state machine, the same technique asyield return.- Code before the first
awaitruns synchronously; code after it may run on a different thread. - Never block with
.Resultor.Wait(): it deadlocks where a synchronisation context exists and wastes a thread everywhere else. - Never use
async voidexcept for event handlers; its exceptions cannot be caught by the caller and crash the process. - Start independent tasks first and
await Task.WhenAllto overlap their latency. - Accept and forward a
CancellationTokenon anything that can take time. ConfigureAwait(false)matters in libraries and UI code; it is a no-op in ASP.NET Core.
Interview questions and answers
1. Does async/await create a new thread?
No. async/await is a way of releasing the current thread while an operation completes elsewhere. For I/O the wait is handled by the operating system with no thread assigned at all; the continuation later runs on some thread from the pool. Only Task.Run explicitly schedules work onto a thread pool thread.
2. What is a Task?
An object representing an operation that will complete in the future, carrying its state and either a result or an exception. It is a promise, not a thread: an I/O-bound task has no thread while it waits, and awaiting one registers a continuation rather than blocking.
3. What does the compiler do with an async method?
It rewrites the method into a state machine class, the same mechanism used for iterators. Each await becomes a suspension point that captures the current state, returns an incomplete Task to the caller if the awaited operation has not finished, and registers a continuation that resumes the method at that point when it does.
4. What is the difference between CPU-bound and I/O-bound async work?
I/O-bound work (network, disk, database) genuinely releases the thread while the OS waits, so async improves scalability. CPU-bound work always needs a thread, so await Task.Run(...) merely moves it to a different one, which helps responsiveness in a UI app but adds overhead on a server without increasing throughput.
5. Why does calling .Result cause a deadlock?
In an environment with a synchronisation context, the blocked thread is the same thread the async method’s continuation is scheduled onto. The caller waits for the method, the method waits for the caller’s thread, and neither can proceed. ASP.NET Core has no synchronisation context so it typically does not deadlock, but blocking still ties up a pool thread and wraps exceptions in AggregateException.
6. Why is async void bad?
Because it returns nothing that can be awaited, so callers cannot know when it completes or observe its failure. An exception thrown inside it has no task to be captured on and is raised directly on the synchronisation context, which usually terminates the process. It is acceptable only for event handlers, whose signature requires void.
7. What is the difference between Task.WhenAll and Task.WaitAll?
Task.WhenAll returns a task that completes when all the supplied tasks complete, and you await it, so no thread is blocked. Task.WaitAll blocks the calling thread until they finish. Use WhenAll in async code; WaitAll reintroduces the problem async was solving.
8. What does ConfigureAwait(false) do and when do you need it?
It tells the awaiter not to resume on the captured synchronisation context, so the continuation runs on any available thread. Libraries should use it everywhere, since resuming on a caller’s UI context is unnecessary, slower, and a prerequisite for the .Result deadlock. It is unnecessary in ASP.NET Core application code because there is no synchronisation context to capture.
9. How do you cancel an async operation?
Create a CancellationTokenSource, pass its Token down through every async call, and have the work check ThrowIfCancellationRequested() or hand the token to framework APIs that honour it. Cancelling surfaces as OperationCanceledException. ASP.NET Core supplies the request’s token automatically to any action with a CancellationToken parameter.
10. What is ValueTask and when should you use it?
A struct-based alternative to Task that avoids a heap allocation when the result is already available, a cache hit or buffered data, for instance. It is worth using in hot paths in library code where most calls complete synchronously. It has restrictions: it must not be awaited more than once, and should not be stored or awaited concurrently, so Task remains the default.