PidokuInfra

Channels

Intermediate 55 min Difficulty 3/5 Topic 02 of 06

Prerequisites 01

The idea in one minute#

A channel is a typed conduit between goroutines: one sends a value, another receives it. An unbuffered channel is a rendezvous — the send completes only when a receiver takes the value, so it synchronizes the two. A buffered channel is a fixed-size queue: sends block only when it is full, receives only when it is empty.

Inside, a channel is a small struct with a lock, a ring buffer, and two queues of waiting goroutines. Knowing that makes every rule — what blocks, what panics, what a closed channel returns — something you can derive rather than memorize.

An analogy#

An unbuffered channel is handing a parcel to someone in person: you both have to be at the door at the same moment, and you leave knowing it was received. A buffered channel is a parcel locker with N compartments: you drop off and leave, unless every compartment is full, in which case you wait. Closing the channel is putting up a “no more deliveries” sign — people can still collect what is inside, and after that they find it empty immediately.

A picture#

flowchart TB
  subgraph HCHAN["hchan: what make(chan T, 4) creates on the heap"]
    direction TB
    LOCK["lock (a mutex)"]
    BUF["buf: ring of 4 slots<br/>qcount=2, sendx, recvx"]
    RQ["recvq: goroutines waiting to receive"]
    SQ["sendq: goroutines waiting to send"]
    CL["closed flag"]
  end
  S["sender G<br/>ch <- v"] -->|"1. waiting receiver? copy v directly to it, wake it<br/>2. else room in buf? copy v in<br/>3. else park in sendq"| HCHAN
  HCHAN -->|"1. buf has data? copy out (and move a waiting sender's value in)<br/>2. else waiting sender? take its value directly<br/>3. else park in recvq"| R["receiver G<br/>v := <-ch"]
  class S,R compute
  class LOCK,CL neutral
  class BUF memory
  class RQ,SQ queue

How it really works#

Creating and using#

Go
ch := make(chan int)        // unbuffered
ch := make(chan int, 64)    // buffered, capacity 64
ch <- v                     // send
v := <-ch                   // receive
v, ok := <-ch               // ok is false if the channel is closed and drained
close(ch)                   // no more sends
for v := range ch { }       // receive until closed

A channel variable is a pointer to the hchan struct: copying it or passing it to a function shares the channel. Directional types document and enforce intent: chan<- T can only be sent on, <-chan T only received from.

The rules, as a table#

Operationnil channelOpen, not readyOpen, readyClosed
Send ch <- vBlocks foreverBlocksSucceedsPanics
Receive <-chBlocks foreverBlocksSucceedsReturns buffered values, then the zero value with ok == false, immediately
close(ch)PanicsSucceedsSucceedsPanics

Two rules for staying out of the panics:

  1. Only the sender closes, and only when there is exactly one sender (or the senders coordinate, usually with a WaitGroup: close after all have finished).
  2. Closing is a broadcast: every receiver, present and future, is released. That makes close(done) the idiom for “everyone stop” — which is what context does (lesson 03).

A nil channel blocking forever is useful, not just a trap: setting a channel variable to nil inside a select loop disables that case.

What the runtime does#

  • A send takes the lock. If a receiver is parked in recvq, the value is copied directly into that goroutine’s stack and it is made runnable — no buffer involved. Otherwise, if the buffer has room, the value is copied into the ring. Otherwise the sender is added to sendq and parks.
  • A receive is the mirror image.
  • Values are copied. Sending a struct copies the struct; sending a pointer copies the pointer and shares what it points to. Send values or immutable data when you can; after sending a pointer, treat the object as given away.
  • An uncontended channel operation costs tens of nanoseconds — more than a mutex, far more than an atomic. A handoff that parks and wakes a goroutine costs a few hundred.

Buffered or unbuffered?#

ChooseWhen
UnbufferedYou want synchronization: the sender should know the receiver has it. The default
Buffer of 1A result from a goroutine whose receiver may have given up — the send never blocks, so the goroutine never leaks
Buffer of NYou know N: N workers’ results, a semaphore with N permits, a bounded burst
Large buffer “to be safe”Almost never. It hides a slow consumer until the buffer fills, then you have latency and blocking

A buffer does not make a system faster; it moves the point where backpressure is felt. A bounded queue with an explicit policy when full — block, drop, or reject — is a design decision (Inference Engineering VIII.03).

Common shapes#

Go
done := make(chan struct{})              // a signal carrying no data
sem := make(chan struct{}, 8)            // a semaphore: at most 8 at once
sem <- struct{}{}; defer func() { <-sem }()

results := make(chan Result, len(jobs))  // every worker can send without blocking

