Async Lab – async / await in C#
Duration: max. 40 minutes · File: Async Lab Exercise - Starter · Type: console application
Goal
You already know threads from the ThreadLab. In real applications, however, you will mostly see async / await – and most of the time it is not used to compute faster, but to wait efficiently: for a web request, a database query, a file.
| Kind of work | Example | What the CPU does while it waits | Tool |
|---|---|---|---|
| I/O-bound | HTTP request, DB query, reading a file | nothing – it just waits | await the ...Async method directly |
| CPU-bound | computing primes, image processing | it is busy the whole time | await Task.Run(...) |
Key idea:
awaitdoes not block a thread. While a request is “in flight”, no thread sits there waiting – the method is paused and continues when the answer arrives.
Setup (2 min)
dotnet new console -n AsyncLab
cd AsyncLab
# replace Program.cs with the content of AsyncLab.cs
dotnet runThe program starts a small local web server (LabServer) on http://localhost:5055/. It simulates slow network services – no internet connection needed:
| Endpoint | Response time |
|---|---|
GET /weather/{city} | ~1000 ms |
GET /mirror/1, /mirror/2, /mirror/3 | 2500 / 800 / 1500 ms |
GET /slow | 5000 ms |
Work through the TODOs in order. After each part, run the program, choose the part in the menu and compare your output with the checkpoint.
Part 1 – Sequential downloads (5 min) · TODO 1
Download the weather for five cities one after another using Http.GetStringAsync(...) and await.
Checkpoint: each line appears about 1 s after the previous one, total time ≈ 5 000 ms.
Questions
- Why does it take 5 seconds, even though the CPU has nothing to do?
- The compiler warned you (
CS1998) before you filled in TODO 1. What did the warning say, and why is it gone now?
Part 2 – Concurrent downloads with Task.WhenAll (7 min) · TODO 2a, TODO 2b
Start all five requests first (without await), collect the Task<string> objects in a list, then await them all at once with Task.WhenAll.
Checkpoint: “all 5 requests started” appears after ≈ 0 ms, total time ≈ 1 000 ms.
Questions
- What is the difference between calling an async method and awaiting it?
- We used no
Threadand noTask.Runhere – yet the requests ran at the same time. How many threads do you think were waiting for the five answers?
Remember: concurrency for I/O = start everything, then await everything.
Part 3 – Task.WhenAny: fastest mirror & timeout (10 min) · TODO 3a, TODO 3b, (bonus TODO 3c)
3a – fastest mirror: start downloads from all three mirrors and use Task.WhenAny to take the first answer.
Checkpoint: winner is mirror 2 (Vienna) after ≈ 800 ms.
3b – timeout: race the request to /slow against Task.Delay(2000). If the delay wins, it is a timeout.
Checkpoint: “Timeout!” after ≈ 2 000 ms.
3c (bonus) – real cancellation: in 3b the slow request keeps running in the background – we just stopped waiting for it. Use a CancellationTokenSource with a 2 s timeout, pass its Token to GetStringAsync, and catch the TaskCanceledException.
Questions
- What happens to the two losing mirror requests in 3a?
- Why is 3c the better solution for a timeout than 3b?
Part 4 – CPU-bound work with Task.Run (6 min) · TODO 4a–4c
CountPrimes is pure computation – there is nothing to wait for, so there is no CountPrimesAsync. Run it with Task.Run, and while it is working show a spinner (| / - \) that updates every 100 ms.
Checkpoint: the spinner turns while the primes are counted; result: 283 146 primes below 4 000 000.
Questions
- Change
TODO 4afor a moment toTask.FromResult(CountPrimes(2, max)). What happens to the spinner? Why? - Would
Task.Run(() => Http.GetStringAsync(...))make a web request faster? Explain.
Part 5 – Parallel speed-up (7 min) · TODO 5a, TODO 5b
Split the range [0, 8 000 000) into taskCount chunks, count the primes of each chunk in its own Task.Run, then add up the results of Task.WhenAll.
Checkpoint: every line shows 539 777 primes; the time drops as the number of tasks grows (up to the number of cores).
Questions
- Why does the speed-up stop growing once you exceed the number of logical cores?
- Look closely: equal-sized chunks do not take equally long. Which chunk is the slowest and why? How could you split the work better?
- Compare Part 2 and Part 5: both use
Task.WhenAll. What is the fundamental difference in what the tasks are doing?
Summary
| Situation | Write this | Don’t write this |
|---|---|---|
| Wait for I/O | await Http.GetStringAsync(url) | Http.GetStringAsync(url).Result (blocks a thread, can deadlock) |
| Several I/O operations | start all → await Task.WhenAll(...) | await each one inside a loop |
| First result / timeout | Task.WhenAny, CancellationToken | — |
| CPU work off the main thread | await Task.Run(() => Compute()) | Task.Run around something that is already async |
| CPU work faster | several Task.Run + Task.WhenAll | more tasks than cores and hoping for more speed |