- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
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 Info
Table of Contents
-
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.csandTurbineClient.cshold 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.csholds shared in-memory state that several concurrent fetches update at once -- and a subtle continuation-context bug that corrupts it.TelemetryStream.cs,TelemetryImporter.cs, andLegacyCalibrationAdapter.cscover Step 4: streaming months of vibration readings without loading them all into memory, reporting import progress, and bridging a legacy event-based sensor API intoTask-based code.StressAnalyzer.csruns the CPU-bound anomaly scan you'll parallelize in Step 5, andProgram.csis where every stage finally gets wired together inMain.
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 withCancellationToken, and fixes aConfigureAwaitbug that corrupts shared dashboard state. - Step 4 streams historical telemetry with
IAsyncEnumerable/IAsyncDisposable, reports progress withIProgress<T>, and adapts a legacy event into aTaskwithTaskCompletionSource. - Step 5 parallelizes the stress scan with
Parallel.For, protects shared state withInterlockedandlock, and wires every stage together inMain.
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
solutionfolder.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. -
Challenge
Step 2: Convert blocking turbine I/O into naturally async methods
From blocking calls to real asynchrony
CalibrationLoaderandTurbineClientboth returnTask, 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 completedTask. 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 inTaskgives 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.ReadAllTextAsyncfor disk I/O,Task.Delayfor a timed wait. The thread is released back to the pool the moment theawaitis reached, and only resumes when the operation actually completes. That's the change you're making first. With calibration loading fixed,TurbineClient.GetTurbineStatusAsynchas the same problem in a different shape: it callsThread.Sleepto simulate the latency of a remote status check, blocking the calling thread for the entire simulated delay.Task.Delayis 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. -
Challenge
Step 3: Coordinate concurrent turbine fetches with resilient orchestration
Fanning one call out across the whole fleet
With
GetTurbineStatusAsyncnow truly asynchronous, polling turbines one at a time in aforeachloop wastes the concurrency you just enabled -- eachawaitstill waits for the previous turbine to finish before starting the next.Task.WhenAlllets 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.WhenAllrethrows 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 deadlineEven with partial-failure handling, a turbine that never responds will keep
FetchAllStatusesAsyncwaiting forever.CancellationTokenis how .NET expresses "stop waiting, cooperatively" -- an operation checks the token or lets an awaited call (likeTask.Delay) observe it, and unwinds cleanly with anOperationCanceledExceptioninstead 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.CreateLinkedTokenSourcecombines both into a single token that fires as soon as either source does. ## Where does the code afterawaitactually run?By default,
awaitcaptures the currentSynchronizationContext(orTaskScheduler) and resumes its continuation there once the awaited task completes.FleetDashboardrelies 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 anawaitto 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:UpdateDashboardAsyncuses 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. -
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 methodyield returnitems one at a time acrossawaitpoints, 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.
IAsyncDisposableandawait usinghandle that asynchronously, the same wayIDisposable/usingdo for synchronous resources. ## Consuming the stream and reporting progressawait foreachis the consuming half ofIAsyncEnumerable<T>: it awaits each item as it becomes available instead of materializing the whole sequence first, which is exactly whatImportReadingsAsyncneeds 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 callsReportwhenever it has new progress to share, without knowing or caring what happens to that report. ## Bridging a legacy event into a TaskNot every API in PulseGrid speaks
Task. The legacy calibration sensor still reports completion through a plain C# event,CalibrationCompleted, the way code was written beforeasync/awaitexisted. Rather than rewriting the sensor, you can wrap it:TaskCompletionSource<TResult>gives you aTaskyou control by hand, completing it withSetResult,SetException, orSetCanceledwhenever you decide the underlying operation is done.That makes it the standard adapter for exactly this situation -- subscribe to the event, complete the
TaskCompletionSourcefrom inside the handler, and hand theTaskback to a caller who canawaitit like any other asynchronous call. -
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/awaitfrees a thread while nothing is happening. The stress-anomaly scan inStressAnalyzeris 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.Fordoes 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 sharedintis not atomic; two threads can read the same value, both add to it, and one increment is lost.Interlocked.Add(andInterlocked.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 counterInterlockedcovers atomic updates to a single primitive value, butRecordAnomaliesdoes somethingInterlockedcan't help with: appending a batch of anomalies to a sharedList<Anomaly>.List<T>has no built-in thread safety at all -- two threads callingAddRangeat the same moment can corrupt its internal array, silently dropping entries or throwing.lockis 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 alockon a dedicated object once more than one thread can reach it. ## Wiring the stages togetherEvery piece PulseGrid needs now exists: calibration loading, a resilient concurrent fetch with a timeout, streaming telemetry import, and a parallel stress scan.
RunPipelineAsyncis where they become one coherent operation -- awaiting each stage in the order the next one depends on, and folding their results into a singleFleetHealthSummarythe rest of the app can report on. ## Running it end to endMainis the last piece: a modern C# entry point can beasync Task, which lets itawaitthe pipeline directly instead of blocking on.Resultor.Wait()-- the same blocking-call mistake you fixed back in Step 2, just at the very top of the program. OnceMainawaitsRunPipelineAsyncand prints the result, PulseGrid runs as the responsive, concurrency-safe pipeline you built it to be. -
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.csprojThe 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_1Swap 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
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.