Channels or mutexes?#

“Share memory by communicating” is good advice for passing ownership of data and for coordinating goroutines: pipelines, worker pools, signalling completion. It is poor advice for protecting a piece of state: a counter or a cache guarded by a channel-and-goroutine is slower and more code than a mutex. Use a channel when a value changes hands; use a mutex when several goroutines need the same value (lesson 04).

Deadlock#

If every goroutine is blocked, the runtime reports fatal error: all goroutines are asleep - deadlock!. It only detects the total case. A partial deadlock — a few goroutines stuck forever while others run — is silent, and is the usual form of a goroutine leak (lesson 06).

Code#

Go
// channels.go — every row of the rules table, demonstrated, plus the cost of an operation.
package main

import (
	"fmt"
	"sync"
	"testing"
	"time"
)

// try runs f and reports whether it finished, blocked, or panicked.
func try(f func()) string {
	done := make(chan string, 1)
	go func() {
		defer func() {
			if r := recover(); r != nil {
				done <- fmt.Sprint("PANIC: ", r)
			}
		}()
		f()
		done <- "ok"
	}()
	select {
	case s := <-done:
		return s
	case <-time.After(30 * time.Millisecond):
		return "blocks"
	}
}

func main() {
	var nilCh chan int
	fmt.Println("send on nil channel:        ", try(func() { nilCh <- 1 }))
	fmt.Println("receive from nil channel:   ", try(func() { <-nilCh }))
	fmt.Println("close nil channel:          ", try(func() { close(nilCh) }))

	unbuf := make(chan int)
	fmt.Println("send, unbuffered, no reader:", try(func() { unbuf <- 1 }))

	buf := make(chan int, 2)
	fmt.Println("send, buffered, has room:   ", try(func() { buf <- 1 }))
	buf <- 2
	fmt.Println("send, buffered, full:       ", try(func() { buf <- 3 }))

	close(buf)
	v1, ok1 := <-buf
	v2, ok2 := <-buf
	v3, ok3 := <-buf
	fmt.Printf("receive after close:         %d,%v  %d,%v  %d,%v  (drains, then zero value)\n", v1, ok1, v2, ok2, v3, ok3)
	fmt.Println("send on closed channel:     ", try(func() { buf <- 4 }))
	fmt.Println("close twice:                ", try(func() { close(buf) }))

	// Close is a broadcast: it releases every waiting receiver at once.
	start := make(chan struct{})
	var wg sync.WaitGroup
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func() { defer wg.Done(); <-start }()
	}
	close(start)
	wg.Wait()
	fmt.Println("close(start) released 5 waiting goroutines")

	// Cost: buffered channel vs mutex-protected slot, single goroutine (no parking).
	bc := make(chan int, 1)
	rch := testing.Benchmark(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			bc <- i
			<-bc
		}
	})
	var mu sync.Mutex
	slot := 0
	rmu := testing.Benchmark(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			mu.Lock()
			slot = i
			mu.Unlock()
			mu.Lock()
			_ = slot
			mu.Unlock()
		}
	})
	// Cost with a real handoff between two goroutines.
	ping, pong := make(chan int), make(chan int)
	go func() {
		for v := range ping {
			pong <- v
		}
	}()
	rho := testing.Benchmark(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			ping <- i
			<-pong
		}
	})
	close(ping)
	fmt.Printf("\nsend+receive, buffered, same goroutine: %4d ns\n", rch.NsPerOp())
	fmt.Printf("two lock/unlock pairs on a mutex:        %4d ns\n", rmu.NsPerOp())
	fmt.Printf("round trip between two goroutines:       %4d ns\n", rho.NsPerOp())
}

Remember this#

  • A channel is a lock, a ring buffer and two wait queues. Unbuffered means direct handoff.
  • nil blocks forever; send on closed panics; receive on closed returns zero values at once.
  • Only the sender closes. Close is a broadcast.
  • Buffers move backpressure; they do not remove it.
  • Channels to transfer ownership and coordinate; mutexes to protect state.

Try it#

  1. Run channels.go and match each line to a cell of the rules table.
  2. Write a function that starts a goroutine sending its result on an unbuffered channel, then returns early without receiving. Show with runtime.NumGoroutine that it leaked. Fix it with a buffer of 1.
  3. Build a semaphore from a buffered channel that limits a loop of 100 tasks to 5 at a time, and record the maximum concurrency actually observed.

Check yourself#

  1. What happens when you receive from a closed channel that still has buffered values?
  2. Why should only the sender close a channel?
  3. What does adding a buffer change about backpressure?

↑↓ navigate↵ openesc close