"Mastering Go Concurrency: Goroutines, Channels, and the Scheduler"
Introduction
Go's concurrency philosophy: "Do not communicate by sharing memory; instead, share memory by communicating." This isn't a mutex rejection — it prioritizes channel-based signaling as the primary design idiom. Goroutines give you cheap concurrency, channels give composable pipelines, and the runtime scheduler handles M:N threading without thread pool management.
Goroutines — NOT Threads, NOT Coroutines
A goroutine is a stackful fiber managed by the runtime. Startup stack ~2 KB (vs ~1 MB for OS threads), growable on demand via copying, and thousands multiplexed onto a handful of OS threads.
go func() { doWork() }()No async, no await, no thread pool config. One keyword.
The GMP Scheduler
Three primitives: G (goroutine — stack, IP, state), M (OS thread), P (processor — scheduling context with local run queue). GOMAXPROCS controls P count.
Work Stealing & Preemption
When a P's local queue drains, it steals half the Gs from a random victim P — no centralized scheduler lock. Pre-Go 1.14 used cooperative preemption at function calls; a tight loop stalled an entire P. Go 1.14+ uses asynchronous preemption via SIGURG forcing a yield after ~10 ms.
GOMAXPROCS
runtime.GOMAXPROCS(0) // Default: NumCPUCPU-bound: GOMAXPROCS = NumCPU. I/O-bound: exceed NumCPU (blocked Ms release their P). Leave at default for servers.
Channels
Typed, concurrency-safe FIFO queues.
ch := make(chan int) // Unbuffered — sender blocks until receiver is ready
bch := make(chan int, 10) // Buffered — sender blocks only when fullUnbuffered = synchronization guarantee. Buffered = decouples production from consumption.
Patterns
Pipeline — staged processing:
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() { for _, n := range nums { out <- n }; close(out) }()
return out
}
func sq(in <-chan int) <-chan int {
out := make(chan int)
go func() { for n := range in { out <- n * n }; close(out) }()
return out
}Fan-in — merge multiple channels into one:
func merge(cs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, c := range cs {
wg.Add(1)
go func(ch <-chan int) {
defer wg.Done()
for v := range ch { out <- v }
}(c)
}
go func() { wg.Wait(); close(out) }()
return out
}Fan-out / Worker pool — split work across N goroutines reading from one channel:
func worker(id int, jobs <-chan Job, results chan<- Result) {
for j := range jobs { results <- process(j) }
}
// Launch N workers reading from the same jobs channel
for i := 0; i < workers; i++ { go worker(i, jobs, results) }Select multiplexing:
select {
case msg := <-ch1: handle(msg)
case <-time.After(100 * time.Millisecond): fallback()
case <-done: return
default: // Non-blocking
}Nil channels block forever — toggle cases on/off by assigning nil to a channel variable.
Sync Primitives
| Usage | Tool | |-------|------| | Data flow, signaling | Channel | | Protect a struct field | Mutex | | Read-heavy state | RWMutex | | One-time init | sync.Once | | Broadcast wake-up | sync.Cond | | Coordinate goroutines | WaitGroup |
var mu sync.Mutex
mu.Lock(); counter++; mu.Unlock()
var rw sync.RWMutex
rw.RLock(); v := config["key"]; rw.RUnlock()
var once sync.Once
once.Do(func() { initResource() }) // Runs exactly onceRules: Never copy a sync primitive — pass by pointer. Call wg.Add() in the parent goroutine before the worker starts.
Context Package
Carries deadlines, cancellation, and request-scoped values across API boundaries.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ctx, cancel := context.WithCancel(context.Background())
ctxDeadline, cancel := context.WithDeadline(parent, time.Now().Add(2*time.Second))
ctx := context.WithValue(parent, reqIDKey, "abc-123")ctx.Done() pattern — every blocking goroutine should listen for cancellation:
func longOp(ctx context.Context, data []byte) (Result, error) {
resultCh := make(chan Result, 1)
go func() { resultCh <- expensiveWork(data) }()
select {
case res := <-resultCh: return res, nil
case <-ctx.Done(): return Result{}, ctx.Err()
}
}Propagate ctx as the first parameter. Never store it in a struct.
Production Patterns
errgroup — bounded concurrency with first-error propagation:
g, ctx := errgroup.WithContext(context.Background())
for _, item := range items {
item := item
g.Go(func() error { return process(ctx, item) })
}
if err := g.Wait(); err != nil { log.Printf("failed: %v", err) }Rate limiting — channel-based token bucket:
type Limiter struct{ tokens chan struct{} }
func NewLimiter(rate int) *Limiter {
l := &Limiter{tokens: make(chan struct{}, rate)}
for i := 0; i < rate; i++ { l.tokens <- struct{}{} }
go func() {
for range time.NewTicker(time.Second / time.Duration(rate)).C {
l.tokens <- struct{}{}
}
}()
return l
}
func (l *Limiter) Wait() { <-l.tokens }For production, use golang.org/x/time/rate.
Circuit breaker — fail fast when error threshold is exceeded:
type CircuitBreaker struct {
failures int
threshold int
lastErr time.Time
mu sync.Mutex
}
func (cb *CircuitBreaker) Call(fn func() error) error {
cb.mu.Lock()
if cb.failures >= cb.threshold && time.Since(cb.lastErr) < 10*time.Second {
cb.mu.Unlock()
return fmt.Errorf("circuit open")
}
cb.mu.Unlock()
err := fn()
cb.mu.Lock()
defer cb.mu.Unlock()
if err != nil { cb.failures++; cb.lastErr = time.Now(); return err }
cb.failures = 0
return nil
}Graceful shutdown with signal.NotifyContext:
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080"}
go srv.ListenAndServe()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)Debugging
Race detector (go test -race ./...) — instruments memory accesses at runtime. ~5-10x slower. Never ship -race binaries; always run in CI.
Execution tracing (go test -trace trace.out ./pkg/... && go tool trace trace.out) — goroutine lifecycle, blocking events, syscalls. Use when you don't know why something is slow.
pprof — import net/http/pprof, then:
go tool pprof http://localhost:6060/debug/pprof/goroutine # Find leaks
go tool pprof http://localhost:6060/debug/pprof/mutex # Lock contention
go tool pprof http://localhost:6060/debug/pprof/block # Blocking opsCommon Pitfalls
Goroutine leak: ch := make(chan int) then go func() { ch <- 42 }() with no receiver blocks forever. Use a buffered channel or ensure a receiver exists.
Copying sync.Mutex: Passing a struct with sync.Mutex by value copies the mutex — undefined behavior. Always pass by pointer.
WaitGroup.Add inside goroutine: Must be called in the parent before launching the worker goroutine.
time.Sleep for synchronization: Fragile and timing-dependent. Use channels, WaitGroup, or Cond instead.
Summary
| Concept | Key takeaway |
|---------|-------------|
| Goroutines | ~2 KB stack, M:N scheduling, stackable by the thousands |
| GMP Scheduler | Work stealing, async preemption since Go 1.14 |
| Channels | Unbuffered for sync, buffered for hand-off, select for multiplexing |
| Sync primitives | Mutex for state, channel for flow |
| Context | First param, ctx.Done() for cancellation |
| Production | errgroup, rate limiting, circuit breaker, signal.NotifyContext |
| Debugging | -race in CI, trace for latency, pprof for goroutines/mutex |
Go's concurrency model gives a clean separation: design data flow with channels, protect state with mutexes, and let the runtime handle the rest.