Chapter 5: Buffered Channels
Chapters 3 and 4 established unbuffered channels as synchronization points—when two goroutines communicate through an unbuffered channel, they must rendezvous. The sender blocks until a receiver is ready; the receiver blocks until a sender provides a value. This tight coupling is powerful for coordination but limiting when you need timing independence. Buffered channels decouple send from receive timing, enabling patterns impossible with unbuffered channels—but introducing complexity you must understand to use correctly.
The same baseline as the rest of the book:
Go 1.25+, matching the go.mod in
every exercise. Two things matter here—since Go 1.22 each
loop iteration gets its own variable, so passing the loop value as a
parameter is no longer needed, and since Go 1.19 the typed
atomics (atomic.Int64 and friends) replace the
atomic.AddInt64(&x, 1) form.
- Buffered channel mechanics: capacity, length, and blocking behavior
- When buffering solves real problems: timing decoupling, burst absorption, known-size collection
- When buffering masks bugs: design problems, unbounded queues
- How to choose buffer sizes with concrete heuristics
- Buffered channels as counting semaphores
- Performance characteristics and trade-offs
This chapter focuses on buffered channel mechanics and sizing decisions. Master these concepts before exploring directional channel types (Chapter 6), channel-based patterns like pipelines and worker pools (Chapter 7), and context for cancellation (Chapter 13).
You should understand unbuffered channel semantics from Chapter 3
(creation, send/receive blocking, closing, sender-closes principle)
and select fundamentals from Chapter 4 (multiplexing,
timeouts, non-blocking operations).
Consider this pattern from earlier chapters—a timeout-protected computation using an unbuffered channel:
// Illustrative snippet — not a complete program
func processWithTimeout(
data []byte,
timeout time.Duration,
) (Result, error) {
resultCh := make(chan Result) // Unbuffered
go func() {
result := expensiveComputation(data)
resultCh <- result // BLOCKS FOREVER if timeout wins
}()
select {
case r := <-resultCh:
return r, nil
case <-time.After(timeout):
return Result{}, errors.New("timeout")
// Worker blocked on send—LEAK
}
}
The problem: When the timeout fires first, the handler returns. The worker completes its computation, then blocks forever trying to send to an abandoned channel—a goroutine leak.
A timeline of a handler that spawns a worker and gives up at 100ms. The handler returns at the timeout, so nobody is left to receive; the worker finishes at 200ms, blocks forever on its send into an unbuffered channel, and is leaked.
Buffered channels solve this:
// Illustrative snippet — not a complete program
func processWithTimeout(
data []byte,
timeout time.Duration,
) (Result, error) {
resultCh := make(chan Result, 1) // Buffer size 1
go func() {
result := expensiveComputation(data)
resultCh <- result // Never blocks—buffer has space
// Goroutine exits cleanly
}()
select {
case r := <-resultCh:
return r, nil
case <-time.After(timeout):
return Result{}, errors.New("timeout")
// Worker sends to buffer and exits—no leak
}
}
The buffer decouples send from receive timing. The sender doesn't need a receiver waiting—it can deposit the value in the buffer and exit cleanly.
Two channels side by side. On an unbuffered channel the sender and receiver meet at a single synchronization point and neither proceeds without the other. On a buffered one the value goes into a queue, so the sender continues while the receiver is still elsewhere.
The buffered channel prevents the
goroutine leak (worker can exit), but it doesn't
cancel the computation. The worker still runs to
completion even after the timeout fires. The buffer solves
cleanup (goroutine exits), not
efficiency (work stops). For true cancellation, use
context.Context (Chapter 13).
This decoupling enables patterns impossible with unbuffered channels:
- Prevent goroutine leaks—senders complete even when receivers disappear
- Absorb bursts—handle temporary spikes without blocking producers
- Implement semaphores—limit concurrency with buffer capacity
- Collect known-size results—gather N results without N sequential receives
But buffering isn't free. It introduces complexity: you must choose a capacity, understand when blocking occurs, and avoid the trap of using buffers to mask design problems.
This chapter teaches buffering, but don't mistake coverage for recommendation. Most channels should be unbuffered. Unbuffered channels provide clear synchronization semantics, immediate backpressure, and simpler reasoning. Buffer when you have a specific reason—timing decoupling, burst handling, or leak prevention. “Just in case” is not a reason. If your primary coordination logic only works with a buffer, you likely have a design bug—buffering handles edge cases, not core flow.
5.1 Capacity, Length, and Blocking Behavior
Section 3.1 introduced channel creation with
make(chan T)—unbuffered channels with zero
capacity. Buffered channels add a capacity parameter that
fundamentally changes when operations block.
This section covers the mechanics: what capacity means, how to inspect buffer state, and precisely when operations block. Master these fundamentals before exploring when and why to use buffering.
Creating Buffered Channels
The second argument to make specifies buffer capacity:
// Illustrative snippet — not a complete program
ch := make(chan int, 3) // Buffered channel, capacity 3
This creates a channel with an internal queue that can hold up to 3 values. Sends succeed without blocking as long as the buffer isn't full; receives succeed without blocking as long as the buffer isn't empty.
Syntax comparison:
// Illustrative snippet — not a complete program
make(chan int) // Unbuffered (capacity 0)
make(chan int, 0) // Unbuffered (explicit 0)
make(chan int, 1) // Buffered, capacity 1
make(chan int, 100) // Buffered, capacity 100
The capacity is fixed at creation and cannot be changed.
make(chan int) (capacity 0):
every send blocks until another goroutine
receives. make(chan int, 1) (capacity 1):
one value can be sent immediately—only the
second send would block. This single-slot difference matters for
timeout-safe sends and event notifications where at most one
pending signal is needed. When you do need a buffer, start with
capacity 1—increase only with specific justification
(Section 5.2).
Capacity vs Length
Two built-in functions query channel state:
// Illustrative snippet — not a complete program
cap(ch) // Max capacity—set at creation
len(ch) // Current values in buffer
package main
import "fmt"
func main() {
ch := make(chan int, 5)
fmt.Println("Capacity:", cap(ch)) // 5
fmt.Println("Length:", len(ch)) // 0 (empty)
ch <- 1
ch <- 2
fmt.Println("Capacity:", cap(ch)) // 5 (unchanged)
fmt.Println("Length:", len(ch)) // 2
<-ch // Receive one
fmt.Println("Capacity:", cap(ch)) // 5 (still unchanged)
fmt.Println("Length:", len(ch)) // 1
}
A three-slot buffer walked through four states. Capacity stays 3 throughout; only length moves — 0 when empty, rising as values are sent, falling as they are received. Capacity is fixed at make time; length is the current occupancy.
For unbuffered channels:
// Illustrative snippet — not a complete program
unbuf := make(chan int)
fmt.Println(cap(unbuf)) // 0
fmt.Println(len(unbuf)) // 0 (always)
Unbuffered channels always have length 0—values pass directly from sender to receiver with no intermediate storage.
len(ch) returns the length
at the instant you call it. By the time you act
on that information, another goroutine may have changed it. This
is a classic
TOCTOU (Time-Of-Check-Time-Of-Use) bug. Never use
len() for synchronization decisions.
// Illustrative snippet — not a complete program
// ✗ RACE CONDITION: State can change
if len(ch) < cap(ch) {
// Another goroutine might send here!
ch <- value // May still block
}
// ✓ CORRECT: Atomic non-blocking send
select {
case ch <- value:
// Sent successfully
default:
// Would block—handle accordingly
}
A time-of-check to time-of-use race. Goroutine A reads len(ch) at T1 and acts on it at T3, but goroutine B sends at T2. The length A observed is already stale by the time it is used, which is why len is safe for reporting and unsafe for flow control.
Monitoring/metrics: observing queue depth for
dashboards. Debugging: inspecting state in tests.
Logging: reporting buffer utilization. Never use
len() for synchronization decisions.
The Three Buffer States
A buffered channel exists in one of three states that determine blocking behavior:
The three states of a capacity-3 buffer. Empty: sends succeed at once, receives block. Partial: both succeed. Full: sends block, receives succeed. Blocking depends only on which of these three the buffer is in.
ch <- v)
<-ch)
ch <- v)
<-ch)
ch <- v)
<-ch)
ch <- v)
<-ch)
Key insight: Buffering delays blocking, it doesn't prevent blocking. When the buffer is full, sends block just like unbuffered channels.
Unbuffered channels (capacity 0) exist only in the
“empty” state—there's no buffer to be partial or
full. With len=0 and cap=0: send blocks
(waiting for receiver) and receive blocks (waiting for sender).
Both operations block because there's never a
“partial” or “full” state to enable
one-sided progress. This is why unbuffered channels require
rendezvous.
Blocking Behavior Demonstration
package main
import "fmt"
func main() {
ch := make(chan int, 2) // Capacity 2
ch <- 1 // Buffer: [1][_] — succeeds
fmt.Println("Sent 1")
ch <- 2 // Buffer: [1][2] — succeeds
fmt.Println("Sent 2")
ch <- 3 // Buffer full — BLOCKS
fmt.Println("Sent 3")
}
Without a receiver, the third send blocks forever:
The runtime's deadlock detector fires when
all goroutines are blocked. If even one goroutine
remains runnable (a ticker, a server, a waiting loop), blocked
goroutines will leak silently. In production, monitor goroutine
count with runtime.NumGoroutine() to detect leaks.
With a concurrent receiver:
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 2)
go func() {
time.Sleep(100 * time.Millisecond)
fmt.Println("Received:", <-ch)
}()
ch <- 1
ch <- 2
fmt.Println("Buffer full, about to block...")
ch <- 3 // Blocks until receiver runs
fmt.Println("Sent 3")
time.Sleep(200 * time.Millisecond)
}
The third send unblocks when the receiver removes a value, creating space. (Output order of the last two lines may vary—both goroutines are runnable simultaneously.)
Buffering Delays Blocking
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 3)
start := time.Now()
// First 3 sends: instant
for i := 1; i <= 3; i++ {
ch <- i
fmt.Printf("Sent %d at %v\n",
i, time.Since(start))
}
// Fourth send: blocks
go func() {
fmt.Printf("Send 4 starting at %v\n",
time.Since(start))
ch <- 4
fmt.Printf("Send 4 completed at %v\n",
time.Since(start))
}()
time.Sleep(100 * time.Millisecond)
<-ch // Receive one, unblock sender
time.Sleep(50 * time.Millisecond)
}
The first three sends complete in microseconds. The fourth send blocks for 100ms until space becomes available. The buffer absorbed the burst, but the fourth value had to wait.
FIFO Ordering Guarantee
Buffered channels maintain strict FIFO (First-In-First-Out) order—values are received in exactly the order they were sent:
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
fmt.Println(<-ch) // 1 (first sent)
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3 (last sent)
}
This ordering is guaranteed even with multiple senders—if sender A's send completes (value enters buffer) before sender B's send completes, A's value will be received first. What matters is when the value enters the buffer, not when the send is initiated. With concurrent goroutines, the order of buffer entry is scheduling-dependent—FIFO preserves that entry order but cannot make it predictable across goroutines. The buffer acts as a queue, not a stack.
The N-1 Goroutine Leak: Critical Buffer Sizing
Now that you understand buffer states and blocking rules, let's examine the most common buffered channel bug in production Go code: mismatched buffer size and sender count.
// Illustrative snippet — not a complete program
// ✗ BUG: No buffer, but N concurrent senders
func queryMultiple(
endpoints []string,
timeout time.Duration,
) (Result, error) {
results := make(chan Result) // Unbuffered!
for _, endpoint := range endpoints {
go func() {
result := query(endpoint)
results <- result // N-1 block forever!
}()
}
select {
case result := <-results:
return result, nil
case <-time.After(timeout):
return Result{}, errors.New("timeout")
}
// N-1 goroutines blocked on send—LEAK
}
A timeline of three goroutines racing to send one result. Main receives the first at 75ms and returns. The other two arrive at an unbuffered channel with no receiver left and block forever — N senders with one receiver leaks N minus 1 goroutines.
The fix—buffer size must match sender count:
// Illustrative snippet — not a complete program
// ✓ CORRECT: Buffer matches sender count
func queryMultiple(
endpoints []string,
timeout time.Duration,
) (Result, error) {
results := make(chan Result, len(endpoints))
for _, endpoint := range endpoints {
go func() {
result := query(endpoint)
results <- result // All can send
}()
}
select {
case result := <-results:
return result, nil
case <-time.After(timeout):
return Result{}, errors.New("timeout")
}
// All goroutines can complete cleanly
}
When multiple goroutines might send to a channel that could be
abandoned (timeout, early exit, first-wins), the buffer size must
accommodate all of them. N concurrent senders
need make(chan T, N). This is a
correctness issue, not an optimization.
Consider 20 endpoints: 19 leaked goroutines
× ~2–4KB each (minimum 2KB stack + overhead) =
~40–80KB per call.
At 1,000 calls/day: tens of MBs daily, growing
weekly. Until: OOM crash or manual restart.
The fix:
make(chan Result, len(endpoints))—zero leaks.
Demonstrating the Leak
package main
import (
"fmt"
"runtime"
"time"
)
func demonstrateLeak() {
before := runtime.NumGoroutine()
// Wrong: unbuffered, but 3 senders
results := make(chan string)
for i := 0; i < 3; i++ {
go func() {
time.Sleep(10 * time.Millisecond)
results <- fmt.Sprintf("result-%d", i)
}()
}
<-results // Receive one, abandon others
time.Sleep(50 * time.Millisecond)
after := runtime.NumGoroutine()
fmt.Printf("Leaked goroutines: %d\n", after-before)
}
func main() {
demonstrateLeak()
}
Closed Buffered Channels: Drain Then Zero
When a buffered channel is closed, buffered values are preserved. Receives drain the buffer before seeing the closure signal:
// Illustrative snippet — not a complete program
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
v1, ok1 := <-ch // v1=1, ok1=true (buffer)
v2, ok2 := <-ch // v2=2, ok2=true (buffer)
v3, ok3 := <-ch // v3=3, ok3=true (buffer)
v4, ok4 := <-ch // v4=0, ok4=false (closed)
A closed channel holding three buffered values. Receives keep returning real values with ok=true until the buffer is empty; only then does a receive return the zero value with ok=false. Closing does not discard what is already queued.
For buffered channels, ok remains
true until the buffer is empty. A receive of
v=0, ok=true means it's a
real zero value from the buffer, not a close
signal. Always check ok when zero might be valid
data.
Why Drain-Before-Close Matters
This behavior is critical for producer-consumer patterns:
// Illustrative snippet — not a complete program
// Producer signals "no more data"
for i := 0; i < 100; i++ {
workCh <- Task{ID: i}
}
close(workCh) // "I'm done producing"
// Consumer drains ALL buffered tasks
for task := range workCh {
process(task) // Processes all 100
}
If closing discarded buffered values, consumers would miss work. The drain-before-close guarantee ensures clean completion.
The for range loop handles drain automatically—it
receives until ok=false, then exits:
// Illustrative snippet — not a complete program
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
// ✓ Idiomatic: for range drains
for v := range ch {
fmt.Println(v) // 1, 2, 3, then exits
}
// ✗ Manual: more error-prone
for {
v, ok := <-ch
if !ok {
break // Easy to forget!
}
fmt.Println(v)
}
Complete Channel Behavior Reference
This table consolidates unbuffered (Chapter 3), buffered (this section), closed, and nil channel behaviors:
ch <- v
<-ch
v,ok:=<-ch
ok=trueok=trueclose(ch)
len(ch)
cap(ch)
* Blocks forever. † Drains buffered values (ok=true), then returns zero (ok=false). Sends always panic.
Section Summary
make(chan T)
make(chan T, n)
len(ch)
ok=false
Key Takeaways
-
Capacity is fixed, length varies—
cap(ch)never changes,len(ch)tracks current buffer occupancy - Three states determine blocking—empty (receive blocks), partial (neither blocks), full (send blocks)
- FIFO order guaranteed—values received in exact send order (order of buffer entry)
- N senders need buffer size N—prevents goroutine leaks when receiver might abandon
-
Closed channels drain buffer first—
ok=truewhile draining,ok=falseafter empty -
Don't use
len()for synchronization—TOCTOU race between check and action - Buffering delays blocking, doesn't prevent it—full buffer still blocks
Next: Section 5.2 explores when buffering solves real problems—timing decoupling, burst absorption, and known-size result collection.
Capacity is fixed at make time and decides only
when an operation blocks: a send waits when the buffer is
full, a receive when it is empty. cap never moves,
len is a snapshot that is stale the moment you read it,
and the sizing rule that actually matters is N−1: with N
senders and one receiver, anything smaller leaks goroutines.
5.2 When to Buffer
Section 5.1 covered the mechanics—how buffered channels work. This section covers the judgment—when buffering is the right choice.
The default should be unbuffered. Unbuffered channels provide clear synchronization semantics and immediate backpressure when consumers can't keep up. Add a buffer only when it solves a specific problem.
Three scenarios consistently justify buffering:
- Preventing goroutine leaks—sender must complete even if receiver abandons
- Absorbing bursts—temporary production spikes shouldn't block producers
- Known-size result collection—N goroutines produce N results collected together
Each scenario has a clear problem that buffering solves and a principled way to choose buffer size.
Examples in this section use placeholder functions
(fetch(), query(),
process()) to represent your actual work—HTTP
requests, database calls, computations, etc. Focus on the channel
mechanics and buffer sizing, not the placeholder implementations.
Pattern 1: Preventing Goroutine Leaks
The chapter opening showed this pattern: a sender blocks forever when the receiver disappears. This is the most common and critical use of buffering.
Single-Sender Pattern
When one goroutine sends a result that might not be received:
// Illustrative snippet — not a complete program
func fetchWithTimeout(
url string,
timeout time.Duration,
) ([]byte, error) {
result := make(chan []byte, 1) // Buffer size 1
errCh := make(chan error, 1) // Buffer size 1
go func() {
data, err := fetch(url)
if err != nil {
errCh <- err
return
}
result <- data // Won't block (cap=1, one sender)
}()
select {
case data := <-result:
return data, nil
case err := <-errCh:
return nil, err
case <-time.After(timeout):
// Worker sends to buffer and exits cleanly
return nil, errors.New("timeout")
}
}
Why buffer size 1 on each channel? The goroutine sends to exactly one channel (result or error). Each channel needs capacity for that one potential send. The sender can always complete—whether the receiver gets the value, receives an error, or times out first.
Two scenarios for a single sender. If the receiver is still there, the value is taken directly. If it has gone, a buffer of one gives the value somewhere to land so the sender can finish and exit rather than blocking forever.
First-Response-Wins Pattern
When multiple goroutines race to provide a result, only one
wins—but all must exit cleanly. This is exactly the
queryMultiple fix from Section 5.1:
make(chan T, len(endpoints)) ensures all N senders can
complete. After the first response is received, the remaining N-1
goroutines still finish their work and send—without sufficient
buffer space, they block forever.
Three endpoints answering at 50, 80 and 100ms into a channel buffered to 3. The first answer wins and is returned; the two later ones still have room to deposit their values and exit cleanly, which is what the buffer is for.
Why A and C must send despite losing: They've already completed their work. With an unbuffered channel, they'd block forever waiting for a receiver that's gone. Buffer size 3 ensures all can exit cleanly. The abandoned values are garbage collected when the channel becomes unreachable.
Fire-and-Forget Notifications
When you need to notify without blocking, and it's acceptable if notifications are dropped under load:
// Illustrative snippet — not a complete program
import "sync/atomic"
type AsyncLogger struct {
logs chan string
done chan struct{}
dropped atomic.Int64 // Go 1.19+ typed atomic
}
func NewAsyncLogger(bufferSize int) *AsyncLogger {
l := &AsyncLogger{
logs: make(chan string, bufferSize),
done: make(chan struct{}),
}
go l.writeLoop()
return l
}
func (l *AsyncLogger) Log(msg string) {
select {
case l.logs <- msg:
// Queued successfully
default:
// Buffer full—drop and count
l.dropped.Add(1)
}
}
// Dropped reports how many messages were shed. Without this
// the "always monitor drops" advice below has nothing to read.
func (l *AsyncLogger) Dropped() int64 {
return l.dropped.Load()
}
// Illustrative snippet — not a complete program
import (
"fmt"
"os"
)
func (l *AsyncLogger) writeLoop() {
for {
select {
case msg := <-l.logs:
fmt.Fprintln(os.Stderr, msg)
case <-l.done:
l.drain()
return
}
}
}
func (l *AsyncLogger) drain() {
for {
select {
case msg := <-l.logs:
fmt.Fprintln(os.Stderr, msg)
default:
return
}
}
}
// Close must be called exactly once.
// Caller must stop calling Log first.
func (l *AsyncLogger) Close() {
close(l.done)
}
Safety contract: The caller must stop calling
Log before calling Close. If
Log is called concurrently with or after
Close, messages can be silently lost without incrementing
the dropped counter. In production, enforce this with a
shutdown sequence that stops producers before closing the logger.
Fire-and-forget differs from other leak prevention patterns:
Because drops are acceptable, we can use smaller buffers sized for typical load rather than worst-case sender count.
When using non-blocking sends with default, track
dropped items. Silent drops are invisible bugs. Monitored drops
are operational data.
// Illustrative snippet — not a complete program
select {
case ch <- msg:
default:
droppedCount.Inc() // Make drops visible
}
When are dropped messages acceptable?
- Debug logs, performance metrics, best-effort telemetry
- Not acceptable: financial transactions, user data, security audit logs, critical errors
Rule of thumb: If losing the message would require customer support intervention, it's not acceptable to drop.
What About Abandoned Values?
When the receiver times out, buffered values are never retrieved. Two common questions:
Q: Is this a memory leak? No. When the channel becomes unreachable (no references remain), the garbage collector reclaims both the channel and its buffered contents.
Q: Is this wasteful?
The goroutines complete their work even though results are discarded.
Buffering prevents the goroutine leak, not the
work waste. For true cancellation (stopping work
mid-execution), use context.Context (Chapter 13).
Single sender, receiver might abandon: buffer size 1. N senders, first response wins: buffer size N. Fire-and-forget: expected burst size. The rule: buffer size ≥ number of goroutines that might send after the receiver stops listening.
Pattern 2: Absorbing Bursts
Production rates fluctuate. A web server might receive 10 requests/second normally but spike to 100 during peak moments. Without buffering, producers block during spikes, creating backpressure that can cascade through the system.
The Burst Problem
A traffic graph that sits near a low steady rate then spikes sharply for a short period before returning to baseline. This is the shape a buffer can absorb: the area under the spike is finite and the system has time to catch up afterwards.
Sizing for Burst Absorption
Formula: Buffer size ≈ (peak_rate − processing_rate) × spike_duration
// Illustrative snippet — not a complete program
// E-commerce checkout during flash sale
//
// Normal: 20 checkouts/sec
// Spike: 200 checkouts/sec for 5s
// Processing: 50 checkouts/sec (DB limit)
//
// What buffer prevents blocking?
// During 5-second spike:
arrivals := 200 * 5 // = 1000 arrive
processed := 50 * 5 // = 250 processed
queued := arrivals - processed // = 750 queued
// Buffer needed: 750
// Add ~33% for load variability: ~1000
checkouts := make(chan Checkout, 1000)
Key insight: Buffer size depends on deficit during spike, not peak rate alone.
Without buffer: 750 checkout requests block, users see timeouts. With buffer: All 1000 requests queued, processed over next 15 seconds (750 ÷ 50/sec = 15 sec to drain queue after spike ends).
Burst Absorption Example
// Illustrative snippet — not a complete program
import (
"net/http"
"runtime"
)
type Server struct {
requests chan Request
}
func NewServer(burstCapacity int) *Server {
s := &Server{
requests: make(chan Request, burstCapacity),
}
for i := 0; i < runtime.NumCPU(); i++ {
go s.worker()
}
return s
}
func (s *Server) HandleRequest(
w http.ResponseWriter,
r *http.Request,
) {
req := parseRequest(r)
select {
case s.requests <- req:
w.WriteHeader(http.StatusAccepted)
default:
http.Error(w,
"Server overloaded",
http.StatusServiceUnavailable)
}
}
func (s *Server) worker() {
for req := range s.requests {
process(req)
}
}
The buffer absorbs temporary bursts. During a spike, requests queue up. When the spike subsides, workers drain the queue. Handlers don't block as long as the buffer has space.
If your producer is consistently faster than your consumer, buffering only delays the problem. The buffer fills, stays full, and you get the same blocking—just delayed. If production consistently exceeds consumption, you need: more consumers (increase capacity), load shedding (reject excess), or backpressure (slow down producers).
Two traffic patterns contrasted. A burst rises above capacity briefly and then falls back, so a buffer absorbs it and the system recovers. Sustained overload stays above capacity indefinitely, so the buffer fills and the failure is only postponed.
Demonstrating Burst Absorption
package main
import (
"fmt"
"time"
)
func demonstrateBurstAbsorption() {
measure := func(ch chan int) time.Duration {
// Consumer: processes items (finishes after close)
go func() {
for range ch {
time.Sleep(
200 * time.Millisecond)
}
}()
// Let consumer start
time.Sleep(10 * time.Millisecond)
// Producer: send 10 items (burst)
start := time.Now()
for i := 0; i < 10; i++ {
ch <- i
}
elapsed := time.Since(start)
close(ch)
return elapsed
}
unbufTime := measure(make(chan int))
bufTime := measure(make(chan int, 20))
fmt.Printf("Unbuffered: %v\n",
unbufTime)
fmt.Printf("Buffered: %v\n",
bufTime)
}
func main() {
demonstrateBurstAbsorption()
}
The unbuffered producer blocks on 9 of 10 sends (the first succeeds immediately because the consumer is already waiting), so 9 × 200ms ≈ 1.8s. The buffered producer deposits all 10 items into the capacity-20 buffer in microseconds—the buffer absorbed the entire burst.
Pattern 3: Known-Size Result Collection
When you spawn N goroutines to produce N results, buffering enables independent completion:
// Illustrative snippet — not a complete program
func processAllItems(items []Item) []Result {
results := make(chan Result, len(items))
for _, item := range items {
go func() {
results <- process(item) // Independent
}()
}
output := make([]Result, 0, len(items))
for range items {
output = append(output, <-results)
}
return output
}
Why this works: Each goroutine sends exactly once. With buffer size N, all N sends succeed without waiting for the collector.
Five items processed concurrently into a channel buffered to five. Each goroutine sends its result whenever it finishes, in no particular order, and none can block because the buffer is sized to the known number of results.
Before spawning goroutines in a collection pattern, check four
properties: Exit? Returns after sending exactly
once to results. Communicate? Via
buffered results channel.
Errors? Sent to separate errs
channel (same buffer size).
Data? Closure captures item by
value; no shared mutable state.
Handling Errors
When work can fail, account for both outcomes:
// Illustrative snippet — not a complete program
func processAll(
items []Item,
) ([]Result, error) {
results := make(chan Result, len(items))
errs := make(chan error, len(items))
for _, item := range items {
go func() {
result, err := process(item)
if err != nil {
errs <- err
return
}
results <- result
}()
}
var collected []Result
for i := 0; i < len(items); i++ {
select {
case r := <-results:
collected = append(collected, r)
case err := <-errs:
// Remaining goroutines still
// send; buffer accommodates N
return collected, err
}
}
return collected, nil
}
Why both channels need buffer size N: Each of the N
goroutines sends to exactly one channel—either
results or errs, never both. If all N
succeed, all N send to results. If all N fail, all N send
to errs. Buffer size N on each channel handles either
extreme.
Why not smaller? If results has capacity
1 but all N succeed, N-1 goroutines block forever on send—a
leak.
For production code, golang.org/x/sync/errgroup
handles this pattern with cleaner semantics (including cancellation).
The manual approach here illustrates the underlying channel mechanics.
Preserving Order
The buffer collects results in completion order. If original order matters:
// Illustrative snippet — not a complete program
type indexedResult struct {
index int
result Result
}
func processAllOrdered(items []Item) []Result {
results := make(chan indexedResult, len(items))
for i, item := range items {
go func() {
results <- indexedResult{
index: i,
result: process(item),
}
}()
}
output := make([]Result, len(items))
for range items {
ir := <-results
output[ir.index] = ir.result
}
return output
}
Decision Framework
Before adding a buffer, answer these questions:
A decision tree for whether to buffer. The first question is whether the receiver could disappear before the sender completes — a timeout, an early return, a first-wins race. If so, size the buffer to the number of senders; otherwise carry on to the next question.
The “Would Unbuffered Work?” Test
A useful mental check: If you had infinitely fast consumers, would you still need the buffer?
Example 1: Timeout pattern
// Illustrative snippet — not a complete program
ch := make(chan Result, 1)
go func() { ch <- compute() }()
select {
case r := <-ch: return r
case <-time.After(timeout): return err
}
With infinitely fast consumer: Still need buffer (receiver might not exist after timeout). Real need is leak prevention, not speed.
Example 2: Web server queue
// Illustrative snippet — not a complete program
queue := make(chan Request, 100)
With infinitely fast workers: Don't need buffer (workers process faster than requests arrive). Real need is burst absorption due to speed mismatch.
Example 3: Parallel collection
// Illustrative snippet — not a complete program
results := make(chan Result, N)
for i := 0; i < N; i++ {
go func() { results <- compute() }()
}
With infinitely fast collector: Don't need buffer (collector receives each send instantly, no blocking). Real need is decoupling—without a buffer, goroutines complete their work but block on send until the single collector reaches them. The buffer lets them exit immediately.
This test clarifies your actual requirement and prevents cargo-cult buffering.
Section Summary
Key Takeaways
- Three legitimate reasons to buffer—leak prevention, burst absorption, known-size collection
- Leak prevention—buffer size = number of senders that might outlive the receiver
- Burst absorption—buffer size ≈ (peak − capacity) × spike duration
- Known-size collection—buffer size = number of results expected
- Buffers smooth bursts, not sustained overload—if production continuously exceeds consumption, you need more consumers or backpressure
- The “infinitely fast consumer” test—would you still need the buffer? Clarifies your actual requirement
Next: Section 5.3 examines when buffering is the wrong choice—masking design bugs, enabling unbounded growth, and deferring problems instead of solving them.
Three cases justify a buffer, and all three are about a receiver that might not be there: a sender that must not block after a timeout, a burst whose area under the curve is finite, and a known number of results. Size it to the number that could be in flight—never to a round number that feels safe.
5.3 When NOT to Buffer
Sections 5.1 and 5.2 covered buffering mechanics and legitimate use cases. This section covers the opposite: when buffering is the wrong solution.
Buffering has a seductive quality. When code blocks unexpectedly, adding a buffer often makes the immediate symptom disappear. But this “fix” frequently masks deeper design issues that resurface under load, in production, or in subtle ways that are harder to diagnose than the original problem.
The warning signs:
- “I added a buffer and the deadlock went away”
- “It works if I make the buffer big enough”
- “We just increase the buffer when it fills up”
- “I'm not sure why it needs a buffer, but it doesn't work without one”
Each of these suggests buffering is hiding a problem rather than solving one.
Anti-Pattern 1: Buffering to “Fix” Deadlocks
The most dangerous misuse of buffering: code deadlocks with an unbuffered channel, so you add a buffer until it stops deadlocking.
The Symptom
// Illustrative snippet — not a complete program
// ✗ BROKEN: Deadlocks
func process() {
ch := make(chan int)
ch <- 1 // DEADLOCK: No receiver yet
go func() {
fmt.Println(<-ch)
}()
}
The “Fix” That Isn't
// Illustrative snippet — not a complete program
// ✗ WRONG: Buffer hides the bug
func process() {
ch := make(chan int, 1) // "Fixed" with buffer
ch <- 1 // Goes to buffer
go func() {
fmt.Println(<-ch)
}()
// Returns immediately—
// goroutine may not execute!
}
The code runs without deadlock. Problem solved? No. The buffer masked a fundamental design issue: sending before any receiver exists, and returning before the goroutine completes.
This demonstrates the buffering anti-pattern but is incomplete
even with the buffer—the function returns before the
goroutine prints. In real code, you'd coordinate with
sync.WaitGroup or receive a completion signal. The
key point: the buffer allowed the send to complete, but proper
coordination is still missing.
The buffered version works only because:
- There's exactly one send before the receive
- The buffer size (1) happens to match the send count
Change either condition and it breaks again:
// Illustrative snippet — not a complete program
func process() {
ch := make(chan int, 1)
ch <- 1
ch <- 2 // DEADLOCK: Buffer full
v := <-ch
fmt.Println(v)
}
The “fix” was fragile—it worked by accident, not by design.
The same program with and without a buffer. Unbuffered, the sends block and the runtime reports a deadlock immediately, so the developer sees the coordination bug during development. Buffered, the sends succeed and the bug is hidden until the buffer is exhausted in production.
Real Example: Missing Receives
// Illustrative snippet — not a complete program
// ✗ BUG: Sends but never receives
func fetchMultiple(urls []string) {
// "Should be enough"
responses := make(chan Response, 100)
for _, url := range urls {
go func() {
responses <- fetch(url) // Nothing receives!
}()
}
// Missing: receive and use responses
// Works until someone passes 101 URLs
}
// Illustrative snippet — not a complete program
// ✓ CORRECT: Fix the actual bug
func fetchMultiple(urls []string) []Response {
// Size for N senders
responses := make(chan Response, len(urls))
for _, url := range urls {
go func() {
responses <- fetch(url)
}()
}
// Actually collect the results
results := make([]Response, 0, len(urls))
for range urls {
results = append(results, <-responses)
}
return results
}
The Correct Fix: Proper Coordination
// Illustrative snippet — not a complete program
// ✓ CORRECT: Fix ordering, ensure completion
func process() {
ch := make(chan int) // Unbuffered is fine
go func() {
ch <- 1 // Sender in goroutine
}()
v := <-ch // Blocks until goroutine sends
fmt.Println(v)
}
Before adding a buffer, ask: Which pattern from Section 5.2 does this implement?
Patterns from Section 5.2 (like first-response-wins) do require buffers and would deadlock without them. The difference: you can articulate why the buffer is needed. “N senders might outlive the receiver” is a reason. “It doesn't work otherwise” is not.
Anti-Pattern 2: Unbounded Queues
Using buffers as general-purpose queues without backpressure leads to memory exhaustion.
The Broken Pattern
// Illustrative snippet — not a complete program
// ✗ CATASTROPHIC: Unbounded queue → OOM
func (q *UnboundedQueue) Enqueue(
req Request,
) {
select {
case q.incoming <- req:
// Sent to channel
default:
// Channel full—grow overflow
q.mu.Lock()
q.items = append(q.items, req)
q.mu.Unlock() // Grows forever!
}
}
The channel buffer itself is fixed at creation (make(chan Request, 100)). The anti-pattern is implementing overflow handling (the
growing slice) that defeats this natural bound. The key problem:
append() with no upper limit.
What makes this appear attractive:
- “Never blocks”—enqueue always succeeds
- “Handles bursts”—overflow goes to slice
- “Simple API”—caller doesn't see backpressure
Why it's catastrophic:
Scenario: Producer rate > consumer rate. Production: 100 req/sec, Processing: 50 req/sec, Deficit: 50 req/sec accumulate.
Result: Linear memory growth until OOM kill.
Why Large Buffers Make It Worse
When production rate exceeds consumption rate, the buffer fills at the deficit rate (production − consumption). Larger buffers just delay the inevitable:
At 10/sec deficit
The larger the buffer, the longer until you discover the problem—and the more catastrophic the failure.
Day 1: Queue size ~100, launch successful. Week 1: Queue at 10,000—“probably fine.” Month 2: Queue at 2M, memory at 8GB. Month 2, 3 AM: OOM kill—all 2M queued jobs lost (in-memory queue gone with process). Root cause: Job creation at 105/sec, processing at 100/sec. Deficit of just 5 jobs/sec = 432,000 jobs/day. Fix: Add one more worker. Had they used bounded queue with rejection: Rejections visible immediately, added capacity before crisis, zero data loss.
The Correct Approach: Bounded Queue with Backpressure
// Illustrative snippet — not a complete program
// ✓ CORRECT: Bounded queue with rejection
func (q *BoundedQueue) Enqueue(req Request) error {
select {
case q.items <- req:
return nil
default:
q.dropped.Add(1) // atomic.Uint64 (Ch 11)
return errors.New("queue full")
}
}
Benefits:
- Memory bounded—maximum size known at creation
- Failure is immediate—caller knows request was rejected
- Observable—dropped count shows backpressure
- Honest—system reports when it can't keep up
A bounded buffer that fills up is telling you something important: production exceeds consumption capacity. This information lets you scale up consumers, implement load shedding, alert on sustained overload, and investigate the rate mismatch. An unbounded queue hides this information until the system fails catastrophically.
Anti-Pattern 3: Arbitrary Buffer Sizing
Choosing buffer sizes based on intuition or imitation rather than actual requirements.
The Symptoms
// Illustrative snippet — not a complete program
// ✗ WRONG: No justification
ch := make(chan Task, 1000) // "Should be enough"
ch = make(chan Task, 10000) // "Better safe"
ch = make(chan Task, 100000) // "Never fills"
Questions that reveal arbitrary sizing:
- Q: “Why buffer size 1000?”
- A: “It seemed like a good number” / “That's what we use elsewhere” / “I don't know”
Why Arbitrary Sizes Are Wrong
1. Memory waste
// Illustrative snippet — not a complete program
type Task struct {
Data [1024]byte // 1KB per task
}
ch := make(chan Task, 10000)
// 10MB allocated for buffer
// Typical usage: 10 items = 10KB
// Waste: 99.9%
2. False confidence
// Illustrative snippet — not a complete program
// "Buffer is huge, so we're safe"
results := make(chan Result, 10000)
for _, ep := range endpoints { // 15,000!
go func() {
results <- query(ep) // ~5K block
}()
}
return <-results // ~5,000 goroutines leaked
The buffer size doesn't match the pattern (15K senders need 15K buffer), so leaks still happen.
3. Delayed feedback
Larger buffers delay problem detection (see the “LARGER BUFFER = LATER FAILURE” table in Anti-Pattern 2). What could be a 5-second alert becomes a 3 AM pager.
Right-Sizing Instead
make(chan T, len(eps))
make(chan T, 75)
make(chan T, 20)
The Documentation Test
Rule: Every buffer size > 1 should have a comment explaining the reasoning.
// Illustrative snippet — not a complete program
// ✓ GOOD: Justification clear
// Buffer for 3 concurrent senders
// (primary + 2 fallbacks) to prevent
// goroutine leaks on early abandon.
results := make(chan Result, 3)
// ✓ GOOD: Measured and documented
// Buffer for 95th percentile burst of
// 120 requests, plus 25% margin.
queue := make(chan Request, 150)
// ✓ GOOD: Simple pattern acknowledged
// Buffer 1 to prevent leak on timeout.
result := make(chan Result, 1)
// ✗ BAD: No reasoning
queue := make(chan Request, 1000) // Why?
When choosing buffer size, complete this sentence: “I need buffer size N because _____.” If you can't complete it, start unbuffered and add buffer when you observe a specific problem.
Anti-Pattern 4: Buffering Sustained Overload
Using larger buffers when you actually need more processing capacity. Section 5.2 emphasized that buffers smooth bursts, not sustained overload.
The Misdiagnosis
// Illustrative snippet — not a complete program
// 200 req/sec arriving, 100 req/sec capacity
// ✗ WRONG: Increase buffer size
requests := make(chan Request, 100) // Fills up
requests = make(chan Request, 1000) // Still fills
requests = make(chan Request, 10000) // Just delayed
Why This Fails
This is the same dynamic as Anti-Pattern 2: the deficit accumulates at (production − consumption) per second, and no buffer size changes that rate. As the “LARGER BUFFER = LATER FAILURE” table showed, bigger buffers just delay the inevitable—turning a 10-second alert into a 3 AM pager.
The Correct Solutions
Solution 1: Add more consumers
// Illustrative snippet — not a complete program
// ✓ CORRECT: Scale workers to match load
queue := make(chan Request, 100) // Small for bursts
for i := 0; i < 10; i++ { // 10 workers
go worker(queue)
}
Solution 2: Apply backpressure
// Illustrative snippet — not a complete program
// ✓ CORRECT: Reject when overloaded
select {
case queue <- req:
return nil
default:
return errors.New("service unavailable")
}
Solution 3: Shed low-priority load
// Illustrative snippet — not a complete program
// ✓ CORRECT: Drop non-critical under load
// Soft threshold: len() is approximate (see
// 5.1); select/default below is the hard guard
if req.Priority == Low &&
len(queue) > cap(queue)*8/10 { // 80%
return errors.New("load shed")
}
// Hard limit: never block unexpectedly
select {
case queue <- req:
return nil
default:
return errors.New("queue full")
}
Buffers smooth variance, not throughput. If sustained load exceeds capacity: add more consumers, apply backpressure, or shed load. Making the buffer bigger delays failure—it doesn't prevent it.
The Hidden Costs of Buffering
Even when buffering is appropriate, it has costs that should inform sizing decisions.
Latency Cost
Items in a buffer are items waiting. High buffer utilization means high latency:
A latency calculation. With a 100ms service target, 500 requests already queued and 10ms of processing each, a request joining the back of the queue waits five seconds before work even begins — fifty times the target, entirely because of buffering.
For latency-sensitive systems, smaller buffers that reject quickly may be preferable to large buffers that accept work destined to timeout.
Memory Cost
// Illustrative snippet — not a complete program
type LargeMessage struct {
Payload [64 * 1024]byte // 64KB per msg
}
ch := make(chan LargeMessage, 1000)
// 64MB allocated at creation, even when empty
Debugging Cost
Buffers add indirection that complicates debugging:
- Timing changes make bugs intermittent
- Items “in flight” are harder to inspect
- The producer that sent a bad value may have moved on
Throughput Cost: What a Buffer Actually Buys
Everything above is about the costs of buffering. It is fair to ask what you get back, and the answer is smaller than most people expect—and it stops improving much sooner.
One producer, one consumer, 300,000 handovers, timed per send. The
benchmarks are in code/ch05/bench_test.go if you want to
run them on your own machine:
Two things to take from this. Going from unbuffered to a buffer of 64 cuts the per-send cost by about four times, because the producer stops paying for a rendezvous on every value. Going from 64 to 1024 buys nothing at all—43.3 against 46.8 ns, which is to say slightly worse. The floor is 34.6 ns, which is what a send costs with no handover to arrange.
That flattening is the whole argument of §5.3 in one line: past the point where the buffer covers your burst, extra capacity is not buying throughput. It is only buying you a longer wait before you find out something is wrong.
Memory Cost, Measured
Capacity is allocated up front, in full, at
make time—element size times capacity, plus a small
header:
-benchmem,
which reports one allocation per make. The channel
header is 96 bytes in this build, but the allocator rounds to its
112-byte size class — both figures are implementation
details and may change between releases.
A buffered chan int of 1000 costs 8 KB whether or
not a single value is ever queued. A chan struct{} of the
same capacity costs
exactly what an unbuffered channel costs—the
two numbers are identical because a zero-size element needs no storage
at all, so both allocate only the header. That is why signal and
semaphore channels can be sized generously without thinking about it,
and data channels cannot.
Monitoring: Detecting Anti-Patterns
Even with good intentions, anti-patterns slip through. Monitor these signals:
// Illustrative snippet — not a complete program
// Pattern only — requires a metrics library
// (Prometheus, expvar, etc.)
import (
"log/slog"
"time"
)
func monitorBuffer(
ch chan Request,
name string,
done <-chan struct{},
) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
var prevDepth int
for {
select {
case <-ticker.C:
depth := len(ch)
capacity := cap(ch)
util := float64(depth) / float64(capacity)
metrics.Gauge(
name+".depth", depth)
metrics.Gauge(
name+".util", util)
// Detect growing trend
if depth > prevDepth && util > 0.5 {
slog.Warn(
"Buffer depth increasing",
"name", name,
"depth", depth)
}
if util > 0.9 {
slog.Error(
"Buffer nearly full",
"name", name,
"capacity", capacity)
}
prevDepth = depth
case <-done:
return
}
}
}
Warning Signs Checklist
A checklist of symptoms that a buffer is being misused: raising the size until a deadlock stopped happening, which masks a coordination bug; and buffer depth growing steadily over time, which means sustained overload rather than a burst.
Decision Framework: Buffer or Fix Design?
A decision tree for avoiding buffering. The first question is whether the buffer is being added to make a deadlock go away. If it is, the answer is not to buffer at all but to fix the coordination.
Section Summary
Key Takeaways
- If code deadlocks without a buffer, adding one doesn't fix it—it hides the coordination bug until production
- Unbounded queues always fail—linear growth until OOM, just a question of when
- “Increase until it works” is a red flag—you're hiding a coordination issue
- Bounded buffers are a feature—they provide essential backpressure signals
- Buffer size ≠ throughput capacity—buffers absorb variance, not volume
- Justify every buffer size—if you can't explain the number, reconsider
- Monitor buffer utilization—sustained high utilization signals capacity problems
Next: Section 5.4 explores buffered channels as counting semaphores—using buffer capacity to limit concurrent access to resources.
A buffer added to make a deadlock go away does not fix the coordination bug; it moves the failure from your laptop to production, and a bigger buffer only moves it further out. If the arrival rate exceeds the service rate, nothing you put in front of it is a queue—it is just a countdown.
5.4 Buffered Channels as Counting Semaphores
Sections 5.1–5.3 covered buffering for timing decoupling, burst absorption, and result collection. This section explores a different use: using buffer capacity to limit concurrent access to resources.
A counting semaphore controls how many goroutines can access a resource simultaneously. Buffered channels implement this pattern naturally—the buffer capacity becomes the concurrency limit.
The Semaphore Concept
A counting semaphore maintains a fixed pool of permits:
- Acquire—take a permit (blocks if none available)
- Release—return a permit (unblocks a waiter)
- Capacity—maximum permits in the pool
Five permits drawn as filled circles. Each acquire removes one and each release returns one; when none are left, the next acquirer waits. That counting behavior is all a semaphore is.
Common uses:
- Limit concurrent API calls to external services
- Control concurrent database connections
- Bound parallel file operations
- Manage resource pools
Semaphores limit concurrency (N simultaneous
operations), not rate (N operations per time
unit). If each request takes 100ms and you allow 10 concurrent,
you could still make 100 requests/second. For true rate limiting,
use golang.org/x/time/rate. The two concepts are
distinct.
Buffered Channels as Semaphores
A buffered channel naturally implements a counting semaphore:
// Illustrative snippet — not a complete program
// Semaphore with 5 permits
sem := make(chan struct{}, 5)
The mapping:
sem <- struct{}{} (send)
<-sem (receive)cap(sem) - len(sem)cap(sem) (buffer capacity)
Why this works:
- Send blocks when full → Acquire blocks when no permits available
- Receive frees a slot → Release makes a permit available
- Capacity is fixed → Permit count is bounded
A capacity-3 channel used as a semaphore. Each send takes a slot and each receive returns one, so the number of goroutines past the acquire can never exceed the capacity. The values sent are empty structs and carry no data.
struct{}?
Semaphores don't carry data—only the count matters.
struct{} has zero size, so
make(chan struct{}, 1000) allocates 0 bytes for
element storage vs 8KB for chan int. Use
struct{} to signal “this is for coordination,
not data.”
The Basic Pattern
// Illustrative snippet — not a complete program
sem := make(chan struct{}, maxConcurrent)
func worker() {
sem <- struct{}{} // Acquire permit
defer func() { <-sem }() // Release permit
doWork() // Protected section
}
Key elements:
-
Acquire before work:
sem <- struct{}{} - Defer release: guarantees release on all exit paths
-
Work protected: only
cap(sem)goroutines execute simultaneously
Two Acquisition Strategies
Where you acquire the semaphore affects what gets limited.
Strategy 1: Acquire Inside Goroutine
Limits concurrent execution:
// Illustrative snippet — not a complete program
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
for _, task := range tasks {
wg.Go(func() {
sem <- struct{}{} // Acquire INSIDE
defer func() { <-sem }()
process(task)
})
}
wg.Wait()
All goroutines created immediately. They wait on semaphore before executing. Coordination is simple via WaitGroup.
Strategy 2: Acquire Before go
Limits goroutine creation:
// Illustrative snippet — not a complete program
sem := make(chan struct{}, maxConcurrent)
for _, task := range tasks {
sem <- struct{}{} // Acquire BEFORE spawn
go func() {
defer func() { <-sem }()
process(task)
}()
}
// Wait by refilling: once we hold all cap(sem) permits,
// every worker must have released. This SPENDS the
// semaphore — it is full afterwards, so a further
// acquire would block forever. One-shot, not reusable.
for i := 0; i < cap(sem); i++ {
sem <- struct{}{}
}
At most maxConcurrent goroutines exist simultaneously.
Lower memory overhead but harder coordination.
Comparison
One hundred tasks against a semaphore of five, under both strategies. Acquiring inside creates all hundred goroutines and five of them work while ninety-five wait. Acquiring before the go statement means only five goroutines exist at a time.
When in doubt, acquire inside the goroutine—it’s
simpler to coordinate with a WaitGroup, and a parked goroutine
costs only its stack (~2KB). Note that acquiring inside still
gates the
work: process(t) runs after the permit is
taken either way. What it does not gate is the goroutines
themselves. Acquire before go when the task
count is large enough that those stacks add
up—a million waiting goroutines is ~2GB before any work
starts.
Use Case: Limiting Concurrent HTTP Requests
The most common use: preventing your application from overwhelming an external service.
// Illustrative snippet — not a complete program
import (
"net/http"
"sync"
)
type FetchResult struct {
URL string
Response Response // Your application type
Error error
}
func fetchAll(
urls []string,
maxConcurrent int,
) []FetchResult {
sem := make(chan struct{}, maxConcurrent)
results := make(chan FetchResult, len(urls))
var wg sync.WaitGroup
for _, url := range urls {
wg.Go(func() {
sem <- struct{}{} // Acquire
defer func() { <-sem }() // Release
resp, err := http.Get(url)
if err != nil {
results <- FetchResult{
URL: url, Error: err,
}
return
}
defer resp.Body.Close()
results <- FetchResult{
URL: url,
Response: processResponse(resp),
}
})
}
wg.Wait()
close(results)
allResults := make([]FetchResult, 0, len(urls))
for result := range results {
allResults = append(allResults, result)
}
return allResults
}
What happens with 100 URLs and
maxConcurrent=10:
- All 100 goroutines start immediately
- Only 10 can hold permits at once
- Others block on
sem <- struct{}{} - As each completes and releases, a waiting goroutine proceeds
- At most 10 concurrent HTTP requests at any moment
One hundred URLs fetched with a concurrency limit of ten. The first ten acquire immediately and fetch; the next ten wait for a release before starting. Throughput is bounded by the limit, not by the number of URLs.
Choosing Semaphore Capacity
SetMaxOpenConns val
GOMAXPROCS(0)
ulimit)
totalMem / opMem
Use Case: Database Connection Limiter
// Illustrative snippet — not a complete program
import (
"context"
"database/sql"
)
type DB struct {
pool *sql.DB
sem chan struct{}
}
func NewDB(dsn string, maxConns int) (*DB, error) {
pool, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
pool.SetMaxOpenConns(maxConns)
return &DB{
pool: pool,
sem: make(chan struct{}, maxConns),
}, nil
}
func (db *DB) Query(
ctx context.Context,
query string,
args ...any,
) (*sql.Rows, func(), error) {
select {
case db.sem <- struct{}{}:
case <-ctx.Done():
return nil, nil, ctx.Err()
}
rows, err := db.pool.QueryContext(
ctx, query, args...)
if err != nil {
<-db.sem
return nil, nil, err
}
// Hold the permit until the caller is done: *sql.Rows
// keeps the connection checked out until Close().
release := func() {
rows.Close()
<-db.sem
}
return rows, release, nil
}
defer
The obvious version puts
defer func() { <-db.sem }() right after the
acquire. It is wrong here, and subtly so:
QueryContext returns as soon as the rows are
ready, but *sql.Rows keeps the underlying
connection checked out until you Close it. A deferred
release would therefore fire while the connection is still in use,
and the semaphore would bound only the time taken to
issue a query—not the time a connection is held,
which is the whole point.
Returning the permit alongside the rows makes the lifetime
explicit:
rows, release, err := db.Query(...) then
defer release(). Methods that do not hand
back a live connection—ExecContext,
QueryRowContext—can use the plain deferred
release safely.
sql.DB already has SetMaxOpenConns, so
why add a semaphore? Early backpressure.
Without the semaphore, goroutines queue inside
sql.DB invisibly. With a semaphore, you control where
waiting happens and can apply timeouts, monitor wait time, and
reject requests before they enter the pool queue.
Critical: Always Release with defer
The most common semaphore bug: forgetting to release on error paths.
// Illustrative snippet — not a complete program
// ✗ BUG: No release on early return
func process(item Item) error {
sem <- struct{}{}
if !item.Valid() {
// Permit NOT released!
return errors.New("invalid")
}
doWork(item)
<-sem
return nil
}
// Illustrative snippet — not a complete program
// ✓ CORRECT: defer ensures all paths
func process(item Item) error {
sem <- struct{}{}
defer func() { <-sem }()
if !item.Valid() {
// defer still runs on this return
return errors.New("invalid")
}
return doWork(item)
}
Without defer, forgotten releases accumulate: T=0:
10/10 available. T=10: 7/10 (3 errors leaked permits). T=100: 3/10
(system degrading). T=500: 0/10—FROZEN, all permits leaked. Always use defer func() { <-sem }()—runs
on return or panic.
Timeout and Non-Blocking Acquisition
Try-Acquire (Non-Blocking)
Proceed only if a permit is immediately available:
// Illustrative snippet — not a complete program
func tryProcess(
sem chan struct{},
item Item,
) (bool, error) {
select {
case sem <- struct{}{}:
defer func() { <-sem }()
return true, doWork(item)
default:
return false, nil // Don't wait
}
}
Use cases: Return “server busy” immediately, load shedding, checking availability without committing.
Acquire with Timeout
// Illustrative snippet — not a complete program
func processWithTimeout(
sem chan struct{},
item Item,
timeout time.Duration,
) error {
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
return doWork(item)
case <-timer.C:
return errors.New("timeout")
}
}
Acquire with Context
// Illustrative snippet — not a complete program
func processWithContext(
ctx context.Context,
sem chan struct{},
item Item,
) error {
select {
case sem <- struct{}{}:
defer func() { <-sem }()
return doWorkWithContext(ctx, item)
case <-ctx.Done():
return ctx.Err()
}
}
When NOT to Use Semaphores
Semaphores limit concurrent access. Don't use them when other primitives are clearer:
make(chan struct{},1)
sync.Mutex (clearer intent)
sync.WaitGroup
close(done) or ctx
// Illustrative snippet — not a complete program
// ✗ UNCLEAR: Semaphore capacity 1
sem := make(chan struct{}, 1)
sem <- struct{}{}
defer func() { <-sem }()
// ✓ CLEARER: Mutex signals intent
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
Semaphore vs Worker Pool
Both limit concurrency. When to use which?
Semaphore:
// Illustrative snippet — not a complete program
sem := make(chan struct{}, 10)
var wg sync.WaitGroup
for _, item := range items {
wg.Go(func() {
sem <- struct{}{}
defer func() { <-sem }()
process(item)
})
}
wg.Wait()
Worker Pool:
// Illustrative snippet — not a complete program
work := make(chan Item, 100)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Go(func() {
for item := range work {
process(item)
}
})
}
for _, item := range items {
work <- item
}
close(work)
wg.Wait()
Semaphore: “I have N goroutines already; only M should access the resource at once.” Worker pool: “I create M dedicated workers to process items from a queue.” Semaphore limits existing concurrency. Worker pool creates controlled concurrency. Worker pools are covered in Chapter 7.
Weighted Semaphores
Sometimes different operations need different amounts of capacity—a large file might need more permits than a small one. A simple channel-based implementation:
// Illustrative snippet — not a complete program
type WeightedSemaphore struct {
sem chan struct{}
}
func NewWeightedSemaphore(capacity int) *WeightedSemaphore {
return &WeightedSemaphore{
sem: make(chan struct{}, capacity),
}
}
func (s *WeightedSemaphore) Acquire(n int) {
for i := 0; i < n; i++ {
s.sem <- struct{}{}
}
}
func (s *WeightedSemaphore) Release(n int) {
for i := 0; i < n; i++ {
<-s.sem
}
}
This simple implementation has critical issues:
Non-atomic acquisition—acquiring N permits
isn't atomic, partial acquisition can deadlock.
No fairness—large acquisitions can starve.
For production weighted semaphores, use
golang.org/x/sync/semaphore which handles atomicity
and fairness correctly.
// Illustrative snippet — not a complete program
import "golang.org/x/sync/semaphore"
sem := semaphore.NewWeighted(100)
if err := sem.Acquire(ctx, 10); err != nil {
return err
}
defer sem.Release(10)
Monitoring Semaphore Utilization
Track semaphore usage to tune capacity:
// Illustrative snippet — not a complete program
// Pattern only — requires a metrics library
// (Prometheus, expvar, etc.)
import (
"log/slog"
"time"
)
func monitorSemaphore(
sem chan struct{},
name string,
done <-chan struct{},
) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
capacity := cap(sem)
// len() safe here: observation only, not flow control
inUse := len(sem)
util := float64(inUse) / float64(capacity)
metrics.Gauge(name+".in_use", inUse)
metrics.Gauge(name+".available", capacity-inUse)
if util > 0.9 {
slog.Warn(
"Semaphore exhausted",
"name", name,
"in_use", inUse,
"capacity", capacity)
}
case <-done:
return
}
}
}
Common Mistakes
All acquires block forever—an unbuffered channel has no space for permits.
Capacity must be ≥ 1. The capacity is the concurrency limit.
Creating a semaphore per request limits nothing—each request gets its own full pool.
Share the semaphore across goroutines. Create once, use everywhere.
Calling <-sem from a path that never acquired
corrupts the limit—the semaphore now allows more than
cap(sem) concurrent operations.
Always pair acquire and release. Use
defer func() { <-sem }() immediately after a
successful acquire to guarantee exactly one release per
acquire.
Section Summary
make(chan struct{}, N)
sem <- struct{}{}<-semselect with default
select with time.NewTimer or
ctx
Key Takeaways
- Buffer capacity = concurrency limit—the channel naturally enforces the bound
- Send = acquire, Receive = release—filling buffer = taking permits
-
Always release with
defer—prevents permit leaks on panic or early return -
struct{}is the canonical token—zero size, signals coordination intent -
Two acquisition strategies—inside goroutine
(simpler) vs before
go(lower memory) -
Use
selectfor timeout/cancellation—don't block forever on overloaded systems - Semaphore vs worker pool—semaphore limits existing concurrency, worker pool creates controlled concurrency
-
For weighted permits, use
x/sync/semaphore—channel-based weighted acquire isn't atomic
A buffered chan struct{} is a counting semaphore: send
to acquire, receive to release, capacity is the limit. Always
release with defer, and choose where you acquire by
task count—inside the goroutine is simpler,
before go is what keeps a million waiting stacks from
existing.
Chapter 5 Self-Check
Test your understanding of the concepts covered in this chapter. Click each question to reveal the answer.
Buffered Channel Mechanics
Answer: 5—buffer size must equal the number of senders that might outlive the receiver.
Answer: (400 − 150) × 10 = 2,500—deficit per second × spike duration. With safety margin: ~3,000.
if len(ch) < cap(ch) { ch <- value }
unsafe?
Answer:
TOCTOU race—another goroutine can send
between your check and your send, filling the buffer. Use
select with default
for atomic non-blocking send.
ok=true?
Answer: 3—buffered
values drain first with ok=true, then
ok=false after the buffer is empty.
When to Buffer
Answer: No—sustained 100% utilization means production exceeds consumption. Buffer size doesn't fix capacity problems. Add more consumers or implement backpressure.
Answer: No—you masked a coordination bug. The code works by accident (buffer size happens to match current input). Change conditions and it breaks again.
Semaphores
make(chan struct{}, 10). Which operation acquires a
permit—send or receive?
Answer: Send—sem <- struct{}{}
acquires (fills buffer); <-sem releases (empties
buffer).
defer?
Answer: Permit leak—the
permit is never returned. Over time, all permits leak and new
acquires block forever. Always use
defer func() { <-sem }().
go?
Answer:
Before go—but not for the reason that first suggests itself.
Acquiring inside still gates the 50MB allocation behind
the semaphore, because process(t) runs only after
the permit is taken. Peak allocation is
cap(sem) × 50MB under either
strategy. What acquiring inside does not gate is the
goroutines themselves: all one million are created at once, and
at roughly 2KB of stack each that is
~2GB of stacks doing nothing but queueing.
Acquiring before go caps live goroutines at
cap(sem) too, so the deciding factor here is task
count, not task size.
select/default to
grow an overflow slice when a buffered channel is full?
Answer: Unbounded queue anti-pattern—the overflow slice grows without limit when production exceeds consumption. Memory increases linearly until OOM. Use a bounded buffer with rejection (backpressure) instead.
Exercise 5.1 — Room for the Losers
Size a buffer so the slow answers have somewhere to land
§5.1 gave you the N−1 rule and
leak_detect.go measured it: three senders, one
receive, two goroutines left standing. Here is the same rule in
the shape you will actually meet it — a first-answer-wins
fan-in across several backends.
package ch05
import "time"
type Result struct {
Source string
Value int
}
// Provided for you: one backend answering after a delay.
func query(source string, delay time.Duration) Result {
time.Sleep(delay)
return Result{Source: source, Value: len(source)}
}
// TODO(reader): First asks every source at once and returns whichever
// answers first, discarding the rest. It is sized wrong in exactly the
// way §5.1 describes.
//
// `results` is unbuffered, so the one goroutine whose answer is
// received finishes — and the other N-1 block forever on a send that
// nobody will ever take. What makes this easy to miss is that First
// still returns the right answer, quickly: from the caller's side
// nothing looks wrong.
//
// Fix it with §5.1's rule. Two constraints, so you reach for the
// right tool:
// - do not add a WaitGroup and do not wait for the slow sources,
// - do not change the signature,
// - First must still return as soon as the first answer lands.
func First(sources []string, delays []time.Duration) Result {
results := make(chan Result) // <- your move
for i, src := range sources {
go func() {
results <- query(src, delays[i])
}()
}
return <-results
}
package ch05
import (
"runtime"
"testing"
"time"
)
func TestFirstReturnsTheFastestAnswer(t *testing.T) {
sources := []string{"alpha", "be", "gamma-source"}
delays := []time.Duration{
80 * time.Millisecond,
10 * time.Millisecond,
120 * time.Millisecond,
}
got := First(sources, delays)
if got.Source != "be" {
t.Fatalf("First returned %q, want %q (the fastest)",
got.Source, "be")
}
}
// The test the naive version cannot pass. First returns the right
// answer either way; what changes is whether the losers can finish.
func TestFirstLeavesNoGoroutinesBehind(t *testing.T) {
sources := []string{"a", "bb", "ccc", "dddd", "eeeee"}
delays := []time.Duration{
10 * time.Millisecond,
20 * time.Millisecond,
30 * time.Millisecond,
40 * time.Millisecond,
50 * time.Millisecond,
}
settle()
before := runtime.NumGoroutine()
First(sources, delays)
// Well past the slowest source: any goroutine still alive here is
// parked on a send, not merely slow.
time.Sleep(200 * time.Millisecond)
settle()
leaked := runtime.NumGoroutine() - before
if leaked > 0 {
t.Fatalf("%d goroutines leaked (want 0).\nWith %d senders and "+
"one receiver, the %d that lose the race are still parked "+
"on a send. See §5.1's N-1 rule.",
leaked, len(sources), len(sources)-1)
}
}
func settle() {
for i := 0; i < 5; i++ {
runtime.Gosched()
time.Sleep(5 * time.Millisecond)
}
}
The first test passes already. That is the point: First
returns the right answer, in the right time, whether or not you fix
it. The leak is invisible from the caller’s side, which is why
the second test has to go looking for it:
Five senders, four leaked — N−1 exactly, the same
arithmetic leak_detect.go printed earlier in the chapter.
Change the sources and the number moves with them.
go test -race ./... in
code/ch05/ reports ok for both tests, and
First still returns as soon as the fastest source answers
— if your fix made it wait for the stragglers, you solved a
different problem.
labs/go-concurrency/code/ch05/. A worked answer sits in
solution/fanin.go.txt — worth noticing first that
the passing test tells you nothing, because that is the part which
transfers to real code.
Further reading
-
The Go Programming Language Specification —
make— the one paragraph that defines buffer capacity, and the sentence that makes §5.1 true: an unbuffered channel is one whose capacity is zero, not a different kind of thing. - Effective Go — Channels — where the semaphore idiom in §5.4 comes from, written when it was the only way to bound concurrency in Go.
-
golang.org/x/sync/semaphore— the weighted version, for when permits are not interchangeable. Worth reading the source: it is roughly a hundred lines, and seeing what it adds over a buffered channel is the clearest way to understand what the channel version cannot do. - Little’s Law — the queueing result behind §5.3. Average queue length equals arrival rate times average wait, which is why a buffer in front of a system that is genuinely overloaded only converts a throughput problem into a latency problem.
- Go Concurrency Patterns: Pipelines and cancellation — the wider setting for the leak that §5.1’s N−1 rule prevents, and the pattern Chapter 7 builds on.
You can now say what a buffer changes and what it does not, size one
against the number of senders rather than a round number, name the
four situations where adding one makes things worse, and use a
buffered chan struct{} to bound concurrency. Every
channel so far has been bidirectional—any holder could send
or receive. Chapter 6 takes that away:
directional channel types, what
chan<- T and <-chan T let the compiler
enforce, and how narrowing a channel at a function boundary turns the
sender-closes principle from a convention you remember into a rule the
compiler keeps.