Go Internals - Concurrency
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:
- If the channel is closed → panic (
send on closed channel). - If there is a waiting receiver on
recvq→ direct send: copy the element into the receiver'ssudog.elem, dequeue that sudog, unlock,goreadythe receiver. No buffer involved. - Else if the buffer has space → copy into
buf[sendx], advance indices, unlock, return. - 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:
- Poll — lock every involved channel in a stable order (by
hchanaddress) 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). - Enqueue — if nothing is ready and there is no
default, park sudogs on every case's wait queue, thengopark. - Dequeue losers — when one case wins the wake-up race (
g.selectDoneCAS for select waiters), remove thisGfrom 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.