Preventing Goroutine Leaks — Specification¶
Table of Contents¶
- Introduction
- What the Go Specification Says (and Doesn't)
contextPackage Guarantees- Channel Close and Receive Semantics
sync.WaitGroupSemanticsselectSemanticsruntimePackage Hooks- Standard Library Lifecycle Contracts
- What Is Not Guaranteed
- Summary
Introduction¶
Leak prevention rests on a few primitives whose semantics are specified in the Go language spec, the context package documentation, and the sync package documentation. This file is the precise reference: what is guaranteed, what is implementation-defined, and what would be a portability mistake to rely on.
The patterns at junior, middle, and senior levels are built from these primitives. Knowing exactly what each one promises lets you reason about edge cases (e.g., "if I cancel the parent context, is my child's Done channel guaranteed to be closed when my next select runs?").
What the Go Specification Says (and Doesn't)¶
The Go specification (https://go.dev/ref/spec) defines:
- The
gostatement (a function call started concurrently). - Channels (send, receive, close, capacity).
- The
selectstatement (random choice among ready communications). - The
defermechanism.
It does not define:
- Goroutine lifecycle states (
_Grunnable, etc.) — these are runtime implementation details. - Cancellation. There is no language-level cancellation primitive;
contextis a standard library package. - Garbage collection's interaction with goroutines.
- Scheduler fairness or starvation guarantees beyond "the runtime makes goroutines progress."
Leak prevention is therefore a library discipline, layered on top of the language. The spec gives you channels and go; the standard library gives you context, sync, and the patterns that turn the spec primitives into safe systems.
context Package Guarantees¶
Cancellation propagation¶
From context documentation:
- A
Contextreturned byWithCancel,WithDeadline, orWithTimeoutis cancelled when: - The corresponding
cancelfunction is called, or - The parent context is cancelled, or
- The deadline passes (for
WithDeadline/WithTimeout). - Once cancelled,
<-ctx.Done()returns immediately (the channel is closed).ctx.Err()returns a non-nil error.
Idempotency¶
cancel() may be called multiple times. After the first call, subsequent calls are no-ops.
Memory release¶
Calling cancel() releases resources held by the context (a small struct plus the Done channel). If you do not call cancel(), the resources are released when the parent is cancelled or, eventually, when the context becomes unreachable to the GC. However, WithCancel, WithDeadline, and WithTimeout register a timer or a parent watcher; not calling cancel() keeps that registration alive longer than necessary. go vet warns about this.
Done channel identity¶
ctx.Done() returns the same channel each time it is called on the same context. Reading it after cancellation is safe and returns the zero value immediately.
Value lookup¶
ctx.Value(key) walks up the parent chain. It is O(depth). Don't put a value 50 levels deep and read it in a hot loop.
Context is goroutine-safe¶
A Context may be used by multiple goroutines simultaneously. cancel() may be called from any goroutine.
Channel Close and Receive Semantics¶
From the Go spec:
Sending on a closed channel¶
Panics. The runtime emits "send on closed channel."
Closing an already-closed channel¶
Panics. The runtime emits "close of closed channel."
Closing a nil channel¶
Panics. The runtime emits "close of nil channel."
Receiving on a closed channel¶
Succeeds immediately, returning the zero value and ok == false. Repeated receives continue to return zero, false.
Receiving on a nil channel¶
Blocks forever. This is sometimes used intentionally to disable a select case:
var done <-chan struct{}
if shouldWatch {
done = ctx.Done()
}
select {
case <-done:
// Only reachable when shouldWatch is true.
case msg := <-in:
// ...
}
Sending on a nil channel¶
Blocks forever. Same use case.
Close before send¶
Receivers see all sent values, then ok == false. The channel acts as a finite stream.
Capacity¶
cap(ch) returns the buffer capacity. len(ch) returns the number of elements currently in the buffer. Neither is goroutine-synchronisation-safe; they are observational.
sync.WaitGroup Semantics¶
From the sync package documentation:
wg.Add(delta)increments the counter bydelta(may be negative).wg.Done()decrements the counter by 1 (equivalent towg.Add(-1)).wg.Wait()blocks until the counter reaches 0.
Rules¶
Addcalls with positive delta must happen before the correspondingWait. Specifically, you mustAddbefore launching the goroutine that willDone.- If the counter goes negative, the program panics.
WaitGroupmay not be copied after first use.- Reusing a
WaitGroupafterWaitreturns is allowed, but the firstAddafter aWaitmust not run concurrently withWait.
Common bug¶
wg.Wait() in the caller might run before this goroutine's Add, see counter == 0, and return immediately. The caller proceeds, races with the goroutine's work, and leaves the WaitGroup with a Done that runs after Wait already returned. Always wg.Add(1) before the go statement.
select Semantics¶
From the Go spec:
- All cases are evaluated for readiness simultaneously.
- If multiple cases are ready, one is chosen uniformly at random.
- If no case is ready and there is a
default, the default executes. - If no case is ready and there is no
default, the select blocks until at least one case is ready.
Implications for cancellation¶
If both <-ctx.Done() and <-jobChan are ready, the runtime picks one at random. You cannot rely on cancellation taking priority. If you need cancellation to win, check ctx.Err() at the top of each iteration:
for {
if err := ctx.Err(); err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case j := <-jobs:
handle(j)
}
}
The ctx.Err() check at the top means: even if select picks jobs after Done was closed for a while, the next iteration will see Err() and return.
select{}¶
An empty select blocks forever. Sometimes used in main for "run forever" daemons. Use with caution; you usually want a signal-aware approach via signal.NotifyContext.
runtime Package Hooks¶
runtime.NumGoroutine¶
Returns the number of currently existing goroutines, including the calling one. Useful for leak detection but not for hot-path decisions (it walks runtime internals).
runtime.Stack¶
Writes all (or only the current) goroutine stacks into a buffer. Used by goleak to print stacks of leaked goroutines.
runtime.Gosched¶
Yields the processor to allow other goroutines to run. Rarely needed in modern Go (the scheduler preempts). Don't use it as a cancellation mechanism.
runtime.Goexit¶
Terminates the calling goroutine after running its deferreds. Distinct from panic (no stack unwinding past recover). Rare; mostly used by the testing package.
runtime.SetFinalizer¶
Schedules a finalizer to run when the GC determines the object is unreachable. Not a substitute for explicit cleanup of goroutines. Finalisation order and timing are not guaranteed.
Standard Library Lifecycle Contracts¶
net/http.Server¶
ListenAndServereturnshttp.ErrServerClosedafterShutdownis called; any other error is an actual server failure.Shutdown(ctx)returns after all active connections have closed gracefully or the context expires.- If the context expires, in-flight requests are abandoned and
Shutdownreturns the context's error. - After
Shutdownreturns, the server cannot be reused. Create a new one.
database/sql.DB¶
DB.Closewaits for in-flight queries to complete (or for their contexts to be cancelled).- Connections are released to the pool, the pool is closed.
Closeis safe to call multiple times.
os/signal.NotifyContext¶
- Returns a context cancelled when one of the specified signals is received.
- The returned
stopfunction unregisters the handler. Callingstopdoes not cancel the context; it only stops listening for further signals.
time.Ticker¶
NewTicker(d)returns a ticker whose.Creceives the time at everydinterval.Stop()stops the ticker. The channel.Cis not closed; receivers will park forever if they wait for a value.- A stopped ticker can be GC'd; resources are released.
time.Timer¶
NewTimer(d)returns a timer whose.Cfires once afterd.Stop()returnstrueif the timer was stopped before firing,falseif it had already fired.Reset(d)reuses the timer. The standard pattern requires draining.CifStopreturnedfalseand.Chas not been read.
What Is Not Guaranteed¶
Goroutine ordering¶
If two goroutines both send on a channel, the order of arrival at the receiver is not guaranteed. Don't rely on it.
Scheduler fairness¶
The runtime tries to make all goroutines progress, but it does not guarantee fairness in the strict sense. A goroutine spinning on runtime.Gosched() may be starved by busier goroutines on some systems.
Goroutine ID¶
There is no public API to identify a goroutine. The runtime has internal IDs but does not expose them. Don't try to use them for keys, locks, or identity. Each goroutine should carry its identity in its context if needed.
When Done() is observed¶
After cancel() returns, <-ctx.Done() is guaranteed to be ready eventually. The Go memory model gives you the happens-before relation, but there can be a small delay (typically nanoseconds) before another goroutine's select notices. For tests, do not race-test cancellation in tight timing.
Default context propagation¶
Nothing in the spec says you must pass context down. The standard library expects it (every method that does I/O takes a context), but the language does not enforce it. Code that ignores context is legal Go; it is just poorly behaved.
Summary¶
The Go specification provides the primitives: channels, select, go, defer. The standard library (context, sync, runtime, net/http, time) provides the contracts. Leak prevention is the discipline of using these contracts correctly:
context.Contextpropagates cancellation.cancel()is idempotent.- Channel close: sender's responsibility, exactly once. Receivers check
ok. selectpicks randomly among ready cases. Cancellation may not win.WaitGroup.Addbeforego. Always.- Tickers and Timers must be
Stop()ped. - Servers and DBs have documented
Shutdown/Closecontracts. Use them. - The runtime does not promise to clean up goroutines; only your code can do that.
These guarantees are stable across Go versions and form the foundation for every pattern in this section.