Concurrency
Concurrency is one of Go’s distinguishing features. The language provides two principal primitives: goroutines (lightweight, runtime-managed concurrent functions, started with the go keyword) and channels (typed conduits for sending and receiving values between goroutines). The select statement admits multi-channel coordination. The sync package provides traditional primitives — mutexes, wait groups, atomic operations — for shared-memory concurrency. The Go discipline is summarised by the slogan “Do not communicate by sharing memory; instead, share memory by communicating” — channels-and-goroutines as the conventional model, with sync primitives as a complementary surface for cases where channels are awkward.
Goroutines
A goroutine is a function executing concurrently with other goroutines in the same address space. Started with the go keyword:
func say(s string) {
for i := 0; i < 5; i++ {
time.Sleep(100 * time.Millisecond)
fmt.Println(s)
}
}
func main() {
go say("world") // concurrent
say("hello") // synchronous
}
Goroutines are cheap — typical applications run thousands or millions of them. The runtime multiplexes goroutines onto OS threads; the conventional Go scheduler is M:N (many goroutines on a few OS threads).
A goroutine starts with a small stack (typically 8 KiB) that grows as needed. The mechanism admits substantially higher concurrency than thread-per-task models (where each thread pre-allocates 1+ MiB).
When main returns, the program exits — even if other goroutines are still running. The conventional defence is to coordinate explicitly via channels or wait groups.
Channels
A channel is a typed conduit for communication:
ch := make(chan int) // unbuffered
ch := make(chan int, 10) // buffered, capacity 10
ch <- 42 // send
v := <-ch // receive
v, ok := <-ch // two-value form (ok=false if closed)
close(ch) // close (only the sender should close)
Unbuffered channels
An unbuffered channel synchronises sender and receiver: a send blocks until a corresponding receive (and vice versa).
func produce(ch chan<- int) {
ch <- 1
ch <- 2
ch <- 3
close(ch)
}
func main() {
ch := make(chan int)
go produce(ch)
for v := range ch { // receive until closed
fmt.Println(v)
}
}
The mechanism admits message passing without shared mutable state.
Buffered channels
A buffered channel admits sending without blocking, up to the buffer’s capacity:
ch := make(chan int, 3)
ch <- 1 // doesn't block
ch <- 2
ch <- 3
// ch <- 4 // would block; buffer is full
v := <-ch // 1
v = <-ch // 2
ch <- 4 // OK now; one slot freed
The conventional uses are decoupling sender and receiver speeds and bounded queues.
Channel directions
A channel parameter may be restricted to send-only or receive-only:
func producer(ch chan<- int) { // send-only inside
ch <- 1
}
func consumer(ch <-chan int) { // receive-only inside
v := <-ch
fmt.Println(v)
}
func main() {
ch := make(chan int) // bidirectional
go producer(ch) // becomes send-only
consumer(ch) // becomes receive-only
}
The form admits compile-time enforcement of communication direction.
Closing channels
Closing signals “no more values”:
close(ch) // close
v, ok := <-ch // ok is false on a closed empty channel
for v := range ch { // range exits when channel closes
process(v)
}
The conventional discipline:
- Only the sender closes — receivers should never close.
- Only close once — closing a closed channel panics.
- Closing is optional — leaks-free programs may not close at all.
- A closed channel admits unlimited reads — returning zero values.
select
The select statement admits waiting on multiple channels:
select {
case v := <-ch1:
fmt.Println("ch1:", v)
case v := <-ch2:
fmt.Println("ch2:", v)
case ch3 <- 42:
fmt.Println("sent to ch3")
case <-time.After(time.Second):
fmt.Println("timeout")
default:
fmt.Println("nothing ready")
}
The semantics:
- If exactly one case is ready, it runs.
- If multiple cases are ready, one is chosen randomly.
- If no case is ready and there is a
default, it runs. - If no case is ready and no
default, theselectblocks until a case becomes ready.
The conventional uses:
- Multi-channel coordination — wait on the first of several events.
- Timeouts — combine with
time.Afterortime.NewTimer. - Cancellation — combine with a context’s
Done()channel. - Non-blocking operations — with
default.
The context package
The context.Context admits cancellation and deadline propagation:
import "context"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
select {
case result := <-doWork(ctx):
fmt.Println("got:", result)
case <-ctx.Done():
fmt.Println("cancelled or timed out:", ctx.Err())
}
The principal constructors:
context.Background() // root context
context.TODO() // placeholder for not-yet-decided
ctx, cancel := context.WithCancel(parent)
ctx, cancel := context.WithTimeout(parent, duration)
ctx, cancel := context.WithDeadline(parent, time)
ctx := context.WithValue(parent, key, value) // for request-scoped values
The conventional discipline:
- Pass
ctxas the first parameter of long-running functions. - Always call
cancel()— typically withdefer cancel(). - Check
ctx.Done()in long-running operations. - Use
context.WithValuesparingly — it is for request-scoped values, not for routine parameter passing.
sync primitives
The sync package provides traditional concurrency primitives.
Mutex
import "sync"
type SafeCounter struct {
mu sync.Mutex
n int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *SafeCounter) Get() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
The Mutex admits mutual exclusion; Lock() and Unlock() are the principal operations. The conventional defer mu.Unlock() pattern admits cleanup on early returns and panics.
RWMutex
For read-heavy workloads:
var mu sync.RWMutex
var data map[string]int
func get(key string) int {
mu.RLock() // read lock; many readers OK
defer mu.RUnlock()
return data[key]
}
func set(key string, value int) {
mu.Lock() // write lock; exclusive
defer mu.Unlock()
data[key] = value
}
Many readers may hold the lock simultaneously; writers are exclusive.
WaitGroup
For waiting on a collection of goroutines:
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
process(id)
}(i)
}
wg.Wait() // blocks until all Done() called
The pattern is conventional for “spawn N workers, wait for all”.
Once
For “exactly once” initialisation:
var (
once sync.Once
instance *Service
)
func GetInstance() *Service {
once.Do(func() {
instance = createService()
})
return instance
}
The mechanism is thread-safe and admits substantial use for singletons and lazy initialisation.
Atomic operations
For lock-free shared state:
import "sync/atomic"
var counter atomic.Int64 // since Go 1.19
counter.Add(1)
n := counter.Load()
counter.Store(0)
// Older form:
var n int64
atomic.AddInt64(&n, 1)
val := atomic.LoadInt64(&n)
Atomics admit substantial efficiency for simple counters and flags but are limited to specific operations on basic types.
Pool
For reusing short-lived objects:
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func process() {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufPool.Put(buf)
}()
// ... use buf ...
}
Treated in Memory and the runtime.
Common patterns
Worker pool
func worker(id int, jobs <-chan Job, results chan<- Result) {
for j := range jobs {
results <- process(j)
}
}
func main() {
jobs := make(chan Job, 100)
results := make(chan Result, 100)
for w := 1; w <= 5; w++ { // five workers
go worker(w, jobs, results)
}
go func() {
for j := 0; j < 100; j++ {
jobs <- Job{ID: j}
}
close(jobs)
}()
for r := 0; r < 100; r++ {
result := <-results
fmt.Println(result)
}
}
The pattern is conventional for parallel processing of a known workload.
Pipeline
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
func main() {
for n := range square(generate(1, 2, 3, 4, 5)) {
fmt.Println(n)
}
}
The pipeline admits stage-by-stage transformation; each stage runs in its own goroutine.
Fan-out, fan-in
func fanOut(in <-chan int, n int) []<-chan int {
outs := make([]<-chan int, n)
for i := 0; i < n; i++ {
out := make(chan int)
outs[i] = out
go func() {
defer close(out)
for v := range in {
out <- compute(v)
}
}()
}
return outs
}
func fanIn(channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(ch <-chan int) {
defer wg.Done()
for v := range ch {
out <- v
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
Timeout
select {
case result := <-doWork():
fmt.Println(result)
case <-time.After(2 * time.Second):
fmt.Println("timeout")
}
Cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
if userPressedCancel() {
cancel()
}
}()
result, err := doLongWork(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
fmt.Println("cancelled by user")
}
return err
}
Done-channel idiom
func worker(done <-chan struct{}) {
for {
select {
case <-done:
return
default:
doStep()
}
}
}
done := make(chan struct{})
go worker(done)
// ... when ready to stop:
close(done)
Closing the done channel signals all listeners simultaneously.
Single-flight / once-only
var once sync.Once
func ensureInitialised() {
once.Do(initialise) // runs once across all goroutines
}
Producer-consumer
ch := make(chan int, 100)
go func() { // producer
defer close(ch)
for i := 0; i < 1000; i++ {
ch <- i
}
}()
for v := range ch { // consumer
process(v)
}
Rate limiting
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
<-ticker.C // wait for next tick
doSomething()
}
Concurrent map access
import "sync"
var (
mu sync.RWMutex
m = make(map[string]int)
)
func Get(key string) (int, bool) {
mu.RLock()
defer mu.RUnlock()
v, ok := m[key]
return v, ok
}
func Set(key string, value int) {
mu.Lock()
defer mu.Unlock()
m[key] = value
}
For substantial concurrent map use, sync.Map provides specific patterns:
var m sync.Map
m.Store("a", 1)
v, ok := m.Load("a")
m.Delete("a")
m.Range(func(k, v interface{}) bool {
fmt.Println(k, v)
return true // continue
})
sync.Map is optimised for “write once, read many” patterns; for general concurrent maps, sync.RWMutex plus a regular map is conventionally faster.
The race detector
go test -race and go run -race enable dynamic race detection:
go test -race ./...
go run -race main.go
The detector reports race conditions discovered during execution. The conventional discipline is to run tests with -race in CI; the overhead is substantial (~5-10× slowdown) but admits substantial confidence.
A note on the conventional discipline
The contemporary Go concurrency advice:
- Channels for ownership transfer; sync primitives for shared state.
- “Do not communicate by sharing memory; share memory by communicating” — when channels fit, use them.
- Pass
context.Contextas the first parameter of long-running functions. - Always
defer cancel()afterWithTimeoutorWithCancel. - Use
sync.Mutexfor mutual exclusion;sync.RWMutexfor read-heavy. - Use
sync.WaitGroupfor goroutine coordination. - Use
sync.Oncefor “exactly once” initialisation. - Use atomics only for simple counters and flags.
- Use
selectwith timeouts and cancellation — the conventional pattern. - Run tests with
-racein CI. - Avoid sharing channels across many parties — keep ownership clear.
The combination — goroutines as cheap concurrent functions, channels for typed message passing, select for multi-channel coordination, context for cancellation propagation, sync primitives for shared state, the race detector — is the substance of Go’s concurrency story. The discipline produces correct, scalable concurrent code with substantial built-in tooling.