Open to Work: Senior Backend & Distributed Systems Engineer (Go / Python) View LinkedIn →

How Go Channels Actually Work

Go channels look simple, but they rely on heap-allocated ring buffers and runtime scheduler parks. Learn how the hchan structure, stack-to-stack copies, and lock ordering work to prevent leaks and panics.

WORDS: 3058 | CODE BLOCKS: 13 | EXT. LINKS: 1
// TL;DR
Go channels are heap-allocated ring buffers managed by a runtime mutex and parked goroutine queues. Unbuffered channels bypass memory buffers via direct stack-to-stack copies, while select blocks sort channel locks by memory address and randomize evaluation to prevent deadlocks and starvation.

A single unbuffered channel send without an active receiver is enough to silently lock up a production worker pool, leaking memory until your service gets killed by the OS kernel.

On the surface, Go channels look simple: they synchronize concurrent execution without manual mutex management. But under heavy load, a minor oversight in channel sizing or worker coordination blocks the scheduler, leaks memory, or crashes processes with runtime panics.

The Go scheduler coordinating 10,000 goroutines on a single-core CPU

Writing concurrent systems that stay stable under production load requires understanding the underlying machinery: how channels sit in memory, how the scheduler coordinates parked goroutines, and how to structure production pipelines.


Why Channels Exist

Go’s concurrency model comes from Tony Hoare’s 1978 paper, Communicating Sequential Processes (CSP).

In traditional multi-threaded environments, threads communicate by sharing memory. To prevent race conditions, you lock that memory using mutexes. This approach has well-known failure modes: it is easy to introduce deadlocks, lock contention degrades throughput, and tracking ownership of shared data structures becomes difficult as codebases grow.

CSP flips this pattern. Concurrent processes communicate by passing data through explicit, synchronized input and output ports:

Do not communicate by sharing memory; instead, share memory by communicating.

Channels are first-class values that connect independent execution units. They decouple the sender from the receiver. The sender does not need to know which goroutine receives the data, and the receiver does not need to know who sent it. The channel manages both the data transfer and the synchronization.


Inside the hchan Struct

In Go, a channel is a heap-allocated structure called hchan, defined in src/runtime/chan.go.

When you call ch := make(chan int, 10), the compiler allocates an hchan struct on the heap and returns a pointer to it. Channels are passed by reference because passing a channel copies the pointer to this underlying struct.

Glossary // Stack vs Heap Allocation

When Go runs your code, it places variables in either stack or heap memory.

  • The Stack: Fast, per-goroutine memory allocated in stack frames. When a function returns, its frame is reclaimed immediately. It only holds data whose lifetime is strictly bound to that function call.
  • The Heap: Global storage for variables that outlive the function that created them. Heap allocations require the runtime to locate free memory and later track and clean it up via the Garbage Collector (GC).

Because channels are shared across independent goroutines with different lifecycles, Go allocates the hchan struct on the heap.

The hchan memory layout in src/runtime/chan.go:

go
 1type hchan struct {
 2	qcount   uint           // Total data currently in the queue
 3	dataqsiz uint           // Size of the circular queue buffer
 4	buf      unsafe.Pointer // Points to an array of dataqsiz elements (nil if unbuffered)
 5	elemsize uint16
 6	closed   uint32
 7	elemtype *_type         // Element type descriptor for GC and runtime checks
 8	sendx    uint           // Buffer index where the next send will write
 9	recvx    uint           // Buffer index where the next receive will read
10	recvq    waitq          // Linked list of blocked receivers (waiting goroutines)
11	sendq    waitq          // Linked list of blocked senders (waiting goroutines)
12
13	lock mutex              // Protects all fields in hchan and blocked sudogs
14}

The Ring Buffer

For buffered channels, buf points to a contiguous block of memory allocated for the queue, managed as a circular ring buffer:

  • dataqsiz is the total capacity passed to make.
  • qcount is the number of active items in the buffer.
  • sendx is the array index for the next write.
  • recvx is the array index for the next read.

When sendx or recvx reaches dataqsiz, it wraps back to index 0.

The Wait Queues

When a goroutine sends to a full channel or receives from an empty channel, it cannot proceed. The runtime parks the goroutine and registers it in sendq or recvq.

