2026-10-31·par Sijie Wang

Channel

Channel: share memory by communicating

The motivation: do not communicate by sharing memory; share memory by communicating. A mutex guards a shared variable, but two goroutines still have to agree on which variable, which lock, and who touches it when. A channel turns that agreement into a single object: one side sends, the other receives, and the handoff itself is the synchronization. Communication is not an extra on top of shared state; it replaces it.

The database analogy, and where it breaks. A channel feels like a message-queue table in a database: producers insert rows, consumers read them in order. Two differences matter. First, a channel does not persist: nothing is written to disk, and when the program ends, whatever is still queued is gone. Second, an unbuffered channel has no database equivalent at all: there is no table, no row, only a face-to-face handoff where the sender waits until a receiver arrives and the value passes directly from one memory to the other.

Unbuffered: a pure handoff. make(chan T) has capacity 0 and no buffer. A sender parks in a wait queue holding its value; a receiver arrives and copies the value directly out of the sender's memory. The send completes at the copy plus the wakeup enqueue, before the receiver has necessarily run yet. The waiting queues hold goroutines, not values.

Buffered: a bounded queue. make(chan T, N) has N slots. A send completes as soon as the value is copied into a free slot; a receive completes as soon as a value is taken out. A full buffer parks the sender, an empty buffer parks the receiver. Capacity is fixed at creation and never grows: unlike a slice, which doubles when appended past capacity, a channel buffer has no growth path. A full channel pushes back instead of growing, and that backpressure is the point.

Value semantics, and the reference-type danger. A channel copies the value. For a value type, the receiver gets an independent copy. For a reference type (slice, map, pointer), only the reference is copied and the underlying data is shared. The sender touching a sent slice or map afterwards is a data race, and a concurrent map write is fatal. "The sender stops touching it after send" is a convention, not something the mechanism enforces; the race detector is the check, not the channel.

One lock, held for nanoseconds. Each channel has a single mutex guarding its buffer and wait queues. That sounds coarse, but the critical section is tiny: lock, pointer arithmetic, one value copy, one wakeup enqueue. No syscall, no I/O, no sleep happens inside it, so the lock is held for tens of nanoseconds. Contrast a database row lock, which can span disk I/O in microseconds to milliseconds. This nanosecond scale is why one lock suffices.

Close: the producer's job, and a broadcast. Only the sender side closes, never the receiver: untying is the tier's job, as the saying goes. Close is a "no more data" broadcast, not a deletion: values already buffered still drain in order, and a receive on a closed, drained channel returns the zero value with ok false. Two panics guard the contract deliberately: sending after close panics, and closing twice panics. range over a channel ends only on close plus drain; without a close it waits forever. And close does not free memory; that is the garbage collector's job.

Panic is not an error. A panic is a programming-bug crash with a stack trace that kills the whole process unless a deferred recover catches it. An error is an expected, handleable failure. Normal code must not use panic as error handling; the close panics above are fail-fast guards for contract violations, not error values.

Select: waiting at several doors at once. A plain receive waits on one channel, but a goroutine often waits on several things: a job, a timeout, a shutdown signal. select waits on many channel operations at once and takes whichever becomes ready first. If several are ready at once, the choice is random, not in written order, so no case can starve the others. A default branch makes it non-blocking: try once, take the default immediately if nothing is ready. A case on a nil channel is never ready, so setting a channel variable to nil switches that case off without restructuring.

Done: a channel that carries no data. done is not a keyword or a special type; it is an ordinary channel, conventionally chan struct{}, where the empty struct costs zero bytes because no data is ever sent. The only operation is close(done), and close is a broadcast: every goroutine waiting on <-done wakes at once. Sending a single value would wake only one waiter, which is why close, not send, is the shutdown signal. The standard library's context.Context method Done() is the official version of this pattern.

Directional channels: one door, two passes. chan T is bidirectional: whoever holds it can send and receive. chan<- T is send-only and <-chan T is receive-only. The channel underneath is the same; only the pass differs, and the compiler enforces it. A bidirectional channel narrows to a directional parameter automatically, but a directional channel never widens back. A send-only channel can still be closed; a receive-only channel cannot. The value is twofold: the signature documents who sends and who receives, and the compiler catches a mistaken send or receive before it becomes a runtime deadlock or panic.

