Day 11 — Race Detector, Concurrency Patterns & errgroup
Day 11 — Race Detector, Concurrency Patterns & errgroup
Day 25 — Race detector, leaks & deadlocks
Stage III · ~3h (theory-heavy)
Goal: Read and fix data races with the race detector, recognize deadlock classes, and develop a practical leak-hunting habit—including awareness of Go 1.26 goroutine leak profiles.
A program that “works” without -race can still be wrong. Data races are undefined behavior in Go’s memory model. Make -race non-optional for concurrent packages.
Why this day exists
Symptoms of concurrency bugs:
- Flaky tests
- Impossible counter values
- Heisenbugs under load
- Gradual memory growth (leaked goroutines)
- Full process freeze (deadlock)
Tools:
| Tool | Catches |
|---|---|
go test -race / go run -race |
Data races at runtime |
| Runtime deadlock detector | All goroutines blocked |
runtime.NumGoroutine |
Coarse leak signal |
Go 1.26 goroutineleak profile (experiment) |
Many blocked-forever Gs |
| Manual review | Logic races, lost wakeups |
Theory 1 — What is a data race?
A data race occurs when:
- Two goroutines access the same memory location concurrently
- At least one access is a write
- No happens-before synchronization orders them
Not every bug is a data race (wrong lock ordering can be race-free yet deadlocked). Not every race detector silence means correct (logical races, atomic misuse across fields).
Example
var n int
go func() { n = 1 }()
go func() { fmt.Println(n) }() // race: concurrent read/writeTheory 2 — Enabling the race detector
go run -race .
go test -race ./...
go build -race -o app .Properties:
- Instrumentation slows CPU-bound code significantly (often 2–20×)
- Uses more memory
- Supported on major platforms (Go 1.26:
linux/riscv64race support added)
- Not a substitute for production always-on race detection (too slow)—use in CI and local tests
CI recommendation for concurrent modules:
go test -race -count=1 ./...-count=1 disables test caching so flaky races reappear.
Theory 3 — Reading a race report
Typical report sections:
- Read at stack A
- Previous write at stack B
- Goroutine creation stacks
- Your code lines highlighted
Method:
- Identify the shared variable
- Identify the missing synchronization
- Choose fix: mutex, atomic, channel ownership, or redesign
- Re-run until clean under load (
-count=10or higher)
False confidence
Absence of a race report on one run does not prove freedom—only that that execution didn’t trip instrumentation. Still, -race finds the vast majority of classic races.
Theory 4 — Deadlocks
Global deadlock
All goroutines are asleep; runtime prints:
fatal error: all goroutines are asleep - deadlock!
Partial deadlock / livelock
Some Gs stuck; process still “alive.” Harder—needs timeouts, metrics, leak profiles.
Classic lock ordering
G1: lock A → lock B
G2: lock B → lock A
Fix: global lock order (always A then B).
Channel deadlock patterns
- Send without receiver
- WaitGroup mismatch
- Mutual wait on two channels
Theory 5 — Goroutine leaks
A leaked goroutine typically blocks forever on:
- Channel op
- Mutex
selectwithout exit case
- Condition wait
Go 1.26 experimental profile:
GOEXPERIMENT=goroutineleakprofile go test -race ./...
# pprof: goroutineleak profile / debug endpoint when enabledTheory (reachability): if a G blocks on primitive P and nothing reachable from runnable code can unblock P, the G is leaked.
You do not need production enablement today—know the tool exists and prefer designs that cancel and join.
Coarse check in tests
func TestNoLeak(t *testing.T) {
before := runtime.NumGoroutine()
runScenario(t)
// allow scheduler to settle
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= before+1 { // slack
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("goroutines before=%d after=%d", before, runtime.NumGoroutine())
}Fragile but educational; prefer structured cancel (errgroup) over heuristics.
Theory 6 — Fix strategies catalog
| Bug | Fix |
|---|---|
| Shared counter | atomic or mutex |
| Shared map | mutex / sync.Map (know tradeoffs) |
| Init race | sync.Once |
| Message ownership unclear | channel transfer exclusive ownership |
| Early return leaves workers blocked on send | buffered results, context cancel, or drain |
| Timer/ticker leak | Stop + drain pattern |
Worked example — race then fix
package bank
import "sync"
type Account struct {
mu sync.Mutex
balance int
}
func (a *Account) Deposit(v int) {
a.mu.Lock()
defer a.mu.Unlock()
a.balance += v
}
func (a *Account) Balance() int {
a.mu.Lock()
defer a.mu.Unlock()
return a.balance
}Test:
func TestAccountConcurrent(t *testing.T) {
var a Account
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
a.Deposit(1)
}()
}
wg.Wait()
if a.Balance() != 1000 {
t.Fatalf("got %d", a.Balance())
}
}go test -race ./...Remove the mutex temporarily and watch the race report—then restore.
Lab 1 — Break five programs
Workspace: ~/lab/90daysofx/01-go/day25
Create racy/ with intentional races:
- Concurrent map write (
map[string]intwithout lock)
- Concurrent slice append from two goroutines
- Check-then-act without lock (
if !exists { m[k]=v })
- Publishing a struct field without sync
- WaitGroup-add race (optional)
For each:
go test -race ./racy -run TestNameSave one full race report in notes/race-report.txt (or journal).
Lab 2 — Fix all five
Implement production-shaped fixes. Suite must pass:
go test -race -count=20 ./...Lab 3 — Deadlock museum
Write programs that:
- Double-lock same mutex in one goroutine (self-deadlock)
- Lock order inversion between two goroutines (use timeouts in a supervisor test so the test suite doesn’t hang forever—or run manually)
- Unbuffered channel self-send in one goroutine
Document stack traces from runtime where applicable.
Lab 4 — Leak the pool, then plug it
- Start workers that send on unbuffered
results
- Main returns early on first error without draining or cancelling
- Observe
NumGoroutinegrowth
Fix with either:
- Context cancel (Day 24), or
- Drain remaining results after error, or
- Buffered channel sized to in-flight
Re-check goroutine counts. Optionally experiment with Go 1.26 leak profile if available:
GOEXPERIMENT=goroutineleakprofile go test -race ./...Common gotchas
| Gotcha | Fix |
|---|---|
Only running -race once |
CI + -count |
| Ignoring races in benchmarks | Don’t ship racy code even if “fast” |
sync.Map as default |
Often plain map+mutex is clearer |
Fixing races with time.Sleep |
Never—use real synchronization |
| Assuming deadlock detector finds partial hangs | It won’t |
Checkpoint
- Can define data race in three conditions
- Read a real race report and fixed the root cause
go test -race -count=20clean on labs
- Documented one deadlock and one leak scenario
- Knows Go 1.26 goroutine leak profile exists
- Added
-raceto personal checklist for Stage III
Commit
git add .
git commit -m "day25: race detector labs — break, fix, leak plug"Write three personal gotchas before continuing.
Tomorrow
Day 26 — Concurrency patterns catalog: fan-out/fan-in, or-done, tee, bridge, and error propagation shapes you will reuse for years.
Day 26 — Concurrency patterns
Stage III · ~3h (theory-heavy)
Goal: Internalize a catalog of reusable concurrency patterns—fan-out/fan-in, or-done, tee, bounded parallelism, and error-aware pipelines—and implement fan-out with correct error propagation.
Patterns are vocabulary. Name them in code reviews: “this is fan-out with an or-done parent,” not “a bunch of goroutines.”
Why this day exists
Days 19–25 gave primitives. Without patterns you re-invent:
- How to stop a pipeline mid-stream
- How to duplicate a stream
- How to limit in-flight work while collecting errors
Today is a map of the territory before errgroup (Day 27) packages the most common structure.
Theory 1 — Fan-out / fan-in
Fan-out: multiple workers pull from one jobs channel (or each gets a subset).
Fan-in: merge multiple result channels into one (Day 21).
┌→ worker1 ─┐
jobs →───┼→ worker2 ─┼→ results → consumer
└→ worker3 ─┘
Use when work is independent and CPU/I/O parallelizable.
Theory 2 — Or-done (cancel-aware receive)
Problem: for v := range in ignores cancellation until the producer closes.
func orDone[T any](done <-chan struct{}, in <-chan T) <-chan T {
out := make(chan T)
go func() {
defer close(out)
for {
select {
case <-done:
return
case v, ok := <-in:
if !ok {
return
}
select {
case out <- v:
case <-done:
return
}
}
}
}()
return out
}Modern code often uses ctx.Done() instead of a raw done channel—the shape is identical.
Theory 3 — Tee (split a stream)
func tee[T any](done <-chan struct{}, in <-chan T) (<-chan T, <-chan T) {
out1 := make(chan T)
out2 := make(chan T)
go func() {
defer close(out1)
defer close(out2)
for v := range orDone(done, in) {
// careful: both sends must not block forever
o1, o2 := out1, out2
for i := 0; i < 2; i++ {
select {
case <-done:
return
case o1 <- v:
o1 = nil // disable case
case o2 <- v:
o2 = nil
}
}
}
}()
return out1, out2
}Use for “log and process” dual consumers. Prefer careful nil-case disabling so one slow tee side doesn’t deadlock forever without cancel.
Theory 4 — Bridge (chan of chans)
Flatten <-chan <-chan T into <-chan T:
func bridge[T any](done <-chan struct{}, chanCh <-chan <-chan T) <-chan T {
out := make(chan T)
go func() {
defer close(out)
for {
var stream <-chan T
select {
case <-done:
return
case maybe, ok := <-chanCh:
if !ok {
return
}
stream = maybe
}
for v := range orDone(done, stream) {
select {
case out <- v:
case <-done:
return
}
}
}
}()
return out
}Appears when each job produces a channel of chunks.
Theory 5 — Bounded parallelism pattern
func mapLimit[T any, R any](ctx context.Context, items []T, limit int, fn func(context.Context, T) (R, error)) ([]R, error) {
// semaphore + results collection; first error cancels ctx
}Skeleton:
- Derive cancellable ctx
- Semaphore capacity
limit
- Store results by index (avoid append races)
- First error → cancel
- Wait all in-flight
- Return first error or results
This is the heart of Day 27’s errgroup + SetLimit.
Theory 6 — Error propagation styles
| Style | Behavior |
|---|---|
| Best effort | Collect all errors (errors.Join) |
| Fail fast | Cancel siblings on first error |
| All-or-nothing | Fail fast + no partial commit |
Channels often carry:
type result[T any] struct {
v T
err error
idx int
}Never lose the index if order matters.
Theory 7 — Pipeline with cancel (composed)
ctx → gen → transform → sink
↘ cancel on error
Each stage takes ctx and exits on Done, closing outbound channels so downstream ranges finish.
Worked example — fan-out with error awareness
package main
import (
"context"
"fmt"
"sync"
)
func fanOut(ctx context.Context, jobs <-chan int, workers int, fn func(context.Context, int) (int, error)) <-chan result {
out := make(chan result)
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
v, err := fn(ctx, j)
select {
case out <- result{v: v, err: err}:
case <-ctx.Done():
return
}
}
}
}()
}
go func() {
wg.Wait()
close(out)
}()
return out
}
type result struct {
v int
err error
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
jobs := make(chan int)
go func() {
defer close(jobs)
for i := 1; i <= 20; i++ {
select {
case jobs <- i:
case <-ctx.Done():
return
}
}
}()
out := fanOut(ctx, jobs, 4, func(ctx context.Context, n int) (int, error) {
if n == 13 {
return 0, fmt.Errorf("unlucky")
}
return n * n, nil
})
for r := range out {
if r.err != nil {
fmt.Println("err", r.err)
cancel() // fail fast signal; workers exit
// continue draining out until closed
continue
}
fmt.Println(r.v)
}
}Improve tomorrow with errgroup so cancel + wait is less manual.
Lab 1 — Catalog implementations
Workspace: ~/lab/90daysofx/01-go/day26
Implement in package patterns:
OrDone
FanIn
Tee(two outputs)
MapLimit(bounded)
go test -race ./...Table-driven tests with small channels and timeouts (t.Fatal if >2s).
Lab 2 — Fan-out square with fail-fast
Process numbers 1..100; function fails on multiples of 17. On first error, cancel remaining work. Assert:
- Error returned to caller
- Not all squares necessarily computed
- No leaked goroutines after short settle (coarse)
- Race-clean
Lab 3 — Best-effort mode
Same as Lab 2 but collect all errors with errors.Join and do not cancel. Compare counts of successes.
Journal when each mode is appropriate (payments vs metrics).
Lab 4 — Name the pattern
Take your Day 23/24 fetcher and add comments:
// Pattern: worker pool (fixed fan-out)
// Pattern: or-done via ctx.DoneRefactor one piece toward the catalog helpers.
Common gotchas
| Gotcha | Fix |
|---|---|
| Fan-in without waiting senders | WaitGroup then close |
| Tee deadlock on slow consumer | ctx cancel + nil cases |
| Losing error index order | Result with index |
| Infinite goroutines in mapLimit | Semaphore / fixed workers |
| Closing shared jobs from workers | Only producer closes |
Checkpoint
- Implemented OrDone, FanIn, Tee, MapLimit
- Explained fail-fast vs best-effort
- Fan-out lab race-clean with cancel
- Can sketch bridge pattern verbally
- Annotated an older lab with pattern names
Commit
git add .
git commit -m "day26: concurrency patterns catalog + fan-out errors"Write three personal gotchas before continuing.
Tomorrow
Day 27 — errgroup: structured concurrency with golang.org/x/sync/errgroup—first-error cancel and SetLimit.
Day 27 — errgroup
Stage III · ~3h (theory-heavy)
Goal: Use golang.org/x/sync/errgroup for structured concurrent work: wait for a group, return the first error, cancel siblings, and bound parallelism with SetLimit.
errgroup is not in the standard library—it lives in golang.org/x/sync. It is the de-facto companion for production Go concurrency.
Why this day exists
Manual patterns from Day 26 work, but:
- Error channels are easy to mishandle
- Cancel + WaitGroup boilerplate repeats
- Limits are reinvented poorly
errgroup.Group encodes a battle-tested contract:
Run N functions; wait; return the first non-nil error; optionally cancel the rest.
Theory 1 — Module setup
go get golang.org/x/sync/errgroupimport "golang.org/x/sync/errgroup"Pin versions via go.mod like any other dependency. Keep x/sync reasonably current; API used here is stable.
Theory 2 — Basic Group
var g errgroup.Group
g.Go(func() error {
return taskA()
})
g.Go(func() error {
return taskB()
})
if err := g.Wait(); err != nil {
return err
}Rules:
Gostarts a goroutine immediately.
Waitblocks until all finish.
- First non-nil error is returned; others are discarded (they still run unless using cancel context).
- If a function panics,
Waitpanics with the same value (don’t use panic for control flow).
Without a derived context, siblings keep running after the first error—they just don’t change the returned error.
Theory 3 — WithContext for fail-fast cancel
g, ctx := errgroup.WithContext(parent)
g.Go(func() error {
return work(ctx, a)
})
g.Go(func() error {
return work(ctx, b)
})
err := g.Wait() // ctx cancelled when first Go func returns errorSemantics:
WithContextreturns a child context cancelled when the firstGofunction returns a non-nil error or whenWaitreturns
- Pass
ctxinto all blocking ops
- After
Wait, resources associated with the group are done
This is the structured form of Day 26 fail-fast.
Theory 4 — SetLimit
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // max concurrent Go funcs running
for _, item := range items {
item := item
g.Go(func() error {
return process(ctx, item)
})
}
if err := g.Wait(); err != nil {
return err
}SetLimit(n)caps concurrent goroutines inside the group
- Additional
Gocalls block until a slot frees (or panic if limit is 0? — limit must be positive; 0 means unlimited historically—check docs for your version; use positive n)
- Call
SetLimitbeforeGo
This replaces many hand-rolled worker pools for the “run this function per item” shape.
Theory 5 — What errgroup is not
| Not for | Prefer |
|---|---|
| Streaming pipelines with continuous channels | Channel stages |
| Long-lived worker servers | Dedicated pool + lifecycle |
| Collecting all errors | Mutex slice + errors.Join, or custom |
| Shared mutable aggregation without sync | Still need mutex/atomic |
Wait returns one error. For multierror:
var (
mu sync.Mutex
errs []error
)
// inside Go: on error append under mu; return nil to not cancel
// after Wait: errors.Join(errs...)Or return nil from workers and join manually—know you give up fail-fast cancel unless you cancel yourself.
Theory 6 — Panic and recovery policy
Prefer errors over panics. If a dependency panics:
- errgroup will surface panic on
Wait
- Recover inside
Goonly if you have a clear policy:
g.Go(func() error {
defer func() {
if r := recover(); r != nil {
// convert to error if required by policy
}
}()
return risky()
})Usually: fix the panic source.
Worked example — parallel fetch with limit
package main
import (
"context"
"fmt"
"net/http"
"time"
"golang.org/x/sync/errgroup"
)
func headOK(ctx context.Context, url string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("%s: %w", url, err)
}
res.Body.Close()
if res.StatusCode >= 400 {
return fmt.Errorf("%s: status %d", url, res.StatusCode)
}
return nil
}
func checkAll(ctx context.Context, urls []string, limit int) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(limit)
for _, u := range urls {
u := u
g.Go(func() error {
return headOK(ctx, u)
})
}
return g.Wait()
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
urls := []string{
"https://example.com",
"https://example.com/404", // may fail
}
if err := checkAll(ctx, urls, 4); err != nil {
fmt.Println("failed:", err)
return
}
fmt.Println("all ok")
}Use -race in tests; live network optional—prefer fake servers (httptest) in automated tests.
Worked example — pure CPU with cancel
func squares(ctx context.Context, n int) ([]int, error) {
out := make([]int, n)
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(runtime.GOMAXPROCS(0))
for i := 0; i < n; i++ {
i := i
g.Go(func() error {
if err := ctx.Err(); err != nil {
return err
}
if i == n/2 {
return fmt.Errorf("boom at %d", i)
}
out[i] = i * i // each index exclusive — no race
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return out, nil
}Index-exclusive writes need no mutex.
Lab 1 — Install and basic Wait
Workspace: ~/lab/90daysofx/01-go/day27
mkdir -p ~/lab/90daysofx/01-go/day27 && cd ~/lab/90daysofx/01-go/day27
go mod init example.com/day27
go get golang.org/x/sync/errgroup- Run three tasks: two succeed, one fails.
- Print error from
Wait.
- Race-clean.
go test -race ./...Lab 2 — WithContext fail-fast
- Tasks sleep 50ms, 100ms, 200ms.
- The 50ms task returns an error.
- Instrument longer tasks to exit early via
ctx.Done().
- Assert they did not run to full sleep (timing with tolerance).
Lab 3 — SetLimit correctness
Spawn 100 tasks each holding a critical section counter:
var current atomic.Int64
var max atomic.Int64
// on enter: c := current.Add(1); update max; sleep; current.Add(-1)SetLimit(5) ⇒ observed max concurrent ≤ 5. Race-clean.
Lab 4 — Refactor Day 23/24 pool to errgroup
Replace hand-rolled worker pool for batch jobs with errgroup + SetLimit. Keep context timeout. Compare line count and clarity in journal.
Optional: multierror mode that does not cancel—document tradeoff.
Common gotchas
| Gotcha | Fix |
|---|---|
Forgetting to use returned ctx |
All work must observe it |
SetLimit after many Go |
Set limit first |
| Capturing loop variable wrong | item := item before Go |
| Assuming all errors returned | Only first; design multierror explicitly |
| Mixing panic with Wait | Convert or prevent panics |
Not go get x/sync |
Module dependency required |
Checkpoint
- Added
golang.org/x/sync/errgroupto a module
- Explains Group vs WithContext
- Uses
SetLimitand proved concurrency bound
- Fail-fast lab shows sibling cancellation
- Refactored one older pool toward errgroup
go test -race ./...clean
Commit
git add .
git commit -m "day27: errgroup WithContext + SetLimit labs"Write three personal gotchas before continuing.
Tomorrow
Day 28 — Memory model lite: happens-before, what races really mean, and documenting synchronization so future you trusts the code.