skip to content

Channels and select

Go's alternative to shared memory: typed pipes that carry values between goroutines, and the select statement that waits on several of them at once. Almost every Go concurrency interview is a channel-design question in disguise.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

explore

questions

29

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
open as a page

When you send a struct value on a Go channel, does the receiver get a copy or the sender's original?

level: juniorimportance: must knowfreq 65%

basics

~20 s

A channel send copies the value. The receiver gets its own copy of the struct, so later writes by the sender are invisible. The copy is shallow: pointer, slice and map fields still refer to the same underlying data.

open as a page

What does close(ch) do to a Go channel, and what do receivers see afterwards?

level: juniorimportance: must knowfreq 82%

basics

~20 s

close(ch) marks a Go channel as finished. Receives stop blocking: they deliver any values still buffered, then return the element type's zero value with ok false, and a for range loop over the channel exits.

open as a page

What does a nil channel in Go do to a goroutine that sends or receives on it?

level: juniorimportance: must knowfreq 62%

basics

~10 s

Both operations block forever. A send on a nil channel and a receive from a nil channel park the goroutine permanently, because no counterparty can ever exist. Closing a nil channel panics instead.

open as a page

What does a `default` case do in a Go `select` statement?

level: juniorimportance: must knowfreq 68%

basics

~20 s

A default case makes the select non-blocking. If no other case can proceed at that instant, Go runs default immediately instead of waiting. Without a default, the select parks the goroutine until one of its cases is ready.

open as a page

In Go, what does a `select` statement do that a plain channel receive like `v := <-ch` cannot?

level: juniorimportance: must knowfreq 78%

basics

~20 s

A select waits on several channel operations at once and proceeds with whichever one is ready first. A plain receive waits on exactly one channel, so a goroutine sits there even when a different channel has work for it.

open as a page

How do you use time.After to put a timeout on a channel receive in a Go select?

level: juniorimportance: must knowfreq 80%

basics

~20 s

time.After(d) returns a channel that delivers one value after duration d. Put a receive from it in a select beside the real receive; whichever is ready first wins, so the select never waits longer than d.

open as a page

When should a Go program guard shared state with sync.Mutex instead of handing it over a channel?

level: middleimportance: must knowfreq 78%

basics

~20 s

Use a mutex when goroutines share one structure in place and nothing changes hands: a cache, a counter, a registry. Use a channel when a value is handed off, so the sender stops using it and the receiver takes over.

open as a page

Which Go channel operations panic once the channel is closed or nil?

level: middleimportance: must knowfreq 66%

basics

~10 s

Three operations panic: sending on a closed Go channel, closing an already-closed channel, and closing a nil channel. Receiving from a closed channel never panics; it returns the zero value immediately with ok false.

open as a page

In Go, what do the parameter types chan<- T and <-chan T mean, and why declare them?

level: juniorimportance: should knowfreq 55%

basics

~10 s

chan<- T is send-only and <-chan T is receive-only. Declaring a parameter that way makes the compiler reject the wrong operation, so the signature states which side of the channel that function owns.

open as a page

What do len(ch) and cap(ch) return for a buffered Go channel?

level: middleimportance: should knowfreq 48%

basics

~20 s

For a channel, cap returns the capacity passed to make and never changes; len returns how many values are queued in the buffer right now. Both are 0 for an unbuffered channel, whose values are never queued.

open as a page

A parser sends a *Document on a channel and then sets a field on it — why is that a bug?

level: middleimportance: should knowfreq 48%

basics

~10 s

The channel copies the pointer, not the document. After the send both goroutines reach the same struct, so the parser's write races with the indexer's reads. Sending a pointer transfers ownership: stop touching it.

open as a page

Why would you set a channel variable to nil inside a Go select loop?

level: middleimportance: should knowfreq 42%

basics

~20 s

To switch that select case off. A nil channel is never ready, so assigning nil to the variable a case reads makes that case unselectable until a real channel is assigned back. It is how a drained source is retired.

open as a page

Why does a Go for-select loop keep processing work after its `ctx.Done()` case is ready?

level: middleimportance: should knowfreq 54%

basics

~20 s

Select picks uniformly at random among the cases that can proceed; source order gives no preference. A cancelled context's Done channel is ready, but so is the work channel, so each pass is a coin flip.

open as a page

How do you write a non-blocking send and a non-blocking receive on a Go channel?

level: middleimportance: should knowfreq 56%

basics

~20 s

Wrap the operation in a select with a default case: select { case ch <- v: default: } is a try-send, and select { case v := <-ch: default: } is a try-receive. Reaching default means the operation did not happen.

