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 workExampleWhat the CPU does while it waitsTool
I/O-boundHTTP request, DB query, reading a filenothing – it just waitsawait the ...Async method directly
CPU-boundcomputing primes, image processingit is busy the whole timeawait Task.Run(...)

Key idea: await does 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 run

The program starts a small local web server (LabServer) on http://localhost:5055/. It simulates slow network services – no internet connection needed:

EndpointResponse time
GET /weather/{city}~1000 ms
GET /mirror/1, /mirror/2, /mirror/32500 / 800 / 1500 ms
GET /slow5000 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

  1. Why does it take 5 seconds, even though the CPU has nothing to do?
  2. 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

  1. What is the difference between calling an async method and awaiting it?
  2. We used no Thread and no Task.Run here – 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

  1. What happens to the two losing mirror requests in 3a?
  2. 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

  1. Change TODO 4a for a moment to Task.FromResult(CountPrimes(2, max)). What happens to the spinner? Why?
  2. 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

  1. Why does the speed-up stop growing once you exceed the number of logical cores?
  2. Look closely: equal-sized chunks do not take equally long. Which chunk is the slowest and why? How could you split the work better?
  3. Compare Part 2 and Part 5: both use Task.WhenAll. What is the fundamental difference in what the tasks are doing?

Summary

SituationWrite thisDon’t write this
Wait for I/Oawait Http.GetStringAsync(url)Http.GetStringAsync(url).Result (blocks a thread, can deadlock)
Several I/O operationsstart all → await Task.WhenAll(...)await each one inside a loop
First result / timeoutTask.WhenAny, CancellationToken—
CPU work off the main threadawait Task.Run(() => Compute())Task.Run around something that is already async
CPU work fasterseveral Task.Run + Task.WhenAllmore tasks than cores and hoping for more speed