Go Concurrency, Distilled: A Mental Model That Holds
At 9:00, an HTTP handler reads a request, asks a database for metadata, and waits on a remote service. A sequential version spends much of its life waiting. Go concurrency lets those activities overlap through goroutines, channels, select, and cancellation, but the durable skill is not sprinkling go in front of function calls. It is giving every piece of work an owner, an exit path, and a way to report completion.
Concurrency means arranging several activities so they can make progress during the same period. Parallelism means actually executing work at the same time, often on multiple CPU cores. Go supports both, but the design rules are different from the syntax. The practical question is: how do you keep Go concurrency from becoming a pile of goroutines that nobody can stop? (go.dev)
Start work, then own its lifetime
A goroutine is a function running independently from the code that started it. The go keyword launches one, and the calling goroutine continues without waiting. The program's main function is special: when it returns, the program exits even if other goroutines are still working. That makes completion part of the design, not a detail to patch in later.
var wg sync.WaitGroup
wg.Go(func {
fmt.Println("worker finished")
})
wg.Wait
A WaitGroup is a counter used to wait for a collection of tasks. WaitGroup.Go, added in Go 1.25, starts a function in a goroutine and adjusts the counter automatically; Wait blocks until the counter reaches zero. Older code commonly uses Add, Done, and a deferred Done call. Either way, a wait group tracks completion. It does not decide how errors should travel or how the work should be canceled. (pkg.go.dev)
Channels are handoff points
Think of a channel as a loading dock between goroutines. One goroutine places a value on the dock, and another takes it away. An unbuffered channel is a handshake: the sender waits until a receiver is ready. A buffered channel adds a fixed-size queue, allowing the sender to get ahead until that queue fills. (go.dev)
func generate(limit int) <-chan int {
out:= make(chan int)
go func {
defer close(out)
for i:= 0; i < limit; i++ {
out <- i
}
}
return out
}
for n:= range generate(5) {
fmt.Println(n)
}
The return type <-chan int is a receive-only channel. It tells callers they may read values but cannot send or close the stream. The goroutine that owns sending closes the channel after the final value, and range stops when it observes that close. Closing is an end-of-stream signal; it is not required for garbage collection.
select and context give work an exit
The previous generator has a hidden weakness. If the consumer stops reading early, the sender can remain blocked forever. That is a goroutine leak: work that is still alive but can no longer make useful progress.
A select statement waits for whichever channel operation becomes ready first. A context.Context carries request-scoped cancellation and deadlines through a call chain, so it gives a blocked goroutine another path out. Its Done method returns a channel that closes when cancellation occurs. (pkg.go.dev)
func numbers(ctx context.Context, limit int) <-chan int {
out:= make(chan int)
go func {
defer close(out)
for i:= 0; i < limit; i++ {
select {
case out <- i:
case <-ctx.Done:
return
}
}
}
return out
}
A caller can create a deadline with context.WithTimeout, consume the values, and defer cancel so resources are released when the operation finishes early. Cancellation is cooperative: it signals that work should stop, but each goroutine must check the signal and return. This is why every blocking send, receive, network call, or loop deserves a cancellation story. (pkg.go.dev)
Shared state needs a different tool
Not every concurrent problem wants a channel. A shared counter is often clearer with a mutex. A mutex, short for mutual exclusion lock, allows only one goroutine at a time to enter a protected section of code.
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Add {
c.mu.Lock
defer c.mu.Unlock
c.n++
}
The lock must protect every access that participates in the invariant, including reads. A data race occurs when conflicting reads and writes happen without proper synchronization. A race condition is broader: the result depends on timing, even if the individual memory accesses are technically synchronized. Go's memory model guarantees much more predictable behavior for race-free programs, which is a strong reason to treat races as correctness bugs rather than unlucky output. (go.dev)
For one independent value, an atomic operation can be appropriate. An atomic operation completes as one indivisible step with respect to other atomic operations:
var hits atomic.Int64
hits.Add(1)
Atomics are low-level building blocks, not a replacement for a mutex around a multi-field data structure. The standard library documentation recommends channels or the sync package for most synchronization, reserving atomics for narrow cases where their behavior is easy to explain. (pkg.go.dev)
Limit the crowd
Concurrency can also fail by becoming too wide. A semaphore is a counter of permits; a goroutine must acquire a permit before using a limited resource and return it afterward. In Go, a buffered channel makes a useful semaphore.
slots:= make(chan struct{}, 8)
var wg sync.WaitGroup
for i:= range jobs {
job:= jobs[i]
slots <- struct{}{}
wg.Go(func {
defer func { <-slots }
process(job)
})
}
wg.Wait
The send into slots happens before the goroutine starts, so the loop does not create thousands of goroutines waiting for eight available slots. For a continuous stream of work, a fixed worker pool can make the limit even more visible: start a known number of workers, feed them jobs, and close the job channel when no more work exists. (go.dev)
Test the timing, not only the result
Concurrent code can pass a hundred ordinary test runs and still contain a race. Go's race detector instruments memory accesses and reports unsynchronized conflicts that occur during execution. Run it with:
go test -race./...
The detector only finds races along paths your tests or workload actually exercise, so integration tests and realistic traffic can reveal problems that small unit tests miss. For one-time initialization, sync.Once provides another useful guarantee: exactly one call performs the initialization, and other callers wait for it to finish. (go.dev)
The mental model
Before starting a goroutine, write down five answers: who owns its output, who closes that output, how cancellation reaches it, how completion or errors return, and which shared state needs protection. If any answer is vague, the concurrency design is not finished.
The distilled lesson is modest but powerful. Go concurrency is not a contest to create the most goroutines. It is a set of protocols for ownership, communication, cancellation, bounded work, and safe access to shared state. Once those protocols are visible, the code stops feeling like a race against the scheduler and starts reading like a system with clear handoffs.
Comments (0)
No comments yet. Be the first to respond!
Leave a Comment
Your comment will be visible after review.