How Channel Internals Work in Go (hchan Struct)

Go channels are implemented via the hchan struct in src/runtime/chan.go, managing buffers, wait queues, and synchronization locks.

The warehouse behind the channel

You are building a worker pool. You create a channel to feed jobs to goroutines. Sometimes you buffer it, sometimes you don't. The code works, but you hit a weird deadlock at scale, or the CPU spikes on a tight loop. You look at the docs and see chan is just a type. You want to know what the runtime is actually doing when you send a value. The answer lives in the hchan struct inside the Go runtime. Understanding this structure explains why buffered channels differ from unbuffered ones, why closing a channel works, and where the lock lives.

Channels are reference types

A channel is a synchronized queue. Under the hood, the runtime uses a struct called hchan. Think of hchan as a warehouse manager. The manager controls a circular buffer (the warehouse shelves), a line of goroutines waiting to send data (trucks arriving with cargo), and a line of goroutines waiting to receive data (trucks waiting to pick up cargo). The manager holds a single lock. Any operation that touches the channel state requires the manager's permission. This lock is why channels are thread-safe.

Channels are reference types. When you assign a channel to a new variable, you copy the reference to the underlying hchan struct. Both variables point to the same buffer, the same queues, and the same lock. This is why closing a channel in one goroutine affects receivers in another. The struct is shared state protected by the mutex. Copying a channel does not create a new queue. It creates another handle to the existing queue.

The hchan struct fields

The hchan struct lives in src/runtime/chan.go. You cannot access it directly from user code, but knowing its fields demystifies channel behavior.

The struct tracks the buffer state with qcount and dataqsiz. dataqsiz is the capacity you pass to make. qcount is the number of elements currently in the buffer. The buf field is an unsafe.Pointer to a raw memory block. The runtime treats this block as a circular array. The elemsize field stores the size of the element type in bytes. The runtime uses elemsize to calculate how many bytes to copy during send and receive operations.

Two indices, sendx and recvx, track positions in the circular buffer. sendx points to the next slot where a value will be written. recvx points to the next slot where a value will be read. When sendx reaches dataqsiz, it wraps back to zero. The runtime handles this modulo arithmetic automatically.

The recvq and sendq fields are linked lists of parked goroutines. When a send blocks because the buffer is full, the runtime adds the goroutine to sendq. When a receive blocks because the buffer is empty, the runtime adds the goroutine to recvq. The lock field is a mutex that protects all these fields. The runtime acquires the lock before inspecting or modifying the channel state.

The closed flag indicates whether the channel has been closed. Once set, no more values can be sent. Receivers can still drain the buffer. The elemtype field holds type information for the element type. The runtime uses this for reflection and type checks.

Minimal example

This program demonstrates how buffer size changes behavior. The code maps directly to hchan state.

package main

import "fmt"

// Main demonstrates channel behavior driven by internal hchan state.
func main() {
	// Unbuffered channel creates an hchan with dataqsiz == 0.
	// Sends block until a receiver is ready because there is no buffer space.
	unbuffered := make(chan int)

	go func() {
		// This goroutine blocks here.
		// The runtime adds it to hchan.sendq and parks it.
		unbuffered <- 42
	}()

	// Receive checks hchan.sendq.
	// It finds the waiting sender, copies the value directly, and wakes the sender.
	// This is a direct handoff. The buffer is bypassed.
	val := <-unbuffered
	fmt.Println("Received:", val)

	// Buffered channel creates an hchan with dataqsiz > 0.
	// The runtime allocates a memory block for the buffer.
	buffered := make(chan int, 1)

	// Send checks hchan.qcount.
	// Since qcount (0) < dataqsiz (1), the runtime writes to the buffer.
	// It increments sendx and qcount, then returns immediately.
	// No goroutine parking occurs.
	buffered <- 99

	fmt.Println("Buffered send succeeded")
}

Channels are synchronized queues, not magic pipes. The buffer size dictates whether work happens immediately or waits.

Walkthrough: send and receive

When you call make(chan int, 5), the runtime allocates an hchan struct on the heap. It sets dataqsiz to 5. It allocates a memory block for the buffer and points buf to it. sendx and recvx start at zero. qcount is zero. The sendq and recvq are empty. The closed flag is zero.

When a goroutine executes ch <- value, the runtime performs a sequence of steps. First, it checks if the channel is nil. Sending on a nil channel blocks indefinitely. The runtime parks the goroutine without acquiring the lock. This is a common bug when you forget to initialize a channel with make. The program deadlocks silently until the runtime kills it.

If the channel is not nil, the runtime acquires the mutex lock. It checks recvq. If a receiver is waiting, the runtime copies the value directly to the receiver's stack and wakes the receiver. This is a direct handoff. The value never touches the buffer. The runtime releases the lock and the send returns.

