Reading goal: do not begin by asking which tool is faster. If data must be handed to another goroutine, think of a channel first. If several pieces of code must update one shared state, think of a lock first. The second half explains the source paths for blocking, wake-up, and memory ordering.

At the language level, begin with the synchronization guarantees in the Go Memory Model. Source details are pinned to a fixed Go development snapshot: runtime/chan.go, runtime/select.go, internal/sync/mutex.go, and runtime/sema.go. This is a fixed snapshot of the official master development branch, not a released stable version. Queue shapes, state bits, the 1 ms threshold, and direct handoff describe that gc runtime; application code must not treat them as fixed constants.

1. Start with Two Different Concurrency Problems

In fetchd, a worker finishes fetching a page and hands one fetchResult to the aggregator. This is a transfer of work: the sender gives away a value and the receiver continues processing it, so a channel is natural. A different requirement asks every worker to increment one shared success count. Every worker updates the same memory, so a Mutex around the read-modify-write operation is usually more direct.

Ask who may mutate the data before and after a send, who should wait when the system is busy, who signals completion, and how receivers know production has ended. Those questions define the concurrency protocol; channel and lock syntax merely implement it.

Problem shapePreferMakes explicitCommon failure
Transfer work or a result to another goroutinechannelSender and receiver roles, queueing, capacity, closureBlocked send, leak, duplicate close
Choose one of several events and support cancellationselect + channel/contextReady set and exit pathNever-ready case, incorrect default
Maintain one map/counter/cache in a short critical sectionsync.MutexWho may access shared stateLong hold time, lock order, copied Mutex
Lock-free read or one atomic state variablesync/atomicAtomic operations on one locationSplitting a multi-field invariant

The table expands those questions into four common shapes. A channel contains a lock internally; a contended Mutex can park a goroutine. The distinction is not “lock-free versus blocking,” but the protocol exposed to the application. Building a command channel solely to protect a map also creates a dedicated goroutine, request/reply messages, and shutdown rules. Rebuilding a work queue behind a Mutex hides when producers wait, how much may queue, and who processes dequeued items in fields and conditions.

2. hchan Holds Both a Buffer and Waiters

One fetchd result handoff:
the handler creates a result channel with capacity N
  → each worker owns its local fetchResult
  → send copies directly to a waiting receiver when possible
  → otherwise it copies into a free buffer slot
  → only when neither path works does the sender wait
  → after N receives, the handler owns the batch it encodes

make(chan fetchResult, n) eventually creates a runtime.hchan. It is more than “a queue”: qcount/dataqsiz describe the ring buffer, sendx/recvx are write/read positions, sendq/recvq hold blocked senders and receivers, closed records closure, and lock protects those fields and related sudogs.

Read those fields in four groups: how many buffer slots are occupied; where the next read and write occur; who is waiting without a match; and whether the channel is closed plus what protects that state. “The worker stops mutating after send” is an application rule, not a restriction imposed by the channel. A slice, map, or pointer field still copies only its descriptor and may share underlying data.

type hchan struct {
    qcount   uint
    dataqsiz uint
    buf      unsafe.Pointer
    elemsize uint16
    closed   uint32
    elemtype *_type
    sendx    uint
    recvx    uint
    recvq    waitq
    sendq    waitq
    lock     mutex
}
Field excerpt (timer and test-bubble fields omitted): hchan and waitq.

2.1 makechan Allocation Depends on Element Pointers

When elements contain no GC pointers, hchan and its buffer can share one allocation. Pointer-bearing elements use a header plus a separately typed buffer; an unbuffered channel or zero-sized element only needs the header allocation. This optimizes layout and scanning without changing send/receive semantics. Once constructed, dataqsiz is immutable during channel operations.

2.2 The Four Paths Through chansend

Compiled c <- x enters chansend1, which calls chansend with block=true. A nil channel parks forever. A non-nil channel, under its lock, checks closure, a waiting receiver, and buffer space before it queues the sender.

A sorting machine for blocking sends on a non-nil channel: panic if closed, copy directly to a waiting receiver and make it ready, copy into available buffer space, or enqueue a sudog in sendq and park. A nil channel waits forever, and woken tasks still need scheduling
lock(&c.lock)
if c.closed != 0 {
    unlock(&c.lock)
    panic(plainError("send on closed channel"))
}
if sg := c.recvq.dequeue(); sg != nil {
    send(c, sg, ep, func() { unlock(&c.lock) }, 3)
    return true
}
if c.qcount < c.dataqsiz {
    qp := chanbuf(c, c.sendx)
    typedmemmove(c.elemtype, qp, ep)
    c.sendx = (c.sendx + 1) % c.dataqsiz
    c.qcount++
    unlock(&c.lock)
    return true
}
Pinned source: runtime.chansend.

