The question this answers
What does a channel guarantee that a shared queue does not, and how does a receiver learn that the sender is finished?
A crawler: one fetcher goroutine-equivalent sends parsed pages into a channel, three parser tasks receive them, and a writer collects results. When the URL frontier is exhausted the fetcher must tell the parsers to stop.
The channel buffer and its closed flag. Nothing else — items sent into a channel are, by convention and sometimes by the type system, no longer touched by the sender.
Every value sent is received exactly once by exactly one receiver; no value is received after the channel is closed and drained; and every receiver blocked on a closed, drained channel is woken exactly once with an end-of-stream result.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
Capacity is the whole personality of a channel
An unbuffered channel is a *rendezvous*: the sender blocks until a receiver is ready, and the receiver blocks until a sender arrives. The handoff is a synchronization point, which means an unbuffered send tells you something a queue never can — that someone has actually taken the value. That is occasionally exactly what you want (a handoff you need to confirm) and usually more coupling than you want (the sender is now pinned to the receiver's pace).
A buffered channel of capacity N lets N sends complete without a receiver present, and then behaves like a bounded blocking queue (Bounded vs Unbounded Queues). The capacity is a burst allowance, not a performance knob: capacity 1 vs capacity 1 000 changes how much decoupling you get and how much memory you commit, and changes nothing about steady-state throughput when the consumer is the constraint.
The unbounded channel — offered by some libraries, and by every naive implementation on top of a list — is the one to distrust for the reasons in Bounded vs Unbounded Queues. It makes send infallible and non-blocking, which is exactly the property that breaks the backpressure chain (Backpressure).
1// No channel in the standard library. This is the primitive you build from.2template <class T> class Channel {3 std::mutex m; std::condition_variable cv;4 std::queue<T> buf; size_t cap; bool closed = false;5public:6 bool send(T v) {7 std::unique_lock lk(m);8 cv.wait(lk, [&]{ return closed || buf.size() < cap; });9 if (closed) return false; // send on closed channel10 buf.push(std::move(v)); cv.notify_all(); return true;11 }12 std::optional<T> recv() {13 std::unique_lock lk(m);14 cv.wait(lk, [&]{ return closed || !buf.empty(); });15 if (buf.empty()) return std::nullopt; // closed AND drained16 T v = std::move(buf.front()); buf.pop(); cv.notify_all(); return v;17 }18 void close() { { std::lock_guard lk(m); closed = true; } cv.notify_all(); }19};You write the close protocol yourself, and the notify_all in close() is the line everyone forgets — without it, receivers already parked in wait() are never woken and the program hangs on shutdown.
1// No channel primitive. An async generator is the idiomatic stand-in:2async function* pages(urls) {3 for (const u of urls) yield await fetchPage(u)4} // "close" = the generator returning5 6for await (const page of pages(urls)) {7 parse(page)8} // loop exits on return; no explicit close call9 10// Backpressure is implicit: the generator does not advance until the11// consumer asks for the next value. One consumer only, though.An async generator gives you close-for-free (return ends the loop) and rendezvous-style backpressure for free, but it is single-consumer: you cannot fan one out to three parsers without building a distributor.
1// A minimal typed channel over promises. Closing resolves waiting receivers2// with a sentinel rather than leaving them pending forever.3type Closed = typeof CLOSED4const CLOSED = Symbol('closed')5 6class Channel<T> {7 private buf: T[] = []8 private waiters: ((v: T | Closed) => void)[] = []9 private closed = false10 send(v: T): void {11 if (this.closed) throw new Error('send on closed channel')12 const w = this.waiters.shift()13 if (w) w(v); else this.buf.push(v)14 }15 recv(): Promise<T | Closed> {16 if (this.buf.length) return Promise.resolve(this.buf.shift() as T)17 if (this.closed) return Promise.resolve(CLOSED)18 return new Promise((res) => this.waiters.push(res))19 }20 close(): void {21 this.closed = true22 for (const w of this.waiters.splice(0)) w(CLOSED) // wake every receiver23 }24}The type system carries the end-of-stream signal: recv returns T | Closed, so the compiler forces every call site to handle the drained case. That is the strongest version of the close contract available without a language primitive.
1import asyncio2q: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=64)3 4async def producer(urls):5 for u in urls:6 await q.put(await fetch(u)) # blocks when full: real backpressure7 for _ in range(3):8 await q.put(None) # one poison pill per consumer9 10async def consumer():11 while True:12 item = await q.get()13 if item is None: # asyncio.Queue has no close()14 q.task_done(); return15 parse(item); q.task_done()asyncio.Queue is bounded and blocking but has no close, so end-of-stream is a sentinel you send once per consumer. Send one pill for three consumers and two of them hang forever — the classic version of this bug.
- Only some ecosystems give you a close primitive. C++ and Python asyncio do not, so the end-of-stream signal is something you design — and a sentinel must be sent once per receiver, not once per channel.
- Rendezvous (unbuffered) semantics are natural in CSP-style languages and awkward everywhere else; a capacity-zero channel built on a condition variable needs an explicit acknowledgement handshake to be a true rendezvous.
- JavaScript has no shared-memory channel between the main thread and a worker — values crossing a
postMessageboundary are structured-cloned or transferred, so "send" is a copy or a move, never a shared reference (Web Workers, Worker Threads). - Blocking behaviour differs in kind: C++ blocks an OS thread, asyncio suspends a task on one thread, and JavaScript cannot block at all — an "empty channel" there is a pending promise, never a parked thread (A Task Is Not a Thread).
- Multi-consumer fan-out is free with a real channel and needs a distributor with generators; the difference matters the moment you want three parsers instead of one.
Closing: the part that goes wrong
Close answers one question: how does a receiver distinguish "nothing has arrived yet" from "nothing will ever arrive"? Those two states are identical from inside a blocking receive, and a receiver that cannot tell them apart waits forever. That is the single most common pipeline shutdown bug, and it presents as a service that takes exactly its termination-grace period to stop on every deploy (Draining a Pipeline).
The rules that make close work are narrow and worth memorising. Only the sender closes, because only the sender knows there is no more data — a receiver closing is a receiver deciding on behalf of a party it cannot see. Close once, because a double close is a programming error in every implementation that has the concept. Close after the last send, not before, and never concurrently with a send from another goroutine or task; if there are many senders, they need their own coordination (a wait group, a counter) and the *last one out* closes.
And the receiver's side: receiving from a closed channel must drain the buffer first and only then report end-of-stream. A close that discards buffered items turns "the producer finished" into "the last 40 items were thrown away", which is a data-loss bug disguised as a shutdown bug. The schedule below shows both the correct sequence and the multi-sender close race.
| # | Sender 1 | Sender 2 | Receiver | State |
|---|---|---|---|---|
| 1 | send(page-A) | · | · | buffered=1 closed=false |
| 2 | finishes its URL list; calls close() | · | · | buffered=1 closed=true |
| 3 | · | · | recv() → page-A (drains buffer before reporting end) | buffered=0 closed=true |
| 4 | · | send(page-B) on a closed channel | · | buffered=0 closed=true ✕ A send occurred after close. Depending on the implementation this panics, throws, or silently drops page-B — and silently dropping is the worst of the three. |
| 5 | · | · | recv() → end-of-stream | buffered=0 closed=true |
| 6 | close() again (defensive double close) | · | · | closed=true ✕ A second close is a programming error in every implementation that defines close, and usually aborts the process. |
Channel or queue?
A channel is a bounded queue plus three things: a close signal, an ownership convention, and usually a select-style operation that lets a task wait on several channels (or a channel and a cancellation signal) at once. That last one is more important than it looks, because it is how a receiver waits for data *and* remains cancellable — without it, a task blocked in receive cannot be interrupted, which makes Cancellation impossible to implement cleanly.
Prefer a channel when the language has one, when you need the close signal, or when you want to select over multiple sources. Prefer a plain bounded queue when you are on a platform without channels and building one would mean re-implementing close semantics badly, or when the consumers are a fixed worker pool that never needs to select. The distinction is not about performance — under contention both are a lock and a condition variable, and both cost about the same (Concurrent Queues).
What a channel does not give you: durability, delivery guarantees across a process boundary, or ordering across multiple receivers. It is an in-process construct. The moment the two ends are on different machines, every property changes and you are choosing a broker instead (Message Queues in Architecture).
| Property | Unbuffered channel | Buffered channel (cap N) | Bounded queue |
|---|---|---|---|
| Send completes when | A receiver has taken the value | There is a free slot, or a receiver is waiting | There is a free slot |
| Confirms delivery to sender | Yes — that is the rendezvous | No | No |
| Decouples producer and consumer rates | Not at all | For up to N items | For up to N items |
| End-of-stream signal | close(), built in | close(), built in | Sentinel per consumer, hand-rolled |
| Wait on several sources at once | select / alternation | select / alternation | Not without extra machinery |
| Cancellable receive | Yes, via select on a cancel signal | Yes | Only if the API offers a timed poll |
| Good for | Handoffs you must confirm; strict lockstep stages | Pipelines with bursty stages | Fixed worker pools on platforms without channels |
Key points
- Capacity is the design decision: zero means rendezvous and confirmed handoff, N means a burst allowance, unbounded means the backpressure chain is broken.
- Close exists to let a receiver distinguish "nothing yet" from "nothing ever". Without it, blocked receivers hang at shutdown.
- Only the sender closes, exactly once, after the last send. With multiple senders, the close belongs to whoever observes the last one finishing.
- Receive from a closed channel must drain the buffer before reporting end-of-stream, or close becomes silent data loss.
- A sentinel value substituting for close must be sent once per receiver — one pill for three consumers hangs two of them.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • A send acquires the channel state, waits while the buffer is full and the channel is open, appends the value, and wakes one waiting receiver.
- • A receive acquires the state, waits while the buffer is empty and the channel is open, removes a value, and wakes one waiting sender.
- • Close sets a flag and wakes *every* waiter — senders so they can fail, receivers so they can re-evaluate and observe end-of-stream.
- • After close, receive returns buffered values until the buffer is empty, then returns the end-of-stream result to every subsequent caller, forever, without blocking.
- • A select waits on several channels at once by registering interest on each and taking the first that becomes ready, which is what makes a receive cancellable.
- • Unbuffered: S calls send and blocks; R calls recv; the value transfers and both proceed. Neither ran before the other was ready — that is the synchronization the rendezvous buys.
- • Buffered cap 2: S sends three times; the first two return immediately, the third blocks; R receives once; S's third send completes. The capacity absorbed exactly two items of burst.
- • S1 closes while S2 is mid-send: the send lands on a closed channel and panics, throws, or is dropped — the multi-sender close race, and the reason close needs a join point.
- • R is blocked in recv; S closes; R must be woken. A close implemented with notify_one instead of notify_all wakes one of three receivers and the other two hang — the same failure as a Lost Wakeups: The Notify That Arrived Before the Wait bug, but in the shutdown path where tests rarely go.
- • Sentinel-based close with three consumers and one sentinel: consumer A takes the sentinel and exits; B and C block on an empty, never-again-written queue forever.
- • A channel guarantees each value is delivered to exactly one receiver, and that values from a single sender arrive in send order.
- • It does NOT guarantee order across multiple senders — interleaving is whatever the scheduler produced, and there is no global sequence.
- • It does NOT guarantee the receiver processed the value. An unbuffered send confirms *receipt*, never completion.
- • It does NOT guarantee the sender or receiver still exists. A send into a channel whose only receiver has exited blocks forever unless something closes or cancels it — a leak that looks exactly like slow work.
- • Close guarantees that receivers observe end-of-stream after draining. It does NOT guarantee that in-flight senders are stopped, which is why send-after-close is an error rather than a no-op.
- • Every send and receive touches the same lock and condition, so a channel is a serialisation point exactly like a queue — high fan-in on one channel is the same bottleneck (What Contention Actually Costs).
- • An unbuffered channel maximises context switching: every single value costs a block and a wake on both sides, which is why rendezvous is expensive for high-rate streams.
- • Close using notify_all wakes every waiter at once; with many receivers that is a small thundering herd, though a one-time one (Thundering Herd).
- • A select over many channels costs registration on each one, so waiting on 200 channels is meaningfully more expensive than waiting on two.
- • Shutdown hang: receivers blocked on a channel nobody closes.
- • Send on closed channel: panic, exception, or silent drop depending on implementation.
- • Double close, which is a fatal error in most implementations.
- • Lost items when close discards a non-empty buffer.
- • Sentinel undercount: N receivers, fewer than N poison pills, and the remainder block forever.
- • Deadlock on an unbuffered channel when both parties are in the same task, or when a cycle of stages each waits to send to the next (The Four Conditions).
- • When stages have genuinely different rates and you want the burst absorbed explicitly, with a capacity number in the code stating how much.
- • When shutdown correctness matters, because close is a first-class signal rather than a convention you have to enforce in review.
- • When a task must wait on data *and* remain cancellable — select over the channel and a cancellation signal is the clean form of that (Cancellation).
- • When you want to eliminate shared mutable state between stages rather than lock it (Message Passing).
- • When the values are large and the language copies them across the boundary — a channel between JavaScript workers structured-clones, which can dominate the work (Copy or Share?).
- • When per-item cost is tiny: unbuffered channels at a million items a second spend most of their time in the scheduler.
- • When multiple senders make close ownership genuinely unclear — that is a signal the pipeline shape is wrong, not that you need a cleverer close.
- • When the language has no channel and you build one: you will get capacity right and close wrong, which is the expensive half.
- • Channel depth over time, if the implementation exposes it; if it does not, wrap send and receive with counters, because an opaque channel is an unobservable bottleneck.
- • Send block time — the direct measure of whether the downstream stage is the constraint.
- • Shutdown duration per deploy. A pipeline that always takes exactly the grace period to exit has a receiver blocked on an unclosed channel, every time.
- • Send-after-close errors as a counter; a non-zero value means the multi-sender close race is live in production.
- • For unbuffered channels, context switches per item — if it approaches two per item, the rendezvous is the cost (Context Switching in Operating Systems).
- • You own a close protocol, and it has to be correct on the error path too — a sender that throws before closing hangs every receiver.
- • Multiple senders require a join mechanism purely so that someone can close, which is real coordination code with its own failure modes.
- • Select-based receive spreads the cancellation signal into every loop, so the shape of every stage changes when you add cancellation.
- • Debugging a hung pipeline means finding which receiver is parked on which channel, which needs thread or task dumps rather than logs (Task Dumps: When the Threads Look Idle and Nothing Is Moving, Reading a Thread Dump).
- • A bounded blocking queue with an explicit sentinel, when the platform has no channel — same semantics, and the close protocol is visible rather than assumed.
- • An async generator or iterator for single-consumer pipelines: end-of-stream and backpressure both come for free from the iteration protocol.
- • A callback or an event emitter when there is no need to bound or block — cheaper, but note that this is the shape with no backpressure at all (Backpressure).
- • A broker when the two ends may be in different processes now or later; retrofitting a channel into a network hop is a rewrite (Message Queues in Architecture).
What people believe, and what is true
Closing a channel cancels the senders.
Close is a signal to *receivers*. A sender mid-send is not stopped; it fails, panics or drops, which is why close needs a join point when there are several senders.
An unbuffered channel is slower because it has no buffer.
It is slower because every item costs a block and a wake on both sides. The absence of a buffer is the reason, but the cost is scheduler work, not memory access.
A sentinel value is equivalent to close.
A sentinel is consumed by one receiver. Close is observed by all of them. With N receivers you need N sentinels, and the count is a thing that drifts when someone changes the pool size.