A channel is a typed conduit that one thread or task sends values into and another receives them from. Behind that simple description sit three things at once: a queue, a synchronization mechanism that blocks senders or receivers at the right moments, and a transfer of ownership, because once a value is sent the sender should stop touching it. Channels come from Tony Hoare's Communicating Sequential Processes (CSP), and Go made them a mainstream tool with the motto that you should share memory by communicating rather than communicate by sharing memory.

This article explains channels from the inside. It covers the design choices every channel implementation makes, walks through how the Go runtime implements send, receive and select, shows pipeline and fan-in code that closes correctly, compares Kotlin, Rust and Java, and lists the failure modes that cause leaks, deadlocks and panics. Demand signalling in reactive libraries is a related but different model, covered in reactive streams backpressure.

Advertisement

What a channel combines

Passing work between two threads by hand takes a queue, a mutex, a condition variable to wake the consumer when the queue becomes non-empty, another to wake the producer when it becomes non-full, and a convention for saying no more items are coming. Each piece is easy to get subtly wrong, and a missed wakeup shows up once a week under load.

A channel packages all of that behind send, receive and close. It also means a value has one owner at a time, and ownership moves when it is sent. As long as senders stop using what they sent, there is no shared mutable state left to protect, and all synchronization is visible at the channel operations.

Inside a buffered Go channel: a locked ring buffer plus two queues of parked goroutinesSender goroutinessend: ch, vReceiver goroutinesreceive: v, okhchanlock, qcount, closed, sendx, recvxbuf: ring of dataqsiz slotselements copied in and outsendqsenders parked: buffer fullrecvqreceivers parked: buffer emptylocklockpark on fullpark on emptynever both parked at onceA send first checks recvq. If a receiver is parked, the value is copied straight to it and the buffer is skipped.close sets closed, then wakes every parked sender (they panic) and every parked receiver (they get zero, false).
A Go channel is a heap object holding a lock, an optional ring buffer and two wait queues. Parked goroutines are woken by the operation that can complete them.

The design choices

Every channel implementation makes four decisions, and understanding them makes the differences between languages easy to follow.

  • Capacity. An unbuffered or rendezvous channel has no storage: a send completes only when a receiver takes the value, so it synchronizes the two sides. A bounded channel holds up to N values, so a sender blocks only when it is full, which absorbs bursts while still pushing back on a fast producer. An unbounded channel never blocks the sender, which removes backpressure and turns a slow consumer into a memory leak.
  • Multiplicity. Single-producer single-consumer (SPSC), multi-producer single-consumer (MPSC) or multi-producer multi-consumer (MPMC). Restricting the shape allows faster implementations: an SPSC ring can be lock-free with two indices.
  • Close. A way to signal that no more values will be sent, so receivers can finish. Implementations differ in who may close, what happens to buffered values and what a send after close does.
  • Select. A way to wait on several channel operations at once and proceed with whichever becomes ready first, which is what makes timeouts and cancellation composable.
Advertisement

Inside a Go channel

In Go, make(chan T, n) allocates an hchan on the heap: a lock, the count qcount, the capacity dataqsiz, a ring buffer of n elements with send and receive indices, a closed flag, and two queues of parked goroutines, sendq and recvq. Unbuffered channels simply have n = 0.

The model below simplifies the runtime's chansend and chanrecv. The key detail is send's first branch: if a receiver is parked, the sender copies the value straight into its slot and wakes it, skipping the buffer. That direct handoff is what makes an unbuffered channel, a pure rendezvous, work.

// Simplified model of the Go runtime's chansend and chanrecv (runtime/chan.go).
func send(c *hchan, v T, block bool) bool {
    lock(&c.lock)
    if c.closed {
        unlock(&c.lock); panic("send on closed channel")
    }
    if r := c.recvq.dequeue(); r != nil {       // 1. a receiver is already waiting
        copyTo(r.elem, v)                        //    copy straight to its slot
        unlock(&c.lock); ready(r.g)              //    and make it runnable
        return true
    }
    if c.qcount < c.dataqsiz {                   // 2. room in the ring buffer
        c.buf[c.sendx] = v
        c.sendx = (c.sendx + 1) % c.dataqsiz
        c.qcount++
        unlock(&c.lock); return true
    }
    if !block { unlock(&c.lock); return false }  // select with default
    me := enqueueSelf(&c.sendq, v)               // 3. park until a receiver takes v
    parkAndUnlock(&c.lock)
    if me.closedWhileWaiting { panic("send on closed channel") }
    return true
}

