Back to home

Go Internals - Concurrency

10 min read
Cover Image for Go Internals - Concurrency
Lucas LemosLucas Lemos

Introduction

In Go Internals - Scheduler we saw that blocking on a channel or a sync.Mutex parks a G instead of pinning an OS thread. Reflection closed the type-metadata arc. This article is the sync layer those parks actually land on: channels, select, and the semaphore underneath sync.

You already know how to use ch <- x and mu.Lock(). Here the question is what runtime.chansend, selectgo, and semacquire do with the goroutine when the operation cannot complete immediately — and what wakes it when it can.

Channels are hchan

make(chan T, n) ends in runtime.makechan. The concrete object is hchan in chan.go:

// runtime/chan.go — simplified
type hchan struct {
  qcount   uint           // elements currently in buffer
  dataqsiz uint           // buffer capacity
  buf      unsafe.Pointer // circular buffer of dataqsiz elems
  elemsize uint16
  closed   uint32
  elemtype *_type
  sendx    uint           // send index into buf
  recvx    uint           // recv index into buf
  recvq    waitq          // waiting receivers
  sendq    waitq          // waiting senders
  lock     mutex
}

Unbuffered channels set dataqsiz == 0 and leave buf unused for payload. Buffered channels allocate a contiguous ring of n elements next to (or reachable from) the hchan. Element size and type come from the compiler's channel type descriptor — the same metadata story as slices and maps, just pointed at by elemtype.

Two wait queues sit on the channel: recvq and sendq. Each entry is a sudog — a waiter binding a G, optional element pointer, and links for the queue. The file header in chan.go states the core invariants plainly: at least one of sendq / recvq is empty except for a narrow select case, and for buffered channels a non-empty buffer means no waiting receivers, while free buffer slots mean no waiting senders.

flowchart LR
  subgraph hchan["hchan"]
    BUF["buf ring"]
    RQ["recvq"]
    SQ["sendq"]
  end
  S["sender G"] -->|chansend| hchan
  R["receiver G"] -->|chanrecv| hchan
  RQ -.->|"sudog"| R2["parked receiver"]
  SQ -.->|"sudog"| S2["parked sender"]

hchan.lock protects the fields and the sudogs parked on that channel. The comment next to the lock matters in practice: do not change another G's status while holding it (in particular, do not goready under the lock). Stack shrinking races with channel waiters; the runtime is careful about unlock-then-ready ordering for that reason.

Send and receive paths

ch <- v lowers to chansend. <-ch lowers to chanrecv. Both take a block flag so non-blocking selects and select with default can fail without parking.

Rough order inside a blocking send once the channel is locked:

  1. If the channel is closed → panic (send on closed channel).
  2. If there is a waiting receiver on recvqdirect send: copy the element into the receiver's sudog.elem, dequeue that sudog, unlock, goready the receiver. No buffer involved.
  3. Else if the buffer has space → copy into buf[sendx], advance indices, unlock, return.
  4. Else → enqueue a sudog on sendq, gopark (releasing the channel lock in the park commit), and wait to be readied by a future receive or close.

Receive mirrors that: waiting sender → direct recv; else take from buffer; else park on recvq.

func ping() {
  ch := make(chan int) // unbuffered
  go func() {
    ch <- 1 // parks until someone receives
  }()
  v := <-ch // direct handoff from the parked sender
  _ = v
}

Unbuffered send/recv is therefore a rendezvous: one side copies into the other's stack (or heap slot) and both proceed. Buffered ops are a queue until the ring fills or empties, after which they fall back to the same park/ready machinery.

Closing (close(ch)closechan) sets closed, wakes every waiter on both queues, and makes further receives return the zero value with ok == false. Sends after close panic. Closing an already-closed channel also panics. Those are language rules enforced in the runtime, not conventions.

Nil channels are a special case worth remembering when reading stack traces: send or receive on a nil channel parks forever (waitReasonChanSendNilChan / ChanReceiveNilChan). select on only nil channels does the same. That is intentional — it is how you disable a case by setting the channel variable to nil.

select and selectgo

A multi-way select does not try cases left-to-right in source order. The compiler emits a call to runtime.selectgo with an array of scase (channel + element pointer) plus scratch for lock/poll order.

// runtime/select.go — simplified
type scase struct {
  c    *hchan
  elem unsafe.Pointer
}

selectgo roughly runs three passes:

  1. Poll — lock every involved channel in a stable order (by hchan address) so two selects cannot deadlock each other. Shuffle case order randomly, then look for a case that can proceed immediately (waiting peer, buffer space/data, or closed channel for recv).
  2. Enqueue — if nothing is ready and there is no default, park sudogs on every case's wait queue, then gopark.
  3. Dequeue losers — when one case wins the wake-up race (g.selectDone CAS for select waiters), remove this G from the other channels' queues so it is not woken twice.
sequenceDiagram
  participant G as selecting G
  participant SG as selectgo
  participant C1 as hchan A
  participant C2 as hchan B
  G->>SG: select { case <-A / case B<- }
  SG->>C1: lock (address order)
  SG->>C2: lock
  SG->>SG: shuffle + poll ready cases
  alt a case is ready
    SG->>G: return chosen index
  else block
    SG->>C1: enqueue sudog
    SG->>C2: enqueue sudog
    SG->>G: gopark
    Note over G: peer op or close readies one sudog
    SG->>C1: dequeue losers
    SG->>C2: dequeue losers
  end