These queues are doubly-linked lists of sudog structures. A sudog represents a parked goroutine:

  • g: Pointer to the parked goroutine.
  • elem: Pointer to the memory address for data transfer.
  • next and prev: Pointers for wait queue linking.

The Channel Lock

Every channel operation (sending, receiving, closing, querying length) acquires hchan.lock first. This is a low-level runtime spinlock.

Channels are not lock-free data structures. The runtime uses standard locking internally, but keeps critical sections short and optimized to maintain low contention.

Glossary // What is a sudog?

The Go scheduler represents every goroutine as a g struct. But a single goroutine can wait on multiple channels simultaneously in a select statement.

Because a g struct cannot be linked into multiple wait queues at the same time, the runtime creates a sudog as an intermediary ticket. If a goroutine blocks on a select with three channels, the runtime creates three sudog structs, each pointing back to the same g, and enqueues one into each channel’s wait list.

Glossary // How gopark and goready coordinate sleep

When a goroutine blocks on a channel, it cannot spin in a busy loop without wasting CPU cycles. The runtime calls gopark() to detach the goroutine from its operating system thread (M) and changes its state from running to waiting.

When another goroutine completes the transfer, it finds the waiting sudog in the queue and calls goready(). This moves the parked goroutine back to runnable and pushes it to the scheduler’s run queue.

Glossary // What is a spinlock?

A spinlock repeatedly checks a lock status in a tight loop instead of putting the operating system thread to sleep.

Go uses a spinlock for hchan.lock because channel queue updates take only a few nanoseconds. Spinning for a fraction of a microsecond avoids the expensive context switch of sleeping and waking an OS thread.

Memory layout of a buffered channel (dataqsiz: 8, qcount: 3) with active wait queues:

graph TD subgraph hchan ["hchan Struct in Heap"] hchan_lock["lock (mutex)"] qcount["qcount: 3"] dataqsiz["dataqsiz: 8"] sendx["sendx: 5"] recvx["recvx: 2"] subgraph RingBuffer ["Circular Buffer (buf)"] buf0["[0] Data"] buf1["[1] Data"] buf2["[2] Data (recvx)"] buf3["[3] Data"] buf4["[4] Data"] buf5["[5] Empty (sendx)"] buf6["[6] Empty"] buf7["[7] Empty"] end subgraph wait_queues ["Wait Queues"] recvq["recvq (waitq)"] --> recv_head["sudog (G1)"] sendq["sendq (waitq)"] --> send_head["sudog (G2)"] --> send_next["sudog (G3)"] end end G1["Goroutine 1 (Blocked Receiver)"] G2["Goroutine 2 (Blocked Sender)"] G3["Goroutine 3 (Blocked Sender)"] recv_head -.->|points to| G1 send_head -.->|points to| G2 send_next -.->|points to| G3

Channel Types and Memory Behavior

Go provides unbuffered and buffered channels. Both share the hchan layout, but their scheduling and memory mechanics differ.

Unbuffered Channels

An unbuffered channel has zero buffer capacity (dataqsiz == 0 and buf == nil).

go
1ch := make(chan int)

A send cannot complete until a receive starts, and vice versa. The two goroutines synchronize at a shared rendezvous point.

Unbuffered Send Execution Flow:

  1. Goroutine A attempts to send x to ch.
  2. Goroutine A acquires ch.lock.
  3. It inspects recvq.
  4. If a receiver (Goroutine B) is waiting in recvq:
    • The runtime executes a direct stack-to-stack copy: it copies x from Goroutine A’s stack directly to the memory address stored in Goroutine B’s sudog.elem.
    • This direct copy bypasses intermediate buffer allocations entirely.
    • Goroutine A releases ch.lock.
    • The runtime calls goready(gB) to transition Goroutine B back to a runnable state on the scheduler’s run queue.
  5. If no receiver is waiting:
    • Goroutine A allocates a sudog on its stack.
    • It writes the address of x into sudog.elem.
    • It pushes the sudog into ch.sendq.
    • It releases ch.lock.
    • It calls gopark(), which instructs the Go scheduler to detach Goroutine A from its OS thread (M) and put it into a waiting state. The OS thread is then free to run other runnable goroutines.
