Concurrency in Go: A Detailed Guide
Concurrency is one of the defining features of the Go programming language. Where many languages bolt concurrency on through libraries or heavyweight threads, Go bakes it into the runtime and the language itself, with goroutines, channels, and the select statement forming a small but expressive vocabulary. This article walks through the model from first principles, then builds up to the patterns and pitfalls you will meet in real production code.
Concurrency Is Not Parallelism
The distinction matters and Go's designers stress it repeatedly. Concurrency is about structure: decomposing a program into independently executing pieces that may run in overlapping time windows. Parallelism is about execution: actually running multiple pieces at the same instant on multiple CPU cores. A concurrent program can run on a single core by interleaving its parts; a parallel program requires more than one core to do work simultaneously.
Go gives you the concurrency structure through goroutines and channels. Whether that structure runs in parallel depends on the runtime scheduler and the value of GOMAXPROCS, which controls how many operating-system threads may execute Go code simultaneously. You design for concurrency; the runtime decides on parallelism.
Goroutines
A goroutine is a function executing independently, multiplexed onto a small pool of OS threads by the Go runtime. Goroutines are cheap: they start with a small stack of a few kilobytes that grows and shrinks on demand, so launching hundreds of thousands of them is routine. Starting one is a matter of prefixing a function call with the go keyword.
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 3; i++ {
fmt.Println(s)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go say("world") // runs concurrently with main
say("hello") // runs on the main goroutine
}
There is a subtle bug hiding in that program: when main returns, the program exits immediately, and any goroutines still running are killed without ceremony. In this example the two functions happen to take roughly the same time, but you must never rely on timing. The whole point of the sections that follow is to coordinate goroutines properly instead of hoping they finish in time.
The Go proverb captures the philosophy: do not communicate by sharing memory; instead, share memory by communicating. Channels are the primary tool for that.
Channels
A channel is a typed conduit through which you send and receive values with the <- operator. Channels give you both data transfer and synchronization in a single primitive, which is what makes them so central to Go's model.
package main
import "fmt"
func main() {
ch := make(chan int) // unbuffered channel of int
go func() {
ch <- 42 // send blocks until a receiver is ready
}()
value := <-ch // receive blocks until a value arrives
fmt.Println(value)
}
Unbuffered Channels
The channel above is unbuffered. A send on an unbuffered channel blocks until another goroutine performs a matching receive, and vice versa. This makes the exchange a synchronization point: the sender knows that when the send completes, the receiver has taken the value. Unbuffered channels are the natural choice when you want a guarantee that two goroutines meet at a specific moment.
Buffered Channels
A buffered channel holds a fixed number of values before senders block. Providing a capacity as the second argument to make creates one.
ch := make(chan int, 2) // capacity of two
ch <- 1 // does not block, buffer has room
ch <- 2 // does not block, buffer now full
// ch <- 3 would block until someone receives
Sends block only when the buffer is full; receives block only when it is empty. Buffered channels are useful to smooth out bursts or to hand off work without forcing lock-step synchronization, but a buffer is not a substitute for correct coordination. Sizing it is a tuning decision, not a correctness one.
Closing and Ranging
The sender closes a channel with close to signal that no more values will be sent. Receivers can loop over a channel with range, which ends automatically when the channel is closed and drained. A two-value receive reports whether the channel is still open.
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // only the sender should close
}
func main() {
ch := make(chan int)
go producer(ch)
for v := range ch {
fmt.Println(v)
}
// explicit form of the two-value receive
v, ok := <-ch
fmt.Println(v, ok) // 0 false: channel is closed and empty
}
Two rules prevent the most common channel crashes. Only the sender closes a channel, never the receiver, because sending on a closed channel panics. And you never close a channel twice. A closed channel still yields buffered values until drained, after which receives return the zero value with ok set to false.
Directional Channel Types
Notice the parameter type chan<- int in the producer above. Go lets you constrain a channel to send-only (chan<- T) or receive-only (<-chan T) at the function boundary. This is a compile-time guarantee that documents intent and prevents a function from accidentally receiving when it should only send. Use directional types on function signatures wherever possible.
The select Statement
The select statement waits on multiple channel operations at once, proceeding with whichever is ready first. If several are ready, one is chosen at random to avoid starvation. A default clause makes the whole statement non-blocking, and a case built on time.After gives you timeouts.
select {
case msg := <-ch1:
fmt.Println("received", msg)
case ch2 <- "data":
fmt.Println("sent to ch2")
case <-time.After(2 * time.Second):
fmt.Println("timed out")
default:
fmt.Println("nothing ready right now")
}
A common idiom combines select with a dedicated done channel to tell a goroutine to stop. When the done channel is closed, the receive succeeds immediately for every goroutine watching it, which is a clean broadcast signal.
func worker(done <-chan struct{}, jobs <-chan int) {
for {
select {
case <-done:
return // stop signal received
case j := <-jobs:
fmt.Println("processing", j)
}
}
}
An empty struct struct{}{} is the conventional signal value: it occupies zero bytes, so a channel of struct{} communicates pure occurrence without carrying data.
The sync Package
Channels are idiomatic, but sometimes shared state with explicit locking is simpler and faster. The sync package provides the classic tools.
WaitGroup
A sync.WaitGroup waits for a collection of goroutines to finish. You call Add before launching, each goroutine calls Done when it completes (almost always via defer), and the coordinator calls Wait to block until the counter returns to zero.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // increment before starting the goroutine
go func(id int) {
defer wg.Done() // decrement when the goroutine returns
fmt.Printf("worker %d done\n", id)
}(i)
}
wg.Wait() // block until the counter reaches zero
fmt.Println("all workers finished")
}
Two mistakes recur. Calling Add inside the goroutine instead of before it creates a race in which Wait may return before the goroutine even registers. And forgetting defer means an early return or a panic leaves the counter stuck, so Wait blocks forever. Since Go 1.22 each loop iteration has its own copy of the loop variable, so passing i as an argument is no longer strictly required to avoid capture bugs, though passing it explicitly remains a clear and safe habit.
Mutex and RWMutex
A sync.Mutex protects shared state so that only one goroutine touches it at a time. The pattern is to lock, defer the unlock, then mutate.
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
When reads vastly outnumber writes, a sync.RWMutex lets many readers hold the lock simultaneously through RLock and RUnlock, while writers still take the exclusive Lock. Embed the mutex as an unexported field and keep the locking discipline confined to the type's methods, so callers cannot forget to lock. Never copy a value that contains a mutex; pass a pointer instead, because copying a locked mutex breaks it.
Once
sync.Once guarantees a function runs exactly one time, no matter how many goroutines call it, which is ideal for lazy initialization of a shared resource.
var (
once sync.Once
instance *Config
)
func GetConfig() *Config {
once.Do(func() {
instance = loadConfig() // runs only on the first call
})
return instance
}
Atomic Operations
For simple counters and flags, the sync/atomic package offers lock-free operations that are faster than a mutex. The typed wrappers introduced in Go 1.19, such as atomic.Int64, are safer than the raw functions because they cannot be accessed non-atomically by mistake.
import "sync/atomic"
var counter atomic.Int64
func worker() {
counter.Add(1) // atomic increment, no lock needed
}
func report() {
fmt.Println(counter.Load())
}
Atomics are the right tool only for individual values. As soon as you need to keep two related fields consistent with one another, reach for a mutex, because a sequence of atomic operations is not itself atomic.
The context Package
Long-running or blocking operations need a way to be cancelled, whether because a deadline passed, a request was aborted, or a parent operation gave up. The context package carries cancellation signals and deadlines across API boundaries and goroutine trees. By convention, a context.Context is the first parameter of a function.
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("stopping:", ctx.Err())
return
default:
time.Sleep(200 * time.Millisecond)
fmt.Println("working...")
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel() // always call cancel to release resources
worker(ctx)
}
The Done channel closes when the context is cancelled or its deadline expires, and Err then reports why. Always call the cancel function returned by WithCancel, WithTimeout, or WithDeadline, even when the operation completes normally, since failing to do so leaks the timer and the context. Use context.Background as the root in main and top-level requests, and context.TODO as a placeholder while you are still wiring cancellation through a codebase.
Worker Pools
A worker pool bounds concurrency by running a fixed number of goroutines that all pull from a shared jobs channel and push to a shared results channel. This is the workhorse pattern for processing a queue of tasks without spawning an unbounded number of goroutines.
package main
import (
"fmt"
"sync"
)
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
results <- j * 2 // pretend this is expensive work
}
}
func main() {
const numWorkers = 3
jobs := make(chan int, 100)
results := make(chan int, 100)
var wg sync.WaitGroup
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
// send all the work, then close so workers can finish
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs)
// close results once every worker has returned
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Println(r)
}
}
The structure repays study. Closing jobs after all work is enqueued lets each worker's range loop terminate. A separate goroutine waits for every worker and then closes results, which lets the main range over results end cleanly. Closing results from the main goroutine before the workers finished would panic when a worker tried to send.
Pipelines: Fan-Out and Fan-In
A pipeline is a series of stages connected by channels, where each stage receives values, does something, and sends results downstream. Each stage is a small function that returns a receive-only channel, which composes beautifully.
// stage one: emit numbers
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
// stage two: square each number
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
for result := range square(generate(1, 2, 3, 4)) {
fmt.Println(result) // 1 4 9 16
}
}
Fan-out means starting several goroutines that all read from the same input channel to parallelize a slow stage. Fan-in means merging several channels into one. A fan-in merge waits on all its inputs and closes the output once every input has drained.
func merge(inputs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, in := range inputs {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for v := range c {
out <- v
}
}(in)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
Common Pitfalls
Race Conditions
A data race occurs when two goroutines access the same variable concurrently and at least one of them writes. The result is undefined behavior, and races are notoriously hard to reproduce because they depend on timing. Go ships with a race detector that instruments your program to catch them at runtime.
// buggy: value is written by many goroutines with no synchronization
func main() {
value := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
value++ // DATA RACE
}()
}
wg.Wait()
fmt.Println(value) // almost never exactly 1000
}
Run the detector with a single flag and let it point you at the offending accesses:
go run -race main.go
go test -race ./...
The fix is to protect value with a mutex, replace it with an atomic.Int64, or restructure so a single goroutine owns the variable and others send it updates over a channel. Make running tests with -race part of your continuous integration; it is the single most effective habit for catching concurrency bugs early.
Deadlocks
A deadlock happens when every goroutine is blocked waiting for something that will never occur. The most common beginner deadlock is a send on an unbuffered channel with no receiver, which the runtime detects and reports when all goroutines are stuck.
func main() {
ch := make(chan int)
ch <- 1 // blocks forever: no receiver exists
// fatal error: all goroutines are asleep - deadlock!
}
Deadlocks also arise from lock-ordering problems, where goroutine A holds lock one and wants lock two while goroutine B holds lock two and wants lock one. The remedy is to always acquire multiple locks in the same global order.
Goroutine Leaks
A goroutine that blocks forever never gets collected, so its stack and any variables it references stay alive for the life of the program. Leak enough of them and you exhaust memory. The classic cause is a goroutine blocked on a channel send whose receiver went away.
// leaky: if the caller stops reading, this goroutine blocks forever
func leak() <-chan int {
ch := make(chan int)
go func() {
val := computeExpensiveValue()
ch <- val // blocks if nobody ever receives
}()
return ch
}
Prevent leaks by giving every goroutine a clear exit path: pass a context.Context and select on ctx.Done(), use a buffered channel of size one so the send always succeeds, or make sure the receiver always drains. Whenever you write go, ask yourself how that goroutine will end.
Coordinating Errors with errgroup
Plain WaitGroup does not propagate errors. The golang.org/x/sync/errgroup package extends the idea: it runs a group of goroutines, returns the first non-nil error, and can cancel a shared context when one of them fails, so the rest stop early.
import (
"context"
"golang.org/x/sync/errgroup"
)
func fetchAll(ctx context.Context, urls []string) error {
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
g.Go(func() error {
return fetch(ctx, url) // first error cancels the group's context
})
}
return g.Wait() // returns the first error, or nil
}
You can also bound concurrency with g.SetLimit(n), which turns the group into a self-managing worker pool with error handling built in. For most request-scoped fan-out work, errgroup is more ergonomic than assembling channels and a WaitGroup by hand.
Guidelines for Real Code
A few principles keep concurrent Go maintainable. Prefer the simplest tool that solves the problem: a mutex around a small piece of shared state is often clearer than an elaborate channel dance. Reach for channels when goroutines must hand off ownership of data or coordinate their lifecycles, and reach for the sync primitives when you are merely guarding shared memory. Keep the concurrency at the edges of your design and write the core logic as ordinary sequential functions, so most of your code stays easy to test.
Give every goroutine an owner responsible for its termination, thread a context through anything that might need cancelling, and make go test -race non-negotiable in your build. Concurrency in Go is powerful precisely because the primitives are few and composable, but that same power rewards discipline. Start with clear ownership and explicit cancellation, and the rest of the patterns fall into place.