Lesson 12 • Expert Track
Async & Await
By the end of this lesson you'll be able to write responsive, non-blocking C# with async/await and Task<T> — running slow operations concurrently instead of one-at-a-time, handling their errors, and cancelling them cleanly. This is how real apps stay snappy while they wait on networks, files, and databases.
What You'll Learn
- Understand async, await, and the difference between Task and Task<T>
- See how await pauses without blocking the thread
- Run operations concurrently and combine them with Task.WhenAll
- Tell sequential code from concurrent code — and why concurrent is faster
- Handle exceptions in async methods with try/catch
- Cancel long-running work with a CancellationToken
List<T>, and lambda expressions — async builds directly on a method that returns a value, except the value arrives later.💡 Real-World Analogy
Picture ordering food at a busy café. Synchronous (blocking) is standing frozen at the counter staring at the kitchen until your meal is ready — you can't do anything else, and the queue behind you can't move. Asynchronous is placing your order, taking a buzzer, and going to find a seat, reply to a message, or order a coffee while the kitchen cooks. The await keyword is the buzzer going off: you come back and collect your food the moment it's ready. Crucially, you didn't hire a second person to cook — you just stopped wasting your own time standing still. That's the heart of async: freeing the thread to do other work while it waits.
The mental model: Task is a "promise of a result"
A Task is an object that represents work that is in progress and will finish in the future. Think of it as a receipt: you hold it now, and later it can be redeemed for a result (or for an error if the work failed).
- 📦
Task— async work that produces no value (the async equivalent ofvoid). - 🎁
Task<T>— async work that will eventually produce a value of typeT(e.g.Task<int>,Task<string>). - ⏸️
await— "wait for this Task and give me its result", but release the thread while waiting instead of blocking. - 🏷️
async— a keyword on the method that permitsawaitinside it. It does not, by itself, make anything run on another thread.
That last point surprises people: async is not the same as multithreading. When you await a real I/O operation (a network or disk call), there is often no thread at all tied up during the wait — the OS notifies you when the data arrives. Async is about not blocking; using extra threads is a separate decision.
📊 Async Building Blocks
| Piece | Means | Example |
|---|---|---|
| async | Method may use await | async Task DoAsync() |
| await | Wait for a Task, non-blocking | await DoAsync(); |
| Task | Async work, no result | Task SaveAsync() |
| Task<T> | Async work returning a value | Task<int> GetAsync() |
| Task.WhenAll | Wait for ALL tasks at once | await Task.WhenAll(a, b) |
| Task.WhenAny | Wait for the FIRST to finish | await Task.WhenAny(a, b) |
| Task.Delay | Async pause (no blocking) | await Task.Delay(1000) |
By convention, async methods end in Async — GetDataAsync() — so callers know at a glance they should be awaited.
1. async / await Mechanics
Mark a method async and it gains the power to use await. When you await a Task, the method pauses at that line until the work completes — but it does so without freezing the thread, so the rest of your app stays alive. An async method returns Task if it produces no value, or Task<T> if it returns one; the return value you write becomes the Task's result. Read this worked example, run it, then you'll write your own.
Worked example: await a Task<string>
Read every comment, run it, and watch the method pause at await then resume.
using System;
using System.Threading.Tasks;
class Program
{
// 'async Task Main' lets Main itself use 'await'.
// A method marked 'async' returns Task (no value) or Task<T> (a value).
static async Task Main()
{
Console.WriteLine("Order placed, waiting for food...");
// 'await' pauses HERE until BoilEggAsync finishes — but it does NOT
// block the thread. Control is freed; when the egg is done, we resume.
string egg = await BoilEggAsync(); // re
...Your turn. The program below is almost complete — it just needs the async keyword, a return value, and an await. Fill in the three ___ blanks using the hints, then run it.
🎯 Your turn: complete an async Task<int>
Add async, return 42, and await the result — output should read 42.
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 3) Call GetNumberAsync and AWAIT its result.
int value = ___ GetNumberAsync(); // 👉 the keyword that waits: await
Console.WriteLine($"The number is {value}");
// ✅ Expected output:
// The number is 42
}
// 1) This method must be marked async so it can use await.
static ___ Tas
...2. Sequential vs Concurrent
Here's the single most valuable async skill. If you await a task immediately, the next task can't start until it finishes — that's sequential, and the times add up. If you start several tasks first (storing each Task) and await them afterwards, they overlap — that's concurrent, and the total is roughly the time of the slowest one. The worked example below times both so you can see 5 seconds shrink to 3.
Worked example: 5s sequential vs 3s concurrent
Same two tasks, two strategies — watch the elapsed seconds differ.
using System;
using System.Diagnostics;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// SEQUENTIAL: await the first, THEN start the second.
// Total ~= 2s + 3s = 5s, because nothing overlaps.
var sw = Stopwatch.StartNew();
string toast = await MakeToastAsync(); // wait 2s
string coffee = await MakeCoffeeAsync(); // THEN wait 3s
Console.WriteLine($"Sequential: {toast} + {coffee} in {sw.Elapsed.Seconds}s");
...3. Combining Tasks with Task.WhenAll
Starting tasks then awaiting each one by hand works, but Task.WhenAll is the clean idiom: pass it several tasks and it returns one Task that completes when all of them do. For Task<T> it hands you back an array of results in the same order you supplied the tasks — perfect for fanning out a batch of calls and then combining the answers. (Its sibling Task.WhenAny completes as soon as the first task finishes — handy for racing servers or timeouts.)
Worked example: await Task.WhenAll(a, b)
Run two downloads concurrently and sum their sizes from the results array.
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// Start both downloads — they run at the same time.
Task<int> a = DownloadSizeAsync("report.pdf", 1500, 120);
Task<int> b = DownloadSizeAsync("photo.jpg", 800, 80);
// Task.WhenAll waits for EVERY task and returns their results
// together as an array, in the same order you passed them in.
int[] sizes = await Task.WhenAll(a, b); // [120, 80]
...Now you try. Start two price lookups so they run concurrently, await them both with WhenAll, then add the results for the order total. Fill in the three ___ blanks:
🎯 Your turn: combine two tasks with WhenAll
Await both prices at once, then sum them — total should be $45.
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) Start BOTH price lookups so they run concurrently.
Task<int> cart = GetPriceAsync("cart", 600, 30);
Task<int> book = GetPriceAsync("book", 900, 15);
// 2) Await BOTH at once with Task.WhenAll -> gives an int[] of results.
int[] prices = await Task.___(cart, book); // 👉 method name: When
...4. Exceptions in Async Code
When an async method fails, the exception doesn't vanish — it's captured on the Task and re-thrown at the moment you await it. So you handle it with an ordinary try/catch around the await, exactly as you would for synchronous code. (This is also why forgetting await is dangerous: with no await, nothing ever observes the exception.)
Worked example: try/catch around await
An async method throws; the catch handles it and the program continues.
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// Exceptions in async code use a normal try/catch around the await.
// The exception is stored on the Task and re-thrown when you await it.
try
{
string data = await FetchDataAsync();
Console.WriteLine(data);
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Caught: {ex.Message}"); // Caught: Serv
...5. Cancellation with CancellationToken
Long-running work should be cancellable — a user closes a window, a request times out. The pattern is a CancellationTokenSource that produces a CancellationToken you pass into the async method. The method checks the token (and passes it on to calls like Task.Delay); when cancellation is requested, an OperationCanceledException is thrown, which you catch to stop gracefully instead of crashing.
Worked example: cancel after 1 second
A counting loop is cancelled partway through and stops cleanly.
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// A CancellationToken lets a caller politely STOP a long operation.
using var cts = new CancellationTokenSource();
cts.CancelAfter(1000); // request cancellation after 1 second
try
{
await CountAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Cancelled — stoppe
...🔎 Deep Dive: async ≠ multithreading
A common myth is "async makes my code run on another thread." It doesn't, not necessarily. async/await is a mechanism for not blocking while you wait. The big win is for I/O-bound work — calling a web API, reading a file, querying a database — where the program is just waiting for something external. During that wait there's frequently no thread occupied at all; the operating system signals back when the data is ready.
Threads come into play for CPU-bound work — heavy calculation that must actually keep a core busy. For that you reach for Task.Run(...) to push the work onto a background thread, then await the resulting Task. So the rule of thumb is:
// I/O-bound (waiting on the network/disk): await the async API directly string page = await httpClient.GetStringAsync(url); // CPU-bound (heavy maths): offload to a thread with Task.Run, then await int result = await Task.Run(() => CrunchBigNumbers());
In short: await on its own means "don't block while waiting"; Task.Run means "use a background thread." They're different tools for different problems.
Putting It Together: a Dashboard Loader
Here's a small but realistic program that loads three "services" for a dashboard — exactly the pattern behind a real web page that pulls users, orders, and revenue at once. It starts all three concurrently, awaits them with Task.WhenAll, and times the result. You understand every line now.
Worked example: load three services concurrently
Fetch users, orders, and revenue in parallel and time the saving.
using System;
using System.Diagnostics;
using System.Threading.Tasks;
class Program
{
// === Dashboard loader: fetch three "services" concurrently ===
static async Task Main()
{
var sw = Stopwatch.StartNew();
// Kick off all three at once — they overlap instead of queuing.
Task<int> users = FetchAsync("users", 1200, 1500);
Task<int> orders = FetchAsync("orders", 900, 320);
Task<int> revenue = FetchAsync("revenue", 1500, 9800);
...Because the calls overlap, the total time is roughly the slowest single call (~1.5s), not the sum of all three (~3.6s). That's the entire reason to run them concurrently.
Pro Tips
- 💡 Start tasks early, await them late: kick off independent work first, then
await— that's what turns sequential code into concurrent code. - 💡 Never block on async with
.Resultor.Wait(): it can deadlock and it wastes a thread.awaitall the way up the call chain instead. - 💡 Avoid
async voidexcept for event handlers — useasync Taskso callers can await and catch exceptions. - 💡 Flow a
CancellationTokenthrough every async method that accepts one, so long operations can be stopped cleanly. - 💡 Suffix async methods with
Async(SaveChangesAsync) and useConfigureAwait(false)in library code to avoid context deadlocks.
Common Errors (and the fix)
- "CS4032: The 'await' operator can only be used within an async method": you used
awaitin a method that isn't markedasync. Addasyncto the method signature and make it returnTaskorTask<T>. - "CS1996: Cannot await in the body of a lock statement": you can't
awaitinside alock. Use an async-aware primitive likeSemaphoreSlim.WaitAsync()instead, or restructure so the await happens outside the lock. - Deadlock from
.Result/.Wait(): blocking on a Task (e.g.GetDataAsync().Result) in a UI or ASP.NET context can freeze forever. The fix is toawaitinstead of blocking, all the way up. - Forgetting
await(fire-and-forget): writingDoWorkAsync();with noawaitstarts the task but never observes it — the result is discarded and any exception is swallowed. Addawait(the compiler usually warns: "CS4014: this call is not awaited"). async voidon a normal method: exceptions thrown from anasync voidmethod can't be caught by the caller and crash the process. ReturnTaskinstead — reserveasync voidfor event handlers only.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Async method, no result | async Task DoAsync() | Returns Task |
| Async method, returns value | async Task<int> GetAsync() | return becomes result |
| Await a result | int x = await GetAsync(); | Non-blocking wait |
| Async pause | await Task.Delay(1000); | 1 second, no block |
| Run concurrently | await Task.WhenAll(a, b); | Waits for all |
| First to finish | await Task.WhenAny(a, b); | Race / timeout |
| Offload CPU work | await Task.Run(() => Work()); | Background thread |
| Cancel work | cts.Token / CancelAfter(ms) | CancellationToken |
Frequently Asked Questions
Q: Does async run my code on another thread?
Not by itself. async/await is about not blocking while you wait on something (usually I/O). For real I/O waits there's often no thread tied up at all. If you need heavy CPU work on a background thread, that's a separate step: await Task.Run(...).
Q: What's the difference between Task and Task<T>?
Task is async work that returns nothing (like void). Task<T> is async work that will produce a value of type T — await-ing it gives you that value, e.g. int n = await GetCountAsync();.
Q: Why shouldn't I just call .Result to get the value?
Because .Result and .Wait() block the current thread until the Task finishes, which wastes the thread and can deadlock in UI/ASP.NET apps. Always await instead — and make the calling method async too.
Q: How do I run several async operations at the same time?
Start them all first (store each Task without awaiting), then await Task.WhenAll(...). They overlap, so the total time is about the slowest one rather than the sum — that's the concurrency win from Section 2.
Mini-Challenge: Fetch Two Results Concurrently
No blanks this time — just a brief and an outline. Start two GetResultAsync calls so they run at the same time, await them both with Task.WhenAll, print the combined total, and (bonus) use a Stopwatch to show how much time you saved versus running them one after another. Run it and check your output against the expected lines in the comments.
🎯 Mini-Challenge: concurrent fetch + time saved
Run two tasks concurrently with WhenAll; the total should be 65 in ~1.5s.
using System;
using System.Diagnostics;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// 🎯 MINI-CHALLENGE: Fetch two results concurrently
// 1. Start TWO calls to GetResultAsync at the same time
// (e.g. one with 1500ms/40, one with 1000ms/25). Don't await yet —
// store each in a Task<int> variable so they run concurrently.
// 2. await Task.WhenAll(...) to get an int[] of both results.
// 3. Print the com
...🎉 Lesson Complete
- ✅
asynclets a method useawait; it returnsTask(no value) orTask<T>(a value) - ✅
awaitpauses until a Task finishes without blocking the thread - ✅ Start tasks early and await late to run them concurrently — total time ≈ the slowest one
- ✅
Task.WhenAllwaits for all tasks;Task.WhenAnywaits for the first - ✅ async ≠ multithreading —
awaitavoids blocking;Task.Runuses a thread - ✅ Handle errors with
try/catcharoundawait; cancel with aCancellationToken - ✅ Next lesson: Delegates & Events — passing methods around and building event-driven code
Sign up for free to track which lessons you've completed and get learning reminders.