A waiting receiver bypasses the buffer

Even on a buffered channel, a non-empty recvq lets send use sendDirect to copy the element into the receiver's destination, then unlock and goready that G. The buffer is queueing space for unmatched values, not a mandatory mailbox slot for every value.

A send copies the element, not its reachable object graph

The buffered path's typedmemmove(c.elemtype, qp, ep) and the direct path both copy the channel element. A value-only fetchResult gets independent fields. If an element contains a slice, map, or pointer, the descriptor or pointer is copied while the underlying object may remain shared—the practical result of Chapter II's value semantics during a send.

2.3 How a Full Channel Turns a Sender into a Waiting G

With no receiver and no buffer slot, chansend acquires a sudog, associates the element address, current G, and channel, enqueues it in sendq, then calls gopark(chanparkcommit, &c.lock, waitReasonChanSend, ...). The park commit releases the channel lock at a safe state transition, allowing the M/P to execute another G.

gp := getg()
mysg := acquireSudog()
mysg.elem.set(ep)
mysg.g = gp
mysg.c.set(c)
gp.waiting = mysg
c.sendq.enqueue(mysg)
gp.parkingOnChan.Store(true)
gopark(chanparkcommit, unsafe.Pointer(&c.lock),
    waitReasonChanSend, traceBlockChanSend, 2)
Blocked send: sudog, sendq, and gopark; wait queue: waitq enqueue/dequeue.

A sudog is one record of a G waiting on one synchronization object, not the goroutine itself. One G executes one instruction stream, yet a select may give it one sudog per channel. Wake-up only changes waiting back to runnable; as Chapter III showed, scheduling still lies between ready and resumed.

2.4 chanrecv Is Symmetric, with a Full-Buffer Exchange

Under the lock, receive handles closed+empty and then checks sendq. With a waiting sender, an unbuffered channel copies directly from the sender stack. A full buffered channel takes the old value at recvx, writes the blocked sender's value into that same slot, advances the ring, and wakes the sender. The queue remains full, but its oldest element was delivered and the blocked send completed.

// Buffered channel is full; qp is both queue head and next tail.
qp := chanbuf(c, c.recvx)
typedmemmove(c.elemtype, ep, qp)            // queue → receiver
typedmemmove(c.elemtype, qp, sg.elem.get()) // sender → queue
c.recvx++
c.sendx = c.recvx
goready(sg.g, skip+1)
Pinned source: chanrecv and recv.

Capacity decides when senders wait; it is not a casual speed knob

fetchd uses make(chan fetchResult, len(targets)). Every worker can deposit one result even if the handler has not started collecting, preventing an abandoned receive path from stranding workers after request cancellation. The tradeoff is up to one batch of queued results when aggregation slows. Capacity zero enforces rendezvous; a small capacity returns pressure early; a larger one absorbs a burst while increasing memory, queue age, and discard cost.

2.5 close Broadcasts a State Change; It Does Not Free the Channel

closechan sets closed, dequeues all receivers so they wake with a zero value and ok=false, then dequeues all senders so they wake and panic. The runtime drops the channel lock before calling goready, avoiding another G's state transition while holding that lock. Buffered values remain receivable; only closed+empty produces the zero value.

The code that knows every producer has finished should usually close the channel; receivers should not guess whether producers remain. With multiple senders, have one coordinator wait for a WaitGroup to confirm that every send has finished, then close. sync.Once only prevents duplicate close; it cannot stop a still-running sender from sending on the closed channel. Recover is not coordination either.

3. selectgo Is a Three-Pass Protocol

select {
case results <- result:
case <-ctx.Done():
    return
}

A multi-case select does not start multiple goroutines or execute every ready case. The runtime generates a randomized pollorder, then a stable lockorder sorted by hchan address so every select locks its channels in a consistent order.

For this application code, the runtime answers only three questions: can either case proceed now; if neither can, how can one worker register both waits; and after one case wins, how are the losing wait tickets removed? The three passes below implement those three answers.

The selectgo workshop checks readiness in randomized order and returns directly for a ready case or an available default; only when neither exists does it register multiple sudog tickets and park. After waking and being scheduled again, it removes the other wait tickets

Pass 1: poll for an already-ready case

A receive case checks for a waiting sender, buffered data, and closure; a send case checks closure, a waiting receiver, and buffer space. The first match executes. Randomized poll order reduces source-order bias, but it is not cross-goroutine FIFO, a fairness SLA, or priority. If result delivery and ctx.Done() are both ready, the send can still win; moving the cancellation case first does not give it priority. A non-blocking select with default unlocks and chooses default when no case is ready.

Passes 2/3: one G joins several queues, then removes losers

With nothing ready, the runtime acquires one sudog per non-nil channel, links them into gp.waiting in lock order, enqueues each on sendq or recvq, and calls gopark. When one channel wins the wake race, selectDone prevents another case from waking the same G again. After resuming, pass three relocks, removes the losing sudogs from their queues, releases every record, and returns the winner index.

// pass 1: poll ready cases in permuted order
for _, casei := range pollorder { /* recv/send/buffer/closed */ }

// pass 2: enqueue one sudog for every participating channel
sg := acquireSudog()
c := cas.c
sg.g = gp
sg.isSelect = true
sg.c.set(c)
c.recvq.enqueue(sg) // or sendq
gopark(selparkcommit, nil, waitReasonSelect, traceBlockSelect, 1)

// pass 3: remove every unsuccessful sudog
c.recvq.dequeueSudoG(sglist) // or sendq
releaseSudog(sglist)
Pinned source: preparation and pass 1, passes 2/3.

4. The sync.Mutex Fast Path Is One CAS

mu.Lock()
counts["completed"]++
mu.Unlock()

The public sync.Mutex delegates to internal/sync.Mutex, whose core is state int32 plus sema uint32. Uncontended Lock uses CAS to change state from zero to mutexLocked and returns, so short low-contention critical sections never enter a runtime wait table.

These three lines promise that only one worker at a time performs the read-increment-write operation. CAS, state, and the semaphore are simply the uncontended and contended implementation paths that preserve that promise.

A Mutex workshop: successful CAS enters the critical section; the slow path may spin when allowed and registers a wait if the lock remains unavailable. Normal mode wakes a competitor; starvation mode attempts ticket handoff and calls goyield only when permitted. The 1ms threshold is a heuristic, not a wait-time limit
func (m *Mutex) Lock() {
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        return
    }
    m.lockSlow()
}

func (m *Mutex) Unlock() {
    new := atomic.AddInt32(&m.state, -mutexLocked)
    if new != 0 {
        m.unlockSlow(new)
    }
}
Pinned source: Mutex state and Lock, Unlock.

state encodes locked, woken, starving, and waiter count

The low three bits mean locked, woken, and starving; high bits count waiters. lockSlow may briefly active-spin. If it still cannot acquire, it registers a waiter with CAS and calls runtime_SemacquireMutex(&m.sema, queueLifo, ...). A waiter that has already slept may requeue at the front so running newcomers cannot repeatedly overtake it.

4.1 The Runtime Semaphore Supplies Lossless Sleep/Wakeup

The comment in runtime/sema.go explicitly says not to think of it as an application semaphore. It is a sleep/wakeup mechanism for primitives such as Mutex, pairing every sleep with one wake even when a racing wake happens first. An address hashes into one of 251 semaRoots; sudogs for one address form a queue, while distinct addresses are organized in a treap.

if cansemacquire(addr) { return }
s := acquireSudog()
root := semtable.rootFor(addr)
root.nwait.Add(1)
if cansemacquire(addr) { /* consume wake without sleeping */ }
root.queue(addr, s, lifo)
goparkunlock(&root.lock, reason, traceBlockSync, 4+skipframes)
Pinned source: semaphore design goal, semacquire1.

Normal mode favors throughput; starvation mode bounds pathological tails

Normal mode queues FIFO, but a woken waiter does not own the Mutex and competes with newly arriving Gs already on CPU. After a waiter exceeds the internal starvationThresholdNs = 1e6, it requests starvation mode. Unlock uses Semrelease(handoff=true) to try to transfer a semaphore ticket to the dequeued waiter, then makes it runnable. It calls goyield only if the ticket was acquired, the current M holds no runtime locks, and execution is not on g0. Under those conditions the waiter inherits the P's time slice; wake-up alone does not promise immediate execution.

