skip to content

In Go, when does a send block on make(chan int) versus make(chan int, 3)?

level: juniorimportance: must knowfreq 82%

answer

  1. two goroutines meeting versus a mailbox
  2. what make's second argument changes
  3. capacity zero forces sender and receiver to meet
  4. a buffered send waits only when full

basics

~20 s

A send on a channel made with make(chan int) blocks until another goroutine is ready to receive. With make(chan int, 3) the send returns immediately while the buffer holds fewer than three values, and blocks only once it is full.

solid answer

~40 s

`make(chan int)` creates an unbuffered channel: `cap` is 0, and `ch <- v` parks the sending goroutine until another goroutine reaches a receive, at which point the value is handed straight across. The send and the receive are a rendezvous — both sides wait for each other. `make(chan int, 3)` creates a fixed ring of three slots: the first three sends copy their value into a free slot and return with no receiver in sight, and the fourth send blocks until something is taken out. Receives block when the buffer is empty in both cases. Nothing is ever dropped and nothing overwrites — Go channels have no lossy mode and no unbounded mode, and the capacity you pass to `make` is fixed for the channel's life.

code

go · 10 lines
go
buffered := make(chan int, 2)
buffered <- 1
buffered <- 2 // still returns: the buffer now holds two values
fmt.Println(len(buffered), cap(buffered)) // prints: 2 2

unbuffered := make(chan int) // cap 0
fmt.Println(cap(unbuffered))  // prints: 0

go func() { <-unbuffered }()
unbuffered <- 42 // returns only once that goroutine reaches the receive

go deeper

for a junior

Be ready to state the rule in one sentence: make(chan int) makes a send wait for a receiver, make(chan int, 3) lets three sends through first. Know that the default, with no second argument, is capacity zero.

for a middle

Explain what the buffer physically is — a fixed ring of n slots filled and drained in order — and be precise about what a completed send does and does not prove about the receiving goroutine.

for a senior

Show that you treat capacity as a design decision: a buffer buys burst tolerance and costs you the delivery guarantee, and it never raises consumer throughput. Say what your producer should do when the buffer is full.

for a principal

Own the position that a channel's capacity is part of a component's contract even though callers cannot see it: it decides whether overload shows up as a blocked producer or as silent latency, and it cannot be retuned in production.

## One keyword, two behaviours A Go channel is created with the builtin `make`, and the optional second argument is its **buffer capacity**: - `make(chan int)` — capacity 0, an **unbuffered** channel. - `make(chan int, 3)` — capacity 3, a **buffered** channel with room for three values. The type is identical (`chan int` in both cases); only the runtime behaviour of send and receive changes. Capacity is not part of the type, so a function taking a `chan int` cannot tell which kind it was handed — the capacity is a property of the value, readable with `cap(ch)`. ## The unbuffered case: a rendezvous On `ch := make(chan int)`, the statement `ch <- v` **parks the sending goroutine** until another goroutine executes a receive (`<-ch`) on the same channel. When that happens the runtime copies the value directly from the sender's stack to the receiver, and both goroutines become runnable again. Symmetrically, a receive on an unbuffered channel parks until some goroutine sends. The practical consequence is that a completed send on an unbuffered channel is a **delivery acknowledgement**: when `ch <- v` returns, you know a specific goroutine has taken `v`. You do not know it has finished doing anything with it — only that the handoff happened. This is why an unbuffered channel doubles as a synchronisation point: the two goroutines are, for that instant, in lockstep. The cost is exactly that lockstep. If the receiving side is busy for 200 ms, the sender is stopped for 200 ms too. A producer that must keep to a schedule — a sampler ticking once a second, say — inherits every hiccup on the consuming side. ## The buffered case: a fixed ring On `ch := make(chan int, 3)`, the runtime allocates a circular buffer of three `int` slots when the channel is created. A send copies its value into the next free slot and **returns immediately**, without any receiver existing at all. A receive takes the oldest queued value out. Values come out in the order they went in, per channel. The blocking rules become: - **Send** blocks only when the buffer is full (three values queued and nobody taking them). - **Receive** blocks only when the buffer is empty. So a buffered channel decouples the two goroutines *in time*, up to `n` values of slack. It never decouples them in *rate*: if the consumer is permanently slower than the producer, the buffer fills and the producer ends up blocked anyway — the buffer only delayed that moment. A completed send on a buffered channel therefore proves much less: it proves there was room, not that anyone has looked at the value. Treating a returned send as "the work is done" is one of the most common mistakes with buffered channels. ## Things Go deliberately does not offer - **There is no unbounded channel.** Every channel is either a rendezvous (capacity 0) or a fixed ring. If you want unbounded queueing you must build it yourself out of a slice plus a goroutine or a mutex — and then you own the memory growth that the channel was refusing to hide from you. - **There is no overwrite or drop mode.** A full channel blocks the sender; it does not discard the oldest value. Any dropping policy is something your code has to express. - **Capacity cannot be changed after `make`.** You cannot grow a channel that turned out to be too small; you create a new one. - **Values are copied.** Both the send into a buffer and the handoff on an unbuffered channel copy the value, with normal Go value semantics. ## Choosing between them Start unbuffered. An unbuffered channel is the honest default: it makes a slow consumer visible immediately as a blocked producer, and it gives you a delivery guarantee for free. Add a small buffer when you have a concrete reason — a producer that must not be stalled by a brief consumer pause, or a burst you can measure and want to absorb. Choose the number from the burst you intend to survive, not from optimism about how long a slow consumer might stay slow. A useful mental model: capacity 0 means *hand it to a person*; capacity n means *drop it in a pigeonhole with n slots*. The pigeonhole lets you walk away sooner. It does not make the person collecting the post any faster, and a full set of pigeonholes stops you just as dead as no pigeonhole at all.

  • When a send on a buffered channel returns, what do you actually know?
    Only that there was a free slot and the value was copied into it. You do not know that any goroutine has received it, let alone processed it. On an unbuffered channel a returned send does mean a receiver took the value — that is the one delivery guarantee the rendezvous gives you, and it disappears the moment you add capacity.
  • Can you create an unbounded channel in Go?
    No. Capacity is fixed at `make` and there is no growing channel in the language or the standard library. If you need unbounded queueing you build it yourself — typically a goroutine holding a slice, taking from an input channel and offering the head on an output channel. That makes the unbounded memory growth your explicit decision rather than a hidden one.
  • Does the receive side behave differently between the two forms?
    A receive blocks when there is nothing to take in both cases. The difference is what it unblocks: on an unbuffered channel the receive completes the rendezvous and releases a waiting sender, so receiving is also a synchronisation event. On a buffered channel a receive frees a slot, which releases a sender only if one was blocked on a full buffer.

Capacity 0 is handing a parcel to someone in person: you stand there until they take it. Capacity n is a row of n pigeonholes: you drop the parcel and walk away, until every hole is full and you are standing there again.

saying these in an interview costs you the question

  • Says a send on a buffered channel never blocks
  • Thinks an unbuffered channel can hold one value
  • Believes make(chan T) is buffered with some default size
  • Treats a returned send as proof the value was processed
  • Expects a full channel to drop or overwrite the oldest value