How Go Channels Work Internally (hchan, sudog)

Go channels use an hchan struct with a circular buffer and sudog wait queues to synchronize goroutines safely.

The pipe under the hood

You write a web handler that spawns a goroutine to fetch user data from a database. The main handler waits for the result, then writes a JSON response. You pass the result through a channel. It feels like a magical pipe that synchronizes two independent threads of execution. The magic is just memory layout, a mutex, and a parking lot for goroutines.

What a channel actually is

A channel is a synchronized queue built on top of a circular buffer. Think of a relay race baton pass. The runner holding the baton cannot move forward until the next runner reaches out and takes it. The baton is your data. The handoff is the synchronization point. The race official standing between them is the runtime. The official checks if the next runner is ready, hands over the baton, and logs the exchange. If the next runner is late, the official makes the first runner wait in a designated spot until the handoff can happen.

Channels enforce a strict contract. A send blocks until a receive is ready. A receive blocks until a send is ready. Buffered channels relax this rule slightly by allowing a few batons to sit in a rack before the sender has to wait. The runtime handles the waiting, the waking, and the memory copying. You just write ch <- value and value := <-ch.

The hchan structure

The compiler translates every channel declaration into a pointer to a hchan struct. The name stands for hashed channel, a historical artifact from early Go versions. The struct lives on the heap and coordinates everything.

// src/runtime/chan.go
type hchan struct {
    qcount   uint           // tracks how many elements sit in the buffer
    dataqsiz uint           // defines the capacity of the circular buffer
    buf      unsafe.Pointer // points to the preallocated array for buffered channels
    recvq    waitq          // holds goroutines waiting to receive
    sendq    waitq          // holds goroutines waiting to send
    lock     mutex          // serializes all access to this struct
}

type waitq struct {
    first *sudog
    last  *sudog
}

The buf field points to a contiguous block of memory sized to hold dataqsiz elements. Unbuffered channels set dataqsiz to zero and leave buf as nil. The recvq and sendq fields are linked lists of sudog objects. A sudog is a runtime wrapper around a parked goroutine. It stores the goroutine's stack pointer, the memory address where it expects to receive data, and a pointer back to the channel that parked it.

The lock field is a fast mutex. Every send and receive operation grabs this lock before inspecting or modifying the buffer or wait queues. The runtime uses a spinlock for the first few attempts, then falls back to a sleeping lock if contention is high. This keeps the critical section tiny and predictable.

Channels are just structs with a mutex and two queues. Treat them as such.

How the runtime moves data

When you execute ch <- value, the runtime follows a strict sequence. It acquires the hchan.lock. It checks qcount against dataqsiz. If the buffer has space, the runtime copies value into the next slot in the circular buffer, advances the send index, increments qcount, and releases the lock. The operation returns immediately.

If the buffer is full, the runtime cannot copy the data yet. It allocates a sudog, records the memory address of value, and links the sudog into the sendq. The runtime then parks the current goroutine. Parking means the scheduler removes the goroutine from the run queue and marks it as waiting on this channel. The lock releases. The goroutine sleeps until someone else wakes it.

The receive side mirrors this flow. A receiver grabs the lock, checks qcount, and looks at the sendq. If a sender is already waiting in the sendq, the runtime copies the data directly from the sender's memory address to the receiver's memory address. This is the zero-copy fast path. The runtime wakes the sender, removes the sudog from the queue, and returns. If no sender is waiting, the receiver checks the buffer. If the buffer has data, it copies from the buffer, advances the receive index, decrements qcount, and returns. If the buffer is empty and no sender is waiting, the receiver creates a sudog, links it into the recvq, parks itself, and releases the lock.

The circular buffer uses two indices: sendx and recvx. They wrap around using modulo arithmetic when they hit dataqsiz. The runtime never shifts elements in memory. It just moves the indices forward. This keeps buffer operations constant time regardless of capacity.

The runtime parks goroutines. It does not spin. Trust the scheduler.

A realistic pipeline

Production code rarely uses a single channel in isolation. You usually chain them into a pipeline where one goroutine feeds another. Context cancellation is the standard way to shut down the chain cleanly.

// Process reads integers from in, doubles them, and sends them to out.
// It respects ctx cancellation to avoid leaking goroutines.
func Process(ctx context.Context, in <-chan int, out chan<- int) {
    defer close(out) // signals downstream that no more data is coming
    for {
        select {
        case <-ctx.Done():
            return // exits immediately when the parent cancels
        case val, ok := <-in:
            if !ok {
                return // upstream closed the channel, pipeline ends
            }
            out <- val * 2 // blocks until the next stage is ready
        }
    }
}

The select statement multiplexes the channel operations. It compiles down to a loop that checks each case, parks the goroutine on the first matching channel, and resumes when that channel becomes ready. The ok idiom handles closed channels gracefully. A receive from a closed channel returns the zero value and sets ok to false. The loop exits cleanly instead of panicking.

The defer close(out) call ensures the downstream consumer sees the end of the stream. Closing a channel broadcasts to all waiting receivers. The runtime walks the recvq, wakes every parked goroutine, and delivers the zero value. You never close a channel from the receiving side. The sender owns the lifecycle.

Context is plumbing. Run it through every long-lived call site.

When the runtime stops you

Channels are safe by default, but misuse triggers immediate failures. The runtime enforces the rules at execution time.

Sending on a closed channel triggers a panic. The runtime checks the closed flag before acquiring the lock. If the flag is set, it aborts with panic: send on closed channel. The program crashes unless you recover in a deferred function. This is intentional. Sending after close is a logical error that usually means your pipeline ordering is broken.

Receiving from a closed channel does not panic. It returns the zero value. If you forget to check the ok flag, your loop processes garbage data or runs forever. The compiler cannot catch this. You must write the check explicitly.

Deadlocks happen when every goroutine is waiting on a channel that no one else will ever send to. The scheduler detects this state when it runs out of runnable goroutines. It aborts with fatal error: all goroutines are asleep - deadlock!. This usually means you created a circular dependency between channels, or you forgot to spawn the consumer before the producer starts sending.

Goroutine leaks are the silent killer. A goroutine parked on a channel will wait forever if the channel is never closed and no one sends the expected signal. The runtime keeps the goroutine's stack and sudog in memory. Over time, the process exhausts its memory limit. Always provide a cancellation path. Use context.Context or a dedicated done channel to unblock waiting goroutines when the work is finished.

The worst goroutine bug is the one that never logs. Design your exit paths first.

Choosing your synchronization tool

Go gives you several ways to coordinate goroutines. Pick the right one based on the data flow and the concurrency pattern.

Use a channel when you need to pass ownership of data between goroutines and want the runtime to handle the synchronization automatically. Use a buffered channel when you want to decouple producer and consumer speeds temporarily without blocking. Use a mutex when multiple goroutines need to read and write the same shared variable in place without transferring ownership. Use an atomic value when you only need to update a single integer, pointer, or boolean and want lock-free performance. Use a direct function return when the operation completes synchronously and you don't need background execution. Use a worker pool when you need to bound the number of concurrent tasks to protect a downstream service from overload.

Channels move data. Mutexes protect state. Pick the tool that matches your data flow.

Where to go next