Back to Notes

Go Channels

Unbuffered Channels

What: make(chan T) — capacity 0; every send blocks until a receiver is ready and vice versa. It's a synchronization point, not a queue.

Syntax/Signature: ch := make(chan int); send ch <- v; receive v := <-ch / v, ok := <-ch.

done := make(chan struct{})
go func() {
	work()
	close(done) // signal completion; close is broadcast-safe for N waiters
}()
<-done

How it works under the hood: A channel is a heap-allocated hchan struct: mutex, circular buffer (buf, nil for unbuffered), and two waiter queues of sudogs (sendq, recvq). For an unbuffered channel, if a receiver is already parked, the sender copies the value directly into the receiver's stack (a deliberate exception to "goroutines don't touch each other's stacks") and readies it — no intermediate buffer. Otherwise the sender parks itself on sendq via gopark.

Interview angle: "What's the difference between buffered and unbuffered channels?" and the happens-before follow-up: a send on an unbuffered channel happens-before the corresponding receive completes — and the receive happens-before the send completes, making it a bidirectional sync point.

Gotchas / common wrong answers:

  • "Unbuffered = buffer of size 1" — no; make(chan T, 1) lets one send proceed without a receiver.
  • Sending from the same goroutine that later receives → immediate deadlock (all goroutines are asleep).
  • Assuming the runtime detects all deadlocks — it only panics when every goroutine is blocked; partial deadlocks silently leak.

Trade-offs / when NOT to use: Great for handoff/signal semantics; wrong for decoupling producer/consumer rates (use buffered) or broadcast to unknown N (use close or context).

Buffered Channels

What: make(chan T, n) — sends succeed without a receiver until the buffer holds n elements; receives succeed while it's non-empty.

Syntax/Signature: ch := make(chan Job, 64); len(ch) = queued items, cap(ch) = capacity.

sem := make(chan struct{}, 10) // counting semaphore: max 10 concurrent
for _, u := range urls {
	sem <- struct{}{}
	go func() {
		defer func() { <-sem }()
		fetch(u)
	}()
}
for i := 0; i < cap(sem); i++ { sem <- struct{}{} } // drain = wait for all

How it works under the hood: Same hchan, but buf is a fixed circular array with sendx/recvx indices, guarded by the channel mutex. Send with free space: copy into buf, bump sendx, return — no park. Only the k-th receive happens-before the (k+C)-th send completes (C = capacity), which is exactly why a size-C buffered channel works as a semaphore.

Interview angle: "How do you limit concurrency to N without a library?" — semaphore channel. "How do you size a buffer?" — measured burst absorption or a semantic count (workers, tokens); anything else is a guess that hides backpressure bugs.

Gotchas / common wrong answers:

  • Using a big buffer to "fix" a deadlock — it delays the block, doesn't remove it, and hides the design flaw.
  • Believing buffered channels reorder — FIFO per channel, guaranteed.
  • len(ch) for control flow — it's racy by the time you act on it; fine for metrics only.

Trade-offs / when NOT to use: Buffers trade latency-coupling for memory and weaker sync guarantees. Don't use when you need the sender to know the value was received (rendezvous) — that's unbuffered. See [[Message Queues & Kafka]] for the general back-pressure trade-off this mirrors.

close, Nil Channels & Directional Types

What: close(ch) transitions a channel to closed: receives drain the buffer then return zero value with ok=false; a nil channel blocks forever on send and receive; chan<- T / <-chan T restrict direction at compile time.

Syntax/Signature: close(ch); v, ok := <-ch; for v := range ch {} (exits on close); func producer(out chan<- int).

func merge(a, b <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for a != nil || b != nil {
			select {
			case v, ok := <-a:
				if !ok { a = nil; continue } // disable this case
				out <- v
			case v, ok := <-b:
				if !ok { b = nil; continue }
				out <- v
			}
		}
	}()
	return out
}

