Channel and Select Internals

Updated

September 8, 2026

Channel and Select Internals

Overview

Channels are not magic pipes — they are runtime objects (hchan) with a lock, optional buffer ring, and wait queues of sudogs (goroutine wait records). Select is a careful algorithm over multiple channel operations so that exactly one case proceeds (or default).

Usage patterns: Channel patterns.

Diagram: Channel park / wake

sequence (top → bottom):
  actors: sender G, hchan, receiver G
  sender G --> hchan  : chansend
  hchan --> receiver G  : hand off / enqueue buf
  hchan --> sender G  : return
  sender G --> sender G  : gopark sendq
  receiver G --> hchan  : chanrecv
  hchan --> sender G  : goready
  note: alt buffer space or waiting recv
  note: else must wait

Channel Structure (Conceptual)

hchan
  qcount      // elements in buffer
  dataqsiz    // buffer capacity (0 = unbuffered)
  buf         // circular queue
  sendx/recvx // indices
  recvq       // waiters for receive
  sendq       // waiters for send
  lock
  closed

Unbuffered (make(chan T))

Send and receive must rendezvous: sender copies directly into receiver (or vice versa). One side parks on a sudog queue until the other arrives.

Buffered (make(chan T, n))

Send succeeds without a receiver while qcount < n. Receive succeeds without a sender while qcount > 0. Still synchronized via the channel lock and happens-before rules.

Send / Receive Paths

Situation Behavior
Recv, buffer non-empty Pop buffer; maybe wake a sender
Recv, buffer empty, sender waiting Direct hand-off
Recv, nothing available Park on recvq
Send, receiver waiting Direct hand-off
Send, buffer space Push buffer
Send, no space Park on sendq
Op on closed Recv: zero + ok=false; Send: panic
v, ok := <-ch // ok false => closed and drained
close(ch)     // idempotent close panics on second close

Select Algorithm (Intuition)

For select with multiple cases:

  1. Randomize case order (fairness).
  2. Probe channels for a case that can proceed immediately.
  3. If none and there is default, take default.
  4. Otherwise, enqueue this G on all involved channels’ wait queues, then park.
  5. On wake, dequeue from other channels; complete the winning case only.

Implications:

  • There is no priority among cases — do not assume left-to-right wins.
  • A busy channel can starve another statistically; redesign if fairness is a product requirement.
  • select with one case ≠ quite the same codegen as bare op, but same semantics.

Close Semantics

close
  |
  +--> fail future sends (panic)
  +--> wake all recvers (they get zero values)
  +--> drained buffer still readable until empty

Only the sender side should close (by convention). Multiple closers require a single closer protocol.

Common Failure Modes

Bug Symptom
Send on closed Panic
Never closed, receivers blocked Goroutine leak
Unbuffered send without recv Deadlock
Select without default in tight loop 100% wake churn if misused with timers
Timer.C not drained after Stop (older patterns) Rare wake bugs — prefer time.After carefully in loops

Cost Model

  • Channel ops take a runtime lock and may park the G — fine for coordination, expensive as a per-byte I/O bus.
  • For huge fan-in of tiny messages, batch or use a shared structure under a mutex (measure).

Experiment

go mod init example
go run .
package main

import (
    "fmt"
    "time"
)

func main() {
    // Rendezvous
    unbuf := make(chan string)
    go func() {
        unbuf <- "ping"
    }()
    fmt.Println("unbuf", <-unbuf)

    // Buffer absorbs one send
    buf := make(chan int, 1)
    buf <- 1
    fmt.Println("buf", <-buf)

    // Select fairness demo: both cases ready each iteration
    counts := map[string]int{}
    for i := 0; i < 1000; i++ {
        a := make(chan int, 1)
        b := make(chan int, 1)
        a <- 1
        b <- 2
        select {
        case <-a:
            counts["a"]++
        case <-b:
            counts["b"]++
        }
    }
    fmt.Println("select counts", counts)

    // Close
    ch := make(chan int, 2)
    ch <- 7
    close(ch)
    v, ok := <-ch
    fmt.Println("after close", v, ok)
    v, ok = <-ch
    fmt.Println("drained", v, ok)

    // Default non-block
    select {
    case <-time.After(10 * time.Millisecond):
        fmt.Println("timer")
    default:
        fmt.Println("default path")
    }
}

What to notice: Both a and b win some iterations — not always a first. Closed channel receives are safe; sends would panic.

Try next: Deliberately leak a goroutine blocked on send; find it with //go:debug tracebackancestors=1 or pprof goroutine profile.