Wait-for-Empty-Channel — Hands-on Tasks¶
Eighteen tasks of increasing difficulty. Each task includes a starting prompt, success criteria, and hints. Work them in order; later tasks assume the skills from earlier ones.
Set up a fresh tasks/ directory in your scratch workspace. Each task gets its own subdirectory.
Task 1: Spot the Anti-Pattern (Warm-up)¶
Prompt. Given the code below, identify the anti-pattern, explain the race, and refactor.
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 10)
for i := 0; i < 10; i++ {
go func(i int) {
ch <- i * i
}(i)
}
for len(ch) > 0 {
time.Sleep(time.Millisecond)
fmt.Println(<-ch)
}
}
Success criteria.
- Explain the race in two sentences.
- Provide a refactored version that uses
sync.WaitGroupandrange. - Test that the refactor prints all 10 values.
Hints. WaitGroup tracks producers; close-after-wait pattern.
Task 2: Refactor a Worker Pool¶
Prompt. Refactor this worker pool to remove the polling.
type Pool struct {
jobs chan Job
}
func (p *Pool) Start(workers int) {
for i := 0; i < workers; i++ {
go func() {
for {
if len(p.jobs) == 0 {
return
}
j := <-p.jobs
process(j)
}
}()
}
}
func (p *Pool) Wait() {
for len(p.jobs) > 0 {
time.Sleep(time.Millisecond)
}
}
Success criteria.
- Worker uses
for j := range p.jobsinstead of polling. Waitusessync.WaitGroup.- Pool's
Closeclosesjobsto signal workers. - Tests pass under
-race -count=100.
Hints. Add a WaitGroup field; Add(1) per worker; defer wg.Done() inside.
Task 3: Build a Drain Helper¶
Prompt. Implement a function Drain(ch <-chan T) []T that reads all values from a channel until it is closed and returns them.
Success criteria.
- The function does not poll
len(ch). - It works for any channel type (use generics).
- Tests verify it returns exactly the values sent.
- Tests verify it blocks until the channel is closed.
Hints. A simple for range loop appending to a slice.
func Drain[T any](ch <-chan T) []T {
var out []T
for v := range ch {
out = append(out, v)
}
return out
}
Task 4: Bounded Wait with Context¶
Prompt. Implement a function WaitWithContext(ctx context.Context, wg *sync.WaitGroup) error that returns nil when the WaitGroup is zero or the context's error if it cancels first.
Success criteria.
- No polling.
- Returns ctx.Err() on cancellation.
- Returns nil on WaitGroup completion.
- Tests cover both paths.
Hints. A separate goroutine watches the WaitGroup and closes a done channel. The main goroutine selects.
Task 5: Audit a Codebase¶
Prompt. Pick any open-source Go project (or your own). Run:
Identify three candidate instances of the polling pattern. For each:
- Determine if it is the anti-pattern or a legitimate use.
- If anti-pattern, design a refactor.
- If legitimate (e.g., metric gauge), explain why.
Success criteria.
- Write a short report with file:line references, classification, and rationale.
- Submit the report as a markdown document.
Hints. Look in older codebases for higher hit rates. Newer projects with active maintenance have fewer.
Task 6: Refactor a Polling-Based Shutdown¶
Prompt. This service polls during shutdown. Refactor it.
type Service struct {
stop atomic.Int32
jobs chan Job
}
func (s *Service) Run() {
for {
if s.stop.Load() == 1 {
for len(s.jobs) > 0 {
time.Sleep(time.Millisecond)
<-s.jobs
}
return
}
select {
case j := <-s.jobs:
handle(j)
default:
time.Sleep(time.Millisecond)
}
}
}
func (s *Service) Stop() {
s.stop.Store(1)
for len(s.jobs) > 0 {
time.Sleep(10 * time.Millisecond)
}
}
Success criteria.
- Use
context.Contextfor shutdown signaling. - Use
sync.WaitGroupfor join. - Workers loop with
selectonctx.Done()ands.jobs. Stop()cancels the context, closess.jobs, and waits for the WaitGroup.- Tests pass under
-race.
Hints. Use errgroup if you want error propagation as well.
Task 7: Build a Settled-After-Quiet Helper¶
Prompt. Implement WaitSettled(ctx context.Context, events <-chan struct{}, settle time.Duration) error that returns when no event has arrived for settle duration.
Success criteria.
- Returns nil when settled.
- Returns ctx.Err() if ctx cancels.
- No polling; use
time.TimerwithReset.
Hints.
timer := time.NewTimer(settle)
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-events:
if !timer.Stop() {
<-timer.C
}
timer.Reset(settle)
case <-timer.C:
return nil
}
}
Task 8: Implement a Countdown Latch¶
Prompt. Build a Latch type that starts at N and decrements via CountDown(). Wait() returns a channel that closes when the count reaches zero.
Success criteria.
- API:
New(n int) *Latch,CountDown(),Wait() <-chan struct{}. - Thread-safe.
- Once the latch reaches zero, future
CountDowncalls have no effect. - Tests cover basic countdown, multiple waiters, and concurrent count-downs.
Hints. Use atomic.Int32 and sync.Once.
Task 9: Implement a Generic Worker Pool¶
Prompt. Build a worker pool with the API:
type Pool[T any] struct { /* opaque */ }
func New[T any](ctx context.Context, workers int, handler func(context.Context, T) error) *Pool[T]
func (p *Pool[T]) Submit(ctx context.Context, item T) error
func (p *Pool[T]) Close() error
Success criteria.
- Constructor starts workers.
Submitrespects both the caller's context and the pool's context.Closecloses the input channel, waits for workers, returns first error.- No polling.
- Tests pass under
-race -count=100. goleak.VerifyTestMainconfirms no leaks.
Hints. Use errgroup internally.
Task 10: Build a Cancellable Bounded Queue¶
Prompt. Build a generic queue with:
type Queue[T any] struct { /* opaque */ }
func New[T any](capacity int) *Queue[T]
func (q *Queue[T]) Push(ctx context.Context, item T) error
func (q *Queue[T]) Pop(ctx context.Context) (T, error)
func (q *Queue[T]) Close()
func (q *Queue[T]) Drain() <-chan T
Success criteria.
PushandPoprespect context.Closeis idempotent.Drainreturns the underlying channel for range iteration.- After Close, Push returns an error.
- Tests pass under
-race.
Hints. Use sync.Once for idempotent close.
Task 11: Add Observability¶
Prompt. Take the worker pool from Task 9. Add:
- A gauge for in-flight count.
- A counter for processed messages.
- A counter for errors.
- A histogram for processing duration.
Success criteria.
- Use the
prometheus/client_golangpackage. - Metrics update with each operation.
- A
/metricsendpoint exposes them. - Tests verify metrics are emitted correctly.
Hints. Atomics for in-flight gauge; track time with time.Since.
Task 12: Build a Graceful HTTP Server¶
Prompt. Build an HTTP server that:
- Listens on
:8080. - Has a
/workendpoint that submits to a worker pool. - Has a
/readyand/healthendpoint for Kubernetes. - Handles SIGTERM and shuts down gracefully within 25 seconds.
Success criteria.
- All endpoints work.
- SIGTERM triggers a clean shutdown that drains in-flight work.
- No polling.
- Test with
kill -TERM <pid>and confirm clean exit.
Hints. Use signal.NotifyContext and http.Server.Shutdown.
Task 13: Detect the Anti-Pattern in CI¶
Prompt. Write a go/analysis pass that detects for len(ch) > 0 { ... } patterns where ch is a channel.
Success criteria.
- The pass identifies the pattern.
- It does not flag legitimate uses (e.g., reading a length for metrics).
- Integrate into a Makefile target:
make lint. - Tests cover both positive and negative cases.
Hints. Walk the AST. Check the type of the argument to len. Flag for statements with that as the condition.
Task 14: Refactor a Pipeline¶
Prompt. Refactor this 3-stage pipeline to remove polling.
func pipeline(input []int) []int {
stage1 := make(chan int, len(input))
stage2 := make(chan int, len(input))
stage3 := make(chan int, len(input))
for _, x := range input {
stage1 <- x
}
go func() {
for len(stage1) > 0 {
v := <-stage1
stage2 <- v + 1
}
}()
go func() {
for len(stage2) > 0 {
v := <-stage2
stage3 <- v * 2
}
}()
var result []int
for len(stage3) > 0 {
result = append(result, <-stage3)
}
return result
}
Success criteria.
- Each stage uses range; no polling.
- Each stage closes its output channel when its input closes.
- The result has the same length as input.
- Tests pass under
-race -count=100.
Hints. The producer of stage1 should close it; each subsequent stage closes its output in a defer.
Task 15: Build a Fan-Out / Fan-In¶
Prompt. Implement:
That fans in out to workers goroutines running fn, then fans back into a single output channel.
Success criteria.
- The output channel closes when
inis exhausted and all workers finish. - No polling.
- Order is not preserved (parallelism).
- Tests verify all values pass through
fn.
Hints. Use WaitGroup to wait for all workers; close after wait.
Task 16: Write a Stress Test¶
Prompt. Take any of your refactored code from earlier tasks. Write a stress test that:
- Submits 100,000 jobs concurrently from 100 goroutines.
- Verifies all jobs are processed.
- Runs under
-race -count=10. - Completes within 30 seconds.
Success criteria.
- The test passes reliably.
- No leaked goroutines (verify with
goleak). - CPU usage is reasonable (the polling-free version should not peg cores).
Hints. Generate input slowly enough to not overflow buffers; use buffered channels of appropriate size.
Task 17: Build an Observability Dashboard¶
Prompt. Take the worker pool from Task 11. Add:
- A Grafana dashboard JSON with panels for:
- In-flight gauge over time.
- Processed rate.
- Error rate.
- P99 processing duration.
- Queue depth (if buffered).
- A README explaining each panel and the relevant SLO.
Success criteria.
- The dashboard imports cleanly into Grafana.
- All panels show meaningful data when the worker pool runs.
- README describes the alert conditions for each panel.
Hints. Use Prometheus queries with rate() and histogram_quantile.
Task 18: Audit Shutdown Logic¶
Prompt. Audit the shutdown logic of any production-grade Go service you have access to (your own, open-source, or a sample). For each:
- Does it use
signal.NotifyContextor equivalent? - Does it call
http.Server.Shutdown(or similar) with a deadline? - Does it wait for worker pools to drain?
- Are there any polling loops in the shutdown path?
- Is the shutdown bounded by a known deadline less than
terminationGracePeriodSeconds?
Success criteria.
- A written audit report covering at least 3 services.
- Each service has a "pass/fail" verdict on each criterion.
- For each fail, propose a specific fix.
Hints. Look in main.go and any Shutdown or Close methods. Trace the order of operations.
Closing¶
These 18 tasks cover the spectrum from "spot the pattern" to "audit a production codebase." Working them in order builds the muscle memory.
Recommended pace: 2-4 tasks per week. After completing all 18, you should be able to:
- Recognise the anti-pattern instantly.
- Refactor any instance with confidence.
- Build polling-free utilities from scratch.
- Audit a codebase for the pattern.
- Set up tooling that prevents regression.
The discipline transfers. The same techniques apply to other concurrency anti-patterns.