Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

C# Awaited Calls and Safe Parallel Work

PulseGrid is a console application that a wind-farm operator uses to monitor a fleet of turbines. It loads calibration data from disk, polls each turbine's simulated remote status endpoint, streams months of historical vibration readings for trend analysis, imports large telemetry batches with progress feedback, and finally runs a CPU-bound stress-anomaly calculation across every turbine before printing a fleet health summary. The starter project compiles but is built entirely on blocking calls and an unparallelized loop with a race condition, so it stalls under load and occasionally reports corrupted anomaly counts; the learner's job is to make it fast, responsive, and correct.

Lab platform
Lab Info
Level
Advanced
Last updated
Oct 07, 2026
Duration
45m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. Challenge

    Step 1: Orientation to the PulseGrid monitoring console

    Welcome to PulseGrid

    PulseGrid is the console app a wind-farm operator runs every few minutes to check on a fleet of turbines. Today it does that job badly: every disk read and every simulated network call blocks a thread until it finishes, the turbines are polled one at a time, and a stress-analysis loop that should scale across cores instead runs single-threaded with a race condition that occasionally reports the wrong anomaly count. Over the next four steps you will turn this prototype into a pipeline that is genuinely concurrent and provably correct under load. ### Tour the starter project

    Open src/PulseGrid/ and note where each stage of the pipeline lives:

    • CalibrationLoader.cs and TurbineClient.cs hold the blocking file read and simulated network call you'll convert to real async I/O in Step 2, plus the concurrent multi-turbine fetch and cancellation logic you'll add in Step 3.
    • FleetDashboard.cs holds shared in-memory state that several concurrent fetches update at once -- and a subtle continuation-context bug that corrupts it.
    • TelemetryStream.cs, TelemetryImporter.cs, and LegacyCalibrationAdapter.cs cover Step 4: streaming months of vibration readings without loading them all into memory, reporting import progress, and bridging a legacy event-based sensor API into Task-based code.
    • StressAnalyzer.cs runs the CPU-bound anomaly scan you'll parallelize in Step 5, and Program.cs is where every stage finally gets wired together in Main.

    The project already compiles and runs end to end -- it's just slow, occasionally wrong under concurrency, and not using any of the async or parallel tools available to it. ### Where you're headed

    • Step 2 turns two blocking calls into naturally asynchronous ones.
    • Step 3 fans a single turbine fetch out across the whole fleet with Task.WhenAll, adds a timeout with CancellationToken, and fixes a ConfigureAwait bug that corrupts shared dashboard state.
    • Step 4 streams historical telemetry with IAsyncEnumerable/IAsyncDisposable, reports progress with IProgress<T>, and adapts a legacy event into a Task with TaskCompletionSource.
    • Step 5 parallelizes the stress scan with Parallel.For, protects shared state with Interlocked and lock, and wires every stage together in Main.

    Run bash runTest.sh Task_2_1, with any task's number, at any point to check that task on its own.

    If you get stuck, you can refer to the provided solution code for each task, available in the solution folder.

    info> This lab experience was developed by the Pluralsight team using an internally developed AI tool. All sections were verified by human experts for accuracy prior to publications. However, content may still contain errors or inaccuracies, and we recommend independent verification.

    To report a problem or provide feedback, click here. Feedback may be used to improve accuracy in accordance with our Privacy Policy.

  2. Challenge

    Step 2: Convert blocking turbine I/O into naturally async methods

    From blocking calls to real asynchrony

    CalibrationLoader and TurbineClient both return Task, so the compiler is happy, but inside each method the code still calls a synchronous, blocking API -- File.ReadAllText, Thread.Sleep -- and just wraps the result in a completed Task. That means every call still occupies a thread pool thread for the full duration of the disk read or the simulated network delay; wrapping a blocking call in Task gives you an API that looks async without any of the benefits.

    A naturally asynchronous method instead awaits an operation that itself doesn't block a thread while it waits -- File.ReadAllTextAsync for disk I/O, Task.Delay for a timed wait. The thread is released back to the pool the moment the await is reached, and only resumes when the operation actually completes. That's the change you're making first. With calibration loading fixed, TurbineClient.GetTurbineStatusAsync has the same problem in a different shape: it calls Thread.Sleep to simulate the latency of a remote status check, blocking the calling thread for the entire simulated delay. Task.Delay is the async equivalent -- it schedules a timer-based continuation instead of parking a thread, so many simulated turbine polls can be in flight at once without consuming a thread each.

  3. Challenge

    Step 3: Coordinate concurrent turbine fetches with resilient orchestration

    Fanning one call out across the whole fleet

    With GetTurbineStatusAsync now truly asynchronous, polling turbines one at a time in a foreach loop wastes the concurrency you just enabled -- each await still waits for the previous turbine to finish before starting the next. Task.WhenAll lets you start every call immediately and await them as a group, so the total wait time is close to the slowest single turbine instead of the sum of all of them.

    The catch: Task.WhenAll rethrows only the first exception it sees, and by default a faulted task can prevent you from seeing any of the results, including the turbines that succeeded. Fetching an entire fleet's status should not fail completely just because one turbine timed out -- each turbine's outcome needs to be captured independently. ## Giving slow turbines a deadline

    Even with partial-failure handling, a turbine that never responds will keep FetchAllStatusesAsync waiting forever. CancellationToken is how .NET expresses "stop waiting, cooperatively" -- an operation checks the token or lets an awaited call (like Task.Delay) observe it, and unwinds cleanly with an OperationCanceledException instead of being forcibly killed.

    You often need two cancellation sources at once: the caller's own token (they might cancel the whole app) and a timeout you impose internally. CancellationTokenSource.CreateLinkedTokenSource combines both into a single token that fires as soon as either source does. ## Where does the code after await actually run?

    By default, await captures the current SynchronizationContext (or TaskScheduler) and resumes its continuation there once the awaited task completes. FleetDashboard relies on this: it has its own single-threaded context that serializes every update to its shared dictionary and counters, so only one continuation touches that state at a time.

    ConfigureAwait(false) tells an await to skip that capture and resume on whatever thread pool thread happens to be free -- a valuable optimization deep inside library code that never touches shared state, but dangerous here: UpdateDashboardAsync uses it on the await that leads straight into mutating the dashboard's shared state, so once several fetches complete around the same time, their continuations land on different pool threads simultaneously and race on the same dictionary and counter.

  4. Challenge

    Step 4: Stream telemetry and report progress with custom completion sources

    Streaming instead of loading everything at once

    Historical vibration telemetry for a turbine can span months of paged records -- loading it all into a List<Reading> before returning anything means holding the entire history in memory and making the caller wait for the last page before seeing the first reading. IAsyncEnumerable<T> lets a method yield return items one at a time across await points, so a caller can start processing the first reading while later pages are still being fetched.

    The connection or paging resource behind the stream still needs to be released once the caller stops enumerating -- whether it consumes every page or abandons the loop early. IAsyncDisposable and await using handle that asynchronously, the same way IDisposable/using do for synchronous resources. ## Consuming the stream and reporting progress

    await foreach is the consuming half of IAsyncEnumerable<T>: it awaits each item as it becomes available instead of materializing the whole sequence first, which is exactly what ImportReadingsAsync needs in order to process the stream you just built.

    A long-running import also needs to tell its caller how far along it is without blocking on a return value. IProgress<T> is the standard shape for that: a caller passes in an implementation (often one that updates a console line or a UI control), and the method calls Report whenever it has new progress to share, without knowing or caring what happens to that report. ## Bridging a legacy event into a Task

    Not every API in PulseGrid speaks Task. The legacy calibration sensor still reports completion through a plain C# event, CalibrationCompleted, the way code was written before async/await existed. Rather than rewriting the sensor, you can wrap it: TaskCompletionSource<TResult> gives you a Task you control by hand, completing it with SetResult, SetException, or SetCanceled whenever you decide the underlying operation is done.

    That makes it the standard adapter for exactly this situation -- subscribe to the event, complete the TaskCompletionSource from inside the handler, and hand the Task back to a caller who can await it like any other asynchronous call.

  5. Challenge

    Step 5: Parallelize stress analysis and assemble the full pipeline

    Parallelizing CPU-bound work

    Everything so far has been about I/O-bound waiting -- disk, simulated network, timers -- where async/await frees a thread while nothing is happening. The stress-anomaly scan in StressAnalyzer is different: it's CPU-bound, actually computing on every turbine's readings, so the way to speed it up is to run several turbines' computations on several cores at once. Parallel.For does exactly that, partitioning a range of indices across the thread pool.

    Once multiple threads run the loop body concurrently, any shared variable they all update -- like a running count of readings processed -- needs protection. A plain += on a shared int is not atomic; two threads can read the same value, both add to it, and one increment is lost. Interlocked.Add (and Interlocked.Increment) perform that read-modify-write as a single atomic hardware operation, so no update is dropped no matter how the threads interleave. ## Locking a shared list, not just a counter

    Interlocked covers atomic updates to a single primitive value, but RecordAnomalies does something Interlocked can't help with: appending a batch of anomalies to a shared List<Anomaly>. List<T> has no built-in thread safety at all -- two threads calling AddRange at the same moment can corrupt its internal array, silently dropping entries or throwing.

    lock is the tool for protecting a whole block of code -- not just one variable -- so that only one thread can execute it at a time. Anything that mutates shared, non-atomic state, like a list, belongs inside a lock on a dedicated object once more than one thread can reach it. ## Wiring the stages together

    Every piece PulseGrid needs now exists: calibration loading, a resilient concurrent fetch with a timeout, streaming telemetry import, and a parallel stress scan. RunPipelineAsync is where they become one coherent operation -- awaiting each stage in the order the next one depends on, and folding their results into a single FleetHealthSummary the rest of the app can report on. ## Running it end to end

    Main is the last piece: a modern C# entry point can be async Task, which lets it await the pipeline directly instead of blocking on .Result or .Wait() -- the same blocking-call mistake you fixed back in Step 2, just at the very top of the program. Once Main awaits RunPipelineAsync and prints the result, PulseGrid runs as the responsive, concurrency-safe pipeline you built it to be.

  6. Challenge

    Step 6: Run the application

    Every task is done, and each one was graded the moment you completed it. What you have not done yet is watch the whole application run at once -- which is the only place the pieces you wrote separately become a single working program.

    Start the application

    Open the Terminal tab and run:

    dotnet run --project src/Lab.csproj
    

    The program runs in the terminal and prints its output there. Every behaviour it shows is code you wrote in the previous steps, so read the output against what each task asked for -- that is the whole lab, working as one program. ### Run the checks yourself

    The lab ran each task's test for you as you went, but they are ordinary tests and you can rerun any of them whenever you like, one task at a time:

    bash runTest.sh Task_2_1
    

    Swap in the number of the task you want to check. Each failure names the task it belongs to, so the output tells you which step to go back to rather than which line to stare at. Two things worth trying before you finish: break something on purpose and watch the matching check fail, then put it back. Knowing what a failure looks like is worth as much as knowing what a pass looks like, and it costs you nothing here.

    When you are done, everything you wrote is still in the editor tabs -- the lab is yours to keep reading.

About the author

Pluralsight’s AI authoring technology is designed to accelerate the creation of hands-on, technical learning experiences. Serving as a first-pass content generator, it produces structured lab drafts aligned to learning objectives defined by Pluralsight’s Curriculum team. Each lab is then enhanced by our Content team, who configure the environments, refine instructions, and conduct rigorous technical and quality reviews. The result is a collaboration between artificial intelligence and human expertise, where AI supports scale and efficiency, and Pluralsight experts ensure accuracy, relevance, and instructional quality, helping learners build practical skills with confidence.

Real skill practice before real-world application

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Learn by doing

Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.

Follow your guide

All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.

Turn time into mastery

On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.

Get started with Pluralsight