Go Concurrency Is a Lifecycle Problem
Launching a goroutine takes one word: go. Making that goroutine finish, return its result, respect a canceled request, and leave shared data intact is the real work. Anton Zhiyanov’s interactive concurrency mini-book is a useful map of the language’s tools. The design principle that connects them is ownership: every concurrent task needs a reason to start, a way to communicate, and a defined way to stop.
Consider a service that reads jobs, enriches them with network calls, and writes results. If each request starts another goroutine without a limit, memory and open connections can grow with incoming traffic. If the writer stops early while a worker is sending on an unbuffered channel, that worker can remain blocked forever. If workers update a shared counter without synchronization, the result can be wrong even when the pipeline seems to complete. Concurrency becomes easier to reason about when these are separate questions.
Goroutines run independently; completion must be explicit
A go statement starts a function concurrently. The runtime schedules goroutines over operating system threads; starting one does not make the caller wait for it. When main returns, the process exits even if its background work is unfinished. A sync.WaitGroup records how many tasks must complete before the owner moves on. Add increments its count, Done decrements it, and Wait blocks until it reaches zero. In current Go, WaitGroup.Go packages the start and completion bookkeeping together; the function passed to it must not panic.
Waiting is only one part of ownership. A wait group cannot stop a goroutine that is stuck on a send, and it does not carry an error or result. Those need separate paths. For a short task, the result can be a value sent on a channel. For a longer request, cancellation should be propagated into every blocking stage.
Channels are handoffs with precise blocking rules
An unbuffered chan T transfers a value when a sender and receiver meet. The sender waits until a receiver is ready. A buffered channel, made with make(chan T, n), lets up to n values wait in a queue before sends block. A buffer smooths a small burst; it is not a substitute for a throughput limit. Once it fills, producers still need a policy for waiting, dropping, or rejecting work.
The sender normally owns closing a channel. Closing means no more values will be sent, so a receiver can use for v := range ch to drain values and finish. A receive from a closed, drained channel returns the zero value and ok == false; a send to a closed channel panics. There is no need to close a channel merely to release its memory. A nil channel is different again: sends and receives block indefinitely, which can be useful for disabling a select case but is usually a bug when accidental.
Directional types make this contract visible. A producer can return <-chan Result so callers can only receive; a consumer can accept chan<- Result so it can only send. The type checker then catches some ownership mistakes before runtime.
Select coordinates competing events
select waits for one of several channel operations. When several cases are ready, it chooses one; code must not depend on a particular winner. A default case makes it nonblocking. That can implement a best-effort send, but a busy loop around select { default: ... } can waste CPU.
In a merge stage, select can take from either upstream channel. When one closes, setting its local channel variable to nil disables that case. In a worker, a select between out <- result and <-ctx.Done() lets the worker abandon a send when the caller leaves. Without the cancellation case, a downstream reader that exits early can strand the worker. The Go team’s pipeline guide treats this as a first-class part of the pipeline, not a cleanup afterthought.
func publish(ctx context.Context, out chan<- Result, result Result) bool {
select {
case out <- result:
return true
case <-ctx.Done():
return false
}
}
This helper protects only the output send. The worker must also use context-aware network calls and check cancellation during long loops. A timeout wrapper that returns while an arbitrary fn() keeps running has limited the caller’s wait, not stopped the work. context.WithTimeout or WithDeadline supplies a cancellation signal; work must cooperate with it. Call the returned cancel function to release associated resources. A child context cannot extend a parent’s deadline. context.Cause can preserve a more useful reason for cancellation, while ordinary function arguments remain a clearer home for application data than context.WithValue in most cases.
Pipelines need an error policy
A pipeline connects a source, one or more processing stages, and a sink. Each stage receives from an input channel and sends to an output channel. The simplest stage closes its own output when its work ends. Once multiple workers share an output, a coordinator must wait for all senders before closing it. Closing from one worker can panic another worker that is still sending.
There are several valid error contracts. A fail-fast pipeline cancels the group at the first error and returns that error. A best-effort pipeline sends a Result{Value, Err} for each item, allowing the caller to decide which failures to tolerate. A separate error channel can work, but it introduces another stream that must be drained and closed correctly. Choose the contract before adding goroutines; otherwise an error can leave a producer blocked behind a consumer that has already returned.
Timers are another source of events. time.After suits a one-off wait, time.NewTimer can be stopped or reset, and time.NewTicker handles periodic work. A ticker may skip ticks if its reader is slow. Whichever timer you use, make the task’s exit condition explicit; an endless ticker loop does not stop just because its caller no longer cares.
Bound work before it grows
A buffered channel can act as a semaphore: a task acquires a slot by sending an empty value and releases it by receiving one. Put acquisition before spawning a goroutine if the goal is to bound both active work and goroutine count. If every incoming item first starts a goroutine and only then waits for a slot, queued goroutines can still grow without limit. Effective Go calls out exactly this resource-boundary issue. A fixed worker pool is another clear option. More complex weighted limits can use golang.org/x/sync/semaphore.
The capacity is a service decision, not automatically the CPU count. Network workers might need more concurrency than available cores, while a downstream API might tolerate far fewer simultaneous calls. Measure throughput, queue depth, tail latency, and error rates as the limit changes.
Shared state needs its own rule
Channels are well suited to transferring ownership of work. A sync.Mutex is often simpler for a small shared map or counter. Guard every read and write that can overlap. An RWMutex allows concurrent readers but still gives writers exclusive access; it is useful only when its extra complexity pays off. TryLock is a deliberate refusal to wait, not a general fix for contention. The Go wiki’s mutex-or-channel guide recommends choosing the primitive that makes the code clearest.
A data race occurs when concurrent operations access the same memory, at least one writes, and there is no synchronization. go test -race ./... can catch races on executed paths. A race condition is broader: the program can be memory-safe yet produce the wrong result because events happen in an unexpected order. For example, a mutex can protect each bank balance read and write while a multi-step transfer still violates a business invariant if the whole operation is not coordinated. The race detector’s documentation is explicit that it sees only paths exercised at runtime.
Some specialized tools complete the picture. sync/atomic offers operations such as Load, Store, and CompareAndSwap for narrowly scoped shared values, but a collection of individually atomic fields is not automatically one atomic transaction. sync.Once runs one initializer once; OnceFunc, OnceValue, and OnceValues package common one-time patterns. A sync.Cond lets waiters sleep until shared state changes; always recheck its condition in a loop after waking. Closing a channel can broadcast a one-time signal to many receivers. sync.Pool can reuse temporary objects, but pooled items may disappear during garbage collection, so it is neither durable storage nor a concurrency limit.
Test schedules, not sleeps
Concurrency bugs often hide because one test run happened to choose a friendly schedule. First test the observable contract: all expected results arrive, early cancellation releases workers, output channels close only after senders finish, and error paths return. Run the race detector under representative workloads. For time-dependent code, testing/synctest runs a group of goroutines with a fake clock and can wait until the group is durably blocked. This makes many timeout tests fast and repeatable. Its bubble is not a model of arbitrary network I/O or external processes, so keep those dependencies fake or outside the test.
When a service hangs or slows, collect evidence before adjusting channel sizes. Goroutine profiles show where tasks are blocked; mutex and block profiles expose contention; execution traces reveal scheduling and waiting over time. The Go runtime can run many goroutines over fewer OS threads, but concurrency does not guarantee parallel speedup. CPU-bound work still needs enough available processors, and extra goroutines can add overhead. A useful diagnostic question is: which owner is expected to make this blocked operation progress, and can that owner still run?
The practical design rule is small: assign one owner to every channel closure, give each worker a completion signal, make cancellation reachable from every blocking operation, and put limits at the point where work enters. Those choices turn Go’s concurrency primitives into a system whose behavior can be explained when the easy path ends.