func recv(c *hchan, block bool) (v T, ok bool) {
    lock(&c.lock)
    if c.closed && c.qcount == 0 { unlock(&c.lock); return zero(T), false }
    if s := c.sendq.dequeue(); s != nil {        // a sender is parked: buffer is full or
        v = takeFromHeadOrSender(c, s)           // unbuffered; take the oldest value and
        unlock(&c.lock); ready(s.g)              // let the sender's value move in behind it
        return v, true
    }
    if c.qcount > 0 {                            // take from the ring buffer
        v = c.buf[c.recvx]
        c.recvx = (c.recvx + 1) % c.dataqsiz
        c.qcount--
        unlock(&c.lock); return v, true
    }
    if !block { unlock(&c.lock); return zero(T), false }
    me := enqueueSelf(&c.recvq)
    parkAndUnlock(&c.lock)                       // woken by a sender or by close
    return me.elem, me.success
}

Consequences: every operation takes a lock, so one busy shared channel is a contention point; values are copied, so large structs are usually sent by pointer under the ownership rule; and a parked goroutine costs a wait record and its stack, not an OS thread.

How select works

A select statement waits on several channel operations. The runtime implements it in two passes. First it shuffles the cases into a random poll order, and sorts the channels by address into a lock order. It locks every channel in lock order, so that two selects over the same channels can never deadlock each other, and checks the cases in poll order. If one can proceed, it completes that case and unlocks everything. The random order stops any ready case from being starved.

If none is ready and there is a default case, select takes it immediately, which is how you write a non-blocking send or receive. Otherwise it enqueues the goroutine on every channel's wait queue, parks, and when woken by one of them, dequeues itself from all the others. A nil channel blocks forever, so setting a variable to nil disables its case in a loop, and a case on ctx.Done() or a timer channel lets any blocking operation be cancelled or timed out.

Close semantics and the ownership rule

Close in Go has asymmetric rules that are worth memorizing. Receiving from a closed channel never blocks: it returns remaining buffered values first, then the zero value with ok false, and a for range loop over the channel ends. Sending on a closed channel panics. Closing an already closed channel panics. Closing a nil channel panics.

Those rules lead to one design principle: only the sender closes, and only when it is the sole sender. With several senders, none of them can know that the others are done, so a coordinator that waits for all of them closes instead. In the stage below, workers drain the input until it closes, a separate goroutine closes the output after every worker finishes, and each send also watches a context so a departed consumer cannot strand workers.

func stage(ctx context.Context, in <-chan Job, workers int) <-chan Result {
    out := make(chan Result, 64)             // bounded: a slow consumer pushes back
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range in {            // ends when the upstream closes in
                r := process(job)
                select {
                case out <- r:
                case <-ctx.Done():           // never block forever on a send
                    return
                }
            }
        }()
    }
    go func() { wg.Wait(); close(out) }()    // exactly one closer: the owner of out
    return out
}

Close is also a broadcast: closing a chan struct{} wakes every waiter, which is how context.Context implements Done(). Use close to say that something has finished, and use a value send to say that something has happened.

Channels in Kotlin, Rust and Java

The same design choices appear in other runtimes with different defaults, and the differences matter when you move code or ideas between them.

RuntimeCapacity optionsCloseNotes
Go chan T0 (unbuffered) or nexplicit close; send after close panicsMPMC; select built into the language
Kotlin ChannelRENDEZVOUS, n, BUFFERED (64 by default), CONFLATED, UNLIMITEDclose(); send after close throwssuspends coroutines, not threads; overflow can be SUSPEND, DROP_OLDEST or DROP_LATEST
Rust std::sync::mpscchannel() unbounded, sync_channel(n) bounded, 0 is rendezvousimplicit: closes when all senders, or the receiver, are droppedMPSC; built on the crossbeam implementation since Rust 1.67
Rust tokio mpscchannel(n) with n greater than 0, or unboundedimplicit on drop, or explicit close() on the receiverasync; select! macro for waiting on several
JavaSynchronousQueue, ArrayBlockingQueue, LinkedBlockingQueuenone; use a sentinel value or interruptqueues, not channels; no select
// Kotlin: capacity and overflow are part of the channel's type-level contract
val ch = Channel<Event>(capacity = 256, onBufferOverflow = BufferOverflow.SUSPEND)
val latest = Channel<Price>(Channel.CONFLATED)      // keeps only the newest value

