How Go Channels Work

Goroutine communication, synchronization, and the magic behind Go's concurrency primitives.

Create Unbuffered Channel
make(chan int)
buf: nil
hchan struct
Unbuffered channel created. No internal storage (= synchronous).
Active
Blocked
Idle
1 / 3

Create Unbuffered Channel

Synchronous communication

An unbuffered channel is created with make(chan T). It has no internal storage.

Key Takeaways

Don't Communicate by Sharing

Share memory by communicating. Channels are safer than locks.

Buffer Wisely

Unbuffered for sync, buffered for async. Don't buffer to "fix" deadlocks.

Close from Sender

Only the sender should close. Sending on closed channel panics.

The Engineering of Go Channels: Concurrency through Communication

"Do not communicate by sharing memory; instead, share memory by communicating." This famous proverb by Rob Pike encapsulates the entire design philosophy of Go's concurrency model. Instead of relying on error-prone Mutexes and Condition Variables to protect shared data, Go provides Channels—first-class typed conduits that safely transfer data between Goroutines while simultaneously handling all synchronization.


Part 1: The Underlying hchan Struct

When you write ch := make(chan int), you are not just allocating a memory pipeline. You are instructing the Go runtime to allocate a complex C-style internal structure on the heap called the hchan (header channel).

The hchan struct contains several ultra-critical fields:

  • qcount & dataqsiz: The total number of items currently in the queue, and the total allocated capacity of the circular buffer.
  • buf: A pointer to the physical memory block representing the circular buffer array.
  • sendx & recvx: The array indices tracking where the next send will write to, and where the next receive will read from.
  • sendq & recvq: Doubly-linked lists (queues) of Goroutines that are currently asleep, waiting to either send or receive data.
  • lock: A microscopic, runtime-internal Mutex used exclusively by the Go scheduler to protect the hchan itself while data is being transferred.

Part 2: The Magic of Unbuffered Channels (Rendezvous)

If you create an unbuffered channel (dataqsiz = 0), the channel fundamentally behaves as a synchronous synchronization point—a Rendezvous.

Imagine Goroutine A executes ch <- 42. Because the channel is unbuffered, the physical buffer size is 0. Goroutine A explicitly acquires the hchan.lock and realizes that the recvq (receiver wait queue) is empty.

At this precise nanosecond, the Go runtime intervenes. It creates a sudog (a wrapper struct representing Goroutine A and its data 42), pushes it onto the channel's sendq, releases the hchan.lock, and forcibly puts Goroutine A to sleep (parks it). The OS thread that was running A is instantly assigned a different Goroutine.

Later, Goroutine B executes <-ch. B checks the sendq, finds A sleeping there, and extracts the value 42 directly from A's stack memory into its own stack memory. B then signals the scheduler to wake A up. Both Goroutines proceed simultaneously. No data ever touched the channel's buffer.

Part 3: Buffered Channels and Memory Copies

A buffered channel (make(chan int, 100)) completely alters the performance dynamics. The hchan now manages a physical circular array capable of holding 100 integers.

When Goroutine A executes ch <- 42, it acquires the lock. If qcount < dataqsiz, there is space available. Goroutine A physically copies the value 42 into buf[sendx] without pausing, increments qcount, releases the lock, and continues executing instantly at full CPU speed.

A buffered channel creates highly efficient asynchronous decoupling, right up until the buffer hits 100% capacity. Once full, any sender is immediately parked onto the sendq and goes to sleep, thus implementing native, zero-configuration Backpressure.

The Golden Rule of Closing Channels

Only the Sender should ever close a channel, never the Receiver. Sending on a closed channel causes an immediate, unrecoverable Panic. Closing a channel is purely a broadcast mechanism to declare, "I am definitively done sending." Once closed, Receivers can seamlessly drain any remaining data from the buffer until empty, after which reads will permanently return the data type's zero-value and a `false` boolean flag.

Part 4: The Core Wizardry of select

The select statement allows a single Goroutine to legally multiplex and wait on dozens of channels simultaneously without spinning the CPU.

How does the Go runtime achieve this? In a multi-step routine:

  1. Locking: The runtime acquires the lock on every single channel referenced in the select statement in a strict, globally ordered sequence to absolutely mathematically guarantee no deadlocks occur during the check.
  2. Polling: It inspects every channel to see if one is immediately ready (data in buffer, or a waiting sender/receiver).
  3. Randomization: If more than one channel is ready, a fast pseudo-random number generator selects the winner. This prevents "channel starvation" (where checking sequentially top-to-bottom would cause the first channel to infinitely starve the bottom channels under high load).
  4. Parking Workspace: If zero channels are ready (and there is no default case), the runtime forcibly parks the Goroutine, but wraps it in multiple sudog structs—one for every channel. The Goroutine is appended to every single channel's wait queue simultaneously. The first channel to receive an event wakes up the Goroutine, which then rapidly dequeues itself from all the losing channels.

Conclusion: The Power of Primitives

Before Go, engineers handled concurrent I/O by meticulously cobbling together OS-level thread pools, non-blocking epoll/kqueue sockets, atomic variables, and dense mutex webs. By abstracting the intense lower-level data moving logic into the hchan struct, and tightly integrating channel blocking states directly into the native runtime scheduler, Go channels weaponize developer throughput—allowing us to write sequential, readable code that safely scales to billions of concurrent transactions.

Glossary & Concepts

Goroutine

A lightweight thread managed by the Go runtime. Cheaper than OS threads (~2KB stack).

Channel (chan)

A typed conduit for sending and receiving values between goroutines with synchronization.

Unbuffered Channel

A channel with no internal buffer. Send and receive must synchronize (rendezvous).

Buffered Channel

A channel with internal queue. Send blocks only when full, receive only when empty.

Select Statement

Waits on multiple channel operations. First ready case wins. Random if multiple ready.

Close(ch)

Signals no more values will be sent. Receives drain remaining values, then return zero.