"Mastering Go Concurrency: Goroutines, Channels, and the Scheduler"

·6 min read← Back to Blog
#go#concurrency#architecture#backend

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: NumCPU

CPU-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 full

Unbuffered = 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 once

Rules: 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 ops

Common 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.