Day 10 — Sync Primitives, Worker Pools & Context
Day 10 — Sync Primitives, Worker Pools & Context
Day 22 — Mutex, WaitGroup, atomic
Stage III · ~3h (theory-heavy)
Goal: Protect shared memory with sync and atomic, know WaitGroup rules cold, and choose mutex vs channel deliberately—with race-clean counters and a small benchmark.
Channels move ownership; mutexes protect invariants on shared structures. Experts use both. Dogma is for tutorials; measurement is for production.
Why this day exists
You already can spawn goroutines and pass messages. Real maps, caches, connection pools, and metrics still need:
- Mutual exclusion (
Mutex)
- Read-mostly sharing (
RWMutex)
- Cheap counters (
atomic)
- One-time init (
Once)
- Correct join (
WaitGroup)
Incorrect locking is deadlock; no locking is data races. Both are production incidents.
Theory 1 — Critical sections and invariants
A mutex guards an invariant: “while I hold the lock, these fields are consistent.”
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}Rules:
- Same lock for all accesses to the protected fields.
- Prefer
defer Unlockunless you need early unlock for performance.
- Do not copy a struct containing a mutex after first use (lock copies are broken).
- Keep critical sections small—no network I/O under lock if avoidable.
Copying hazard
c1 := Counter{}
c2 := c1 // copies mutex state — undefined / brokenPass *Counter, store mutex by value inside the struct, never copy the outer value after use.
Theory 2 — RWMutex (read-mostly)
var (
mu sync.RWMutex
m = map[string]int{}
)
func get(k string) (int, bool) {
mu.RLock()
defer mu.RUnlock()
v, ok := m[k]
return v, ok
}
func set(k string, v int) {
mu.Lock()
defer mu.Unlock()
m[k] = v
}- Many concurrent
RLockholders allowed
Lockis exclusive
- Writers waiting can starve readers depending on usage—don’t assume magic fairness
If write rate is high, RWMutex may lose to a plain Mutex. Benchmark (Lab 3).
Theory 3 — atomic package
For independent scalars (counters, flags, pointers with care):
var hits atomic.Int64
hits.Add(1)
fmt.Println(hits.Load())var flag atomic.Bool
flag.Store(true)
if flag.Load() { ... }var p atomic.Pointer[Config]
p.Store(&Config{...})
cfg := p.Load()atomics are not free composition
Two atomic fields do not update as one transaction:
// NOT atomic as a pair
a.Add(1)
b.Add(1)For multi-field invariants, use a mutex (or immutable snapshot under atomic pointer).
Theory 4 — WaitGroup contracts
var wg sync.WaitGroup
wg.Add(1) // before go, or under careful accounting
go func() {
defer wg.Done()
work()
}()
wg.Wait()| Rule | Why |
|---|---|
Add before the goroutine runs |
Else Wait may return early |
Done exactly once per Add unit |
Too few → hang; too many → panic |
Don’t use negative Add casually |
Easy to desync |
Prefer Add(n) then loop start |
Clear accounting |
WaitGroup must not be copied after first use.
Theory 5 — sync.Once and lazy init
var (
once sync.Once
client *Client
)
func getClient() *Client {
once.Do(func() {
client = dial() // runs once
})
return client
}- Concurrent callers block until
Dofinishes
- If
Dopanics, futureDocalls are skipped (document carefully)
- Great for process-wide init; less great for per-request logic
Theory 6 — Mutex vs channel decision table
| Situation | Prefer |
|---|---|
| Shared map/cache with random access | Mutex / RWMutex |
| Transfer ownership of a job/result | Channel |
| Counting events | Atomic or mutex |
| Pipeline stages | Channels |
| Guarding struct invariants | Mutex |
| Crossing package boundaries with lifecycle | Often channel or callback + context |
Anti-pattern: channel as a mutex
// works, but usually worse than sync.Mutex
token := make(chan struct{}, 1)
token <- struct{}{}
// critical section
<-tokenUse when you already need select/timeout on lock acquisition; otherwise Mutex is clearer and faster.
Worked example — three counters compared
package main
import (
"fmt"
"sync"
"sync/atomic"
)
const workers = 8
const perWorker = 100_000
func mutexCount() int {
var (
mu sync.Mutex
n int
wg sync.WaitGroup
)
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
for j := 0; j < perWorker; j++ {
mu.Lock()
n++
mu.Unlock()
}
}()
}
wg.Wait()
return n
}
func atomicCount() int64 {
var (
n atomic.Int64
wg sync.WaitGroup
)
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
for j := 0; j < perWorker; j++ {
n.Add(1)
}
}()
}
wg.Wait()
return n.Load()
}
func channelCount() int {
ch := make(chan int, workers)
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
local := 0
for j := 0; j < perWorker; j++ {
local++
}
ch <- local
}()
}
go func() {
wg.Wait()
close(ch)
}()
total := 0
for v := range ch {
total += v
}
return total
}
func main() {
fmt.Println("mutex", mutexCount())
fmt.Println("atomic", atomicCount())
fmt.Println("channel-shard", channelCount())
}All should equal workers * perWorker. The channel version avoids shared increments by sharding—often the best design.
Lab 1 — Thread-safe map wrapper
Workspace: ~/lab/90daysofx/01-go/day22
mkdir -p ~/lab/90daysofx/01-go/day22 && cd ~/lab/90daysofx/01-go/day22
go mod init example.com/day22Implement:
type SafeMap struct {
// mu + map[string]string
}
func (s *SafeMap) Set(k, v string)
func (s *SafeMap) Get(k string) (string, bool)
func (s *SafeMap) Len() intSpawn many goroutines setting/getting keys. Run:
go test -race ./...
go run -race .Lab 2 — Fix WaitGroup bugs
Write three broken snippets (or tests that recover?):
Addinside goroutine without prior coordination → flaky Wait
- Missing
Done→ hang (use short timeout parent in a test helper)
- Extra
Done→ panic
Document symptoms. Ship only the fixed version in main.
Lab 3 — Benchmark mutex vs atomic vs sharded
go test -bench=. -benchmem -race
# note: -race slows benches; also run without -race for timing
go test -bench=. -benchmemTable in journal:
| Approach | ns/op (approx) | Notes |
|---|---|---|
| Mutex | ||
| Atomic | ||
| Channel shard |
Do not over-generalize from one machine—learn the method.
Lab 4 — Once for expensive init
Simulate expensive dial with time.Sleep. Call getClient from 100 goroutines; assert dial runs once (atomic counter inside Do). Race-clean.
Common gotchas
| Gotcha | Fix |
|---|---|
| Forgetting Unlock | defer Unlock |
| Lock ordering A→B vs B→A | Consistent global order |
| Holding lock across slow I/O | Copy data out; unlock; I/O |
| Copying structs with mutex | Pointer receivers; no value copy |
| Using atomic for multi-field updates | Mutex or immutable snapshot |
RWMutex everywhere “for speed” |
Benchmark; simple Mutex often wins |
Checkpoint
- Implemented race-clean SafeMap
- Can explain when atomic is insufficient
- WaitGroup Add/Done rules written from memory
- Compared three counter strategies
- Used
Oncecorrectly
go test -raceclean
Commit
git add .
git commit -m "day22: mutex, atomic, WaitGroup, Once; counters + SafeMap"Write three personal gotchas before continuing.
Tomorrow
Day 23 — Worker pools & pipelines: bound concurrency, job queues, and a concurrent fetcher that does not melt the host.
Day 23 — Worker pools & pipelines
Stage III · ~3h (theory-heavy)
Goal: Bound concurrency with worker pools, design multi-stage pipelines with backpressure, and build a race-clean concurrent “fetcher” that does not spawn unbounded goroutines.
Unbounded go per item is the most common production footgun. Pools turn “as much as possible” into “as much as safe.”
Why this day exists
You can start 10k goroutines (Day 19). Doing that per HTTP request or per file in a million-file walk:
- Exhausts memory and file descriptors
- Overwhelms remote APIs
- Makes cancellation (Day 24) painful
Worker pools and pipelines are the default industrial shape of concurrent Go.
Theory 1 — Bound concurrency, not just spawn
Three common bounders:
| Technique | Mechanism |
|---|---|
| Fixed workers | N goroutines ranging over a jobs channel |
| Semaphore channel | Buffered chan struct{} of size N |
| errgroup.SetLimit | Day 27 — structured concurrency with limits |
Fixed worker pool skeleton
jobs := make(chan Job)
results := make(chan Result)
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
results <- process(job)
}
}()
}
go func() {
wg.Wait()
close(results)
}()
// sender:
go func() {
defer close(jobs)
for _, j := range all {
jobs <- j
}
}()
for r := range results {
handle(r)
}Properties:
- At most
workersconcurrentprocesscalls
- Backpressure: slow process fills
jobsbuffer and blocks senders
- Clean shutdown when jobs closed and workers exit
Theory 2 — Semaphore style (ad-hoc pool)
sem := make(chan struct{}, maxConcurrent)
for _, item := range items {
sem <- struct{}{} // acquire
go func(item Item) {
defer func() { <-sem }() // release
work(item)
}(item)
}
// still need WaitGroup to join!This allows up to maxConcurrent goroutines alive for work, but still creates one goroutine per item over time (each exits after work). Fixed workers reuse goroutines—often clearer for long pipelines.
Theory 3 — Pipeline stages
A pipeline is a series of stages connected by channels:
source → stage1 → stage2 → sink
Each stage:
- Receives until input closed
- Sends outputs
- Closes its output when done (usually via defer after WaitGroup if fan-out inside stage)
Stage template
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
out <- v * v
}
}()
return out
}Compose:
out := square(gen(1, 2, 3, 4))
for v := range out {
fmt.Println(v)
}Theory 4 — Errors in pools (preview)
Naive pools only pass happy-path results. Real pools need:
Result{Value, Err}structs
- Or a separate error channel (harder—ordering)
- Or errgroup (Day 27) for first-error cancel
For today, use a result struct:
type Result struct {
URL string
Err error
N int
}Collect all results; decide fail-fast vs best-effort at the aggregator.
Theory 5 — Choosing buffer sizes
| Buffer | Effect |
|---|---|
| 0 | Strong coupling; good for low latency handoff |
| small (workers) | Smooths jitter |
| huge | Hides backpressure; can OOM |
Heuristic: buffer ≈ worker count or small multiple—not “millions.”
Theory 6 — Graceful drain vs cancel
Two shutdown modes:
- Drain: stop accepting new jobs; finish in-flight; close
- Cancel: abandon in-flight soon (needs context + cooperative work—Day 24)
Pools without cancel only support drain. That is OK for batch CLIs; services need cancel.
Worked example — bounded concurrent “fetch”
Simulated fetch with sleep and optional failure:
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
type Job struct{ URL string }
type Result struct {
URL string
OK bool
Ms int
}
func fetch(url string) Result {
ms := 20 + rand.Intn(80)
time.Sleep(time.Duration(ms) * time.Millisecond)
return Result{URL: url, OK: rand.Float32() > 0.1, Ms: ms}
}
func runPool(urls []string, workers int) []Result {
jobs := make(chan Job)
results := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
results <- fetch(j.URL)
}
}()
}
go func() {
wg.Wait()
close(results)
}()
go func() {
defer close(jobs)
for _, u := range urls {
jobs <- Job{URL: u}
}
}()
var out []Result
for r := range results {
out = append(out, r)
}
return out
}
func main() {
urls := make([]string, 50)
for i := range urls {
urls[i] = fmt.Sprintf("https://example.com/%d", i)
}
start := time.Now()
res := runPool(urls, 8)
fmt.Printf("got %d results in %s\n", len(res), time.Since(start))
fail := 0
for _, r := range res {
if !r.OK {
fail++
}
}
fmt.Println("failures", fail)
}Lab 1 — Implement a generic-shaped pool (concrete type OK)
Workspace: ~/lab/90daysofx/01-go/day23
mkdir -p ~/lab/90daysofx/01-go/day23 && cd ~/lab/90daysofx/01-go/day23
go mod init example.com/day23Requirements:
workersflag or const (try 1, 4, 16)
- Process at least 100 jobs
- Each job: hash a string or sleep + counter
- Print throughput (jobs/sec)
go run -race .
Lab 2 — Three-stage pipeline
Stages:
- gen — emit lines / ints
- transform — uppercase or square
- filter — drop some values
Each stage is a function <-chan T → <-chan U. Main only ranges the final channel. Race-clean.
Lab 3 — Unbounded vs bounded comparison
Version A: for _, item := range items { go work(item) } + WaitGroup
Version B: worker pool with workers=8
For items=5000 with 5–20ms simulated I/O:
- Compare max goroutines roughly via
runtime.NumGoroutine()sampling
- Compare elapsed time
- Journal memory feel (Activity Monitor optional)
// sample
go func() {
for range time.Tick(50 * time.Millisecond) {
fmt.Println("g", runtime.NumGoroutine())
}
}()Stop the sampler cleanly (done channel)—don’t leak the ticker goroutine.
Lab 4 — Result aggregation with errors
Return []Result including errors. Exit non-zero if any job failed after draining the pool (do not abandon workers mid-flight yet—that is Day 24/27).
go test -race ./...Common gotchas
| Gotcha | Fix |
|---|---|
Close jobs before send finished |
Defer close in sender only |
Workers blocked on full results + main not reading |
Buffer results or read concurrently |
| Starting workers after closing jobs | Start workers first |
| One huge results slice race | Single aggregator goroutine |
| Pool without join | WaitGroup workers → close results |
workers > len(jobs) worry |
Fine; idle workers exit on close |
Checkpoint
- Explain backpressure via full channels
- Fixed-size pool processes N jobs with W workers
- Multi-stage pipeline closes correctly end-to-end
- Compared unbounded vs bounded goroutine counts
- Aggregated errors without losing workers
go run -race/go test -raceclean
Commit
git add .
git commit -m "day23: worker pool + pipeline + bounded fetcher"Write three personal gotchas before continuing.
Tomorrow
Day 24 — context: cancellation, deadlines, values (sparingly), and cancelling the fetcher cleanly mid-flight.
Day 24 — Context cancel & deadlines
Stage III · ~3h (theory-heavy)
Goal: Use context.Context for cancellation and deadlines across goroutine trees; cancel a worker pool cleanly; treat context values as rare metadata—not a bag of dependencies.
context is how Go signals “stop work” across API boundaries. If a function may block or spawn work, its first parameter is almost always ctx context.Context.
Why this day exists
Without cancellation:
- HTTP handlers outlive client disconnects
- Worker pools finish useless jobs after failure
- Timeouts are ad-hoc
time.Aftersoup
- Tests hang forever
With careless context values:
- Hidden globals
- Untyped bag-of-deps
- Broken testability
Today you learn the narrow, correct use.
Theory 1 — What a Context carries
A context.Context is an interface:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}| Method | Meaning |
|---|---|
Done() |
Closed when cancelled or deadline exceeded |
Err() |
Why: Canceled or DeadlineExceeded (after Done) |
Deadline() |
Absolute time if any |
Value |
Sparse request-scoped data |
Contexts are immutable trees: child derived from parent; cancel parent ⇒ cancel children.
Theory 2 — Constructors you actually use
ctx := context.Background() // root: never cancelled
ctx := context.TODO() // placeholder when unsure
ctx, cancel := context.WithCancel(parent)
defer cancel() // always, to free resources
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
ctx, cancel := context.WithDeadline(parent, time.Now().Add(2*time.Second))
defer cancel()
ctx := context.WithValue(parent, key, val) // rareAlways cancel
Even after success, call cancel (via defer) for timeout/cancel contexts so timers are released.
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()Theory 3 — Cooperative cancellation
Cancellation is not preemptive kill of arbitrary code. Workers must check:
select {
case <-ctx.Done():
return ctx.Err()
case job := <-jobs:
return handle(ctx, job)
}Or at loop heads:
for _, item := range items {
if err := ctx.Err(); err != nil {
return err
}
if err := work(ctx, item); err != nil {
return err
}
}Blocking stdlib calls that accept ctx (http.NewRequestWithContext, db.QueryContext, …) abort when cancelled.
Ignore context = ignore product requirements
A function signature without ctx that does I/O is incomplete for services.
Theory 4 — Error values and wrapping
errors.Is(err, context.Canceled)
errors.Is(err, context.DeadlineExceeded)Prefer returning ctx.Err() on cancel paths so callers can distinguish timeout vs logic errors.
Go 1.20+ also has context.WithCancelCause / context.Cause for richer reasons—useful in libraries; know it exists.
ctx, cancel := context.WithCancelCause(parent)
cancel(fmt.Errorf("upstream failed: %w", err))
// context.Cause(ctx) → your errorTheory 5 — Context values (use sparingly)
type key int
const traceKey key = 0
ctx = context.WithValue(ctx, traceKey, traceID)
id, _ := ctx.Value(traceKey).(string)| Good | Bad |
|---|---|
| Request ID / trace ID | Passing *sql.DB |
| Auth principal (debated; often OK) | Optional function parameters |
| Locale | Config structs |
Rule: if a function needs it to compile-time correct logic, make it a real parameter—not a context value.
Use private key types to avoid collisions.
Theory 6 — Wiring cancel into a worker pool
Pattern:
- Parent creates
ctx, cancel
- Workers select on
ctx.Done()and jobs
- On first fatal error or timeout,
cancel()
- Drain or abandon jobs channel carefully
func runPool(ctx context.Context, jobs <-chan Job, workers int) error {
errCh := make(chan error, 1)
var wg sync.WaitGroup
worker := func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
if err := process(ctx, job); err != nil {
select {
case errCh <- err:
default:
}
return
}
}
}
}
wg.Add(workers)
for i := 0; i < workers; i++ {
go worker()
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case err := <-errCh:
return err
case <-ctx.Done():
<-done // wait workers exit
return ctx.Err()
}
}Day 27’s errgroup expresses this more cleanly—learn the manual shape first.
Worked example — timeout a slow operation
package main
import (
"context"
"fmt"
"time"
)
func slow(ctx context.Context) error {
select {
case <-time.After(500 * time.Millisecond):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := slow(ctx)
fmt.Println(err) // context deadline exceeded
fmt.Println(errorsIsDeadline(err))
}
func errorsIsDeadline(err error) bool {
return err == context.DeadlineExceeded ||
(err != nil && err.Error() == context.DeadlineExceeded.Error())
}Prefer errors.Is(err, context.DeadlineExceeded) in real code (import errors).
Worked example — HTTP-shaped request (stdlib)
package main
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
func fetch(ctx context.Context, url string) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 0, err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer res.Body.Close()
n, _ := io.Copy(io.Discard, res.Body)
return int(n), nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
n, err := fetch(ctx, "https://example.com")
fmt.Println(n, err)
}Lab 2 — Cancel Day 23’s pool
Port your worker pool:
processtakesctxand checks it
- Parent uses
WithTimeout(e.g. 200ms) with jobs that each sleep 50–100ms
- Show that not all jobs complete
- Ensure no goroutine leak: after return,
runtime.NumGoroutine()returns near baseline (sample after short sleep)
go run -race .
go test -race ./...Lab 3 — Deadline vs Timeout
Write two functions producing the same deadline end time—one with WithTimeout, one with WithDeadline. Assert both cancel around the same wall time (tolerance 50ms).
Lab 4 — Context value discipline
- Store a request ID in context; log it in a child function.
- Refactor a bad example that stuffed a
*Loggeror config into context into an explicit parameter.
- Journal why the refactor is clearer for tests.
Common gotchas
| Gotcha | Fix |
|---|---|
Forgetting defer cancel() |
Always defer after WithCancel/Timeout |
Passing context.Background() deep forever |
Thread request ctx from edges inward |
| Storing deps in Value | Explicit params |
Not checking ctx.Done in loops |
Poll ctx.Err() / select |
| Starting work with already-cancelled ctx | Check at entry |
| Cancelling but not joining workers | WaitGroup after cancel |
Checkpoint
- Can list Background, WithCancel, WithTimeout, WithValue
- Always defers cancel in lab code
- Pool respects cancel and exits race-clean
- Uses
errors.Isfor deadline/cancel
- Explains why cancellation is cooperative
- Avoids abusing context values
Commit
git add .
git commit -m "day24: context cancel/timeout wired through worker pool"Write three personal gotchas before continuing.
Tomorrow
Day 25 — Race detector & leaks: break programs on purpose, read race reports, and build a habit of -race in tests.