The Problem Channels Solve
When you use goroutines, they run concurrently. But concurrently brings a question: How do goroutines safely communicate or share data with each other without race conditions?
- use shared memory with locks (like
sync.Mutex) - Go’s philosophy: “Do not communicate by sharing memory; instead, share memory by communicating.” -> That’s what channels are for.
What is a Channel in Go?
A channel is like a pipe:
- One goroutine can send data into the channel.
- Another goroutine can receive data from the channel.
We can think of channels as a typed conveyor belt
ch := make(chan int) // channel that carries int
- Channel are typed(you can only send/receive one type.)
- By default, channels are unbuffered (synchronous) - Sender waits until someone receives, and receiver waits until someone sends.
- Of course, we can create buffered channels (asynchronous) with a specified capacity, In this case, channel are asynchronous - Sender only waits when the buffer is full, and receiver only waits when the buffer is empty.
What does a Channel Look Like in Go’s Runtime?
Channels are implemented in the runtime package. The central struct is called hchan.
The definition look something like this(simplified, names vary slightly in different Go versions):
type hchan struct {
qcount uint // number of elements in queue
dataqsiz uint // buffer capacity
buf unsafe.Pointer // circular buffer
elemsize uint16 // size of each element
closed uint32 // is channel closed?
elemtype *_type // element type (for reflection)
sendx uint // send index (next slot to write)
recvx uint // recv index (next slot to read)
recvq waitq // list of waiting receivers
sendq waitq // list of waiting senders
lock mutex // protects all fields
}
So at runtime, every chan T is really a pointer to an hchan.
The buffer: circular ring
- if
dataqsiz == 0, this is an unbuffered channel - if
dataqsiz > 0, this is a buffered channel,bufpoints to an array of slots(capacity = dataqsiz). sendxandrecvxare indices into this array, forming a circular ring buffer.
Wait queues
sendq and recvq are linked lists of goroutines(sudog objects) that are blocked on this channel.
- If a goroutine does
ch <- valueand the channel is full or unbuffered with no receiver, runtime enqueues it in thesendqand parks it(puts it to sleep). - If a goroutine does
<- chand the channel is empty or unbuffered with no sender, runtime enqueues it in therecvqand parks it. - When the opposite side arrives, runtime dequeues on goroutine and wakes it.
Channel Operations
Send(ch <- value)
- Lock channel
- If there’s a receiver waiting in
recvq, do a direct handoff:- Copy value directly into receiver’s memory.
- Wake receiver goroutine.
- Else if buffer has space, put value at
buf[sendx], incrementsendxandqcount. - Else (buffer full or unbuffered with no receiver):
- Enqueue current goroutine in
sendq. - Park (sleep) goroutine.
- Enqueue current goroutine in
- Unlock channel
Receive(value <- ch)
- Lock channel
- If there’s a sender waiting in
sendq, do a direct handoff:- Copy value directly from sender’s memory.
- Wake sender goroutine.
- Else if buffer has data, read value from
buf[recvx], incrementrecvxand decrementqcount. - Else if
closed, return zero value withok == false. - Else (buffer empty or unbuffered with no sender):
- Enqueue current goroutine in
recvq. - Park (sleep) goroutine.
- Enqueue current goroutine in
- Unlock channel
Close(ch)
closedflag is set automatically under the lock.- All waiting receivers are woken up to receive zero values, ok == false.
- Any further sends panic, “send on closed channel”.
- Any further receive sees closed, returns zero value immediately.
Nil Channel
A chant T variable that is nil doesn’t point to an hchan at all. So:
- Send/Receive just block forever.(because runtime sees
nilpointer, so no queue to join). - Close panics.
So we can set a channel variable to nil inside a select to disable that case.
Select
When we talk about channels, we should also mention select. Select can choose one ready case pesudo-randomly(to
avoid starvation).
If none are ready:
- If there’s a
defaultcase, it runs that. - Otherwise it parks the goroutine, registering interest in all the cases; a matching send/receive will wake it.
Patterns:
- Timeout:
select { case v := <- ch: ...; case <- time.After(d): ... } - Cancellation:
select { case v := <- ch: ...; case <- ctx.Done(): ... }
Performance
Since channel use lock internally, how about performance? Actually it’s not bad, here are some considerations
-
Low-contention fast path: Most channel ops complete quickly (e.g. buffer has space, buffer has data, or a sender/receiver is waiting). The runtime holds the lock for just a few nanoseconds.
-
Hand-off optimization: In the unbuffered case, if a sender and receiver are both ready, the runtime directly copies the value from sender’s stack to receiver’s stack (no buffer). That’s just a memory copy + wakeup.
-
Cache-friendly ring buffer: For buffered channels, the circular queue is contiguous memory. Writing/reading buf[sendx] and buf[recvx] are cache-friendly operations.
-
Runtime tuned locks: Go’s internal mutex is very lightweight compared to OS locks — it often spins briefly, then parks the goroutine efficient