Build the safeguards
Eight explained steps, tradeoffs, pitfalls and a checkpoint before moving on.
Read the tutorialEight steps / Explained code / Checkpoints
A goroutine needs an owner and a way to finish. A bounded worker pool limits active work; cancellation must reach both producers and workers; joining them establishes that shutdown has actually completed.
Prerequisites: Try the playground first. The first pass needs only a modern browser. Editing the source needs the toolchain listed in the downloadable setup guide.
Choose a pace: 15 minutes to compare the failures; about 60–90 minutes for a first guided pass; extra time for the independent challenge.
Step 01
The unguarded example starts one goroutine per message and ignores the configured worker count. Each goroutine eventually tries to send a result on an unbuffered channel. The collector can leave when its context is canceled. At that point, the sender still needs a receiver. Run the default example and read the trace before changing the numbers.
results := make(chan int)
go func() { results <- id }()
// Who receives if the collector has already returned?Tradeoff and pitfall. A buffer may postpone the blockage while leaving ownership unresolved. A short demonstration can also appear fine when cancellation never happens. The harness records the failure and then releases its demonstration workers.
Check before moving on. Without safeguards, peak active work reaches the message count. Early cancellation records workers still active; final active work is 0 after explicit teaching-harness cleanup.
Step 02
The safeguarded path creates a fixed number of workers and a queue whose capacity equals that count. A producer sends messages through the queue. When the queue fills, the producer waits, limiting how far it gets ahead. This is backpressure: the upstream workload must respect downstream capacity.
jobs := make(chan int, config.Workers)
for range config.Workers { /* start one worker */ }Tradeoff and pitfall. A worker limit is a capacity choice, not a promise of lower latency. Too many workers can overload a database or API. Dropping work and returning a busy response are alternatives when waiting is unacceptable.
Check before moving on. Disable cancellation and use 12 messages with 3 workers. With safeguards, peak active work stays at or below 3 and all messages reach a completed or failed outcome.
Step 03
The producer is the sole closer of jobs. Workers receive the open flag and return when the queue is drained. A WaitGroup joins the producer and the workers. This lets Run return a settled result rather than guessing that background work probably finished. In the Go 1.27 toolchain used here, WaitGroup.Go associates launching and completion bookkeeping.
defer close(jobs)
for {
id, open := <-jobs
if !open { return }
process(id)
}Tradeoff and pitfall. Closing a channel from several receivers can panic. Closing the jobs channel does not cancel a worker already blocked somewhere else. Ownership and cancellation address different parts of the lifetime.
Check before moving on. Read the producer closure and worker join in engine.go. Identify every return path and the component that closes each channel.
Step 04
Both the producer send and the worker receive are select operations that also observe ctx.Done. A worker waiting for its simulated downstream result has its own cancellation branch. It stops its timer and records an incomplete outcome before returning. The final join runs after those paths settle.
select {
case <-ctx.Done(): return
case jobs <- id:
}Tradeoff and pitfall. A default branch can turn waiting into a hot loop or silently drop work. Neither is a neutral fix. If a real downstream client ignores context, the goroutine can remain stuck despite a well-designed outer select.
Check before moving on. Use a 200 ms delay and a 30 ms cancellation deadline. The safeguarded engine should finish with no completed messages and no active workers remaining.
Step 05
Workers update the same counters. A mutex protects both updates and snapshots so measurements themselves do not introduce a data race. The live panel displays per-run started, completed, failed and peak values from the engine result. It does not treat process-wide goroutine counts as an exact measure of application leaks.
c.mu.Lock()
defer c.mu.Unlock()
c.active++
c.result.Started++Tradeoff and pitfall. Atomic counters can be useful for independent values. A mutex is straightforward when several fields must describe one consistent snapshot. Race detection only covers the execution paths actually exercised.
Check before moving on. Run the native tests, including parallel independent runs. On a supported native toolchain with a C compiler, also run go test -race ./...; WebAssembly is a separate target.
Step 06
Every fourth message can be made to fail. That is a handled result from the simulated downstream operation, not a panic. Cancellation stops unfinished work; it does not make it successful. Run with cancellation disabled to compare completed plus failed outcomes with the message count.
if config.FailEvery > 0 && id % config.FailEvery == 0 {
c.end("failed")
} else { c.end("completed") }Tradeoff and pitfall. A real retry policy needs a bounded budget, a classification of retryable errors and an idempotent effect. Starting a fresh goroutine forever on every failure is not a recovery strategy.
Check before moving on. Use 12 messages, cancellation 0 and failure frequency 4. Expect 9 completed and 3 failed. Change the worker count and predict what will change and what will stay the same.
Step 07
The source includes two small native exercises beside the pipeline. A nil pointer placed inside an error interface can make that interface non-nil. A subslice can share its backing array with the original; copying creates a different ownership boundary. These are value-model questions and deserve focused tests rather than being buried inside the worker pool.
var concrete *DemoError = nil
var err error = concrete
// concrete == nil, but err != nilTradeoff and pitfall. A nil check on the wrong layer can hide an error or cause an unexpected method call. A copied slice of pointers still shares the pointed-to objects. State the depth of the copy you intend.
Check before moving on. Open traps.go and its tests. Predict the booleans and array changes, then run go test -run TestOwnership -v ./engine.
Step 08
The download includes a loopback HTTP adapter on 127.0.0.1:4188. It accepts a bounded JSON configuration, limits concurrent experiments, derives a deadline from the request context and returns the engine result. It is an independent local test environment, not a new public notification service.
go test ./...
go run ./cmd/server
# POST a sample configuration to /api/runTradeoff and pitfall. The browser reset terminates the entire worker. Native request cancellation cooperatively reaches the Go engine. A production service would also need process shutdown handling, service authentication, quotas and real delivery semantics.
Check before moving on. Run the same configuration through the browser and native adapter. Compare invariant counts, not exact wall-clock timings. Explain which runtime differences prevent a browser throughput claim.
Independent challenge
Add a bounded retry budget for a simulated transient error. Keep one logical message identity across attempts, preserve the worker limit, and make cancellation interrupt the retry delay.
Write the expected behavior before changing the implementation. Keep the original failure as a regression case. Explain the tradeoff you accepted and the result that would make you reconsider it.
References support the language and runtime concepts. The lesson, scenarios and explanations are original Zyel Lab material.
Keep exploring
Eight explained steps, tradeoffs, pitfalls and a checkpoint before moving on.
Read the tutorialChoose a pattern and an environment for the problem you actually have.
Compare approachesA beginner route, a local setup and an extension challenge.
Open the how-to guide