The last big Python-instinct switch. Concurrency is cheap and everywhere in this repo — so the bugs are too.
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.
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.
async def work(): ...
# needs an event loop;
# await yields control
task = asyncio.create_task(work())
await task
func work() { ... }
go work() // launches, returns now
// no await keyword — you
// synchronize via channels
// or sync primitives instead
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.
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]
| Syntax | Meaning |
|---|---|
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 <- v | send v into ch |
v := <-ch | receive from ch (blocks if empty) |
v, ok := <-ch | receive; ok=false if channel is closed & drained ([[comma-ok]]) |
close(ch) | no more sends; receivers can still drain what's queued |
for v := range ch | receive until the channel is closed |
select { case ... } | wait on multiple channel ops; first ready wins |
ok=false).
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.
errgroupFrom 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:
len(changes) so every worker can send without waiting — no goroutine blocks on a full channel.errgroup replaces a raw WaitGroup + error plumbing: g.Wait() returns the first non-nil error and cancels ctx for the rest.close comes AFTER Wait — closing while workers might still send would panic. Order matters; flag it if reversed.range over the channel ends because it was closed — without ⑦ this loop would block forever (a leak).
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.
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]
go func(){...} that sends to / receives from a channel nobody else will touch — blocks forever.ctx and no stop signal — runs till process death.close(ch) before all senders finished → panic; or never closing a channel a range loop reads → that reader blocks forever.g.Go(...) / WaitGroup.Add without a matching Wait — results discarded, ordering unguaranteed.go test -race.go statement:
Instant feedback. Reps, not grades.
go process(item)
save(item) // next lineWhen does save run relative to process?done := make(chan bool) // unbuffered
go func() { done <- true }()
if earlyExit { return } // never receives from done
<-doneWhat's wrong?close(changeValues) to before g.Wait(). Consequence?go s.updateDApps(ctx) runs an infinite for loop. How is it meant to terminate?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.
Added to your GLOSSARY.md:
go; cheap, runtime-scheduled, no await.ctx so cancel() stops it cleanly.WaitGroup that collects the first error and cancels a shared ctx.