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

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:
-
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.
- A sender (
-
Receiver Queue (
recvq
)- A receiver (
G
) is ready to receive. - When it receives
Item 1
, the buffer shifts left, making room for another sender.
- A receiver (
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:
- Sender calls
runtime.chansend
, checksrecvq
. - If a receiver’s waiting, data moves directly (no buffer), and both goroutines wake up.
- If not, the sender joins
sendq
and sleeps. - 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:
- Locks the
hchan
struct. - If there’s space (
qcount < dataqsiz
), adds the value tobuf
atsendx
, bumpsqcount
, and updatessendx
. - If full, sender joins
sendq
and waits. - Receiver pulls from
recvx
, shifts the queue, and wakes a sender ifsendq
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:
- Go Runtime Source (chan.go) - The source.
- Go Concurrency Patterns: Pipelines and cancellation - Practical examples.
- Concurrency in Go by Katherine Cox-Buday - A solid book.
Got questions or cool channel tricks? Drop a comment—I’d love to hear them!