Language and Runtime Concurrency Models: Go

Go was designed around concurrency: starting a goroutine costs a few kilobytes, channels are built into the language, and the runtime spreads goroutines over all cores.

Learning Objectives

Use goroutines; buffered and unbuffered channels; select; sync.Mutex/RWMutex/WaitGroup/Once; context.Context cancellation and deadlines.

Build worker pools and pipelines; use the race detector; find goroutine leaks; understand the scheduler and GOMAXPROCS; apply the Go memory model; shut a server down gracefully.

Plain-Language Explanation

Go was designed around concurrency: starting a goroutine costs a few kilobytes, channels are built into the language, and the runtime spreads goroutines over all cores. Its motto — *"Do not communicate by sharing memory; instead, share memory by communicating"* — pushes you toward message passing, while still offering mutexes when shared state is simpler.

Precise Technical Explanation

go f(x) starts a stackful lightweight thread (Module 12); arguments are evaluated immediately; panics in a goroutine crash the whole program unless recovered in that goroutine.

Typed conduits; send/receive synchronise; closing signals completion (Module 8).

Buffered and unbuffered channels Unbuffered: rendezvous. Buffered: queue of fixed capacity.

Waits on several channel operations; with default it becomes non-blocking; a nil channel case is never ready (useful to disable a case).

sync.Mutex and sync.RWMutex Zero value is an unlocked mutex; not re-entrant; never copy a mutex after use (go vet checks).

Add before starting goroutines, Done (deferred) in each, Wait to join.

Run initialisation exactly once (sync.OnceFunc/OnceValue since Go 1.21).

context.Context cancellation and deadlines Module 14: WithCancel, WithTimeout, WithDeadline, WithCancelCause (1.20), AfterFunc (1.21); pass as the first parameter; check ctx.Done().

N goroutines reading a jobs channel and writing a results channel; bounded by N.

Module 8: stages connected by channels, closed by their senders, with context for early exit.

go test -race, go run -race (Module 3) — needs cgo; finds data races that actually occur during the run.

Goroutines blocked forever (on a channel nobody reads/writes, a lock, a select without ctx.Done()); detect by runtime.NumGoroutine(), pprof goroutine profiles, go.uber.org/goleak in tests (shown below without external packages).

Scheduler and GOMAXPROCS concepts The runtime's M:N scheduler: G (goroutines), M (OS threads), P (processors, count = GOMAXPROCS, default = logical CPUs). Each P has a local run queue; idle Ps steal work; blocking syscalls hand the P to another M; asynchronous preemption (1.14+) stops long-running goroutines.

Memory model and safe communication The Go memory model (revised in Go 1.19) defines happens-before via channel operations, mutexes, sync primitives and sync/atomic (sequentially consistent). Racy programs have limited but unpredictable behavior; maps are not safe for concurrent writes (the runtime may crash with "concurrent map writes").

Graceful server shutdown http.Server.Shutdown(ctx) stops accepting new connections, waits for active requests to finish (until the context's deadline), then returns (demonstrated below).

Key Terms

Go's lightweight, runtime-scheduled thread Wait on multiple channel operations Counter to wait for goroutines

Cancellation, deadline and request-scoped values Goroutine / OS thread / processor in the scheduler Goroutine blocked forever

Syntax and API Overview

The specification's goroutine and channel example (Go 1.23.4):

The goroutine starts; its send on the unbuffered channel blocks until main receives; the receive waits until a value is available — so the print always shows the worker's message, and the send happens-before the receive completes.

package main

import "fmt"

func main() {
    messages := make(chan string)

    go func() {
        messages <- "hello from worker"
    }()

    fmt.Println(<-messages)
}
hello from worker

Complete Code Example

A worker pool with context cancellation, sync.Once, RWMutex, a goroutine leak and its fix, and graceful HTTP shutdown (Go 1.23.4):

package main

import (
	"context"

	"fmt"
	"io"
	"net"
	"net/http"
	"runtime"
	"sort"
	"sync"
	"time"
)

func workerPool(ctx context.Context, jobs []int, workers int) []string {
	in := make(chan int)
	out := make(chan string)
	var wg sync.WaitGroup
	for w := 1; w <= workers; w++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for j := range in {
				select {
				case out <- fmt.Sprintf("job %d by worker %d", j, id):
				case <-ctx.Done():
					return
				}
			}
		}(w)
	}
	go func() { // feed jobs, then close
		defer close(in)
		for _, j := range jobs {
			select {
			case in <- j:
			case <-ctx.Done():
				return
			}
		}
	}()
	go func() { wg.Wait(); close(out) }()
	var res []string
	for r := range out {
		res = append(res, r)
	}
	return res
}

type Config struct {
	mu   sync.RWMutex
	vals map[string]string
}

func (c *Config) Get(k string) string    { c.mu.RLock(); defer c.mu.RUnlock(); return c.vals[k] }
func (c *Config) Set(k, v string)        { c.mu.Lock(); defer c.mu.Unlock(); c.vals[k] = v }

func leaky() {
	ch := make(chan int)
	go func() { ch <- 42 }() // nobody ever receives: this goroutine blocks forever
}

