Concurrency in Go: Goroutines and Channels First
Go treats concurrency as a first-class language feature.
Search across all documentation pages
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.
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.
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 |
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.
ok or range.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.
Prefer channels when passing ownership or work items between stages.
Prefer mutexes when many goroutines update the same struct fields frequently.
main returning ends the process.
Wait with sync.WaitGroup, drain a results channel, or block on select until workers signal done.
The sender blocks forever - a deadlock if no other goroutine can receive.
Always pair senders with receivers or use buffering/timeouts.
Only if one goroutine writes or you synchronize access.
Pass a copy or send the slice through a channel; unsynchronized append races are common.
If multiple cases are ready, Go picks one pseudo-randomly.
Do not rely on priority ordering without restructuring the loop.
No - GOMAXPROCS sets logical Ps (parallel OS-thread execution).
You can run many more goroutines than GOMAXPROCS.
No - goroutines help when work can overlap (I/O, parallel stages).
CPU-bound serial work on small inputs often runs faster sequentially.
The memory model defines ordering edges: channel send happens-before receive, mutex.Unlock happens-before later Lock, etc.
Races violate those edges.
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.
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).
Reviewed by Chris St. John·Last updated Jul 16, 2026