By Chason ArthurRevision 126.014Published Updated

Eight steps / Explained code / Checkpoints

Build the safeguards.

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.

  1. Start work whose lifetime you can explain
  2. Bound work before reaching the downstream service
  3. Give each channel a clear closing owner
  4. Send cancellation through every blocking boundary
  5. Synchronize the shared observations
  6. Distinguish failure, cancellation and successful completion
  7. Inspect interface nil and slice ownership separately
  8. Run the native service and review its boundaries

Step 01

Start work whose lifetime you can explain

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

Bound work before reaching the downstream service

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

Give each channel a clear closing owner

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

Send cancellation through every blocking boundary

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

Synchronize the shared observations

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

Distinguish failure, cancellation and successful completion

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

Inspect interface nil and slice ownership separately

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 != nil

Tradeoff 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

Run the native service and review its boundaries

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/run

Tradeoff 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

Make one new guarantee.

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.

Primary references and further reading

References support the language and runtime concepts. The lesson, scenarios and explanations are original Zyel Lab material.

Keep exploring

Follow the question you have next.

Build the safeguards

Eight explained steps, tradeoffs, pitfalls and a checkpoint before moving on.

Read the tutorial

Compare the choices

Choose a pattern and an environment for the problem you actually have.

Compare approaches