Polyglot
Languages Go concurrency
Go § concurrency

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, the select blocks until a case becomes ready.

The conventional uses:

  • Multi-channel coordination — wait on the first of several events.
  • Timeouts — combine with time.After or time.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 ctx as the first parameter of long-running functions.
  • Always call cancel() — typically with defer cancel().
  • Check ctx.Done() in long-running operations.
  • Use context.WithValue sparingly — 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.Context as the first parameter of long-running functions.
  • Always defer cancel() after WithTimeout or WithCancel.
  • Use sync.Mutex for mutual exclusion; sync.RWMutex for read-heavy.
  • Use sync.WaitGroup for goroutine coordination.
  • Use sync.Once for “exactly once” initialisation.
  • Use atomics only for simple counters and flags.
  • Use select with timeouts and cancellation — the conventional pattern.
  • Run tests with -race in 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.