Exercises — Module 12: Advanced Concurrency Patterns¶
Work through exercises in order — they're designed to build on each other. Attempt each problem genuinely before looking at the solution. Seeing the solution first might feel like progress, but it isn't.
Instructions¶
- Attempt first. Spend at least the estimated time on each problem before checking hints or solutions.
- Write your work. Don't just read code mentally — compile and run your attempts.
- Check your answer against the acceptance criteria, not just the solution code.
- Score yourself honestly in the Scoring Log at the bottom.
- If stuck after a genuine effort, use hints one at a time — not all at once.
Difficulty Legend¶
| Symbol | Difficulty | Expected Time | Points |
|---|---|---|---|
| 🟢 Easy | Recall and basic application | 10–20 min | 1 pt |
| 🟡 Medium | Requires combining 2+ concepts | 20–35 min | 2 pts |
| 🔴 Hard | Multi-step, requires real problem-solving | 40–75 min | 3 pts |
| ⭐ Challenge | Open-ended; more than one good answer | 75+ min | 5 pts |
Exercise 1: Context-Aware Sleeper [🟢 Easy] [1 pt]¶
Context¶
Every context-aware operation follows the same structure: do work OR return early if the context is cancelled. The simplest possible version is a cancellable time.Sleep. Mastering this one pattern unlocks the rest of the module.
Task¶
Write a function sleep(ctx context.Context, d time.Duration) error that:
1. Waits for duration d to elapse — and returns nil
2. OR returns ctx.Err() immediately if the context is cancelled before d elapses
Call it from main with a context that has a 200ms timeout, but pass a 1-second sleep duration. Show that it returns before 1 second.
Requirements¶
- Uses
selectwithtime.After(d)and<-ctx.Done() - Returns
nilwhen the timer fires normally - Returns
ctx.Err()when the context is cancelled first - The function signature is
func sleep(ctx context.Context, d time.Duration) error
Hints¶
Hint 1 (try without this first)
The body is a single `select`:Expected Output / Acceptance Criteria¶
(The exact error message iscontext.DeadlineExceeded.Error() → "context deadline exceeded".)
Solution¶
Show Solution (attempt first!)
package main
import (
"context"
"fmt"
"time"
)
// sleep waits for d or for ctx to be cancelled, whichever comes first.
func sleep(ctx context.Context, d time.Duration) error {
select {
case <-time.After(d):
return nil // timer fired — clean completion
case <-ctx.Done():
return ctx.Err() // cancelled or deadline exceeded
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
start := time.Now()
err := sleep(ctx, 1*time.Second)
fmt.Printf("sleep returned after %v: %v\n", time.Since(start).Round(time.Millisecond), err)
}
Exercise 2: Context Cancellation Propagation [🟢 Easy] [1 pt]¶
Context¶
Understanding how cancellation propagates from parent to child context is critical before building any pipeline. When the parent is cancelled, all derived children are automatically cancelled — you don't need to cancel each child explicitly.
Task¶
Write a program that:
1. Creates a parent context with a 500ms timeout
2. Creates a child context with context.WithCancel(parent) (no deadline on the child)
3. Starts a goroutine that uses the child context: it should loop, printing "tick" every 100ms, and exit when the child context is cancelled
4. The goroutine should NOT be cancelled by calling the child's cancel — it should be cancelled by the parent's deadline expiring
Requirements¶
- Parent uses
context.WithTimeout; child usescontext.WithCancel - The goroutine selects on
childCtx.Done() -
childCancelis deferred but is NOT called before the parent expires - Output shows approximately 4–5 "tick" prints before the goroutine exits
Hints¶
Hint 1
Even though `childCtx` was created with `WithCancel` (no deadline), it inherits the parent's cancellation. When the parent's 500ms deadline fires, `childCtx.Done()` is also closed automatically.Expected Output / Acceptance Criteria¶
tick
tick
tick
tick
tick (approximately)
goroutine: child context cancelled: context deadline exceeded
Solution¶
Show Solution
package main
import (
"context"
"fmt"
"time"
)
func main() {
parentCtx, parentCancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer parentCancel()
childCtx, childCancel := context.WithCancel(parentCtx)
defer childCancel() // child cancel deferred, but parent will fire first
done := make(chan struct{})
go func() {
defer close(done)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fmt.Println("tick")
case <-childCtx.Done():
fmt.Printf("goroutine: child context cancelled: %v\n", childCtx.Err())
return
}
}
}()
<-done
}
Exercise 3: Bounded Worker Pool [🟡 Medium] [2 pts]¶
Context¶
Unbounded goroutine spawning is a common cause of memory exhaustion and cascading failures. A bounded worker pool is the primary tool for controlling concurrency when processing a slice of work items.
Task¶
Implement a function processURLs(ctx context.Context, urls []string, workers int) []string that:
1. Spawns exactly workers goroutines
2. Feeds each URL from a jobs channel to the next available worker
3. Each worker makes a HEAD request and records "OK: <url>" or "ERR: <url>: <error>"
4. Collects all results (preserving any order is fine — results are nondeterministic)
5. Returns the results slice after all workers finish
Requirements¶
- Exactly
workersgoroutines are running, not one per URL - Workers use
context.WithTimeoutfor individual requests (2s per request) - Workers exit cleanly when the jobs channel is closed
- Uses
sync.WaitGroupto wait for all workers before closing the results channel - Compile and run; output shows results for all URLs
Hints¶
Hint 1 (structural)
Structure:jobs := make(chan string, len(urls)) // buffer all URLs upfront
for _, u := range urls { jobs <- u }
close(jobs) // workers exit when jobs is drained
results := make(chan string, len(urls))
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go worker(ctx, &wg, jobs, results)
}
go func() { wg.Wait(); close(results) }()
var out []string
for r := range results { out = append(out, r) }
return out
Hint 2 (worker body)
func worker(ctx context.Context, wg *sync.WaitGroup, jobs <-chan string, results chan<- string) {
defer wg.Done()
for url := range jobs {
reqCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
req, _ := http.NewRequestWithContext(reqCtx, http.MethodHead, url, nil)
_, err := http.DefaultClient.Do(req)
cancel()
if err != nil {
results <- "ERR: " + url + ": " + err.Error()
} else {
results <- "OK: " + url
}
}
}
Expected Output / Acceptance Criteria¶
OK: https://go.dev
OK: https://pkg.go.dev
ERR: https://nonexistent.example.invalid: ...
(order is nondeterministic)
Solution¶
Show Solution
package main
import (
"context"
"fmt"
"net/http"
"sync"
"time"
)
func worker(ctx context.Context, wg *sync.WaitGroup, jobs <-chan string, results chan<- string) {
defer wg.Done()
for url := range jobs { // exits when jobs is closed and drained
reqCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
req, err := http.NewRequestWithContext(reqCtx, http.MethodHead, url, nil)
if err != nil {
cancel()
results <- fmt.Sprintf("ERR: %s: %v", url, err)
continue
}
resp, err := http.DefaultClient.Do(req)
cancel() // always cancel the per-request context
if err != nil {
results <- fmt.Sprintf("ERR: %s: %v", url, err)
} else {
resp.Body.Close()
results <- fmt.Sprintf("OK: %s (%d)", url, resp.StatusCode)
}
}
}
func processURLs(ctx context.Context, urls []string, workers int) []string {
jobs := make(chan string, len(urls))
for _, u := range urls {
jobs <- u
}
close(jobs) // pre-fill and close; workers drain and exit
results := make(chan string, len(urls))
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go worker(ctx, &wg, jobs, results)
}
go func() {
wg.Wait()
close(results)
}()
var out []string
for r := range results {
out = append(out, r)
}
return out
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
urls := []string{
"https://go.dev",
"https://pkg.go.dev",
"https://nonexistent.example.invalid",
}
// NOTE: output order is nondeterministic
for _, r := range processURLs(ctx, urls, 2) {
fmt.Println(r)
}
}
Exercise 4: Three-Stage Pipeline with Cancellation [🟡 Medium] [2 pts]¶
Context¶
A pipeline composes stages where each stage reads from an upstream channel and writes to a downstream channel. Cancellation must propagate: when the context is cancelled, every stage must stop producing and exit, closing its output channel.
Task¶
Build a three-stage pipeline:
1. Stage 1 generate: emits integers 1..N to a channel
2. Stage 2 square: receives integers and emits their squares
3. Stage 3 (in main): reads squares and prints them
All stages must:
- Accept ctx context.Context as the first parameter
- Use select with ctx.Done() on every send
- Close their output channel when they exit (defer close(out))
Cancel the context after 5 values have been printed, and verify that the pipeline stops cleanly (no goroutine leak).
Requirements¶
- Three distinct functions:
generate,square, and a consuming loop inmain - Each stage goroutine defers
close(out) - Each stage
selects onctx.Done()for sends -
generatestops if N is exhausted or ctx is cancelled - The consumer cancels after 5 values and confirms no deadlock
Hints¶
Hint 1
Expected Output / Acceptance Criteria¶
(Exact number depends on when cancel() is called, but should be approximately 5.)Solution¶
Show Solution
package main
import (
"context"
"fmt"
)
// generate emits integers 1..n, stopping early if ctx is cancelled.
func generate(ctx context.Context, n int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 1; i <= n; i++ {
select {
case out <- i:
case <-ctx.Done():
return
}
}
}()
return out
}
// square receives integers and emits their squares.
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
count := 0
for sq := range square(ctx, generate(ctx, 100)) {
fmt.Println(sq)
count++
if count >= 5 {
cancel() // cancel the context — upstream stages will stop
break // stop consuming
}
}
fmt.Println("cancelled after", count, "values")
}
Exercise 5: errgroup Fan-Out with Error Handling [🟡 Medium] [2 pts]¶
Context¶
errgroup.WithContext is the idiomatic replacement for sync.WaitGroup + error channel. It automatically propagates the first error and cancels the shared context, signalling other goroutines to stop early.
Task¶
Write a function checkAll(ctx context.Context, urls []string) error that:
1. Uses errgroup.WithContext to fan out one goroutine per URL
2. Each goroutine makes an HTTP HEAD request for its URL
3. Returns the first error encountered (if any)
4. On error, the other goroutines should respect the cancelled context and stop early
Call it with a mix of valid and invalid URLs and print the result.
Requirements¶
- Uses
errgroup.WithContext(importgolang.org/x/sync/errgroup) - Each goroutine returns
nilon success or a non-nilerroron failure - Loop variable capture:
i, url := i, urlbefore the closure (or Go 1.22+) - Uses
http.NewRequestWithContext(ctx, ...)— respects context cancellation -
g.Wait()returns the first non-nil error
Hints¶
Hint 1
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
url := url
g.Go(func() error {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil { return err }
resp, err := http.DefaultClient.Do(req)
if err != nil { return fmt.Errorf("HEAD %s: %w", url, err) }
resp.Body.Close()
return nil
})
}
return g.Wait()
Expected Output / Acceptance Criteria¶
Ornil if all URLs succeed.
Solution¶
Show Solution
package main
import (
"context"
"fmt"
"net/http"
"time"
"golang.org/x/sync/errgroup"
)
func checkAll(ctx context.Context, urls []string) error {
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
url := url // capture — required before Go 1.22
g.Go(func() error {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return fmt.Errorf("build request %s: %w", url, err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("HEAD %s: %w", url, err)
}
resp.Body.Close()
return nil
})
}
return g.Wait() // blocks until all goroutines finish; returns first non-nil error
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
urls := []string{
"https://go.dev",
"https://nonexistent.example.invalid",
"https://pkg.go.dev",
}
if err := checkAll(ctx, urls); err != nil {
fmt.Println("error:", err)
} else {
fmt.Println("all OK")
}
}
Exercise 6: Semaphore via Buffered Channel [🟡 Medium] [2 pts]¶
Context¶
A semaphore limits the number of goroutines that can execute a critical section simultaneously. A buffered channel is the idiomatic Go semaphore: trying to send when the buffer is full blocks (acquires a slot); receiving releases it.
Task¶
Write a function concurrentProcess(ctx context.Context, items []int, limit int) []int that:
1. Launches one goroutine per item
2. Uses a buffered channel semaphore of size limit to allow at most limit goroutines to "process" simultaneously
3. Each goroutine "processes" its item by squaring it (simulating work with a 10ms sleep)
4. Cancels early if ctx is done
5. Returns a slice of results (order may vary)
Requirements¶
-
sem := make(chan struct{}, limit)is the semaphore - Each goroutine acquires with
sem <- struct{}{}(or exits on ctx.Done) - Each goroutine releases with
defer func() { <-sem }() - Uses
sync.WaitGroupto wait for all goroutines
Hints¶
Hint 1
sem := make(chan struct{}, limit)
results := make([]int, len(items))
var wg sync.WaitGroup
for i, item := range items {
i, item := i, item
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}: // acquire
case <-ctx.Done():
return
}
defer func() { <-sem }() // release
time.Sleep(10 * time.Millisecond)
results[i] = item * item
}()
}
wg.Wait()
return results
Expected Output / Acceptance Criteria¶
The function returns [1, 4, 9, 16, 25] for input [1, 2, 3, 4, 5] with limit=2. The 5 goroutines run, but only 2 at a time execute the "work" block.
Solution¶
Show Solution
package main
import (
"context"
"fmt"
"sync"
"time"
)
func concurrentProcess(ctx context.Context, items []int, limit int) []int {
sem := make(chan struct{}, limit) // semaphore: at most limit goroutines in the work section
results := make([]int, len(items))
var wg sync.WaitGroup
for i, item := range items {
i, item := i, item
wg.Add(1)
go func() {
defer wg.Done()
// Acquire semaphore slot — blocks if limit goroutines are already working.
select {
case sem <- struct{}{}:
case <-ctx.Done():
return
}
defer func() { <-sem }() // release when this goroutine finishes work
// Simulate work.
time.Sleep(10 * time.Millisecond)
results[i] = item * item
}()
}
wg.Wait()
return results
}
func main() {
ctx := context.Background()
results := concurrentProcess(ctx, []int{1, 2, 3, 4, 5}, 2)
fmt.Println(results) // [1 4 9 16 25]
}
Exercise 7: Goroutine Leak Detection and Fix [🔴 Hard] [3 pts]¶
Context¶
Goroutine leaks are among the most common bugs in production Go services. This exercise presents two leaky functions and asks you to diagnose and fix each.
Task¶
Each function below has a goroutine leak. For each: a) Identify the exact leak (which goroutine blocks, and why it will never unblock) b) Fix it without changing the function's observable behavior
Function A:
func doWorkA(input int) int {
ch := make(chan int)
go func() {
ch <- expensiveCompute(input)
}()
return <-ch
}
Function B:
func watchUpdates(ctx context.Context, updates <-chan string, callback func(string)) {
go func() {
for update := range updates {
callback(update)
}
}()
}
Requirements¶
- Correctly identifies the leak condition for both A and B
- Provides a fix for both that allows the goroutine to exit
- Fix for A must handle caller abandoning the returned channel (if applicable)
- Fix for B must handle ctx cancellation
Hints¶
Hint 1 — Function A
Function A actually has no leak when called normally — the goroutine sends and the caller receives. The leak occurs when the caller is itself cancelled before reading from the returned channel. But as written, the goroutine has no escape hatch if the caller disappears. Consider: what if `doWorkA` is called in a goroutine that returns early due to a cancelled context?Hint 2 — Function B
The goroutine in B ranges over `updates`. It will exit when `updates` is closed. But what if `updates` is never closed? For example, a long-running server that monitors `updates` might have `ctx` cancelled (on shutdown) but `updates` kept open by another component. The goroutine will block forever.Hint 3 — Fix structure
For A: add a context parameter and `select` the send with `ctx.Done()`. For B: add a `select` inside the `for` with `ctx.Done()`.Expected Output / Acceptance Criteria¶
- Clear explanation of when each goroutine leaks
- Fixed versions compile and the goroutines exit in all scenarios
- Function B fixed version exits when
ctxis cancelled even ifupdatesis never closed
Solution¶
Show Solution
**Analysis — Function A:** `doWorkA` appears safe in isolation — the goroutine sends and the caller receives. The leak occurs when the caller context is cancelled *before* reading from the returned channel: if the caller does `ctx.Done() → return` instead of reading the channel, the goroutine blocks on `ch <- ...` forever. **Fixed A:**func doWorkA(ctx context.Context, input int) (int, error) {
ch := make(chan int, 1) // buffer of 1 — goroutine can always send and exit
go func() {
ch <- expensiveCompute(input)
}()
select {
case result := <-ch:
return result, nil
case <-ctx.Done():
return 0, ctx.Err() // caller gives up; goroutine sends to buffered ch and exits
}
}
Exercise 8: Graceful Shutdown with Drain [🔴 Hard] [3 pts]¶
Context¶
A server must not kill goroutines mid-operation when it receives SIGINT. Graceful shutdown means: stop accepting new work, then wait for in-flight work to complete (up to a maximum drain timeout), then exit.
Task¶
Write a simulated server that:
1. Uses signal.NotifyContext to listen for SIGINT/SIGTERM
2. Launches 10 "worker" goroutines that each process one item every 300ms (simulate with time.Sleep)
3. On shutdown signal: stops sending new work, waits up to 2 seconds for workers to finish current item
4. Prints "clean shutdown" if all workers finish in time, or "forced shutdown" if the drain timeout expires
Test by running the program and pressing Ctrl+C.
Requirements¶
- Uses
signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - Workers select on
ctx.Done()— they exit when the context is cancelled - A separate
drainCtxwith a 2-second timeout governs the drain phase - Uses
sync.WaitGroup+ a done channel to determine clean vs forced shutdown - Compiles and runs
Hints¶
Hint 1
Hint 2 — drain pattern
Expected Output / Acceptance Criteria¶
worker 3: processing item 1
worker 0: processing item 1
... (workers processing)
^C
shutdown signal received; draining...
worker 0: shutting down
... (all workers shut down within ~300ms, well under 2s)
clean shutdown
Solution¶
Show Solution
package main
import (
"context"
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
var wg sync.WaitGroup
numWorkers := 5
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
item := 0
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d: shutting down\n", id)
return
case <-time.After(10 * time.Millisecond): // check for work
}
item++
fmt.Printf("worker %d: processing item %d\n", id, item)
// Simulate processing time.
select {
case <-time.After(300 * time.Millisecond):
case <-ctx.Done():
fmt.Printf("worker %d: interrupted mid-item\n", id)
return
}
}
}(i)
}
// Block until signal arrives.
<-ctx.Done()
fmt.Println("shutdown signal received; draining...")
// Give workers up to 2 seconds to finish.
drainCtx, drainCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer drainCancel()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
fmt.Println("clean shutdown")
case <-drainCtx.Done():
fmt.Println("forced shutdown — some work incomplete")
}
}
Scoring Log¶
Record your performance honestly. Include the date and whether you used hints.
| Exercise | Date | Score | Used Hints? | Notes |
|---|---|---|---|---|
| Exercise 1 — Context-Aware Sleeper | — | —/1 | — | — |
| Exercise 2 — Context Cancellation Propagation | — | —/1 | — | — |
| Exercise 3 — Bounded Worker Pool | — | —/2 | — | — |
| Exercise 4 — Three-Stage Pipeline | — | —/2 | — | — |
| Exercise 5 — errgroup Fan-Out | — | —/2 | — | — |
| Exercise 6 — Semaphore via Buffered Channel | — | —/2 | — | — |
| Exercise 7 — Goroutine Leak Detection and Fix | — | —/3 | — | — |
| Exercise 8 — Graceful Shutdown with Drain | — | —/3 | — | — |
| Total | —/16 |
Passing threshold: 11/16 (68%). Aim for 14/16 (87%) before taking the test.