Fairness comes from the shuffle: repeated selects do not permanently prefer the first case in the source. Lock ordering by channel address is what keeps nested/competing selects from lock-order deadlocks. Single-case select and select with only default are optimized by the compiler into simpler shapes before you ever hit the full selectgo path.

sudog: the shared waiter

Channels and semaphores both block through sudog (runtime2.go). Think of it as a queue node the runtime can park and ready without tying an M to the wait:

// runtime/runtime2.go — simplified
type sudog struct {
  g *g
  next, prev *sudog
  elem maybeTraceablePtr // channel payload may point into stack
  isSelect bool
  success  bool // channel: delivered vs woken by close
  // ... semaRoot tree links, timing, ticket ...
}

For channels, elem may point into the waiting goroutine's stack. That is why stack copying must cooperate with channel locks (activeStackChans, parkingOnChan in the select/park commit path). For semaphores, sudogs hang off a semaRoot treap keyed by wait address instead of an hchan waitq.

When a peer completes the operation, the runtime calls goready on the sudog's g. The scheduler article's run queues take over from there — concurrency primitives only produce runnable Gs; they do not schedule them onto a P themselves.

sync.Mutex and the semaphore

sync.Mutex in Go 1.26 is a thin wrapper around internal/sync.Mutex: an int32 state word plus a uint32 semaphore word.

// internal/sync/mutex.go — simplified
type Mutex struct {
  state int32
  sema  uint32
}

const (
  mutexLocked = 1 << iota
  mutexWoken
  mutexStarving
  mutexWaiterShift = iota
)

Uncontended Lock is a single CAS of state from 0 to mutexLocked. Contended path (lockSlow) may spin briefly while the holder is running, then increment the waiter count and call runtime_SemacquireMutex on sema. Unlock clears the locked bit and, if waiters exist, Semreleases so one parked goroutine can run.

Two modes live in the comments in that file:

  • Normal — FIFO waiters, but a woken waiter still races with new arrivals that are already on-CPU. Good throughput; can starve a waiter under continuous contention.
  • Starvation — after ~1ms of waiting, a waiter flips mutexStarving. Unlock hands ownership directly to the head of the queue; newcomers queue instead of barging. Tail latency stops being pathological; throughput drops.
var mu sync.Mutex
var count int

func inc() {
  mu.Lock()
  count++
  mu.Unlock()
}

There is no per-mutex OS futex object owned by your process in the Linux sense as the primary API. Contended waits go through runtime/sema.go: semacquire / semrelease hash the semaphore address into a semTable of semaRoots, each holding a treap of sudogs. Sleep and wakeup are paired even if the wakeup races ahead of the sleep — the Plan 9 semaphore model the file cites — so the G parks in the runtime and the P runs someone else, exactly like a channel wait.

RWMutex, WaitGroup, and Once all bottom out on the same semaphore primitive (plus atomics). Once is the textbook pattern: atomic fast path on done, mutex only for the first execution so late callers wait until f finishes rather than racing past an incomplete init.

WaitGroup packs a counter and waiter count into an atomic uint64, with a sema for blocked Waiters. Go 1.26 also documents WaitGroup.Go as the preferred way to spawn counted tasks; Add/Done remain for older call sites. Mis-ordered Add after the counter hit zero while Wait is outstanding is still a panic-shaped footgun — the runtime checks, it does not guess your intent.

Channels vs mutexes

Neither is "more Go." They solve different shapes of coordination:

Situation Usually fits
Handing ownership of a value between goroutines channel (especially unbuffered rendezvous)
Protecting an in-place data structure Mutex / RWMutex
Fan-in / fan-out / cancellation pipelines channels (+ context)
Short critical sections on shared state mutex
Waiting for a set of tasks to finish WaitGroup (or an error-group wrapper)

A channel of size 1 used as a lock works but costs an hchan, sudogs, and copies you did not need. A mutex used to implement a work queue works but pushes queue bookkeeping into your code. Profiles decide: channel-heavy designs show up in chansend/chanrecv/selectgo; lock-heavy designs show up in mutex block profiles (go test -mutexprofile or runtime.SetMutexProfileFraction).

One shared property: both park Gs. Neither is an excuse to ignore the memory model — a mutex unlock "synchronizes before" the next lock; a channel send synchronizes before the corresponding receive. Crossing those edges without a happens-before edge is still a data race, even if the program "usually" looks fine.

Conclusion

Concurrency in the Go runtime is mostly queues of sudogs plus readying Gs. Channels attach those queues to an hchan (optional ring buffer, direct handoff when a peer waits). select locks channels in address order, shuffles cases, and parks on every case until one wins. Mutexes and friends CAS a state word and, under contention, sleep on the runtime semaphore table that parks the same way channels do.

Next — and last — in the series is Compiler: SSA, inlining, bounds-check elimination, and what the toolchain emits so this runtime machinery has stack maps, type descriptors, and call sites to work with.