sequenceDiagram autonumber actor G_Sender as Sender Goroutine (G1) participant Runtime as Go Runtime Scheduler participant Chan as Channel (hchan) actor G_Recv as Receiver Goroutine (G2) Note over G_Sender, G_Recv: Scenario: Sender arrives first on unbuffered channel G_Sender->>Chan: Send value (ch <- val) Chan->>Chan: Lock channel Chan->>Chan: Check recvq (empty) Note over G_Sender: Create sudog on stack,
set elem = &val G_Sender->>Chan: Enqueue sudog to sendq Chan->>Chan: Unlock channel G_Sender->>Runtime: gopark() (Transition G1 to waiting) Runtime->>Runtime: Run other goroutines on OS thread Note over G_Recv: Receiver arrives later G_Recv->>Chan: Receive value (<-ch) Chan->>Chan: Lock channel Chan->>Chan: Check sendq (finds G1 sudog) Note over Chan: Direct Memory Copy:
G1 Stack (&val) to G2 Stack Chan->>G_Sender: Dequeue sudog Chan->>Chan: Unlock channel G_Recv->>G_Recv: Proceed with copied value Chan->>Runtime: goready(G1) (Transition G1 to runnable)

Buffered Channels

A buffered channel maintains an in-memory queue (dataqsiz > 0 and buf != nil).

go
1ch := make(chan int, 3)

Senders proceed as long as empty slots remain. Receivers proceed as long as data remains in the buffer.

Buffered Send Execution Flow:

  1. Goroutine A acquires ch.lock.
  2. It checks buffer capacity (qcount < dataqsiz).
  3. If buffer space is available:
    • It copies x to buf[sendx].
    • It updates sendx = (sendx + 1) % dataqsiz.
    • It increments qcount.
    • It releases ch.lock and returns.
  4. If the buffer is full:
    • It allocates a sudog with elem = &x.
    • It appends the sudog to ch.sendq.
    • It releases ch.lock.
    • It calls gopark(), which detaches Goroutine A from its OS thread (M) and puts it into a waiting state until buffer space becomes available.

Buffered Receive Execution Flow:

  1. Goroutine B acquires ch.lock.
  2. It checks buffer occupancy (qcount > 0).
  3. If data is in the buffer:
    • Case A: sendq is empty.
      • Copies value from buf[recvx] to destination.
      • Clears memory at buf[recvx] for GC safety.
      • Updates recvx = (recvx + 1) % dataqsiz.
      • Decrements qcount.
      • Releases ch.lock.
    • Case B: A sender (Goroutine A) is blocked in sendq.
      • The buffer is full. To maintain FIFO order, Goroutine B copies the item at buf[recvx] to its destination.
      • It copies Goroutine A’s value from sudog.elem directly into buf[recvx].
      • Advances recvx and sendx.
      • Pops Goroutine A from sendq.
      • Releases ch.lock and calls goready(gA) to mark Goroutine A runnable, moving it back to the scheduler’s run queue.

Directional Channels

Directional constraints are compile-time type restrictions that do not alter runtime memory layout:

go
1func worker(jobs <-chan int, results chan<- int) {
2    for job := range jobs {
3        results <- job * 2
4    }
5}
  • <-chan int: Receive-only. The compiler forbids sends and close().
  • chan<- int: Send-only. The compiler forbids receives.

Directional types enforce ownership boundaries and prevent workers from closing shared input channels.


Select Statement Mechanics

The select statement multiplexes multiple channel operations. The runtime implements this in src/runtime/select.go.

go
1select {
2case val := <-ch1:
3    fmt.Println("Received from ch1:", val)
4case ch2 <- 42:
5    fmt.Println("Sent to ch2")
6default:
7    fmt.Println("No channel was ready")
8}

Lock Ordering

Evaluating a select requires locking all participating channels. To prevent deadlocks when concurrent goroutines execute select blocks with overlapping channels in different orders, the runtime sorts all channels by their memory addresses:

  1. Creates an array of scase records.
  2. Sorts the array by hchan pointer addresses.
  3. Acquires locks sequentially in sorted address order.

