Asynchronous Operations · How it works
9 min readHow it works
What "asynchronous" means
- synchronous:
var data = File.ReadAllBytes(path);does not return until the data is read; the calling thread waits the whole time. - asynchronous:
Task<byte[]> t = File.ReadAllBytesAsync(path);returns almost immediately with aTaskthat will complete when the data is available.
A: An operation that is started and then completes independently of the code that started it; the caller gets back an object representing the eventual result and is free to continue. The key distinction is between starting and finishing:
Task<string> pending = http.GetStringAsync(url); // request started, method returned
DoOtherWork(); // runs while the request is in flight
string body = await pending; // take the result when it is readyTask?A: An object representing an operation that may not have finished: it has a status (running, completed, faulted, canceled), holds the result or exception when done, and lets you register what should happen next (a continuation). Task<T> carries a result of type T. It is .NET's version of a "promise" or "future". A Task does not necessarily mean a thread: a Task for a network read has no thread behind it at all.
- Asynchronous: not waiting on a thread while something else (the OS, the network, a timer, another service) does the work.
- Concurrent: several operations are in progress over the same period; they may interleave on one thread.
- Parallel: several operations literally execute at the same instant on different CPU cores.
A:
One thread can have a hundred asynchronous HTTP calls in flight concurrently with zero parallelism. Parallel.ForEach over CPU work is parallel and not asynchronous. Task.WhenAll over async I/O is concurrent and asynchronous.
A: No, and this is the most common misconception. Task.Run(() => Work()) does run on a thread-pool thread: it moves CPU work off the current thread, but that thread is busy the entire time. True asynchronous I/O uses no thread while waiting. As Stephen Cleary put it, "there is no thread": between the network card receiving the request and the response arriving, no .NET thread is blocked on it.
I/O-bound vs CPU-bound
- .NET issues the read to the operating system (an I/O completion port on Windows, epoll/kqueue on Linux/macOS) and gets back "pending".
- The method awaiting it suspends and the thread returns to the thread pool to serve other work.
- The network driver and hardware do the waiting; no thread is involved.
- When data arrives, the OS queues a completion notification; an I/O thread-pool thread picks it up and completes the
Task. - The continuation (the rest of your method) is scheduled and runs.
A: For await socket.ReceiveAsync(buffer):
A thread is only used for the short bursts of actual CPU work before and after the wait.
Task.Run?A: Use async APIs (ReadAsync, SaveChangesAsync, GetAsync) for I/O-bound work: that is where the thread-freeing benefit comes from. Use Task.Run for CPU-bound work only to keep a UI thread responsive, or to run CPU work in parallel. In ASP.NET Core, wrapping synchronous code in Task.Run gains nothing: it just moves the blocking from one pool thread to another, and adds overhead.
A: A single request still waits the same time for the database. What changes is that its thread is released during the wait, so the same thread pool can serve many more requests at once. With synchronous code, 1,000 concurrent requests each waiting 200 ms on the database need about 1,000 blocked threads (each with ~1 MB of reserved stack, context switching, and thread-pool starvation when the pool cannot grow fast enough). With async, the same load can run on a handful of threads.
Pausing asynchronously
- saves where it is and the values of its local variables;
- registers "resume me here" as a continuation on the task;
- returns to its caller (an
async Taskmethod returns its own incompleteTask), so the thread is free.
A: It means the method pauses, but the thread does not. At an await on an incomplete task, the method:
Later, when the awaited task completes, the continuation runs and the method carries on from the line after the await, with its locals restored. From the code's point of view execution just "waited"; from the runtime's point of view nothing was waiting at all.
public async Task<Order> LoadAsync(int id)
{
Log("start"); // runs on the caller's thread
var order = await _db.Orders.FindAsync(id); // PAUSE: method returns an incomplete Task,
// thread goes back to the pool
Log("loaded"); // RESUME: later, possibly on another thread
return order;
}A: Blocking (Thread.Sleep, .Result, .Wait(), synchronous I/O) keeps the thread occupied doing nothing until the operation finishes. Pausing asynchronously (await) gives the thread back, and the method continues later. Side by side:
// Blocking: this thread is stuck for 5 s. On a UI thread the window freezes;
// on a server thread the pool loses a worker for 5 s.
Thread.Sleep(5000);
// Asynchronous pause: a timer is registered, the method returns,
// the thread serves other work, and the method resumes in ~5 s.
await Task.Delay(5000);await always pause?A: No. await first checks whether the task is already complete (IsCompleted). If it is, for example a cached value or data already in a buffer, the method continues synchronously on the same thread with no suspension. It only suspends when the result is not ready. That is why an async method can run entirely synchronously, and why ValueTask exists: to avoid allocating a Task when the result is usually immediate.
A: By default, await captures the current SynchronizationContext (or TaskScheduler) and resumes there: on the UI thread in WPF/WinForms/MAUI, so you can update controls after an await. ASP.NET Core has no synchronization context, so continuations run on any thread-pool thread. ConfigureAwait(false) says "resume on any thread", which library code uses to avoid depending on the caller's context and to avoid the classic deadlock when a caller blocks with .Result on a UI thread.
await for the rest of the method?A: No, and code after the await can run on a different thread from code before it. So thread-affine state such as ThreadLocal<T>, [ThreadStatic] fields, or a lock held across the await does not work (the compiler forbids await inside a lock block). AsyncLocal<T> exists to flow values across awaits.
Memory level: how the pause is stored
A: The compiler rewrites an async method into a state machine struct. Every local variable (and parameter) that is used after an await becomes a field of that struct, together with an integer state recording which await to resume at. While the method runs synchronously the state machine lives on the stack. At the first await that actually suspends, it is boxed onto the heap, because the stack frame is about to disappear and the state must survive until resumption. This is the same idea as a closure hoisting captured variables into a heap object (see the Closures module).
// Roughly what the compiler emits for LoadAsync
struct LoadAsyncStateMachine : IAsyncStateMachine
{
public int state; // -1 running, 0 = suspended at first await, ...
public AsyncTaskMethodBuilder<Order> builder;
public int id; // parameter hoisted into a field
public ThisClass self;
private TaskAwaiter<Order> awaiter; // the awaiter being waited on
public void MoveNext()
{
if (state == 0) goto resume;
self.Log("start");
awaiter = self._db.Orders.FindAsync(id).AsTask().GetAwaiter();
if (!awaiter.IsCompleted)
{
state = 0;
builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); // box + register continuation
return; // <-- the "pause": return to caller
}
resume:
var order = awaiter.GetResult();
self.Log("loaded");
builder.SetResult(order);
}
}A: If it completes synchronously: usually nothing beyond the returned Task (and not even that for cached results like Task.CompletedTask, small Task<bool>, or a ValueTask). If it suspends: the boxed state machine (with all hoisted locals), the Task it returns, and a continuation delegate. That is typically a few hundred bytes per suspended call: small next to a network round trip, but noticeable in a tight loop of calls that almost always complete immediately, which is where ValueTask<T> helps.
Span<T> or ref locals across an await?A: Because locals alive across an await are moved into the heap-boxed state machine, and ref structs like Span<T> must never live on the heap. You can use them in code between awaits, but not keep them alive across one.
Common patterns
A: Start them all first, then await them together:
Task<User> userTask = _users.GetAsync(id);
Task<List<Order>> ordersTask = _orders.GetForUserAsync(id);
await Task.WhenAll(userTask, ordersTask); // total time ~ the slower one, not the sum
var (user, orders) = (userTask.Result, ordersTask.Result); // safe: both completed
Awaiting each one immediately (var u = await …; var o = await …;) runs them one after another. Note that an EF Core DbContext does not support concurrent operations, so parallel queries need separate contexts.
A: Pass a CancellationToken down to every async API. A CancellationTokenSource with CancelAfter(TimeSpan) gives a timeout; task.WaitAsync(timeout) (.NET 6) stops waiting after a timeout but does not stop the underlying operation unless it also observes a token.
A: Once a method awaits, its callers should be async too, up to the entry point (controller action, event handler, Main). Blocking on async code with .Result or .Wait() wastes a thread and, where a single-threaded SynchronizationContext exists (UI, classic ASP.NET), can deadlock: the blocked thread is the one the continuation needs to resume on. Also avoid async void except for event handlers: its exceptions cannot be caught by the caller.
Common interview gotchas
async make a method run on a background thread?A: No. An async method runs synchronously on the calling thread until its first await of an incomplete task. async only enables await and wraps the result and exceptions in a Task. A method marked async with no await runs entirely synchronously (and the compiler warns, CS1998).
await Task.Run(() => File.ReadAllText(path)) in ASP.NET Core?A: It blocks a pool thread on synchronous I/O and adds scheduling overhead; the request thread was already a pool thread. Use await File.ReadAllTextAsync(path), which frees the thread during the read.
Thread.Sleep in an async method hurt, even though the method is async?A: Thread.Sleep blocks whatever thread the method is currently running on; being inside an async method does not change that. Use await Task.Delay(…), which pauses the method without blocking a thread.
await run if the task is already complete?A: Immediately, on the same thread, without suspending: await only pauses when the task is incomplete.
await Task.WhenAll(tasks) use for 100 concurrent HTTP calls?A: Essentially none while waiting. Threads are used briefly to start each request and to process each response; the waiting itself is done by the OS and network stack.