Lesson 03 / 14
Buffered and Unbuffered Channels
A channel's capacity changes where blocking starts: a send passes without waiting at all until capacity fills, and once it fills, it blocks exactly like an unbuffered channel. How many of five attempted sends pass without blocking is measured at three capacities. Bounding measurement: the buffer does not remove synchronization, it only defers it — once a send to a buffered channel completes, the sender still does not know whether the receiver has taken that value.
Published
In this lesson
- Shows that a channel's capacity determines where blocking starts, by measuring how many attempts pass without blocking at three capacities
- Proves, by running it, that a receiver taking a value on a buffered channel reopens the slot that had closed
- Shows that the buffer does not remove synchronization, only defers it, making it impossible for the sender to know the moment of receipt
Contents
The previous lesson measured that an unbuffered channel does two jobs at once: carrying a value and making the sender and the receiver meet at the same point. Adding capacity to a channel does not remove these two jobs — it only loosens the bond between them. This lesson measures where the buffer changes blocking behavior, and bounds what it does not change in a separate section.
Giving a channel capacity turns it into a structure that holds a small array inside itself. The existence of this array gives the sending side a kind of flexibility: even if the receiver is not yet ready, a send can proceed without waiting as long as the buffer still carries an empty slot. But this flexibility has a limit, and that limit is exactly the capacity itself. This lesson measures that limit and where it gets crossed.
Capacity is fixed once, when the channel is created, and cannot be grown or shrunk
afterward — a channel opened with make(chan int, 3) can hold at most three values for its
entire lifetime. This fixedness shows that capacity is not a runtime setting but a design
decision: the person writing the code decides from the start how far the sender is allowed
to “get ahead of” the receiver. There is no single rule for the right size of this decision
— too small a capacity makes the sender wait more often than necessary, too large a capacity
hides how far behind the receiver has fallen; this lesson measures not how the size should
be chosen, but what a chosen size does.
Capacity Is Where Blocking Starts
The second argument in the call make(chan T, n) is the channel’s capacity; if it is left
out (make(chan T)), the capacity is zero, and the previous lesson’s unbuffered channel is
what results. An unbuffered channel is not a separate type — it is a channel whose capacity
is zero, and a zero capacity produces the same result as “the buffer is always full”: every
send waits directly for a receiver, exactly as on a channel whose capacity is already full.
This single difference is what lets both channel uses be written with the same syntax: when
a function takes a chan int parameter, there is no need to tell from inside the code
whether the caller passed a buffered or an unbuffered channel — both use the same send and
receive syntax, the only difference is when they wait.
On a channel with capacity n, the first n sends complete immediately, even with no
receiver at all; the channel holds these values inside itself. The n+1th send, though,
means the buffer is now full, and it waits for a receiver to arrive, exactly as on an
unbuffered channel. This limit is not carried in the channel’s own type but in the second
argument given to the make call; the same chan int type can be created with capacity 0
or with capacity 1000, and both uses look identical in the source code — the difference is
only in the make line.
This can be measured at three separate capacities, with five attempted sends each time,
using select+default — without starting a single goroutine, because having no receiver
at all is part of what is being measured.
// capacity.go — a send goes through unblocked until the buffer fills, once full an attempt does not pass
package main
import "fmt"
func howManyGoThrough(capacity, attempts int) int {
channel := make(chan int, capacity)
passed := 0
for i := 0; i < attempts; i++ {
select {
case channel <- i:
passed++
default:
}
}
return passed
}
func main() {
for _, capacity := range []int{0, 2, 4} {
fmt.Printf("capacity=%d attempts=5 passed=%d\n", capacity, howManyGoThrough(capacity, 5))
}
}
capacity=0 attempts=5 passed=0 capacity=2 attempts=5 passed=2 capacity=4 attempts=5 passed=4
GC9. — No receiver was ever started; the result is therefore strictly deterministic and
depends on no random timing — there is no goroutine here at all, so what is measured is not
timing but the channel’s own capacity rule. With capacity 0, no send passes — the previous
lesson’s “an attempt never passes when there is no receiver at all” showing up again here.
With capacity 2, exactly two pass, and with capacity 4, exactly four: the passed count
is, in every case, exactly equal to the capacity, never reaching the fifth attempt at
all. Fifty attempts instead of five would not change the result — every attempt after the
buffer fills falls into default the same way, until a receiver arrives.
This shows exactly what the buffer does: it does not remove blocking, it defers the point where blocking starts by the size of the capacity. At zero capacity there is no deferral at all; as capacity grows, the sending side also gains a growing number of sends it can make with no receiver at all.
This deferral is the size of the window in which the sender can operate independently of
the receiver. At capacity 0 there is no window at all, the sender has to wait for the
receiver on every step. As capacity grows, the sender can take that many steps in a row
without looking at the receiver’s speed — but once the window closes (from the fifth attempt
onward in the howManyGoThrough measurement), behavior goes back to being identical to an
unbuffered channel’s.
len and cap Show the Buffer’s Instantaneous State
A channel’s current fullness can also be read from inside the program: the built-in len
function returns the number of values waiting in the buffer, not yet received; the cap
function returns the channel’s fixed capacity. The two carry independent information — cap
never changes from the moment the channel is created, len changes on every send and
receive.
// lencap.go — len gives the count of values currently waiting in the channel's buffer, cap gives the capacity
package main
import "fmt"
func main() {
channel := make(chan int, 3)
fmt.Println("empty : len=", len(channel), "cap=", cap(channel))
channel <- 1
channel <- 2
fmt.Println("two filled : len=", len(channel), "cap=", cap(channel))
<-channel
fmt.Println("one received: len=", len(channel), "cap=", cap(channel))
}
empty : len= 0 cap= 3 two filled : len= 2 cap= 3 one received: len= 1 cap= 3
GC10. — While the channel is empty, len is zero; after two sends, it is two; after one
receive, it is one. cap keeps holding three the whole time, unchanged, because the capacity
is fixed at the moment of make and cannot be changed afterward. When len(channel) == cap(channel), the channel is full, and the next send (exactly the point measured throughout
this lesson) cannot pass without waiting; when len(channel) == 0, the channel is empty, and
a receive waits in the same way. These two functions are the inside-looking view of the
previous section’s howManyGoThrough measurement: there, the number of attempts was counted
from outside; here, the channel’s own state is read directly.
These functions have a limit: len only gives the value at the instant it is read, and
if more than one goroutine accesses the same channel at the same time, this value can have
already changed by the time it is read. On a channel used by a single goroutine, len is a
reliable observation; on a shared channel, it is only a snapshot, not a decision input.
Deciding to send, for instance, because len(channel) < cap(channel) holds true is not safe:
between this read and the send itself, some other goroutine can write to the same channel and
fill the buffer, and the send still waits — the “there is room” information len had just
given loses its validity somewhere between those two lines. len and cap are therefore an
observation tool, not a decision tool.
Once a Slot Opens, an Attempt Passes Again
The buffer’s fullness is not fixed — the slot that opens when a receiver takes a value out of the buffer lets an attempt that was blocked before pass now. This, too, can be observed inside a single goroutine, in sequence, requiring no concurrency at all.
// slotopens.go — once a receiver takes a value from a buffered channel, the slot that opens lets an attempt pass again
package main
import "fmt"
func tryToSend(channel chan int, v int) bool {
select {
case channel <- v:
return true
default:
return false
}
}
func main() {
channel := make(chan int, 2)
channel <- 1
channel <- 2
thirdAttemptWhileFull := tryToSend(channel, 3)
fmt.Println("did the third attempt pass while the buffer was full:", thirdAttemptWhileFull)
receivedValue := <-channel
fmt.Println("value received :", receivedValue)
thirdAttemptAfter := tryToSend(channel, 3)
fmt.Println("did the same attempt pass once a slot opened :", thirdAttemptAfter)
}
did the third attempt pass while the buffer was full: false value received : 1 did the same attempt pass once a slot opened : true
GC11. — The channel with capacity 2 is filled with two values; the third attempt
falls to default because the buffer is full. Once a value is taken out with <-channel
(1, because a channel preserves entry order like a queue — this FIFO ordering is part of a
buffered channel’s own definition, not a separate measurement), a slot opens in the buffer.
The same third attempt, with the source code entirely unchanged, now returns true. The
capacity stays fixed; what changes is the capacity’s fill level.
The two measurements so far tried with select+default; when the attempt fails, no
send happens at all, and the program moves straight to the next line. But the real
consequence of the buffer filling up is that a send written directly, without select,
genuinely waits — exactly the behavior seen on the unbuffered channel in the previous
lesson. This cannot be shown inside a single goroutine, because the blocked party cannot
observe its own blocking; but it is possible to confirm, with a separate signal, that a
blocked send completes for certain once a slot opens.
// blocking.go — once capacity fills a blocking send genuinely waits, and completes once the receiver takes a value
package main
import "fmt"
func main() {
channel := make(chan int, 1)
channel <- 1
secondDone := make(chan struct{})
go func() {
channel <- 2
close(secondDone)
}()
firstReceived := <-channel
<-secondDone
secondReceived := <-channel
fmt.Println("first received :", firstReceived)
fmt.Println("second received:", secondReceived)
}
first received : 1 second received: 2
GC12. — The channel with capacity 1 is filled with a single value (1). The started
goroutine’s channel <- 2 line cannot complete immediately, because the buffer is full, and
it waits there — the main goroutine does not observe this directly, since trying to (asking
“is it still blocked” with select+default, for instance) would itself be a race. Instead,
the main goroutine first takes the single value out of the buffer (firstReceived), opening
a slot for the second goroutine’s waiting send, then waits on <-secondDone to be sure that
send has certainly completed. Because close(secondDone) runs after channel <- 2,
seeing this close guarantees the send has also gone through. The final receive
(secondReceived) is therefore 2 on every run.
Bounding Measurement: The Buffer Does Not Remove Synchronization, It Defers It
The previous two measurements showed when the buffer blocks. This section measures what the buffer does not tell: when a send to a buffered channel completes, the sending side does not know that the value was actually read — it only knows that it found room in the buffer. On an unbuffered channel, these two pieces of information would arrive on the same line, because a send only completed once a receiver was ready. On a buffered channel, these two moments come apart.
// deferred.go — a send to a buffered channel completes but does not tell the sender that the receiver took the value
package main
import "fmt"
func main() {
channel := make(chan int, 1)
ack := make(chan struct{})
channel <- 42
fmt.Println("send completed (buffered)")
go func() {
<-channel
close(ack)
}()
<-ack
fmt.Println("now we know the receiver took the value (separate signal)")
}
send completed (buffered) now we know the receiver took the value (separate signal)
GC13. — The channel <- 42 line completes without waiting at all, since the capacity is
not full, and the “send completed” line is printed right after — at this point, no receiving
goroutine has even started yet. The only way to learn that the receiver has actually read
the value is a separate ack channel; without it in the code, the sending side would have no
way left to know when the receive happens, or even whether it happens at all. This shows, at
the same time, both the guarantee a buffered channel gives the sending side and its limit:
the guarantee is only “the buffer held this,” and it carries no promise about the receive at
all.
This difference shows that the choice of which channel to use is an engineering decision. If the sender needs to know that the receive has happened — to confirm that a job has started, for instance — an unbuffered channel or a separate acknowledgment signal is needed. If it is enough for the sender to just leave the value and move on, and the receiver does not need to be ready at that moment, the buffer lets the sender proceed without waiting. Both “synchronize” — but at different moments, and with different information.
This distinction is an example of the difference between “knowing it is ready” and “being
certain it will be ready.” Without the ack channel, the sending side in deferred.go would
continue after leaving the value in the buffer without learning anything — not whether the
receiver runs, not when, not even whether it ever runs at all. As capacity grows, this
uncertainty does not go away, only the number of sends left carrying it grows: a hundred
sends in a row to a channel with capacity 100 each say “completed” separately, but give not
a single piece of information about how many were actually read.
This silence on a buffered channel’s part is not a defect; what the channel commits to is only “hold this value,” never “tell you that the receiver took it.” Code that wants that second piece of information has to set it up separately, exactly as in this measurement.
This lesson’s four measurements come together in a single line. capacity.go showed that
capacity is a limit, and that up to it a send passes without waiting at all. lencap.go
showed that this limit and the buffer’s current fullness can be read from inside the
program. slotopens.go and blocking.go gave two views of crossing that limit: one an
attempt failing and moving on, the other a real send genuinely waiting until a slot opened.
deferred.go, in turn, showed that none of these four measurements tell the moment a send
is received — capacity, fullness, blocking are all measurable on the sender’s own side;
when the receive actually happens stays outside all of them.
Summary
- A channel’s capacity determines where blocking starts: the first
capacitysends pass without waiting at all, the ones after that wait for a receiver to arrive. - A channel with zero capacity is not a separate type; an unbuffered channel is itself a channel with zero capacity.
- The slot that opens when a receiver takes a value out of the buffer lets an attempt that was blocked before pass now.
- A completed send to a buffered channel does not mean the receiver has read that value; this information can only be obtained with a separate signal.
- The choice between an unbuffered and a buffered channel is a design decision that depends on whether the sender needs to know the receive has happened.
lengives the number of values currently waiting in a channel,capgives the fixed capacity; together, they let the channel’s instantaneous fullness be read without making an attempt.
Next Step
Both channel types measured in this lesson were each waiting on only a single channel; the
difference between them was in when that channel made you wait. select+default was used
in this lesson only as an attempt tool on a single channel — to poll whether the capacity
was full. But a goroutine often waits on more than one source at the same time: one of
several result channels, a cancellation signal, a timeout. The next lesson measures this
broader form of select — when more than one channel is waited on together inside the same
select, whichever branch is ready gets chosen, and what happens when none of them are
ready.
To keep your progress and take notes, Log in
My notes
Log in to take notes.