Go for Reviewers · Lesson 06

Goroutines, Channels & the Leak Smell

The last big Python-instinct switch. Concurrency is cheap and everywhere in this repo — so the bugs are too.

Why this, now: You've seen go s.updateDApps(ctx) since L1. Services here fan out work across goroutines constantly. To review them you need three reflexes: read a go launch, read a channel, and smell a goroutine leak — the concurrency bug that has no Python equivalent and that Go's tooling won't always catch.

1 · Goroutine = a function call with go in front

go f() runs f concurrently and returns immediately — the caller does not wait. Goroutines are not OS threads; the Go runtime multiplexes thousands onto a few threads, so they're cheap (a few KB each). Closest Python analog: an asyncio task — but with two sharp differences.

Python · asyncio
async def work(): ...

# needs an event loop;
# await yields control
task = asyncio.create_task(work())
await task
Go · goroutine
func work() { ... }

go work()   // launches, returns now
// no await keyword — you
// synchronize via channels
// or sync primitives instead
Two Python instincts to drop: (1) There is no await — a goroutine is fire-and-forget unless you explicitly wait via a channel, sync.WaitGroup, or errgroup. (2) Goroutines run on real parallelism across CPU cores, not one event loop — so two goroutines touching the same variable is a genuine data race, not a theoretical one. Python's GIL hid this from you; Go does not.

2 · Channels = typed pipes between goroutines

A channel carries values of one type between goroutines, with synchronization built in. The Go motto: "Don't communicate by sharing memory; share memory by communicating." [Effective Go]

SyntaxMeaning
make(chan T)unbuffered channel — a send blocks until someone receives (a handshake)
make(chan T, n)buffered — sends succeed without a receiver until n are queued
ch <- vsend v into ch
v := <-chreceive from ch (blocks if empty)
v, ok := <-chreceive; ok=false if channel is closed & drained ([[comma-ok]])
close(ch)no more sends; receivers can still drain what's queued
for v := range chreceive until the channel is closed
select { case ... }wait on multiple channel ops; first ready wins
Channel rules that become review reflexes: send on a closed channel → panic. Send on a nil channel → blocks forever. The sender closes, never the receiver. A receive from a closed channel returns the zero value immediately (with ok=false).

3 · Pattern A — background worker + cancellation (the leak-safe shape)

From services/dapp/service.go — the constructor you met in L1, now fully readable.

func NewService(...) (*Service, func(), error) {
    ...
    ctx, cancel := context.WithCancel(log.WithLogger(context.Background(), logger))
    cleanup := func() { cancel() }   // ← signals the goroutine to stop
    go s.updateDApps(ctx)              // ← long-lived background loop
    return s, cleanup, nil
}

Read the contract: a goroutine runs updateDApps forever, refreshing a cache. It's handed a ctx whose cancel is returned to the caller as cleanup. The goroutine's lifetime is tied to the context — when the owner calls cleanup(), the loop's next ctx-aware step sees cancellation and returns. That is how you launch a goroutine without leaking it.

Go Code Review Comments — Goroutine Lifetimes: "When you launch goroutines, make it clear when — or whether — they exit." A goroutine with no exit path, or one blocked forever on a channel nobody will send to, is a leak: it lives for the life of the process, holding memory and references. [source]

4 · Pattern B — bounded fan-out with errgroup

From services/change/service.go — compute many asset changes concurrently, sum the results.

sem := helpers.NewSemaphore("AssetChangeConcurrency", s.config.AssetChangeConcurrency)
changeValues := make(chan float64, len(changes))   // ① buffered to N — sends never block
g, ctx := errgroup.WithContext(ctx)                  // ② group; first error cancels ctx
for _, c := range changes {
    c := c                                          // ③ capture loop var (see gotcha)
    g.Go(func() error {
        if err := sem.Acquire(ctx); err != nil { return err }
        defer sem.Release()                         // ④ bound concurrency to N workers
        v, err := s.getAssetChangeValue(ctx, currency, c)
        ...
        changeValues <- v                           // ⑤ push result
        return nil
    })
}
if err = g.Wait(); err != nil { return 0, err }   // ⑥ block till all done / first error
close(changeValues)                                 // ⑦ sender closes after Wait
var result float64
for v := range changeValues { result += v }       // ⑧ drain & sum

This is the canonical concurrent-gather. As a reviewer, verify the ordering invariants:

5 · The loop-variable capture gotcha (line ③)

c := c looks pointless. It isn't (historically). Before Go 1.22, a for-loop variable was shared across iterations; a goroutine capturing c would often see the last value, not its own. The c := c shadow gave each goroutine its own copy.

Version-aware review: Go 1.22+ made each iteration's loop variable fresh, so c := c is now redundant (harmless). But this repo is go 1.24 yet still writes it — habit/older code. If you review a PR on an older module that launches goroutines in a loop and omits the capture, that's a real bug pre-1.22. Always check the module's Go version. [Go 1.22 loopvar change]

6 · The leak smell — what to flag

🚨 A goroutine leaks when it can never exit. Train your nose for:

Reviewer's quick questions for any go statement:
  1. How does this goroutine stop? (ctx? closed channel? returns naturally?)
  2. Who reads what it sends, and can that reader disappear early?
  3. Is the shared state synchronized?
If you can't answer all three from the diff, that's the comment to leave.

7 · Drill — read the concurrency

Instant feedback. Reps, not grades.

Q1 Does it wait?
go process(item)
save(item)   // next line
When does save run relative to process?
Q2 Spot the leak
done := make(chan bool)   // unbuffered
go func() { done <- true }()
if earlyExit { return }    // never receives from done
<-done
What's wrong?
Q3 Close ordering
In the fan-out (Pattern B), a PR moves close(changeValues) to before g.Wait(). Consequence?
Q4 How does it stop?
go s.updateDApps(ctx) runs an infinite for loop. How is it meant to terminate?
I'm your teacher — ask me anything.
Want a deeper pass on context.Context (the propagation/cancel tree that threads through every function here) as its own lesson? Or the -race detector and how to read its output? Both are high-value for this codebase. Next queued: the slices & maps reference gotcha — subtle data bugs that survive review.

Terms introduced

Added to your GLOSSARY.md: