DEV Community

Mohsen
Mohsen

Posted on AI-assisted

What I learned from reading Go's chan.go

Before reading chan.go, my mental model of a channel was one sentence: a safe way for goroutines to send values to each other. That sentence was true, but it was not enough. I could not answer simple follow-up questions: where does a blocked goroutine actually wait? Is an unbuffered channel secretly a buffer of size one? Why does select pick a random case?

So I spent a week reading src/runtime/chan.go (and later select.go) from Go 1.26. This post is what I found, written as the questions I had.

1. What is a channel, really?

Every make(chan T, n) gives you a pointer to an hchan. Here it is, lightly trimmed:

type hchan struct {
    qcount   uint           // values currently in the buffer
    dataqsiz uint           // buffer capacity (the n in make)
    buf      unsafe.Pointer // the ring buffer itself
    elemsize uint16
    closed   uint32
    elemtype *_type
    sendx    uint  // next slot to write
    recvx    uint  // next slot to read
    recvq    waitq // goroutines blocked on receive
    sendq    waitq // goroutines blocked on send
    lock     mutex
}
Enter fullscreen mode Exit fullscreen mode

Three groups of fields: a ring buffer (buf, sendx, recvx, qcount), two wait queues (recvq, sendq), and one lock that protects all of it.

The lock is runtime.mutex, not sync.Mutex. At first I thought that was just "the runtime likes its own tools". The real reason is circularity: sync.Mutex parks goroutines through the scheduler, and the scheduler is exactly the code that uses hchan. The runtime cannot build its foundation on top of something that is built on top of it.

2. Is an unbuffered channel a buffer of size one?

No. When dataqsiz == 0, buf holds nothing. A send and a receive have to meet.

If a receiver is already waiting, the sender copies its value directly into the receiver's variable, which lives on the receiver's stack. The source has a comment about this that surprised me:

Sends and receives on unbuffered or empty-buffered channels are the only operations where one running goroutine writes to the stack of another running goroutine.

That is why an unbuffered channel is a synchronization point and not just a pipe: there is no place for the value to sit, so both sides must be there.

3. How many ways can a send end?

Reading chansend top to bottom, I counted these paths (for a normal, blocking send):

  1. nil channel: the goroutine parks forever. There is no one to wake it.
  2. closed channel: panic("send on closed channel"). It does not return quietly.
  3. a receiver is waiting in recvq: dequeue it and hand the value over directly, even if the channel is buffered. The buffer is skipped.
  4. buffer has space: copy into buf[sendx], advance sendx, done.
  5. otherwise: wrap this goroutine in a sudog, put it on sendq, and gopark.

Path 3 was the most surprising one for me. If a receiver is already waiting, the value never touches the buffer. The source even writes this as an invariant: in a buffered channel, qcount > 0 implies recvq is empty. You never have data in the buffer and a waiting receiver at the same time.

4. Full buffer and a waiting sender: who gets what?

Say the buffer is full and a sender is parked on sendq. Now a receiver arrives. The naive idea is "give the receiver the sender's value". That would break FIFO order, because older values are still in the buffer.

What recv actually does:

// Queue is full. Take the item at the head of the queue.
// Make the sender enqueue its item at the tail of the queue.
// Since the queue is full, those are both the same slot.
qp := chanbuf(c, c.recvx)
typedmemmove(c.elemtype, ep, qp)                // oldest value -> receiver
typedmemmove(c.elemtype, qp, sg.elem.get())     // sender's value -> freed slot
c.recvx++
if c.recvx == c.dataqsiz {
    c.recvx = 0
}
c.sendx = c.recvx
Enter fullscreen mode Exit fullscreen mode

The receiver takes the oldest value, and the waiting sender's value drops into the slot that just became free. Because the buffer is full, the head and the tail are the same slot. One read, one write, order preserved. I think this is the nicest piece of code in the file.

5. What is a sudog, and why not just queue the goroutine?

A sudog is "a goroutine waiting on one thing". It holds a pointer to the goroutine (g), a pointer to the value being sent or received (elem), and links for the wait queue.

Why a separate struct instead of putting the goroutine itself in recvq? Because one goroutine can wait on many channels at once. That is exactly what select does. Each case gets its own sudog, all pointing to the same goroutine. When one of them fires, the runtime has to remove the others. So the relation is one goroutine to many sudogs, and the queue holds sudogs.

6. How does select use all of this?

select.go builds on the same pieces. selectgo works in three passes:

  1. Poll: look at every case in a random order (pollorder). If one is ready, do it and return. Random order is for fairness: a busy channel written first in your code should not starve the others.
  2. Enqueue and park: nothing ready and no default? Put a sudog on the queue of every channel in the select, then gopark once.
  3. Wake up and clean up: whoever woke us set gp.param to the winning sudog. Dequeue all the other sudogs from their channels.

Before touching the channels, select locks them all. The lock order is not the order in your code. It is sorted by the address of each hchan (lockorder). If two goroutines run selects over the same channels in opposite order, a code-order lock could deadlock. A single global order (addresses) makes that impossible.

One practical trick falls out of this: a nil channel case is never ready. The runtime just leaves it out. So inside a loop you can set ch = nil to turn a case off without restructuring the select.

Honest note: I read selectgo carefully, wrote notes, and five days later I could only explain half of it (I scored myself 5/10). Things I only read faded. Things I built stayed. That is the reason for the next section.

7. I built one myself

To check whether I understood hchan, I wrote a bounded channel with a sync.Mutex and two sync.Conds (notFull, notEmpty).

My first version stored items in a slice and received with buf = buf[1:]. It worked in small tests. The problem: re-slicing moves the start forward, and the space at the beginning is never reused. After enough sends and receives, the buffer looked full even when it had free space. The fix was the same idea as hchan: a fixed array with a read index and a write index that wrap around.

func (c *BoundedChannelTest[T]) Send(v T) {
    c.mu.Lock()
    defer c.mu.Unlock()

    if c.closed {
        panic("send on closed channel")
    }
    for c.count == len(c.buf) {
        c.notFull.Wait() // releases mu while waiting
        if c.closed {
            panic("send on closed channel")
        }
    }

    c.buf[c.sendx] = v
    c.sendx = (c.sendx + 1) % len(c.buf)
    c.count++
    c.notEmpty.Signal()
}
Enter fullscreen mode Exit fullscreen mode

I even named the indexes sendx and recvx, like hchan.

A week later I closed it and rewrote it from memory, without notes or AI. It passed with go test -race (5 producers, 5 consumers, 1000 items each). That rebuild is what convinced me I understood it.

What my version does not have, compared to the real one: direct handoff to a waiting receiver (path 3), sudogs, or any way to take part in a select. sync.Cond wakes someone up. hchan knows exactly who is waiting and gives them the value.

8. What I take away

A Go channel is not just a queue. It is a ring buffer, two wait queues, and a lock, plus a scheduler that can park and wake goroutines and copy values straight between their stacks.

After chan.go, the rest of the sync package read differently. sync.Once is a mutex plus an atomic flag, and the detail I missed when I rebuilt it was that the standard library marks it done with a defer, so even a panicking f counts as "done". sync.Pool mostly avoids locks by giving each P (the scheduler's processor) its own local pool. Same questions every time: where does the data live, who waits, and who wakes them?

If you are learning Go concurrency, my advice is: read chan.go once, then close it and build a small version yourself. The reading gives you the words. The building makes them stay.

Source: Go 1.26, src/runtime/chan.go and src/runtime/select.go. Exercises: github.com/mohsenm4/go-fundamentals.

Top comments (0)