Concurrency is one of Go's marquee features. The model — goroutines, channels, and the context package — is small and composes into surprisingly powerful patterns.
Goroutines
A goroutine is a function executing concurrently with the rest of the program. Start one with the go keyword:
func say(msg string) {
fmt.Println(msg)
}
func main() {
go say("hello") // runs concurrently
say("world")
time.Sleep(time.Millisecond) // crude wait; we'll do better
}
Goroutines are not OS threads — they are scheduled by the Go runtime on a small pool of OS threads (default: one per CPU core). A goroutine costs about 2-4 KB of stack initially. You can run hundreds of thousands of them on a single machine.
Channels
A channel is a typed pipe through which goroutines send and receive values. They are synchronisation primitives, not just data carriers.
ch := make(chan int) // unbuffered
go func() { ch <- 42 }() // send (blocks until received)
v := <-ch // receive
fmt.Println(v) // 42
// Buffered channel — sends block only when buffer is full
buf := make(chan string, 3)
buf <- "a"; buf <- "b"; buf <- "c" // no block
close(buf) // signal no more sends
for s := range buf {
fmt.Println(s) // a b c
}
Closing a channel signals "no more values" to receivers. Receiving from a closed channel returns the zero value immediately (with v, ok := <-ch, ok is false).
WaitGroup: Wait for Many Goroutines
var wg sync.WaitGroup
urls := []string{"a", "b", "c"}
for _, u := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
fetch(u)
}(u) // pass u as argument — see closure gotcha below
}
wg.Wait()
Closure capture gotcha: in Go < 1.22, the loop variable is reused across iterations; capturing u by reference inside the goroutine would print the same value many times. Pass as argument, or in 1.22+, the loop variable is per-iteration (problem solved by the language).
Mutexes for Shared State
When goroutines must access the same memory, protect it with a mutex:
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
For maps specifically, sync.Map is sometimes more appropriate. For simple counters, sync/atomic primitives are faster than mutexes.
The Mantra
Do not communicate by sharing memory; share memory by communicating.
Channels first, mutexes only when channels don't fit. In practice:
- Channels for pipelines, fan-out/fan-in, signalling, request/response across goroutines.
- Mutexes for protecting a few fields in a struct that multiple goroutines mutate.
select
select is to channels what switch is to values. It waits for whichever channel is ready first:
select {
case msg := <-results:
handle(msg)
case <-time.After(5 * time.Second):
return fmt.Errorf("timeout")
case <-ctx.Done():
return ctx.Err()
}
Add a default case to make the select non-blocking (poll). Without default, the select blocks until one case fires.
context.Context
The context package is the standard way to propagate cancellation, deadlines, and request-scoped values down a call tree. Every function that does I/O or might be long-running should take a ctx context.Context as its first argument.
func handleRequest(ctx context.Context, id string) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
user, err := db.GetUser(ctx, id)
if err != nil {
return err
}
return notify(ctx, user)
}
If the context is cancelled (timeout, parent cancellation, request aborted), any function checking ctx.Done() returns immediately. The HTTP server cancels the request context when the client disconnects. The database driver returns an error if it sees ctx.Done() while waiting for a result.
Common context constructors
context.Background()— the root; use at the top ofmainor test setupcontext.TODO()— same as Background but a marker that you haven't decided what to do yetcontext.WithCancel(parent)— derive with manual cancellation; remember to callcancel()context.WithTimeout(parent, duration)— auto-cancel after a deadlinecontext.WithDeadline(parent, time)— auto-cancel at a specific timecontext.WithValue(parent, key, value)— attach request-scoped data (request ID, user ID); use sparingly
Context rules
- Pass context as the first argument, never store it in structs
- Don't pass
nil; usecontext.Background()if you have nothing - Don't put optional parameters in context — that is what arguments are for
- Always call
cancel(usedefer cancel()) even after timeout fires, to release resources
The Fan-Out / Fan-In Pattern
func process(ctx context.Context, items []Item) ([]Result, error) {
jobs := make(chan Item)
results := make(chan Result, len(items))
// Fan out — start N workers
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for item := range jobs {
r, err := work(ctx, item)
if err != nil { continue }
results <- r
}
}()
}
// Feed jobs
go func() {
defer close(jobs)
for _, item := range items {
select {
case jobs <- item:
case <-ctx.Done():
return
}
}
}()
// Wait, then close results
go func() { wg.Wait(); close(results) }()
out := []Result{}
for r := range results {
out = append(out, r)
}
return out, ctx.Err()
}
Ten workers process items concurrently; the parent collects results in order of completion; cancellation propagates via the shared context.
Common Pitfalls
- Goroutine leaks. A goroutine that blocks on a channel that is never written or closed lives forever. Always have an exit path —
selectwithctx.Done()is the standard one. - Forgotten cancel. Every
WithCancel/WithTimeoutneeds a matchingdefer cancel(). - Channel direction errors. Sending to a closed channel panics; receiving from a closed channel returns the zero value. Closing twice panics.
- Race detector. Run
go test -racein CI. The data race detector is built in and finds most concurrent-access bugs.
Master goroutines + channels + context and you have most of Go's concurrency story. The pattern library is small enough to fit on a postcard and powerful enough to drive Kubernetes' control plane.