Sorting locks by address eliminates lock-inversion deadlocks.

Randomized Case Evaluation

If multiple cases are ready simultaneously, scanning top-to-bottom would starve lower cases.

To ensure fairness, the runtime generates a pseudo-random permutation of case indices and evaluates them in that randomized order. The first channel found ready executes its transfer, unlocks all channels, and returns.

Multiplexed Parking

When no channel is ready and there is no default case:

  1. The runtime allocates a sudog for each case.
  2. It registers the goroutine in the wait queue (sendq or recvq) of every channel in the select.
  3. It calls gopark() to detach the goroutine from its OS thread and park it across all selected channels.
  4. When any channel wakes the goroutine via goready(), it locks all channels in address order, removes its sudog from the remaining wait queues, unlocks all channels, and executes the selected case block.

Production Concurrency Patterns

Worker Pool with Graceful Shutdown

Worker pools require explicit lifecycle handling to prevent goroutines from hanging when job dispatch finishes:

go
 1package pool
 2
 3import (
 4	"context"
 5	"sync"
 6)
 7
 8type Job struct {
 9	ID    int
10	Data  string
11	Error error
12}
13
14type Result struct {
15	JobID  int
16	Output string
17	Err    error
18}
19
20func Worker(ctx context.Context, id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
21	defer wg.Done()
22	for {
23		select {
24		case <-ctx.Done():
25			return
26		case job, ok := <-jobs:
27			if !ok {
28				return
29			}
30			
31			res, err := processJob(ctx, job)
32			
33			select {
34			case <-ctx.Done():
35				return
36			case results <- Result{JobID: job.ID, Output: res, Err: err}:
37			}
38		}
39	}
40}
41
42func processJob(ctx context.Context, job Job) (string, error) {
43	return "done: " + job.Data, nil
44}

Fan-In Multiplexing

Merging multiple inbound channels into a single consolidated output stream:

go
 1package fanin
 2
 3import (
 4	"context"
 5	"sync"
 6)
 7
 8func Merge[T any](ctx context.Context, channels ...<-chan T) <-chan T {
 9	out := make(chan T)
10	var wg sync.WaitGroup
11
12	multiplex := func(c <-chan T) {
13		defer wg.Done()
14		for {
15			select {
16			case <-ctx.Done():
17				return
18			case val, ok := <-c:
19				if !ok {
20					return
21				}
22				select {
23				case <-ctx.Done():
24					return
25				case out <- val:
26				}
27			}
28		}
29	}
30
31	wg.Add(len(channels))
32	for _, c := range channels {
33		go multiplex(c)
34	}
35
36	go func() {
37		wg.Wait()
38		close(out)
39	}()
40
41	return out
42}

Token Bucket Rate Limiting

A buffered channel pre-filled with empty structs controls throughput by tying execution to token availability:

go
 1package rate
 2
 3import (
 4	"context"
 5	"time"
 6)
 7
 8type Limiter struct {
 9	tokens chan struct{}
10	ticker *time.Ticker
11}
12
13func NewLimiter(rate int, burst int) *Limiter {
14	l := &Limiter{
15		tokens: make(chan struct{}, burst),
16		ticker: time.NewTicker(time.Second / time.Duration(rate)),
17	}
18
19	for i := 0; i < burst; i++ {
20		l.tokens <- struct{}{}
21	}
22
23	go func() {
24		for range l.ticker.C {
25			select {
26			case l.tokens <- struct{}{}:
27			default:
28			}
29		}
30	}()
31
32	return l
33}
34
35func (l *Limiter) Wait(ctx context.Context) error {
36	select {
37	case <-ctx.Done():
38		return ctx.Err()
39	case <-l.tokens:
40		return nil
41	}
42}
43
44func (l *Limiter) Stop() {
45	l.ticker.Stop()
46}

Channel State Matrix

Runtime behavior for every channel state and operation:

Channel State Send (ch <- val) Receive (<-ch) Close (close(ch))
nil Blocks forever (goroutine leak) Blocks forever (goroutine leak) Panic (panic: close of nil channel)
Open & Empty Writes to buffer or direct copy Blocks until sent or closed Closes channel, wakes all receivers
Open & Full Blocks until slot freed Reads from buffer head Closes channel, wakes all receivers
Closed Panic (panic: send on closed channel) Returns zero value (ok == false) Panic (panic: close of closed channel)

