skip to content

Which happens-before edges does an unbuffered channel operation create in Go?

level: middleimportance: must knowfreq 58%

answer

  1. the value is not all that travels
  2. one edge each way on an unbuffered channel
  3. a returned send can mean the receiver arrived
  4. capacity C generalises it to k and k+C
  5. closing is an edge to every receiver

basics

~20 s

Two, in opposite directions. A send is synchronized before the matching receive completes, so the receiver sees everything the sender wrote first. On an unbuffered channel the receive is also ordered before the send completes.

solid answer

~50 s

A send on a channel is synchronized before the corresponding receive from that channel completes. That is the edge that makes "share memory by communicating" safe: everything the sender wrote before the send — including memory reached through a pointer it sent — is guaranteed visible to the receiver after the receive returns, with no lock. An unbuffered channel adds a second edge running the other way: a receive is synchronized before the corresponding *send* completes, which is why a send on an unbuffered channel returning tells the sender that a receiver has arrived. Buffered channels weaken that: with capacity C, the kth receive is synchronized before the (k+C)th send completes, so an early send can return with no receiver in sight. Closing is an edge too — `close` is synchronized before a receive that returns the zero value because the channel is closed.

code

go · 13 lines
go
type Config struct{ Addr string }

var cfg *Config

func publish(ready chan struct{}) {
	cfg = &Config{Addr: ":8080"}
	ready <- struct{}{} // send: ordered before the receive completes
}

func serve(ready chan struct{}) {
	<-ready
	fmt.Println(cfg.Addr) // guaranteed to observe the write above
}

go deeper

for a junior

Know that sending a value over a channel safely hands over the data behind it, so the receiver does not need a lock to read what the sender prepared.

for a middle

State both edges: send before the receive completes, and on an unbuffered channel receive before the send completes. Be ready to say what changes when the channel has capacity.

for a senior

Show you rely on this deliberately — ownership transfer over a channel instead of shared mutable state — and that you can spot code concluding too much from a buffered send returning.

for a principal

Own the convention: pick channel capacity for the backpressure semantics you want, and document which handoffs in a service are rendezvous points versus queued, because the two give different guarantees under load.

## Why channels are a memory-model feature, not just a queue A channel moves a value, but the interesting guarantee is what it moves *besides* the value. Go's memory model gives channel operations happens-before edges, which is what makes the idiom "don't communicate by sharing memory; share memory by communicating" actually sound. Send a pointer over a channel and the receiver may dereference it without a mutex, because the send ordered every write the sender made beforehand. ## Edge one: send before receive completes *A send on a channel is synchronized before the completion of the corresponding receive from that channel.* So in a producer that fills a struct and then sends a pointer to it, the consumer that receives that pointer is guaranteed to see the fully populated struct. This holds for buffered and unbuffered channels alike, and it is transitive: whatever the producer itself had observed through earlier edges travels along too. The practical consequence is an ownership discipline. Once a value has been sent, the sender must stop touching it — the edge orders the sender's *earlier* writes, not any writes it makes afterwards. Sending a pointer and then continuing to mutate the pointee is a data race, and one that no amount of channel usage fixes. ## Edge two: receive before send completes (unbuffered only) *A receive from an unbuffered channel is synchronized before the completion of the corresponding send on that channel.* This reverse edge is the formal statement of "an unbuffered channel is a rendezvous". When your send on an unbuffered channel returns, the matching receive has already happened. That is why an unbuffered channel gives real backpressure and why `ch <- struct{}{}` can be used as a handoff acknowledgement: the sender learns something about the receiver's progress. It is also what makes an unbuffered send a synchronization point in both directions — the receiver sees the sender's earlier writes, and the sender knows the receiver's receive has occurred. ## Buffered channels: the general rule *The kth receive on a channel with capacity C is synchronized before the completion of the (k+C)th send.* Set C = 0 and this reduces to the unbuffered rule above. With C > 0, the first C sends can complete with no receiver having run at all — the capacity is exactly the slack. This is the formal reason a buffered channel of capacity C works as a counting semaphore: acquiring is a send, releasing is a receive, and the (k+C)th acquire cannot complete until the kth release has happened. The practical warning is the flip side: on a buffered channel, a send returning proves **nothing** about the receiver. Code that says "the send returned, so the worker has the job" is only true when the channel is unbuffered. ## Closing *The closing of a channel is synchronized before a receive that returns a zero value because the channel is closed.* That makes `close` a broadcast edge: every goroutine blocked on `<-done` observes everything the closer wrote before closing. It is why a `done` channel is the standard way to publish "initialisation finished" or "shut down now" to an unknown number of goroutines. Note the direction — closing tells receivers about the closer's writes; it says nothing about what the receivers do next. ## What is *not* guaranteed - Two sends from different goroutines are ordered relative to each other only by whatever edges those goroutines already share; the channel does not impose a global order on the senders' unrelated memory. - A receive says nothing about work the *receiver* will do later, so "I received, therefore the sender may reuse the value" is backwards — that needs a second channel or an explicit acknowledgement. - A `select` choosing among several ready cases picks one in an unspecified way; the edges apply to the operation that actually executed, not to the ones that did not. ## Saying it in an interview The compact version is: **a send happens-before the matching receive completes (so the data and everything behind it travels safely), a receive happens-before the matching send completes on an unbuffered channel (so the sender learns the receiver arrived), and capacity C generalises the second rule to k and k+C.** Add that `close` is an edge to every receiver that sees the closure, and you have covered every channel guarantee the model makes.

  • What ordering does closing a channel give you?
    The `close` is synchronized before any receive that returns the zero value because the channel is closed. So every goroutine that observes the closure also observes every write the closing goroutine made beforehand. That makes closing a `done` channel a broadcast edge to an unknown number of waiters — the standard "initialisation complete" or "shut down" signal.
  • Your send on an unbuffered channel has just returned — what may you conclude?
    That the corresponding receive has happened: a receive from an unbuffered channel is synchronized before the completion of that send. The receiver has taken the value, though it has not necessarily finished processing it. On a buffered channel you may conclude nothing — the value may just be sitting in the channel's buffer.
  • How does a buffered channel with capacity 4 change the guarantee?
    The rule becomes: the kth receive is synchronized before the completion of the (k+4)th send. The first four sends can complete with no receiver having run. That slack is exactly what makes a buffered channel usable as a counting semaphore, and it is why a returned send on a buffered channel proves nothing about the receiver.
  • If you send a pointer, may the sender keep writing through it?
    No. The edge orders the sender's writes made *before* the send, not after it. Continuing to mutate the pointee after sending races with the receiver's reads. The convention is ownership transfer: once you send a pointer, treat it as no longer yours.

saying these in an interview costs you the question

  • Says a completed send on a buffered channel proves the receiver ran
  • Thinks only the value travels, not the sender's earlier writes
  • Adds a mutex around data already handed over a channel
  • Claims a receive from a closed channel gives no ordering
  • Keeps mutating a struct after sending a pointer to it