- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Keep a Concurrent Go Pipeline from Getting Stuck
Hundreds of delivery drones stream sensor readings (battery, GPS, and altitude) into a Go ingestion service. The current prototype launches goroutines without proper coordination, uses unbuffered channels that can cause deadlocks when workers encounter errors, and updates a shared stats map without protecting concurrent writes. It also lacks a way to handle unresponsive drone connections. During a fleet-wide firmware rollout, the operations team observed the service hanging as these issues disrupted data ingestion. In this lab, you will rebuild the pipeline stage by stage. You will implement a synchronized worker pool, route errors without blocking, safely close channels and collect results, protect shared counters from data races, and use context cancellation and timeouts to prevent unresponsive connections from stalling the entire pipeline.
Lab Info
Table of Contents
-
Challenge
Step 1: Meet the stuck telemetry pipeline
The telemetry pipeline keeps freezing
Hundreds of delivery drones stream battery, GPS, and altitude readings into a Go ingestion service. During the last fleet-wide firmware rollout, ops watched the service hang mid-shift: goroutines piled up with nothing coordinating them, workers blocked forever on unbuffered channels the moment one reading looked malformed, a shared stats map got corrupted under concurrent writes, and a single unresponsive drone connection was enough to stall the entire ingest.
The repository you've inherited compiles and looks reasonable at a glance, but every one of those failure modes is baked into the current code: a
sync.WaitGroupthat never tracks any workers, bidirectional channels used carelessly, an unprotectedmap[string]inttouched from multiple goroutines, and no way to cancel or time out a stuck reading. You will fix this stage by stage, in the same order ops hit the failures. ### The four stages you'll rebuildworker.go—launch a fixed pool of worker goroutines and use async.WaitGroupso the pipeline reports completion only when every worker has truly finished.channels.go—send readings through a send-only channel, add a buffered error channel, and report errors without blocking so a failing worker never has to wait for someone to listen.dispatcher.go—close the producer-owned input channel once dispatch is done, then drain results and errors withselectandrangeso nothing blocks forever.pipeline.go—guard the shared stats map with async.Mutexand confirm the fix with Go's race detector. Cancel the run on the first error, add a per-reading deadline, and wire the deadline intoworker.goso a hung drone releases its worker instead of freezing the fleet.
Each step builds directly on the previous one: by Step 6, you'll run the whole pipeline and see every stage work together.
-
Challenge
Step 2: Build the foundational worker pool
Coordinating a worker pool
worker.godefines theReadingandResulttypes, aworkerfunction that reads from ajobschannel and writes to aresultschannel, and two functions you'll complete:StartWorkers, which launches a fixed-size worker pool, andCloseWhenDone, which handles shutdown after the workers finish.StartWorkersalso accepts acontext.Contextthat you'll pass to each worker. You'll use this context in Step 5 to cancel a run and release blocked workers.Currently,
StartWorkersis a stub that launches no goroutines, so the pipeline has no way to track when the worker pool has finished. As a result, callers may return too early and miss results or block indefinitely while waiting for completion.You can use
sync.WaitGroupto coordinate the workers. CallAddonce per worker before launching its goroutine, have each worker callDonewhen it finishes, and useWaitto block until every worker has completed. ### Shutting the pool down without a raceStarting the pool correctly is only half the problem—something still has to wait for it to finish and close the shared
resultschannel exactly once, at exactly the right moment. Close it too early and a worker panics trying to send on a closed channel; forget to close it and anyrange resultsloop downstream blocks forever. TheWaitGroupyou just wired up is exactly the signal that tells you it's safe. ### Step summarysync.WaitGrouptracks the pool:Addbefore eachgo worker(...),Donewhen a worker exits,Waitto block until all are done.StartWorkerslaunchesnworkers and returns theWaitGroup.CloseWhenDonewaits for the pool and only then closesresults, so no worker ever sends on a closed channel and downstream readers know when to stop.
-
Challenge
Step 3: Route readings through directional, non-blocking channels
Making channel direction explicit
channels.gois where readings enter the pipeline. Using a plainchan Readingeverywhere means any function that accesses the channel could accidentally receive when it should send, or vice versa. The compiler cannot catch this mistake. Declaring a parameter aschan<- Reading(send-only) or<-chan Reading(receive-only) turns these mistakes into compile-time errors instead of runtime deadlocks.SendReadingsacts as the producer and should only send readings. ### Giving errors somewhere to landAn unbuffered
chan errorforces the sender to wait for a receiver that might be busy elsewhere—exactly the deadlock ops saw during the firmware rollout, where one bad reading froze a worker mid-flight. A buffered channel,make(chan error, size), holds up tosizepending errors so a send can complete immediately even if nothing has read the previous ones yet. ### Sending without ever blockingA buffer only helps until it fills up.
selectwith a send case and adefaultcase gives you a send that never blocks: if the channel can accept the value right now, it does; otherwise thedefaultbranch runs instead and the worker moves on. That's the shape you need for a worker reporting a malformed reading—it should never stall the rest of the pool just because the error channel happens to be full. ### Step summarychan<- Readingand<-chan Readingmake a channel's direction a compile-time guarantee.SendReadingsonly sends, in order, and leaves closing to the dispatcher.NewErrorChannelreturns a buffered channel, so a worker can report a failure without waiting for a receiver.ReportErrorusesselectwith adefaultcase, so a full buffer drops the error instead of stalling the worker.
-
Challenge
Step 4: Close producer channels and drain results cleanly
Closing the channel you own
dispatcher.goowns the producer side of the pipeline. Arangeloop over a channel only ever stops when that channel is closed—until then it blocks waiting for one more value, forever if nothing more is coming. That means whichever function sends the last reading must also be the one that closes the channel, right after the last send, and only once. ### Draining results with rangeOnce
jobscloses, workers finish andCloseWhenDoneclosesresultsbehind them.for r := range resultsis the natural way to consume a channel until it closes: no fixed count to guess, no risk of missing a late value, and the loop exits exactly when the channel does—not before, not after. ### Merging two channels with selectResults and errors arrive on two separate channels, and neither one is guaranteed to close before the other. A
selectwith one receive case per channel lets you handle whichever has a value ready without picking favorites, and checking the second return value from a receive (v, ok := <-ch) tells you when a channel has closed so you can stop selecting on it and eventually exit the loop once both are done. ### Step summaryRunDispatchowns the send side ofjobs: it sends every reading, then closes the channel exactly once.CollectResultsusesfor r := range resultsto drain untilresultsis closed.Aggregateuses oneselectoverresultsanderrs, checks theokvalue to detect a closed channel, and sets that channel tonil, so it finishes only when both are done and neither channel can block the other.
-
Challenge
Step 5: Guard shared stats and cancel stuck workers
Protecting shared state with a mutex
pipeline.gocontains aStatsstruct with amap[string]intthat counts readings per device. Multiple worker goroutines update this map concurrently. Concurrent map writes in Go don't just risk incorrect counts—they can cause a runtime failure. Async.Mutexprevents concurrent access by usingLockbefore accessing the map andUnlockwhen finished (ideally withdefer, which runs when the function returns), ensuring only one goroutine accesses the map at a time. ## Reading safely and proving it with-raceLocking on write isn't enough if reads go unguarded—a caller inspecting stats while a worker writes is just as much a race as two writers colliding.
Snapshotneeds the same lock, plus a copy of the map rather than the live one, so a caller can look at the data without holding the lock or seeing writes that happen after the snapshot was taken.Go's race detector is how you confirm this holds under real concurrent load, not just on paper. The
-raceflag builds the test binary with instrumentation to detect data races. For example, the following command runs the tests for Tasks 5.1 and 5.2 with the race detector enabled:go test . -race -run 'TestTask5_1|TestTask5_2'If a goroutine accesses the map without holding the lock while another goroutine performs a conflicting access, the output can contain a
WARNING: DATA RACEreport identifying the conflicting lines, and the test fails. A successful run ends with anokline and no reported data races. The lab's check for Task 5.2 runs a stress test with many concurrentIncrementandSnapshotcallers under-race, so an unprotected map access can cause the check to fail. ### Canceling the whole run on failureRight now an error in one stage doesn't stop the others—goroutines keep running work that no longer matters.
context.WithCancelgives you a child context plus acancelfunction; pass the context intoStartWorkersso every worker can notice it, and callcancel()the moment an error arrives onerrs. The collection loop selects onctx.Done()too, so aftercancel()it returns instead of draining every remaining result.A canceled worker returns even when it's halfway through a send nobody will receive, and buffering
jobsto hold every reading means the dispatcher can always finish sending and close the channel. Without both, returning early just leaves those goroutines parked forever. The check for this task covers it from both sides: a run with one malformed reading followed by a thousand valid ones must stop long before it collects them all (so removingcancel()fails), and no goroutine may still be running onceRunWithContextreturns. ### Timing out a single silent droneCancellation handles a global stop signal; a per-reading deadline handles the narrower case of a read that never completes, such as a drone that has gone quiet.
context.WithTimeoutderives a context that cancels itself after a duration;selectbetween the channel receive andctx.Done()means whichever happens first wins, so a hung read returnsctx.Err()instead of blocking that worker—and the fleet—indefinitely.A deadline only helps if the workers actually use it. Once
ReadWithDeadlineworks, you'll call it fromworkerinworker.gowith the package'sreadTimeout, so a worker waiting on ajobschannel that never sends or closes reports a deadline error and exits instead of waiting forever. A closed channel is a normal end of work, not a timeout, soReadWithDeadlinereturnsio.EOFfor it and the worker can tell the two apart. ### Step summaryStatsguards its map with async.Mutex:IncrementandSnapshotlock first and unlock withdefer, andSnapshotreturns a copy.go test . -raceproves it: a stress test with concurrent writers and readers passes with noDATA RACEreport.RunWithContextderives a cancelable context, passes it to the workers, and callscancel()as soon as an error arrives, so blocked workers are released.ReadWithDeadlinebounds each read withcontext.WithTimeout, and workers call it, so a silent channel releases its worker with a deadline error instead of blocking it forever.
-
Challenge
Step 6: Run the application
Every task is complete, and you can check each one using its Validate button. What you haven't done yet is run the entire application and see how the pieces you implemented separately work together as a single pipeline.
Start the application
In the Terminal, run:
go run .Expected result: The terminal displays two lines. The first shows the total number of processed readings and errors, and the second shows the reading count for each drone.
processed 3 readings, 0 errors stats: map[drone-1:1 drone-2:1 drone-3:1]The output reflects the functionality you implemented in the previous steps. Compare it with the expected behavior from each task to see how the complete pipeline works.
The readings reached the workers because
RunDispatchcalledSendReadingsto put them on the send-onlyjobschannel, demonstrating Task 3.1, and then closedjobs, demonstrating Task 4.1. Oncejobsis empty and closed, each worker's call toReadWithDeadlinereturnsio.EOF. The worker then returns and callsDone, allowing theWaitGroupto finish.The first line,
processed 3 readings, 0 errors, comes from thePipelineResultthatRunWithContextreturns for the three readings inmain.go.StartWorkerslaunched four workers, callingwg.Add(1)before eachgo worker(...)so theWaitGroupcount was correct from the start, demonstrating Task 2.1.CloseWhenDonethen waited on theWaitGroupand closed theresultschannel after the last worker finished. This allows the collection loop to stop instead of waiting for the five-second timeout inmain.go, demonstrating Task 2.2.The collection loop in
RunWithContextmerges theresultsanderrschannels usingselectand sets each channel variable tonilonce its channel closes. This follows the same drain-until-closed pattern you implemented in Tasks 4.2 and 4.3. The loop finishes after both channels close, ensuring all three readings are counted.The
0 errorsoutput means the error path remained quiet, as expected. Every reading has aDeviceID, so no worker calledReportErroron the buffered channel created byNewErrorChannel, demonstrating Tasks 3.2 and 3.3. Because no error triggered early cancellation, the pipeline completed normally. The deferredcancel()still runs whenRunWithContextreturns, releasing the context's resources. This demonstrates the behavior implemented in Task 5.3. No worker reached its deadline either:ReadWithDeadlinedetected the closedjobschannel and returnedio.EOFinstead of a timeout error, demonstrating Task 5.4.The second line,
stats: map[drone-1:1 drone-2:1 drone-3:1], shows one reading counted for each drone.RunWithContextcalledIncrementonce per result, andIncrementlockedmubefore updating the map, demonstrating Task 5.1.Snapshotthen acquired the same mutex to create an independent copy of the map, so the statistics returned tomain.gocannot be changed by subsequent updates to the original map, demonstrating Task 5.2. Go formats maps with keys in sorted order, which is why the drones appear in this order.Across the lab, you built these controls:
- Track every worker with a
WaitGroupand close theresultschannel after the last worker finishes (Tasks 2.1 and 2.2). - Send readings through a send-only channel, buffer the error channel, and report errors without blocking (Tasks 3.1 to 3.3).
- Close
jobsfrom the producer and drain results and errors until both channels close (Tasks 4.1 to 4.3). - Guard the shared stats map with a
sync.Mutexand return an independent copy fromSnapshot(Tasks 5.1 and 5.2). - Cancel the run when an error occurs and apply a deadline to each read (Tasks 5.3 and 5.4). ### Run the checks yourself
The lab ran each task's test when you clicked Validate, but they are ordinary tests and you can run them whenever you like. In the Terminal, run the whole suite:
go test . -count=1 -timeout 60sExpected result: the output ends with a line that starts with
ok.Or run one task's test at a time, here Task 2.1:
bash runTest.sh TestTask2_1Each 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. One thing worth trying before you finish: intentionally introduce an error and observe how the corresponding check fails, then restore the original code. For example, remove the
wg.Wait()line fromCloseWhenDone, runbash runTest.sh TestTask2_2, and review the failure message to identify which task failed. Understanding what a failed test looks like is just as valuable as knowing what a passing test looks like.When you're done, the code you wrote remains available in the editor for you to review.
- Track every worker with a
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.