// Rust (std): the channel closes when every Sender is dropped
let (tx, rx) = std::sync::mpsc::sync_channel::<Job>(256);   // 0 would be a rendezvous
for job in rx { handle(job); }                              // ends after the last tx drops

// Java: no channel type; a bounded BlockingQueue plus a poison pill for close
BlockingQueue<Job> q = new ArrayBlockingQueue<>(256);
q.put(job);                         // blocks when full
Job j = q.take();                   // blocks when empty; stop on a sentinel POISON job

Rust ties close to ownership: when the last Sender drops, the receiver's loop ends, and sending to a dropped receiver returns the value in an error instead of panicking. Java has no close, so each consumer needs its own stop signal; a missing poison pill leaves a thread that never exits.

Worked example: sizing a buffer

A log shipper reads events from a socket and writes them to a remote store in batches. The store accepts about 20,000 events per second on average, and incoming traffic averages 12,000 per second. Every few minutes the store pauses for up to 250 milliseconds during its own compaction. How large should the channel between reader and writer be?

A buffer exists to absorb the gap between arrival and service during a stall. During a 250 ms pause, 12,000 per second times 0.25 seconds is 3,000 events arrive with nothing leaving, so a capacity of about 4,000 absorbs the pause with some margin. After the pause the writer drains at 20,000 per second against 12,000 arriving, clearing 3,000 extra events in under half a second. A bigger buffer buys nothing and hides trouble: if the store slows permanently, a million-event buffer only delays backpressure while adding latency and memory. When the buffer fills, the reader blocks, the socket window fills and the sender slows, which is the backpressure you want.

The rule: size a buffer as arrival rate times the longest stall to absorb, and alert when it stays near full, because a buffer that is always full is a throughput problem.

Failure modes

  • Goroutine leaks. A goroutine blocked on a send nobody receives, or a receive nobody sends to or closes, lives forever, usually after an early return in the consumer. Pair every blocking operation with cancellation.
  • Deadlock. Two goroutines each waiting to send to the other on unbuffered channels, or a goroutine sending to an unbuffered channel it is also supposed to receive from. If every goroutine is blocked, Go reports all goroutines are asleep - deadlock!; if only some are, the program hangs silently. See deadlock detection for the general theory.
  • Panics on close. Several senders each closing a shared channel, or a sender writing after a coordinator closed it. Give each channel exactly one closer.
  • Unbounded buffers. An unlimited channel in Kotlin or Rust, or a LinkedBlockingQueue with default capacity in Java, grows until the process runs out of memory when the consumer falls behind.
  • Contention. One channel shared by dozens of busy goroutines serializes them on its lock. Shard into several channels, batch several items per send, or use a purpose-built lock-free structure; see lock-free data structures.
  • Shared mutation after send. Sending a pointer or slice and then continuing to modify it reintroduces the data races channels were meant to remove.

Trade-offs: when not to use a channel

Channels are best at transferring ownership of work or data between stages and at signalling events such as completion or cancellation. They are a poor fit for protecting shared state that many goroutines read and update, such as a cache or a counter; a mutex or an atomic is simpler and faster. The per-operation lock and copy also matter when each item takes only nanoseconds of work.

Unlike actors, channels are anonymous and first-class: any goroutine holding one can use it, and one goroutine can listen on many. Actors bind a mailbox to an identity and supervision; see the actor model. Many real systems use both.

What to do next

  1. Audit every unbounded channel or queue in your code and replace it with a bounded one sized by rate times tolerated stall.
  2. For every channel, name its single closer, and make sure multi-sender channels are closed by a coordinator after a wait group.
  3. Add a cancellation case, a context or a done channel, to every blocking send and receive in long-running goroutines.
  4. Run your tests with goroutine leak detection, such as a check that the goroutine count returns to its baseline after each test.
  5. Measure channel contention with the mutex and block profiles in pprof before shard or batch changes.
  6. Rewrite one mutex-guarded work queue as a channel pipeline, and one channel-guarded counter as a mutex or atomic, and compare clarity and speed.
Key takeaway: A channel is a queue, a synchronization mechanism and an ownership transfer in one primitive. Its behaviour is set by four choices: capacity, multiplicity, close semantics and select. In Go, a channel is a locked ring buffer with two queues of parked goroutines, sends hand values directly to waiting receivers, and select locks channels in address order and polls them in random order. Keep channels bounded, give each one a single closer, pair every blocking operation with cancellation, and use mutexes rather than channels to guard shared state.