open as a page

When should you use time.NewTimer instead of time.After for a timeout in Go?

level: middleimportance: should knowfreq 55%

basics

~20 s

Use time.NewTimer when you need the handle: it returns a *time.Timer with a C channel plus Stop and Reset, so one timer can be cancelled and reused. time.After returns only a channel, allocating a fresh timer per call.

open as a page

A metrics daemon sends into make(chan Sample, 50000) and its sink is slow: no errors, but samples land minutes stale. What is the buffer doing?

level: seniorimportance: should knowfreq 42%

basics

~20 s

The buffer is converting a throughput shortfall into latency. It absorbs the gap between production and consumption only until it fills, so samples queue and age instead of failing. Capacity buys time; it never raises the sink's rate.

open as a page

Several parser goroutines each close the shared records channel and it panics with "send on closed channel". How do you fix it?

level: seniorimportance: should knowfreq 57%

basics

~20 s

close describes the whole channel, not one sender's share of it, so with several senders none may close. Let the single owner that started them close once they have all returned, or leave the data channel open and close a separate done channel instead.

open as a page

A Go for-select loop with a default case pins a CPU core while the service is idle. How do you confirm it and fix it?

level: seniorimportance: should knowfreq 44%

basics

~20 s

The default case makes the select return instantly, so the loop never waits and spins at full speed. Confirm it with a CPU profile of the idle process, then delete the default so the select blocks.

open as a page

In a Go for-select loop, what do you check before approving a pull request that adds a new case?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Check that the new case's body never blocks, that it either returns or falls through to the next pass, and that adding it makes the cancellation case rarer, since the choice is uniform among ready cases.

open as a page

Do you still need to drain a time.Timer's channel before calling Reset, and what breaks if you do?

level: seniorimportance: should knowfreq 42%

basics

~20 s

No. Since Go 1.23 a timer's channel is unbuffered and Go guarantees no value prepared before a Stop or Reset call is delivered after it. The old drain idiom, receiving whenever Stop returns false, can now block forever.

open as a page

Should a Go package's exported API hand work over a channel or hide a mutex behind ordinary methods?

level: principalimportance: should knowfreq 42%

basics

~20 s

Default to ordinary methods with synchronization hidden inside; return a channel only when callers must select on it or consume a stream. A channel in an exported signature freezes buffering, direction, lifetime and cancellation into the contract.

open as a page

What does make(chan Sample, 100000) allocate at the moment the channel is created?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

It allocates the channel's bookkeeping plus a ring buffer with room for 100000 Sample values immediately, before any send. Capacity is a reservation made at creation, not storage that grows as values arrive, and it cannot be changed afterwards.

open as a page

Must every Go channel be closed, and does an unclosed one leak?

level: middleimportance: nice to knowfreq 40%

basics

~20 s

No. Closing a Go channel is a signal to receivers, not a resource release, and an unreachable channel is garbage collected whether it was closed or not. Close only when receivers must learn that no more values are coming.

open as a page

When does Go evaluate the channel and send-value expressions of a `select` statement's cases?

level: middleimportance: nice to knowfreq 27%

basics

~20 s

Go evaluates every case's channel expression, and every send case's value, exactly once in source order on entering the select, before any case is chosen. A call in a case header therefore runs on every pass, even when its case does not win.

open as a page

Why does a select with a receive case and a default never pick default once the channel is closed?

level: middleimportance: nice to knowfreq 40%

basics

~20 s

A receive on a closed channel is always ready: it returns the element type's zero value with ok false, without waiting. Since the receive case can always proceed, the select takes it every time and default becomes unreachable.

open as a page

What does time.AfterFunc do, and on which goroutine does its function run?

level: middleimportance: nice to knowfreq 32%

basics

~20 s

time.AfterFunc(d, f) schedules f to run once after duration d, on a new goroutine the runtime starts when the timer fires. It returns a *time.Timer used only for Stop and Reset; that timer's C field is nil.

open as a page

A parser reuses one *Document per file to cut allocations; how do you keep the handoff to the indexer safe?

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

Reuse only what you own. Either allocate a fresh document per file, or add a second channel on which the indexer returns each document once it is done, so exactly one goroutine may write to a given document at any time.

open as a page

A Go multiplexer nils each select case as its stream ends, then dies with a fatal all-goroutines-are-asleep deadlock. What went wrong?

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

The loop disabled its last case. Once every channel in the select is nil and there is no default, the select can never proceed, so the goroutine parks forever. The loop needed its own exit condition.

open as a page