If no receiver waits, the runtime checks qcount. If qcount < dataqsiz, the runtime writes the value into the buffer at the address calculated from sendx and elemsize. It increments sendx. If sendx equals dataqsiz, it resets sendx to zero. It increments qcount. The runtime releases the lock and the send returns immediately.

If the buffer is full (qcount == dataqsiz), the runtime adds the sender to sendq. It parks the goroutine. The runtime releases the lock. The goroutine sleeps until a receiver wakes it.

Receive follows a symmetric path. The runtime acquires the lock. It checks sendq. If a sender is waiting, it copies the value from the sender and wakes the sender. If no sender waits, it checks qcount. If qcount > 0, it reads from the buffer at recvx, increments recvx, wraps if needed, decrements qcount, and returns the value. If the buffer is empty, it adds the receiver to recvq and parks.

Closing a channel sets the closed flag. The runtime wakes all goroutines in sendq and recvq. Senders panic with send on closed channel. Receivers continue to drain the buffer. Once the buffer is empty, receives return the zero value of the element type.

Realistic example

Worker pools use buffered channels to decouple producers from consumers. The buffer absorbs bursts of work.

package main

import (
	"fmt"
	"sync"
)

// ProcessJobs simulates a worker pool using a buffered channel.
// The buffer allows the producer to dispatch work without blocking.
func ProcessJobs() {
	// Buffer size matches worker count.
	// This allows the producer to fill the buffer and return immediately.
	// hchan.qcount will reach 3, then sends will block.
	jobs := make(chan int, 3)
	var wg sync.WaitGroup

	// Start three workers.
	for w := 1; w <= 3; w++ {
		wg.Add(1)
		go func(workerID int) {
			defer wg.Done()
			// Range loop reads from the channel until it is closed and empty.
			// The runtime handles the comma-ok check internally.
			for j := range jobs {
				fmt.Printf("Worker %d processing job %d\n", workerID, j)
			}
		}(w)
	}

	// Producer sends jobs.
	// The first three sends succeed immediately because the buffer has space.
	// The fourth send blocks because qcount == dataqsiz.
	// It blocks until a worker receives a value and frees a slot.
	for j := 1; j <= 5; j++ {
		jobs <- j
	}

	// Close signals workers to stop.
	// The runtime sets hchan.closed and wakes any blocked receivers.
	close(jobs)
	wg.Wait()
}

Close channels to signal completion, not to panic. Only the sender should close a channel.

Pitfalls and errors

Sending on a closed channel panics. The runtime checks the closed flag before the send. If set, it triggers a panic with send on closed channel. You cannot recover from this in normal flow. It crashes the program unless caught. Closing a channel twice also panics with close of closed channel. Always guard close operations or use sync.Once.

Deadlocks happen when all goroutines block on channel operations and no progress is possible. The runtime detects this and aborts with fatal error: all goroutines are asleep - deadlock!. This often occurs when you send to an unbuffered channel with no receiver, or when a worker pool exits without draining the channel. If a goroutine sends to a channel and no one receives, the sender parks. If the receiver has already exited, the sender parks forever. The runtime eventually kills the program.

Goroutine leaks happen when a goroutine waits on a channel that never closes or receives a value. The hchan keeps the goroutine parked in sendq or recvq forever. The memory grows until the process dies. Always ensure channels close or use context.Context for cancellation. Context is plumbing. Run it through every long-lived call site. If your worker reads from a channel, pass a context so you can cancel the worker if the channel stalls.

Zero-capacity channels behave differently from nil channels. make(chan int) creates an unbuffered channel. Sends block until a receiver is ready. var c chan int creates a nil channel. Sends on a nil channel block indefinitely. The runtime checks for nil before acquiring the lock. If nil, it parks the goroutine. This distinction matters when you use channels in select statements to disable cases.

Trust gofmt. Argue logic, not formatting. The channel code follows standard indentation rules. Most editors run gofmt on save. Don't fight the formatter.

Decision matrix

Use an unbuffered channel when you need strict synchronization between a sender and receiver. The send blocks until the receive happens, guaranteeing the receiver is ready.

Use a buffered channel when the producer and consumer run at different speeds and you want to absorb bursts. The buffer decouples the timing.

Use a buffered channel with size equal to the number of workers when implementing a worker pool. This allows the producer to dispatch work to all workers without blocking.

Use select when a goroutine needs to wait on multiple channel operations. It picks a ready case or blocks until one becomes ready.

Use close on a channel when the sender is done and receivers should stop. Receivers can detect this via range or the comma-ok idiom.

Use a nil channel in a select statement to disable a case dynamically. Setting a channel variable to nil removes it from contention.

Use context.Context when you need to cancel long-running operations. Closing a data channel only signals completion, not cancellation.

The worst goroutine bug is the one that never logs. Always verify channel closure and worker termination.

Where to go next