Deadlock: all asleep. Leak: quietly alive. A deadlock means every goroutine is asleep and nobody can wake anybody else: an unbuffered send with no receiver, or two goroutines waiting on each other. The runtime reports it only when all goroutines are asleep; if even one goroutine can still run, the program hangs silently with no error. A leak is the opposite shape: a goroutine with no exit path that stays parked forever on a channel nobody will ever send to or close. The program keeps running, but the parked stack and its references pile up. The runtime never reports leaks: they are found with pprof goroutine profiles, or in tests with goleak, which snapshots all goroutine stacks at test end, filters out the runtime's own and whitelisted ones, and fails on whoever is left. Prevention is design, not mechanism: every spawned goroutine needs a visible exit path (done, close, timeout), and whoever starts it owns stopping it.

hchan: the channel itself. make allocates one runtime struct, hchan, and a channel value is a pointer to it. Every behavior above is a field moving:

A send takes the lock, hands directly to a waiting receiver if one exists, otherwise writes into the slot at sendx if the buffer has room, otherwise parks in sendq. A receive is the mirror. Close sets closed and wakes both queues. The struct grows exactly the fields the behavior needs, and no more.

The ring buffer: finite memory, endless stream. The buffer is one contiguous fixed array, not a linked list: nodes with next pointers scattered in memory would cost an allocation per element and lose cache locality. The "ring" is index arithmetic, not a property of Go arrays: a plain Go array is linear and panics out of range. hchan advances sendx and recvx and wraps them back to zero at the end, so the pointers circle while the data never moves. A separate qcount tells full from empty, since equal pointers alone cannot. The queue is bounded in space (at most N waiting at any moment) and unbounded in time (slots recycle, so sending and receiving can go on forever). That is the whole point: finite memory serving an endless stream.

The wait queues have no cap, but they never pile together. sendq and recvq are linked lists with no size limit. That is safe to say only with the invariant: at most one of them is non-empty at any moment, because a sender arriving to a waiting receiver hands off directly instead of queueing. What piles up is goroutines themselves, each holding a stack, which is exactly the leak shape. The bound comes from design (pool size, done channels, semaphores), not from hchan.

Four patterns, each with its scene. These are cross-language patterns, not Go inventions; Go's distinction is that one primitive expresses all four smoothly.

  • Worker pool. Scene: jobs arrive in waves and each takes a while; one goroutine per job would swamp the downstream. Shape: N fixed workers loop over a jobs channel, main sends jobs and closes it when done, workers exit when it drains. Concurrency equals N.
  • Fan-out and fan-in. Scene: asking several backends at once to assemble one page, or running a batch of evaluations in parallel. Shape: fan-out splits one input across workers; fan-in has many workers send into one result channel for a single receiver to aggregate. The result channel must be closed only after all workers finish, or the receiver ends early.
  • Pipeline. Scene: log processing or ETL, where data passes read, parse, transform, write, each stage slower than the next. Shape: each stage is a goroutine plus a channel, the previous stage's output is the next stage's input, stages run in parallel. Each stage closes its output when done so the next range ends; a slow stage backpressures the one above it instead of piling up.
  • Buffered channel as semaphore. Scene: at most 10 requests at once against a fragile downstream, without keeping workers resident. Shape: sem := make(chan struct{}, 10); take a slot before work, release it after. It caps the number in flight, nothing more.

Where channels belong in production. For an ordinary web service, hand-rolling channel plumbing for request handling is reinventing the framework: the HTTP server already runs one goroutine per request, and middleware, timeouts, and rate limits come configured. Channels live inside the framework, and at the key points: fan-out aggregation, concurrency caps, shutdown, timeouts, and the hub of a server-sent-events or WebSocket fan-out, where one central goroutine plus one channel per client is the standard shape. errgroup is the framework-packaged form of fan-out with limits and error collection: configure the limit and let it run. Learn channels to read the framework's behavior and to find the layer a problem sits in, not to hand-write a set on every project.

à propos de cette entrée

L'une des entrées de wiki de sijie. L'IA sur ce site est fondée sur le même corpus et répond à la voix de sijie, avec citations revenant à des entrées comme celle-ci — répondre coûte argent à sijie, donc cela attend derrière un code : entrer un code d'accès →

Channel — Sijie Wang