How it works under the hood: close sets hchan.closed, then wakes all parked receivers (they get zero values) and all parked senders (they panic). Setting a channel variable to nil makes its select case never ready — the idiomatic way to "remove" a completed source from a select loop. The channel-axioms table: send→nil blocks, recv→nil blocks, send→closed panics, recv→closed returns immediately, close(nil) panics, close(closed) panics.

Interview angle: "Who should close a channel?" — the sender, only when receivers need a termination signal; channels don't need closing for GC. "Recite the nil/closed channel behaviors" is a standard rapid-fire round.

Gotchas / common wrong answers:

  • Closing from the receiver side or from multiple senders → panic risk; with multiple senders, signal shutdown via a separate done channel or context instead.
  • range over a channel that's never closed → goroutine leak (see [[Go Goroutine Leaks]]).
  • Thinking closed channels discard buffered values — buffered items are still delivered before ok=false.

Trade-offs / when NOT to use: close as broadcast is elegant but one-shot; for resettable or multi-event broadcast use sync.Cond (see [[Go Sync Primitives]]) or fresh channels. Directional types cost nothing — use them in every API signature.

select

What: Blocks until one of its channel cases is ready; if several are ready simultaneously it picks uniformly at random; default makes it non-blocking.

Syntax/Signature:

select {
case v := <-in:      _ = v
case out <- result:  // send case
case <-ctx.Done():   return ctx.Err()
default:             // only if nothing ready
}
func doWithTimeout(ctx context.Context, work <-chan Result) (Result, error) {
	select {
	case r := <-work:
		return r, nil
	case <-ctx.Done():
		return Result{}, ctx.Err()
	}
}

How it works under the hood: The runtime (selectgo) locks all involved channels in a consistent order (avoiding lock-order deadlock), does one randomized pass to find a ready case, and if none is ready enqueues a sudog on every channel's waiter queue, then parks. Whichever channel fires first wins; the runtime dequeues the sudog from all the others. Randomization exists to prevent starvation of lower cases.

Interview angle: "Two cases are ready — which runs?" (random, not top-first). "Implement a non-blocking send" (select + default). "How do you prioritize one channel over another?" — nested select: try the priority case with default, then fall through to a blocking select on both.

Gotchas / common wrong answers:

  • "Cases are evaluated top to bottom" — order is random among ready cases.
  • default inside a loop → busy-spin burning a core; almost always wrong without backoff.
  • case <-time.After(d) inside a loop creates a new timer per iteration; in Go 1.22 those timers aren't collected until they fire (fixed in 1.23) — use a reused time.Timer in hot loops.

Trade-offs / when NOT to use: An empty select {} blocks forever (occasionally intentional in daemons). Don't use select+default as a lock substitute; and with many dynamic channels, reflect.Select exists but is a design smell — restructure with a merge goroutine.

Interview Drills

  1. What happens on: send to a nil channel, receive from a closed channel, close of an already-closed channel? — Send to nil blocks forever (deadlock panic if it's the last runnable goroutine). Receive from closed drains any buffered values first, then returns the zero value with ok == false immediately. Closing a closed channel panics — as does closing nil and sending on closed. Rule: only the (single) sender closes.
  2. Why is make(chan T, 1) not "the same as unbuffered but slightly safer"? — It changes the fundamental contract from rendezvous (sender knows the receiver got the value) to a one-slot queue (sender only knows it handed the value off, not that anyone received it) — the two are different synchronization guarantees, not degrees of the same one.
  3. How do you implement priority between two channels in a select? — Nest selects: first try the priority channel alone with a default case (non-blocking check), and only if that's empty, fall through to a blocking select across both channels — a single flat select with both cases gives no priority, since ready cases are chosen uniformly at random.

Cross-links

[[Go Goroutines & Scheduling]] · [[Go Context & Cancellation]] · [[Go Sync Primitives]] · [[Message Queues & Kafka]]