keepcalculator

06 Numbers and Powers

Go Channels Internals: What I Learned from Reading the Runtime

Card No. 06·53638 · Updated · Standard formula, shown below

Reading the Go runtime source code for channels reveals that a channel is not a simple lock-protected queue but a carefully engineered structure built around a mutex, a circular ring buffer, and two doubly-linked lists of blocked goroutines. This article explains how channels work inside the runtime, covering the core data structures, the send and receive paths, and the subtle behaviors that make channels fast and correct.

Background: Why Channels Exist

Channels are Go’s primary mechanism for communication between goroutines. They were designed to avoid the pitfalls of shared-memory synchronization by promoting the mantra “Do not communicate by sharing memory; instead, share memory by communicating.” The runtime implementation has evolved over the years, but the fundamental design has remained stable since Go 1.0. Understanding the internals helps you write more efficient concurrent code and debug subtle deadlocks or performance issues.

The Core Data Structure: hchan

The central type is hchan, defined in runtime/chan.go. It contains:

  • qcount: number of elements currently in the buffer.
  • dataqsiz: total buffer size in elements.
  • buf: pointer to the ring buffer array.
  • elemsize: size of each element in bytes.
  • closed: flag indicating whether the channel is closed.
  • elemtype: type information for the element.
  • sendx: index of the next element to send to.
  • recvx: index of the next element to receive from.
  • recvq: list of goroutines blocked on receiving.
  • sendq: list of goroutines blocked on sending.
  • lock: a mutex protecting all fields.

The buffer is a circular queue. When sendx reaches dataqsiz, it wraps to 0. Similarly, recvx wraps when it reaches the end. This design avoids expensive memory allocation during normal operation.

Send and Receive: The Two Paths

When a goroutine sends a value, the runtime first acquires the channel lock. It then checks if there is a waiting receiver in recvq. If so, it directly transfers the value to that receiver, bypassing the buffer entirely. This is called a direct send. If no receiver is waiting and the buffer has space, the value is copied into the buffer at sendx, and sendx is advanced. If the buffer is full, the sender is added to sendq and the goroutine is put to sleep.

Receiving is symmetric. If sendq has a waiting sender, the receiver takes the value directly from that sender (or from the buffer if the sender was blocked). Otherwise, if the buffer has data, it reads from recvx. If the buffer is empty, the receiver blocks on recvq.

One subtlety: when a receiver is waiting and a sender sends, the runtime copies the value directly from the sender’s stack to the receiver’s stack. This avoids an extra copy through the buffer and reduces latency.

Blocking and Unblocking: The Gopark and Goready Mechanism

When a goroutine blocks on a channel, it calls gopark, which puts the goroutine into a waiting state and releases the CPU. The goroutine is added to the appropriate queue (sendq or recvq). When the channel becomes ready, the runtime calls goready to unblock the goroutine, placing it back into the run queue. This mechanism is efficient because it avoids busy-waiting and uses the scheduler’s existing infrastructure.

Zero-Sized Elements and the Empty Struct

Channels of struct{} (empty struct) are special. Since the element size is 0, the buffer does not allocate any memory. The buf pointer is set to a special placeholder (zerobase). This makes chan struct{} ideal for signaling, as it carries no data but still provides synchronization. The send and receive operations still work correctly, but no data is copied.

Closed Channels: The Poisoned Pill

Closing a channel sets the closed flag and then wakes up all goroutines blocked on recvq. Each woken receiver gets a zero value for the element type. Senders on a closed channel will panic, as will any send to a closed channel. The runtime checks the closed flag before performing a send, and if it is set, it panics with a runtime error. Receiving from a closed channel returns immediately with the zero value, and the second return value (the ok flag) is false. This behavior is crucial for range loops over channels, which terminate when the channel is closed.

Performance Considerations and Common Pitfalls

While channels are efficient, they are not free. Each send or receive involves acquiring a mutex, which can cause contention when many goroutines use the same channel. For high-throughput scenarios, consider using buffered channels to reduce blocking, or use alternatives like sync.Pool or atomic operations for simple counters. Common mistakes include:

  • Closing a channel that is already closed (panic).
  • Sending on a closed channel (panic).
  • Deadlocks when all goroutines are blocked waiting on each other.
  • Forgetting to close a channel that a range loop depends on, causing a leak.

Reading the runtime source also clarifies why unbuffered channels are synchronous: the sender blocks until a receiver is ready, and vice versa. This strict synchronization is often misunderstood, leading to unexpected blocking in code that assumes buffering.

Frequently Asked Questions

Q: Why is a channel faster than a mutex-protected slice?
A: Channels are optimized for the common case of one sender and one receiver. The runtime uses a lock-free fast path when both are ready, and the direct transfer avoids an extra copy. A custom mutex+slice implementation would need to handle more cases manually, often with more overhead.

Q: Can I inspect the buffer size of a channel at runtime?
A: No, the buffer size is not exposed through the standard library. You can only know it at creation time. However, you can use reflect.ValueOf(ch).Cap() to get the capacity, though this is rarely needed.

Q: What happens if I receive from a nil channel?
A: Receiving from a nil channel blocks forever. This is by design and can be used to disable a case in a select statement. Sending to a nil channel also blocks forever.

Q: Is it safe to close a channel from multiple goroutines?
A: No. Closing a channel is not atomic; if two goroutines close the same channel, one will panic. You should ensure that only one goroutine closes a channel, typically the sender.

Q: Why does the runtime use a ring buffer instead of a simple slice?
A: A ring buffer avoids the need to shift elements when the buffer is partially full. It provides O(1) enqueue and dequeue operations without memory reallocation, which is ideal for a fixed-size buffer.

Related

Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.

More cards in this drawer

The whole Numbers and Powers drawer →