Skip to content

Table of Contents

Concurrency

Contents

Goroutines

A lightweight thread managed by the runtime. go f() starts one; it returns immediately.

go doWork()
go func(id int) { fmt.Println(id) }(1)   // pass args, don't capture loop vars carelessly

// main returns -> program exits, killing all goroutines. You must synchronize.

Go 1.22+: loop variables are per-iteration, so for i := range 3 { go func(){ use(i) }() } is safe. Pre-1.22 you'd copy i := i.

Channels

Typed conduits. Send ch <- v, receive v := <-ch. Communication synchronizes.

ch := make(chan int)        // unbuffered: send blocks until a receiver is ready
buf := make(chan int, 10)   // buffered: send blocks only when full

ch <- 5                     // send
v := <-ch                   // receive
v, ok := <-ch               // ok=false if channel closed AND drained
close(ch)                   // no more sends allowed

len(buf)                    // queued elements
cap(buf)                    // buffer size

for v := range ch {         // ranges until ch is closed (and drained)
    use(v)
}

Unbuffered = synchronous handoff (rendezvous). Buffered = async up to capacity.

Channel axioms

Memorize these — they are the source of most concurrency bugs:

Operation nil channel open channel closed channel
send ch <- x blocks forever ok / blocks if full panic
receive <-ch blocks forever ok / blocks if empty returns zero value, ok=false
close(ch) panic ok panic
// Receiving from a closed channel never blocks and yields the zero value:
ch := make(chan int)
close(ch)
v, ok := <-ch               // v=0, ok=false

// Sending on a closed channel panics:
close(ch); ch <- 1          // panic: send on closed channel

// Closing twice panics:
close(ch); close(ch)        // panic: close of closed channel

Rules of thumb:

  • Only the sender closes a channel, never the receiver.
  • Closing is a broadcast: all receivers see it. Use a chan struct{} closed as a "done"/"stop" signal.
  • You don't have to close a channel; GC reclaims it. Close only to signal "no more values."
  • A nil channel disables a select case (useful trick).

Directional channels

Restrict direction in function signatures for safety/clarity.

func produce(out chan<- int) { out <- 1 }   // send-only
func consume(in <-chan int)  { <-in }       // receive-only

ch := make(chan int)
produce(ch)                 // bidirectional converts to send-only automatically
consume(ch)

select

Waits on multiple channel ops; picks a ready one (random among ready).

select {
case v := <-ch1:
    use(v)
case ch2 <- x:
    sent()
case <-time.After(time.Second):
    timeout()               // timeout pattern
default:
    // runs if NO case is ready -> non-blocking select
}

// Loop with a stop signal:
for {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case job := <-jobs:
        process(job)
    }
}

// Disable a case by nilling its channel:
var c chan int              // nil; this case never fires until reassigned
select {
case <-c:                   // blocked forever (nil) -> effectively disabled
case <-other:
}

sync primitives

import "sync"

// Mutex: mutual exclusion
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
// critical section

// RWMutex: many readers OR one writer
var rw sync.RWMutex
rw.RLock(); _ = data; rw.RUnlock()   // concurrent reads
rw.Lock(); data = x; rw.Unlock()     // exclusive write

// WaitGroup: wait for N goroutines
var wg sync.WaitGroup
for _, item := range items {
    wg.Add(1)               // BEFORE go, not inside
    go func(it Item) {
        defer wg.Done()
        process(it)
    }(item)
}
wg.Wait()                   // blocks until counter hits 0

// Once: run exactly once (lazy init, singletons)
var once sync.Once
once.Do(func() { initExpensive() })  // safe across goroutines

// Pool: reuse allocations to reduce GC pressure
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
b := bufPool.Get().(*bytes.Buffer)
b.Reset()
defer bufPool.Put(b)

// sync.Map: concurrent map for append-heavy / disjoint-key workloads
var m sync.Map
m.Store("k", 1)
v, ok := m.Load("k")
m.LoadOrStore("k", 2)
m.Range(func(k, v any) bool { return true })

Never copy a sync.Mutex/WaitGroup/Once after use — pass by pointer (or embed in a struct passed by pointer). go vet catches lock copies.

atomic

Lock-free operations on integers/pointers. Prefer the typed wrappers (Go 1.19+).

import "sync/atomic"

var counter atomic.Int64
counter.Add(1)
counter.Load()
counter.Store(10)
counter.CompareAndSwap(10, 20)   // CAS
old := counter.Swap(5)

var flag atomic.Bool
flag.Store(true)

var p atomic.Pointer[Config]
p.Store(&Config{})
cfg := p.Load()

// Old function form (still common):
atomic.AddInt64(&n, 1)
atomic.LoadInt64(&n)

Use atomics for simple counters/flags; use mutexes when multiple fields must change together.

context

Carries cancellation, deadlines, and request-scoped values across API boundaries. Pass as the first param: ctx context.Context.

import "context"

ctx := context.Background()             // root (main, init, tests)
ctx = context.TODO()                    // placeholder when unsure

ctx, cancel := context.WithCancel(ctx)
defer cancel()                          // ALWAYS call cancel to release resources

ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Minute))

// Respond to cancellation:
select {
case <-ctx.Done():
    return ctx.Err()        // context.Canceled or context.DeadlineExceeded
case res := <-work:
    return res
}

// Request-scoped values (use sparingly; typed keys to avoid collisions):
type ctxKey struct{}
ctx = context.WithValue(ctx, ctxKey{}, "req-123")
id, _ := ctx.Value(ctxKey{}).(string)

Propagate ctx down the call tree; never store it in a struct field (except short-lived request objects). Always defer cancel().

Worker pool

Bounded concurrency over a stream of jobs.

func workerPool(jobs []int, workers int) []int {
    in := make(chan int)
    out := make(chan int)
    var wg sync.WaitGroup

    for w := 0; w < workers; w++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := range in {          // each worker pulls until `in` closed
                out <- j * j
            }
        }()
    }

    go func() {                          // feed jobs
        for _, j := range jobs { in <- j }
        close(in)                        // signal workers: no more jobs
    }()

    go func() { wg.Wait(); close(out) }() // close out after all workers done

    var results []int
    for r := range out { results = append(results, r) }
    return results
}

Fan-out / fan-in

// Fan-out: multiple goroutines read from one channel (the pool above).
// 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(c <-chan int) {
            defer wg.Done()
            for v := range c { out <- v }
        }(c)
    }
    go func() { wg.Wait(); close(out) }()
    return out
}

errgroup

golang.org/x/sync/errgroup: run goroutines, collect the first error, cancel the rest.

import "golang.org/x/sync/errgroup"

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10)                          // optional: cap concurrency

for _, url := range urls {
    url := url
    g.Go(func() error {
        return fetch(ctx, url)          // ctx is cancelled when any returns error
    })
}
if err := g.Wait(); err != nil {        // first non-nil error
    return err
}

Race detector & rules

go test -race ./...
go run -race main.go
go build -race

Core rules:

  • Don't communicate by sharing memory; share memory by communicating (use channels) — or guard shared memory with a mutex/atomic.
  • Any concurrent read+write of the same variable without synchronization is a data race (undefined behavior).
  • The race detector finds races at runtime only on code paths actually exercised; add tests.
  • WaitGroup.Add must happen before the corresponding go; Done in a defer.