func fixed(ctx context.Context) {
	ch := make(chan int, 1
…
worker pool processed 6 jobs; e.g. job 1 by worker 1
sync.Once from 10 goroutines ran 1 time(s)
RWMutex-protected config: fast
goroutines leaked by leaky(): 100
goroutines leaked by fixed(): 0
Shutdown returned after ~250 ms (err=<nil>); in-flight request got "finished"
new request after shutdown refused: true
GOMAXPROCS: 8

Line-by-Line Explanation

The pool: a feeder goroutine sends jobs and closes in; three workers range over in; a closer goroutine closes out after wg.Wait(); every blocking operation also watches ctx.Done().

once.Do runs exactly once across 10 goroutines.

RWMutex: readers share; Set is exclusive.

leaky leaves a goroutine blocked on an unbuffered send forever — 100 calls leak 100 goroutines; fixed uses a 1-slot buffer so the sender never blocks.

Shutdown waits for the 200 ms in-flight request (which completes with "finished") and then the listener refuses new connections.

Output

Shown above; timings rounded from one real run.

Interleaving or State Trace

Graceful shutdown: t = 0 request arrives, handler sleeping; t = 50 ms Shutdown closes the listener and waits; t ≈ 200 ms handler writes "finished" and the connection becomes idle; Shutdown notices on its next poll of idle connections (it polls with growing intervals) and returns — in this run about 250 ms after it was called.

Correctness and Safety Analysis

Every channel has a single closer (its sender side); every goroutine has an exit path on ctx.Done(); shared maps are guarded by a mutex; results cross goroutines only through channels or after wg.Wait().

Common Mistakes and Misconceptions

Closing a channel from the receiver or twice; sending on a closed channel (panic).

Concurrent map writes without a mutex (sync.Map for specific patterns).

Goroutines without an exit path (leaks).

wg.Add inside the goroutine (race with Wait).

Copying a struct that contains a mutex.

Failure Modes and Debugging Steps

go run -race; GODEBUG=schedtrace=1000; pprof goroutine and mutex/block profiles; send SIGQUIT (Unix) to dump all goroutines; go vet for copied locks.

Performance and Scalability Implications

Goroutines and channels are cheap but not free (channel operations cost ~50–100 ns plus scheduling); for hot shared counters use sync/atomic; GOMAXPROCS in containers should match the CPU quota (automatic since Go 1.25; earlier use automaxprocs).

Security Considerations

Every incoming request should carry a context with a deadline; unbounded goroutine creation per request is a DoS risk; http.Server timeouts (ReadHeaderTimeout etc.) protect against slow clients.

Platform and Runtime Caveats

The race detector needs cgo and a supported OS/arch (this course cross-built it for linux/amd64 because the Windows build could not allocate its shadow memory with the available toolchain).

Go 1.22 changed loop variables to per-iteration; Go 1.19 formalised the memory model.

Real-World Applications

Kubernetes, Docker, etcd, CockroachDB, Prometheus — all heavily concurrent Go codebases built on goroutines, channels, contexts and mutexes.

Best Practices

Pass context.Context everywhere; select on ctx.Done().

The sender closes; one closer per channel.

Every goroutine needs an owner and an exit path.

Use mutexes for shared state that is simpler as state; channels for hand-offs and pipelines.

Run tests with -race.

Exercises

E1 · Beginner. Why does fmt.Println(<-messages) never print an empty string in the specification example?

Answer: The receive blocks until the goroutine sends; an unbuffered channel delivers exactly the sent value.

E2 · Intermediate. Explain why leaky leaks and give two fixes.

Answer: The goroutine's send on an unbuffered channel blocks forever because nobody receives. Fixes: make the channel buffered (capacity 1) so the send completes, or have the caller always receive (or select on a cancellation channel in the goroutine).

E3 · Advanced challenge. Make the worker pool stop early when any job fails, returning the first error.

Answer: Create ctx, cancel := context.WithCancel(parent); workers send an error on an errs channel (buffered 1, select with default) and call cancel(); feeder and workers already exit on ctx.Done(); after wg.Wait(), return the error if any — the errgroup pattern.

Quiz

? What happens on a send to a closed Go channel?
+ A panic :: Only senders should close, exactly once.
- The value is dropped silently :: It panics.
- The send blocks forever :: It panics immediately.
- The channel reopens :: Channels cannot be reopened.

? What does http.Server.Shutdown do?
+ Stops accepting new connections and waits for active requests until the context expires. :: Graceful.
- Kills all handler goroutines immediately. :: That would be Close (closes connections).
- Waits forever regardless of the context. :: The context bounds the wait.
- Restarts the server. :: It stops it.

? Which cause goroutine leaks? (select all that apply)
+ A goroutine blocked on a channel send that nobody receives :: Blocked forever.
+ A loop reading from a channel that is never closed, with no ctx.Done() case :: Waits forever.
- A goroutine that returns after its work :: It ends.
+ A goroutine waiting on a mutex that is never released :: Blocked forever.

Summary

Go makes concurrency cheap and idiomatic: goroutines, channels and select for communication; sync primitives and atomics for shared state; context for cancellation and deadlines; the race detector, goroutine profiles and go vet for verification; Server.Shutdown for graceful exits. Every goroutine needs an owner and a way out.

Further Reading

The Go Memory Model (go.dev/ref/mem); "Effective Go: Concurrency".

Go blog: "Pipelines and cancellation", "Context", "Introducing the Go Race Detector".

Katherine Cox-Buday, *Concurrency in Go*.

Dmitry Vyukov, "Scalable Go Scheduler Design Doc".