Asynchronous Operations · How it works

9 min read
Mid-level11 min read
Rapid overview

How 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 a Task that will complete when the data is available.
Q: What is an asynchronous operation?

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 ready
Q: What is a Task?

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.
Q: What is the difference between asynchronous, concurrent and parallel?

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.

Q: Is asynchronous the same as "running on another thread"?

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

  1. .NET issues the read to the operating system (an I/O completion port on Windows, epoll/kqueue on Linux/macOS) and gets back "pending".
  2. The method awaiting it suspends and the thread returns to the thread pool to serve other work.
  3. The network driver and hardware do the waiting; no thread is involved.
  4. When data arrives, the OS queues a completion notification; an I/O thread-pool thread picks it up and completes the Task.
  5. The continuation (the rest of your method) is scheduled and runs.
Q: What actually happens during an asynchronous I/O call?

A: For await socket.ReceiveAsync(buffer):

A thread is only used for the short bursts of actual CPU work before and after the wait.

Q: When should you use async/await and when 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.

Q: Why does async improve server scalability but not the speed of a single request?

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

  1. saves where it is and the values of its local variables;
  2. registers "resume me here" as a continuation on the task;
  3. returns to its caller (an async Task method returns its own incomplete Task), so the thread is free.
Q: What does "pausing asynchronously" mean?

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;
}
Q: What is the difference between blocking and pausing asynchronously?

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);
Q: Does 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.

Q: Where does the method resume?

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.

Q: Does the thread wait at the 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

Q: Where are the local variables kept while a method is paused?

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);
    }
}
Q: What does an async method allocate?

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.

Q: Why can't you use 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

Q: How do you run several asynchronous operations at the same time?

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.

Q: How do you add a timeout or cancellation to an asynchronous operation?

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.

Q: What is "async all the way", and what goes wrong without it?

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

Q: Does 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).

Q: What is wrong with 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.

Q: Why does 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.

Q: When does code after await run if the task is already complete?

A: Immediately, on the same thread, without suspending: await only pauses when the task is incomplete.

Q: How many threads does 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.

See also