Blocking on Nil Channels

A nil channel is declared without initialization:

go
1var ch chan int // nil

Sends and receives on a nil channel bypass the hchan lock and call gopark() directly, putting the calling goroutine into a permanent waiting state with no wake-up signal.

While setting a channel variable to nil is a valid technique to disable a branch in a dynamic select loop, doing so unintentionally permanently parks the calling goroutine.

Sending to Closed Channels

Sending to a closed channel panics immediately:

go
1ch := make(chan int)
2close(ch)
3ch <- 42 // panic: send on closed channel

The sender that produces data must own and close the channel. Receivers should never close channels. When multiple senders exist, coordinate closure using sync.WaitGroup or a dedicated coordinator goroutine.

Leaking Goroutines on Abandoned Channels

When a producer goroutine sends on an unbuffered channel, but the receiver abandons the read due to a timeout or early exit, the producer remains blocked forever:

go
 1func QueryDatabase() string {
 2    ch := make(chan string) // Unbuffered
 3    
 4    go func() {
 5        res := runHeavyQuery()
 6        ch <- res // Leaks if QueryDatabase exits early
 7    }()
 8    
 9    select {
10    case res := <-ch:
11        return res
12    case <-time.After(100 * time.Millisecond):
13        return "timeout"
14    }
15}

Remedies:

  1. Use a buffered channel of size 1 (make(chan string, 1)) so the producer can write and exit without waiting.
  2. Bind the worker to a context.Context cancellation check.

Frequently Asked Questions

Why does receiving from a closed channel return data, but sending panics?

This behavior supports pipeline drain semantics. When an upstream stage closes its output channel, downstream consumers must read all buffered items before terminating.

Once the buffer is drained, val, ok := <-ch returns the zero value with ok == false, signaling EOF. Permitting sends on closed channels would invalidate this termination guarantee by injecting data after the close signal.

Can unclosed channels be garbage collected?

Yes. Channels do not require explicit closing to be garbage collected.

If a channel is unreachable and no goroutines are parked in sendq or recvq, the GC reclaims the hchan memory. However, if goroutines remain blocked on the channel, they retain references to the hchan struct, preventing garbage collection and leaking memory.

How does the runtime enforce channel type safety?

The compiler stores a *_type descriptor in hchan.elemtype and the byte size in hchan.elemsize.

During memory transfers (stack-to-stack copies or ring buffer updates), the runtime copies elemsize bytes directly. The garbage collector reads elemtype to identify and trace heap pointers stored within the buffer.

Why is there no IsClosed(ch) function?

An IsClosed(ch) check creates a Time-of-Check to Time-of-Use (TOCTOU) race condition: a channel checked as open could be closed by another goroutine an instant before the subsequent send executes, triggering a panic. Concurrency designs must manage channel closure through single-sender ownership or synchronization primitives like sync.Once.

What is the performance overhead of channels versus mutexes?

sync.Mutex operations execute in user space using atomic CPU instructions when uncontended.

Channels involve acquiring an internal spinlock, copying memory payloads, and interacting with the scheduler (gopark/goready) when blocking. Use mutexes for protecting internal state within a single struct. Use channels for orchestrating concurrent data flow and process lifecycles across independent components.


  • The Go Programming Language by Alan A. A. Donovan and Brian W. Kernighan: Chapter 8 covers goroutines and channel synchronization.
  • Concurrency in Go by Katherine Cox-Buday: Chapters 3 and 4 detail concurrency building blocks and pipeline patterns.
  • Scheduling In Go by William Kennedy: Deep-dive series on the Go scheduler, OS thread transitions, and context switching.
  • Go Concurrency Patterns by Rob Pike: The foundational Google I/O talk on channel composition and multiplexing.
  • Go Runtime Source Code (src/runtime/chan.go): The authoritative implementation of hchan, chansend, and chanrecv.
  • Google I/O 2012 - Go Concurrency Patterns: https://youtu.be/f6kdp27TYZsc