Skip to content

04 · Async/Await Basics

async/await lets you write code that performs long-running, I/O-bound work (network calls, file access, database queries) without blocking the calling thread while it waits.

A minimal async method

async Task<string> GetGreetingAsync()
{
    await Task.Delay(1000);   // simulate an I/O-bound wait, e.g. a network call
    return "Hello, async world!";
}

var greeting = await GetGreetingAsync();
Console.WriteLine(greeting);
// (after ~1 second)
// Hello, async world!

Task.Delay doesn't block a thread for a second — it schedules the rest of the method to resume after the delay, freeing the thread to do other work in the meantime. await suspends the current method until the awaited task completes, then resumes from that point.

Task vs Task<T> vs void

async Task DoWorkAsync()          // no return value, but callers can await it
{
    await Task.Delay(100);
    Console.WriteLine("Work done");
}

async Task<int> ComputeAsync()    // returns an int once complete
{
    await Task.Delay(100);
    return 42;
}

await DoWorkAsync();
int result = await ComputeAsync();
Console.WriteLine(result);
// Work done
// 42

Avoid async void except for top-level event handlers — exceptions thrown inside an async void method can't be caught by the caller with a normal try/catch, because there's no Task to observe.

Running work concurrently with Task.WhenAll

async Task<int> FetchLengthAsync(string url)
{
    await Task.Delay(200);          // simulate a network call
    return url.Length;
}

var urls = new[] { "https://a.com", "https://bb.com", "https://ccc.com" };
Task<int>[] tasks = urls.Select(FetchLengthAsync).ToArray();

int[] lengths = await Task.WhenAll(tasks);
Console.WriteLine(string.Join(", ", lengths));
// 14, 15, 16

All three FetchLengthAsync calls start immediately and run concurrently; Task.WhenAll waits for every one to finish (taking roughly 200ms total, not 600ms) and returns their results in the original order.

Exception handling with async

async Task<int> DivideAsync(int a, int b)
{
    await Task.Delay(50);
    if (b == 0) throw new DivideByZeroException("Cannot divide by zero");
    return a / b;
}

try
{
    var result = await DivideAsync(10, 0);
    Console.WriteLine(result);
}
catch (DivideByZeroException ex)
{
    Console.WriteLine($"Error: {ex.Message}");
}
// Error: Cannot divide by zero

An exception thrown inside an awaited async method surfaces at the await call site as if it were a regular synchronous throw — ordinary try/catch around the await works exactly as you'd expect.

async Main

// Program.cs -- top-level statements can await directly
Console.WriteLine("Starting...");
await Task.Delay(500);
Console.WriteLine("Done.");

Top-level statement programs are compiled into an async Task Main under the hood, so await works directly at the top level of Program.cs without any extra ceremony.

Common pitfall: blocking on async code

// Don't do this -- can deadlock in UI/ASP.NET contexts and always wastes a thread
// var result = GetGreetingAsync().Result;

// Do this instead
var result = await GetGreetingAsync();

Calling .Result or .Wait() on a task blocks the calling thread synchronously and defeats the point of async; in environments with a synchronization context (classic ASP.NET, WPF, WinForms) it can even deadlock. Prefer await all the way up the call stack.

Concept Meaning
async Marks a method that may suspend and resume with await
await Suspends until the awaited task completes, without blocking the thread
Task Represents an in-progress or completed operation with no result
Task<T> Represents an operation that will produce a T
Task.WhenAll Waits for multiple tasks concurrently
.Result / .Wait() Blocking — avoid in async code

How It Actually Works

  • async methods are rewritten by the compiler into a state machine struct, before any JIT compilation happens. Roslyn transforms GetGreetingAsync into a hidden struct implementing IAsyncStateMachine, with a MoveNext() method containing your method body split into numbered states at each await. Local variables that must survive across an await (anything still "live" after resuming) become fields of that struct instead of stack locals — because when the method suspends, its actual stack frame is popped and the thread is freed; there is no stack frame left to hold them. This is the real mechanism behind "await suspends without blocking a thread": the method's state is moved into a heap-allocated (usually) object, the calling thread returns immediately with an incomplete Task, and MoveNext() gets called again later — from a thread-pool thread — to resume execution from the saved state.
  • await compiles to registering a continuation on the awaited task's awaiter, not a busy-wait or a blocking call. await Task.Delay(1000) calls GetAwaiter() on the task, checks IsCompleted, and if not yet complete, calls OnCompleted(continuation) — where continuation is a delegate wrapping MoveNext() on the state machine — then returns control to the caller. Task.Delay's internal timer, when it fires, invokes that continuation, which schedules MoveNext() to run on a thread-pool thread via SynchronizationContext.Post (in UI apps) or directly on the thread pool (console/server apps, where there's no captured sync context) — explaining exactly why Task.Delay(1000) doesn't tie up a thread for a second: no thread exists between the await and the timer callback.
  • Task<T> wraps a result and a captured exception, replayed at await time. When DivideAsync throws, the exception is caught by the compiler- generated state machine and stored on the Task (marking it Faulted) rather than propagating immediately. awaiting a faulted task re-throws that stored exception (unwrapped from its internal AggregateException wrapper specifically by await, unlike .Result, which surfaces the raw AggregateException) — this is the mechanism, not magic, behind "an exception thrown inside an awaited async method surfaces at the await call site like a normal throw."
  • Task.WhenAll returns a single task that completes once every input task's continuation has fired — concurrency comes from the tasks already running, not from WhenAll itself. Each FetchLengthAsync(url) call starts running (and hits its own await Task.Delay) the instant it's invoked in the Select, before WhenAll is ever called — WhenAll merely subscribes one continuation to fire when the last of the already-in-flight tasks finishes, which is why the three 200ms delays overlap into roughly 200ms total rather than serializing to 600ms.
  • Blocking with .Result/.Wait() can deadlock specifically because of the captured SynchronizationContext. In WPF/WinForms/classic ASP.NET, await by default captures the current SynchronizationContext and schedules the continuation back onto it (typically the UI thread or an ASP.NET request thread) — if that same thread is currently blocked calling .Result and waiting for the task to finish, the continuation that would complete the task can never run on that occupied thread, and the two sides deadlock permanently. ConfigureAwait(false) — commonly seen in library code — tells the state machine not to capture that context, avoiding the deadlock at the cost of resuming on an arbitrary thread-pool thread instead.

Exercise

Write DownloadAllAsync(string[] urls) that simulates downloading each URL (via Task.Delay proportional to the URL's length) and returns a Dictionary<string, int> mapping each URL to its simulated "download size." Run all downloads concurrently with Task.WhenAll, then print the total combined size once everything completes.