Concurrency in Go: Goroutines and Channels First
Go treats concurrency as a first-class language feature.
You launch work with go, coordinate with channels, and reach for sync primitives only when shared state is the simpler model.
This page is the conceptual anchor for the Concurrency section.
Concurrency Basics collects runnable snippets; sibling articles cover scheduling, channel closing rules, select, mutexes, and the race detector.
Summary
- Go follows a Communicating Sequential Processes (CSP) style where independent goroutines exchange values through channels instead of fighting over shared memory.
- Insight: Channels make ownership and synchronization visible in the type system, which reduces data races and makes concurrent pipelines easier to reason about in code review.
- Key Concepts: goroutine, channel, send/receive, select, GPM scheduler, happens-before.
- When to Use: Reach for goroutines plus channels when work can be modeled as pipelines, fan-out/fan-in, or producer-consumer flows; use mutexes when protecting a small shared struct is clearer than threading channels everywhere.
- Limitations/Trade-offs: Goroutines are cheap but not free; channel misuse deadlocks; shared mutable state still needs locks or careful design; the race detector catches races but not logical bugs.
- Related Topics: context cancellation, worker pools, errgroup, atomic counters, HTTP server per-request goroutines.
Foundations
Picture a Go program as a set of goroutines - lightweight tasks scheduled by the runtime - that pass data through channels.
A channel is a typed conduit: chan int carries int values between goroutines.
Sending (ch <- v) and receiving (v := <-ch) are the synchronization points.
An unbuffered channel blocks the sender until a receiver is ready, and blocks the receiver until a sender arrives.
That handshake is a synchronization event: the send happens-before the receive completes.
A buffered channel decouples sender and receiver up to its capacity; sends block only when the buffer is full, receives only when empty.
The famous Go proverb - Do not communicate by sharing memory; share memory by communicating - is practical advice, not a ban on mutexes.
It means: prefer passing ownership of data through channels so only one goroutine mutates a value at a time.
When several goroutines must read and update the same map or counter, sync.Mutex or sync/atomic is often the right tool.
Mechanics & Interactions
Starting a goroutine is go f() or go func() { ... }().
The main goroutine is the program entry point; when main returns, the process exits and outstanding goroutines are terminated without waiting.
Use sync.WaitGroup, channels, or context to coordinate shutdown.
The runtime scheduler uses the GPM model: G (goroutine), P (logical processor holding a run queue), M (OS thread).
Ps are bound to Ms; when a goroutine blocks on I/O or a channel, the scheduler parks it and runs another G on the same M.
That is why you can spawn thousands of goroutines where OS threads would choke.
select multiplexes channel operations: it blocks until one case can proceed, or runs default for a non-blocking attempt.
Timeouts are idiomatic: select between work and <-time.After(d).
Closing a channel signals "no more values"; receivers learn via the two-value form v, ok := <-ch where ok is false after close.
Only the sender should close - sending on a closed channel panics.
main goroutine
│
├── go worker ──► ch ──► go consumer
│ │
│ └── close(ch) when done
│
└── wg.Wait() / drain results
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| Unbuffered channel | Strong sync, clear handoff | Couples producer/receiver timing | Job dispatch, ack pipelines |
| Buffered channel | Smooths bursts | Hides backpressure if mis-sized | Log fan-in, bounded queues |
sync.Mutex | Simple shared struct updates | Easy to deadlock if nested wrong | Caches, in-memory indexes |
sync/atomic | Fast counters/flags | Limited to numeric/pointer ops | Metrics, reference counts |
select + default | Non-blocking probes | Busy loops if misused | Timeouts, try-send/receive |
Advanced Considerations & Applications
HTTP servers in chi, gin, and echo handle each request in its own goroutine - concurrency is the default shape of Go network code.
google.golang.org/grpc streams and worker pools layer channels and context cancellation under RPC handlers.
controller-runtime reconcilers run concurrently; shared informer caches use mutexes internally while events flow through channels.
Production services combine patterns: bounded worker pools limit parallelism, context.Context propagates deadlines, and errgroup cancels siblings on first error (covered in Advanced Concurrency).
Run tests and CI builds with -race to catch unsynchronized map writes and check-then-act bugs.
golangci-lint includes race-prone pattern checks; pair linters with integration tests that exercise concurrent paths.
Profile before spawning unbounded goroutines per item - memory for stacks and scheduler overhead adds up under extreme fan-out.
Common Misconceptions
- "Goroutines are free" - They are cheap (kilobyte-scale stacks that grow), but millions of blocked goroutines still consume memory and scheduler work.
- "Channels replace mutexes entirely" - Channels synchronize communication; shared mutable structs still need locks or careful single-writer design.
- "Buffered channels are always faster" - Buffering hides coordination but can mask overload; unbuffered channels enforce natural backpressure.
- "Close a channel from either side" - Convention and safety say the sender closes; receivers detect close via
okorrange. - "The race detector finds all concurrency bugs" - It finds data races, not deadlocks, logical races, or missing cancellation.
FAQs
What is CSP and how does Go relate to it?
Communicating Sequential Processes is a model where independent processes exchange messages.
Go's goroutines and channels implement that idea without a separate process syntax - concurrency lives in the language.
When should I use a channel instead of a mutex?
Prefer channels when passing ownership or work items between stages.
Prefer mutexes when many goroutines update the same struct fields frequently.
Why does my program exit before goroutines finish?
main returning ends the process.
Wait with sync.WaitGroup, drain a results channel, or block on select until workers signal done.
What happens if nobody receives on an unbuffered channel?
The sender blocks forever - a deadlock if no other goroutine can receive.
Always pair senders with receivers or use buffering/timeouts.
Can I share a slice between goroutines safely?
Only if one goroutine writes or you synchronize access.
Pass a copy or send the slice through a channel; unsynchronized append races are common.
How does select choose among ready cases?
If multiple cases are ready, Go picks one pseudo-randomly.
Do not rely on priority ordering without restructuring the loop.
Is GOMAXPROCS the number of goroutines?
No - GOMAXPROCS sets logical Ps (parallel OS-thread execution).
You can run many more goroutines than GOMAXPROCS.
Should every function spawn its own goroutine?
No - goroutines help when work can overlap (I/O, parallel stages).
CPU-bound serial work on small inputs often runs faster sequentially.
What is happens-before in Go?
The memory model defines ordering edges: channel send happens-before receive, mutex.Unlock happens-before later Lock, etc.
Races violate those edges.
Do I need `-race` in production binaries?
No - the race detector adds overhead and is for tests and staging.
Ship non-race builds; run -race in CI on packages that use concurrency.
Related
- Concurrency Basics - runnable intro examples
- Goroutines: Creation Cost & Scheduling Overview - GPM scheduler depth
- Channels: Buffered, Unbuffered & Closing Rules - close semantics
- select Statement & Default Cases - multiplexing and timeouts
- sync.Mutex, RWMutex & WaitGroup - when locks fit better
- Concurrency Fundamentals Best Practices - review habits for safe concurrency
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).