Unpacking Go Channels: A Peek Under the Hood

Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a tool that makes generating API docs from your code ridiculously easy. If you’ve messed around with Go, you’ve probably bumped into channels. They’re the secret sauce for concurrency, letting goroutines talk to each other without tripping over themselves. But what’s really going on when you make(chan int) or send a value with

Mar 29, 2025 - 19:23
 0
Unpacking Go Channels: A Peek Under the Hood

Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a tool that makes generating API docs from your code ridiculously easy.

If you’ve messed around with Go, you’ve probably bumped into channels.

They’re the secret sauce for concurrency, letting goroutines talk to each other without tripping over themselves.

But what’s really going on when you make(chan int) or send a value with <-?

This post is all about tearing apart Go channels to see how they work internally. Buckle up—we’re going deep!

We’ll break it down into four sections: what channels are, their internal structure, how sending and receiving works, and some gotchas to watch out for.

Along the way, you’ll get code, diagrams, and a few “aha!” moments (hopefully!).

What Are Channels, Anyway?

Channels in Go are like pipes.

You shove data in one end, and it pops out the other—except they’re built for concurrency, so they’re safe for goroutines to use without extra locks.

Think of them as a queue with some magic sprinkled on top.

You create a channel with make:

ch := make(chan string) // Unbuffered channel
bufferedCh := make(chan int, 5) // Buffered channel with capacity 5
  • Unbuffered channels block until both a sender and receiver are ready.
  • Buffered channels let you send data up to their capacity before blocking.

Under the hood, channels are implemented in the Go runtime, written in C and assembly (yep, low-level stuff).

The key player is a struct called hchan, defined in the Go source code (runtime/chan.go).

It’s the backbone of every channel you create. Let’s see what makes it tick.

The Anatomy of a Channel

The hchan struct is where the magic lives. Here’s a simplified version of it from the Go runtime:

type hchan struct {
    qcount   uint           // Number of elements currently in the buffer
    dataqsiz uint           // Size of the circular buffer (capacity)
    buf      unsafe.Pointer // Pointer to the buffer (circular queue)
    elemsize uint16         // Size of each element
    closed   uint32         // Is the channel closed? (0 = no, 1 = yes)
    elemtype *rtype         // Type of elements (e.g., int, string)
    sendx    uint           // Send index in the buffer
    recvx    uint           // Receive index in the buffer
    recvq    waitq          // List of goroutines waiting to receive
    sendq    waitq          // List of goroutines waiting to send
    lock     mutex          // Protects the struct from concurrent access
}

Let’s break it down:

  • qcount: How many items are sitting in the buffer right now.
  • dataqsiz: The total capacity (0 for unbuffered channels).
  • buf: A circular buffer (think ring queue) holding the data.
  • sendx and recvx: Indices tracking where to send or receive next.
  • recvq and sendq: Queues of goroutines waiting to act—super important for blocking.

Here’s a diagram of a buffered channel with capacity 3:

Buffered Channel

  • Channel Buffer Section (Cap: 3)

    • The channel currently holds 3 items (full buffer).
    • No empty slots are available.
  • Sender Queue (sendq)

    • A sender (E) is trying to send a new item but is blocked because the buffer is full.
    • The sender must wait until a receiver takes an item, freeing space.
  • Receiver Queue (recvq)

    • A receiver (G) is ready to receive.
    • When it receives Item 1, the buffer shifts left, making room for another sender.

For an unbuffered channel (dataqsiz = 0), there’s no buf. Instead, data moves directly from sender to receiver, and the runtime uses recvq and sendq to pair them up.

Key takeaway: The hchan struct is a mix of a queue and a coordinator, juggling data and goroutines like a pro.

Sending and Receiving—How It Works

Let’s get into the nitty-gritty of sending (ch <- value) and receiving (<- ch).

The runtime handles these operations differently depending on whether the channel is buffered or unbuffered.

Unbuffered Channels

With an unbuffered channel, it’s a handshake. The sender blocks until a receiver is ready, and vice versa. Here’s an example:

func main() {
    ch := make(chan string)
    go func() {
        ch <- "hello" // Blocks until main receives
    }()
    msg := <-ch // Blocks until goroutine sends
    fmt.Println(msg) // Prints "hello"
}

Internally:

  1. Sender calls runtime.chansend, checks recvq.
  2. If a receiver’s waiting, data moves directly (no buffer), and both goroutines wake up.
  3. If not, the sender joins sendq and sleeps.
  4. Receiver does the reverse with runtime.chanrecv.

Buffered Channels

Buffered channels are more chill. You can send until the buffer’s full. Here’s a quick demo:

func main() {
    ch := make(chan int, 2)
    ch <- 1 // Buffer: [1]
    ch <- 2 // Buffer: [1, 2]
    ch <- 3 // Blocks! Buffer full
    fmt.Println(<-ch) // Prints 1, buffer: [2]
}

The runtime:

  1. Locks the hchan struct.
  2. If there’s space (qcount < dataqsiz), adds the value to buf at sendx, bumps qcount, and updates sendx.
  3. If full, sender joins sendq and waits.
  4. Receiver pulls from recvx, shifts the queue, and wakes a sender if sendq isn’t empty.

Here’s a table comparing the two:

Feature Unbuffered Buffered
Capacity 0 > 0
Blocking Always (handshake) Only when full/empty
Data Transfer Direct goroutine-to-goroutine Via circular buffer

Key takeaway: Buffered channels use a queue, unbuffered ones play matchmaker. The runtime’s job is to keep it smooth and safe.

Gotchas and Cool Tricks

Channels are awesome, but they’ve got quirks. Here’s what to watch out for and some neat tricks.

Closing Channels

Closing a channel (close(ch)) sets closed to 1. Receivers can still drain the buffer, but sending panics. Check it out:

ch := make(chan int, 2)
ch <- 1
close(ch)
val, ok := <-ch // val = 1, ok = true
val, ok = <-ch // val = 0, ok = false (zero value)

Select Statement

The select block is your friend for juggling multiple channels:

select {
case msg := <-ch1:
    fmt.Println(msg)
case ch2 <- 42:
    fmt.Println("Sent to ch2")
default:
    fmt.Println("No action")
}

It picks the first ready case—or default if nothing’s ready.

Panic Pitfalls

  • Sending to a closed channel? Panic.
  • Closing a closed channel? Panic.
  • Nil channel in select? Ignored (handy for toggling).

Key takeaway: Channels are powerful, but test edge cases—closing, nil, and full buffers can bite.

In Summary...

Go channels are a concurrency superpower, blending queues, locks, and goroutine scheduling into one neat package.

The hchan struct ties it all together, and the runtime makes it feel effortless.

Whether you’re syncing goroutines or pipelining data, understanding the internals helps you wield them like a pro.

Want to dig deeper? Check out these resources:

Got questions or cool channel tricks? Drop a comment—I’d love to hear them!