The Mutex leaves starvation mode when the waiter is last or waited less than the threshold, because direct handoff has lower throughput. That 1 ms value is a verified-snapshot implementation heuristic that may change by release or platform; it does not promise that Lock waits at most 1 ms.

5. Both Establish Happens-Before, but Along Different Edges

SynchronizationMemory-model guaranteeCommon misreading
Channel send → completion of matching receiveSend is synchronized before receive completionIt covers the matched communication, not arbitrary racing shared objects
close(c) → receive returning zero because closedClose is synchronized before that receiveClosure does not mean every sender has stopped
kth receive on capacity C → completion of kth+C sendBuffer capacity can model a counting semaphoreA buffered send completing does not mean a consumer processed it
nth Unlock → later mth Lock returnCritical-section writes are visible to the later holderAn unsuccessful TryLock has no synchronizing effect

Correctness rests on these edges, not on the machine “usually flushing caches in time.” Channels and Mutexes cannot repair concurrent accesses that bypass their protocol. go test -race is dynamic evidence: it finds races reached by that run, while uncovered paths still require design review.

6. Make Backpressure, Cancellation, and Contention Observable

concurrency_lab_test.go uses testing/synctest to create an isolated bubble. After a one-slot channel receives its first value, synctest.Wait confirms that every other G is durably blocked, so the test can assert that the second send has not completed. After one receive and another Wait, the send must have resumed—without Sleep or a fragile “10 ms should be enough” guess.

synctest.Test(t, func(t *testing.T) {
    results := make(chan int, 1)
    secondSendCompleted := false
    go func() {
        results <- 1
        results <- 2 // durably blocked while the buffer is full
        secondSendCompleted = true
    }()

    synctest.Wait()
    if secondSendCompleted { t.Fatal("no backpressure") }
    <-results
    synctest.Wait()
    if !secondSendCompleted { t.Fatal("sender did not resume") }
})

Experiment commands and what each result proves

cd go-runtime/examples/fetchd
go test ./...
go test -race ./...
go test -run TestBufferedChannelAppliesBackpressure
go test -run TestMutexProtectsSharedSummary \
  -blockprofile block.out -mutexprofile mutex.out
go tool pprof -top block.out
go tool pprof -top mutex.out
go test -run '^$' -bench BenchmarkCoordinationContracts -benchmem
EvidenceAnswersCannot answer alone
synctestWho is durably blocked in a controlled event orderContention cost on a real machine
Block profileWhere channel/select blocking accumulatedWho held a critical section
Mutex profileWhich unlock paths caused lock waitUncontended fast-path cost
Execution traceTemporal relation among park, unblock, runnable, and runningLong-term low-overhead trends
Race detectorUnsynchronized accesses reached by this executionThat an uncovered path is race-free
BenchmarkCost of one fixed workload in this environmentWhether channels or Mutexes are “universally better”

7. Engineering Rules for fetchd

  1. Use channels to hand work to the next step. A fetchResult moves one way from worker to aggregator; the worker stops mutating it after send, and send completion means only that the value reached the receiver or buffer. Aggregation, encoding, and response completion still need their own completion signals.
  2. Use Mutex for one short-lived shared state. Keep a counter, map, or cache invariant in one critical section instead of inventing an actor protocol for an increment.
  3. Treat capacity as a budget. Specify permitted items, bytes, and age; a buffer is not an infinite warehouse that repairs a slow consumer.
  4. Every potentially blocking send needs an exit story. The receiver outlives it, the select observes ctx.Done(), or bounded capacity covers every result.
  5. The side that knows all senders have exited closes. Receivers consume closure; they do not guess when the last sender is done.
  6. Do not hold a lock across network I/O, channel send, or unknown callback. Those turn a short critical section into cross-component waiting and magnify lock-order risk.
  7. Find waiting with profiles before reading the matching source. Optimize hold time, unnecessary wakeups, or backpressure—not the primitive by slogan.

The transferable result is: a channel combines value copying, queueing, and wake-up into a communication protocol; a Mutex puts access to one state inside a critical section. Both rely on runtime park/ready. Choose by whether data moves to another goroutine, who may still mutate it, and happens-before—not a performance slogan. The next chapter follows fetchResult's error field to show how a two-word interface produces typed nil, then compares generic dictionaries with reflection cost.

Sources and Documentation