Day 10 — Sync Primitives, Worker Pools & Context

Updated

July 30, 2025

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.

Note

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:

  1. Same lock for all accesses to the protected fields.
  2. Prefer defer Unlock unless you need early unlock for performance.
  3. Do not copy a struct containing a mutex after first use (lock copies are broken).
  4. Keep critical sections small—no network I/O under lock if avoidable.

Copying hazard

c1 := Counter{}
c2 := c1 // copies mutex state — undefined / broken

Pass *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 RLock holders allowed
  • Lock is 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 Do finishes
  • If Do panics, future Do calls 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
<-token

Use 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/day22

Implement:

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() int

Spawn 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?):

  1. Add inside goroutine without prior coordination → flaky Wait
  2. Missing Done → hang (use short timeout parent in a test helper)
  3. 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=. -benchmem

Table 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 Once correctly
  • go test -race clean

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.

Note

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 workers concurrent process calls
  • Backpressure: slow process fills jobs buffer 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:

  1. Receives until input closed
  2. Sends outputs
  3. 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:

  1. Drain: stop accepting new jobs; finish in-flight; close
  2. 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/day23

Requirements:

  • workers flag 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:

  1. gen — emit lines / ints
  2. transform — uppercase or square
  3. 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 -race clean

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.

Note

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.After soup
  • 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) // rare

Always 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 error

Theory 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:

  1. Parent creates ctx, cancel
  2. Workers select on ctx.Done() and jobs
  3. On first fatal error or timeout, cancel()
  4. 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 1 — Cancel with a button (channel)

Workspace: ~/lab/90daysofx/01-go/day24

mkdir -p ~/lab/90daysofx/01-go/day24 && cd ~/lab/90daysofx/01-go/day24
go mod init example.com/day24
  1. Start a loop worker that ticks every 100ms.
  2. After 500ms, call cancel().
  3. Worker exits with context.Canceled.
  4. go run -race .

Lab 2 — Cancel Day 23’s pool

Port your worker pool:

  • process takes ctx and 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

  1. Store a request ID in context; log it in a child function.
  2. Refactor a bad example that stuffed a *Logger or config into context into an explicit parameter.
  3. 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.Is for 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.