select, sync, context
Three primitives that complete Go's concurrency story. select waits
on multiple channels at once. sync.Mutex/RWMutex protect
shared state when channels don't fit. context.Context propagates
cancellation and deadlines through goroutine trees — the foundation of every
production Go service.
1 · select — multiplex channels
select is to channels what switch is to values. Each
case is a channel operation. The runtime waits until one is ready,
then picks one (randomly if multiple ready) and executes its body.
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
time.Sleep(200 * time.Millisecond)
ch <- "got it!"
}()
select {
case v := <-ch:
fmt.Println(v)
case <-time.After(100 * time.Millisecond):
fmt.Println("timeout")
}
}time.After.
Cancel: race against a "done" channel.
Non-blocking: add default — runs if no other case is ready.
Multi-channel fan-in: read from whichever of N channels is ready.2 · Non-blocking send / receive
package main
import "fmt"
func main() {
ch := make(chan int, 1)
ch <- 99
// Non-blocking send
select {
case ch <- 1:
fmt.Println("sent")
default:
fmt.Println("would block — drop the value")
}
// Non-blocking receive — drain
select {
case v := <-ch:
fmt.Println("got:", v)
default:
fmt.Println("empty")
}
}3 · sync.Mutex — when channels don't fit
Channels are for moving data between goroutines. When you have shared state that multiple goroutines read and write — a counter, a cache, a map — a mutex is simpler.
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *Counter) Val() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
func main() {
var c Counter
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc()
}()
}
wg.Wait()
fmt.Println(c.Val()) // always 1000, never racy
}4 · sync.RWMutex — many readers, one writer
package main
import (
"fmt"
"sync"
)
type Cache struct {
mu sync.RWMutex
m map[string]string
}
func (c *Cache) Get(k string) string {
c.mu.RLock() // many goroutines can hold this concurrently
defer c.mu.RUnlock()
return c.m[k]
}
func (c *Cache) Set(k, v string) {
c.mu.Lock() // exclusive — no readers either
defer c.mu.Unlock()
c.m[k] = v
}
func main() {
c := &Cache{m: map[string]string{}}
c.Set("answer", "42")
fmt.Println(c.Get("answer"))
}RWMutex when reads outnumber
writes by ~5× or more. The overhead of the extra state isn't worth it for
balanced workloads. Profile if unsure.5 · context — cancellation, deadlines, values
The context.Context is the standard way to signal "stop working" to
a tree of goroutines. Every request handler accepts one. Every long-running
operation checks it. Every spawned goroutine inherits it.
package main
import (
"context"
"fmt"
"time"
)
func work(ctx context.Context, id int) {
select {
case <-time.After(2 * time.Second):
fmt.Printf("worker %d finished
", id)
case <-ctx.Done():
fmt.Printf("worker %d cancelled: %v
", id, ctx.Err())
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel() // release resources
go work(ctx, 1)
go work(ctx, 2)
time.Sleep(1 * time.Second)
}6 · The four context constructors
| Constructor | Returns | Use case |
|---|---|---|
context.Background() | An empty root context | Top of main, tests, init |
context.TODO() | Same as Background | "I don't know which context yet" — use Background or TODO before passing in |
context.WithCancel(parent) | (ctx, cancel) | Explicit cancellation by calling cancel() |
context.WithTimeout(parent, d) | (ctx, cancel) | Auto-cancel after duration d |
context.WithDeadline(parent, t) | (ctx, cancel) | Auto-cancel at absolute time t |
context.WithValue(parent, k, v) | ctx | Carry request-scoped data (sparingly!) |
7 · Other sync primitives
- sync.Once. Run code exactly once across goroutines. Use for lazy initialisation:
var once sync.Once; once.Do(initFn). - sync.WaitGroup. Already met.
Add,Done,Wait. The standard goroutine-tree barrier. - sync.Map. A concurrent map, optimised for "many reads + occasional writes" or "disjoint key spaces per goroutine". Slower than a plain mutex+map for balanced workloads.
- sync.Pool. An ephemeral object pool. Get/Put avoid GC pressure on hot paths. Used by the standard library for
bytes.Bufferrecycling infmt. - sync/atomic. Atomic int/pointer operations without locking. Fastest, lowest-level. Reach for it only when profiling proves a mutex is the bottleneck.
8 · From the wild
func (srv *Server) Shutdown(ctx context.Context) error {
srv.inShutdown.Store(true)
srv.mu.Lock() // sync.Mutex on Server
lnerr := srv.closeListenersLocked()
srv.closeDoneChanLocked()
for _, f := range srv.onShutdown {
go f() // best-effort callbacks
}
srv.mu.Unlock()
pollIntervalBase := time.Millisecond
timer := time.NewTimer(nextPollInterval())
defer timer.Stop()
for {
if srv.closeIdleConns() {
return lnerr
}
select {
case <-ctx.Done(): // caller's timeout
return ctx.Err()
case <-timer.C:
timer.Reset(nextPollInterval())
}
}
}Mutex for shared state. Context for cancellation budget. Select to wait on either. Goroutines for the parallel callbacks. The standard library's canonical concurrency pattern.
9 · Common mistakes
- Forgetting to call
cancel. EveryWithTimeout/WithCancelreturns a cancel function. Forgetting to call it leaks resources.defer cancel()is the reflex. - Storing things in context values. Context values are for request-scoped identifiers (request ID, user ID, tenant). Not for application config. Not for type-checked data.
- Holding a mutex across a channel op. Deadlock waiting to happen. Release the mutex before blocking on a channel.
- Copying a Mutex. Pass
*Mutex, notMutexby value.go vetwarns about this. - RWMutex when writes dominate. RWMutex has more overhead than Mutex. For balanced workloads, plain Mutex wins.
- Select with only one case. If only one channel involved, just receive directly — the
selectadds no value.
10 · Exercises (~15 min)
- Timeout an operation. Wrap a function that takes 2s in a
WithTimeout(500ms). Verify it cancels. - Worker with cancel. Spawn 5 goroutines, each reading from a channel in a loop. After 1 second,
cancel()and watch them all exit. - Mutex vs channel. Implement a thread-safe counter twice: once with mutex, once with a channel + goroutine. Benchmark both with
testing.B. Surprise yourself. - sync.Once. Use it to lazily initialise an expensive resource (a fake "open DB connection"). Verify only one of 1000 concurrent callers triggers the init.
11 · When it clicks
- Every long-running function in your code accepts a
ctx context.Contextas its first argument. - You pair every
context.WithCancel/WithTimeoutwithdefer cancel()on the next line. - You reach for
selectwhen "first one wins" is the requirement. - You can decide instantly whether a piece of shared state belongs behind a Mutex, a channel, or an atomic.