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.
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:
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:
dataqsizis the total capacity passed tomake.qcountis the number of active items in the buffer.sendxis the array index for the next write.recvxis 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.nextandprev: 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:
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).
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:
- Goroutine A attempts to send
xtoch. - Goroutine A acquires
ch.lock. - It inspects
recvq. - If a receiver (Goroutine B) is waiting in
recvq:- The runtime executes a direct stack-to-stack copy: it copies
xfrom Goroutine A’s stack directly to the memory address stored in Goroutine B’ssudog.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.
- The runtime executes a direct stack-to-stack copy: it copies
- If no receiver is waiting:
- Goroutine A allocates a
sudogon its stack. - It writes the address of
xintosudog.elem. - It pushes the
sudogintoch.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.
- Goroutine A allocates a
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).
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:
- Goroutine A acquires
ch.lock. - It checks buffer capacity (
qcount < dataqsiz). - If buffer space is available:
- It copies
xtobuf[sendx]. - It updates
sendx = (sendx + 1) % dataqsiz. - It increments
qcount. - It releases
ch.lockand returns.
- It copies
- If the buffer is full:
- It allocates a
sudogwithelem = &x. - It appends the
sudogtoch.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.
- It allocates a
Buffered Receive Execution Flow:
- Goroutine B acquires
ch.lock. - It checks buffer occupancy (
qcount > 0). - If data is in the buffer:
- Case A:
sendqis 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.
- Copies value from
- 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.elemdirectly intobuf[recvx]. - Advances
recvxandsendx. - Pops Goroutine A from
sendq. - Releases
ch.lockand callsgoready(gA)to mark Goroutine A runnable, moving it back to the scheduler’s run queue.
- The buffer is full. To maintain FIFO order, Goroutine B copies the item at
- Case A:
Directional Channels
Directional constraints are compile-time type restrictions that do not alter runtime memory layout:
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 andclose().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.
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:
- Creates an array of
scaserecords. - Sorts the array by
hchanpointer addresses. - 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:
- The runtime allocates a
sudogfor each case. - It registers the goroutine in the wait queue (
sendqorrecvq) of every channel in theselect. - It calls
gopark()to detach the goroutine from its OS thread and park it across all selected channels. - When any channel wakes the goroutine via
goready(), it locks all channels in address order, removes itssudogfrom 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:
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:
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:
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:
1var ch chan int // nilSends 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:
1ch := make(chan int)
2close(ch)
3ch <- 42 // panic: send on closed channelThe 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:
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:
- Use a buffered channel of size 1 (
make(chan string, 1)) so the producer can write and exit without waiting. - Bind the worker to a
context.Contextcancellation 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.
Recommended Reading
- 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 ofhchan,chansend, andchanrecv. - Google I/O 2012 - Go Concurrency Patterns: https://youtu.be/f6kdp27TYZsc