44. Buffered channels
Give make(chan T, n) a capacity and the channel gets a queue. Sends only
block when the queue is full; receives only block when it's empty. That small
change alters the synchronisation guarantees, so it's worth being deliberate
about.
Capacity in action
package main
import "fmt"
func main() {
ch := make(chan string, 3)
ch <- "first"
ch <- "second"
fmt.Println("sent two without any receiver — len:", len(ch), "cap:", cap(ch))
ch <- "third"
fmt.Println("buffer full — len:", len(ch), "cap:", cap(ch))
fmt.Println(<-ch)
fmt.Println("after one receive — len:", len(ch))
ch <- "fourth"
fmt.Println(<-ch, <-ch, <-ch)
}
len is how many values are queued; cap is how many fit. On an unbuffered
channel both are 0, and every send needs a receiver right now.
A fourth send before any receive would block forever here — the buffer is a queue, not an unlimited mailbox.
Buffered vs unbuffered: the real difference
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
unbuf := make(chan int)
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("unbuffered: about to send")
unbuf <- 1
fmt.Println("unbuffered: send returned (a receiver took it)")
}()
fmt.Println("unbuffered: main received", <-unbuf)
wg.Wait()
buf := make(chan int, 1)
fmt.Println("buffered: about to send")
buf <- 1
fmt.Println("buffered: send returned immediately, nobody has received yet")
fmt.Println("buffered: main received", <-buf)
}
The distinction that matters:
- Unbuffered: a completed send proves a receiver got the value. It's a synchronisation point.
- Buffered: a completed send proves only that the value is in a queue. The receiver may not exist yet.
So when you need "these two goroutines are now at the same point", use unbuffered. When you need to decouple a producer's speed from a consumer's, use a buffer.
Where buffers genuinely help
A known, finite number of results — sized so no worker ever blocks:
package main
import (
"fmt"
"sort"
)
func main() {
const workers = 4
results := make(chan int, workers)
for i := 1; i <= workers; i++ {
go func(n int) {
results <- n * n
}(i)
}
var got []int
for i := 0; i < workers; i++ {
got = append(got, <-results)
}
sort.Ints(got)
fmt.Println(got)
}
Each worker sends and exits immediately instead of waiting for main to get
around to receiving. With an unbuffered channel this still works, but every
worker parks until it's read.
A semaphore that limits concurrency — the buffer's capacity is the limit:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
sem := make(chan struct{}, 2)
var wg sync.WaitGroup
var mu sync.Mutex
maxConcurrent, current := 0, 0
for i := 1; i <= 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
mu.Lock()
current++
if current > maxConcurrent {
maxConcurrent = current
}
mu.Unlock()
time.Sleep(5 * time.Millisecond) // pretend to do work
mu.Lock()
current--
mu.Unlock()
}(i)
}
wg.Wait()
fmt.Println("ten jobs ran, peak concurrency:", maxConcurrent)
fmt.Println("never exceeded the limit:", maxConcurrent <= 2)
}
Ten jobs, but the sem <- struct{}{} at the top blocks whenever two are
already inside. struct{} again because the value carries no information —
only the slot matters. This is how you cap concurrent database connections or
outbound HTTP calls without any extra library.
Choosing a capacity
There is no clever formula. In practice:
- 0 (unbuffered) — the default. Start here; it gives the strongest guarantees and surfaces deadlocks immediately.
- exactly N — when you know exactly how many sends will happen and don't want senders to block.
- a small number — to smooth out bursty producers.
A large buffer is usually a bug in disguise: it hides the fact that your
consumer can't keep up, converting a visible block into growing memory and
latency. If you find yourself typing make(chan T, 10000), the real problem
is somewhere else.
Non-blocking checks
len and cap let you peek, but for a genuine non-blocking send or receive
you want select with a default — the next lesson.
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 1
if len(ch) < cap(ch) {
ch <- 2
fmt.Println("there was room, sent a second value")
}
fmt.Println("queued:", len(ch), "of", cap(ch))
fmt.Println(<-ch, <-ch)
}
Be careful with that pattern in real concurrent code: between checking len
and sending, another goroutine may have filled the buffer. select with
default is atomic and correct; a len check is a race.
Closed buffered channels drain first
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
for v := range ch {
fmt.Println("drained", v)
}
v, ok := <-ch
fmt.Println("after draining:", v, ok)
}
Closing doesn't discard queued values — receivers get everything already in the buffer, and only then see the channel as closed. That makes "send everything, close, let the consumer finish at its own pace" a safe shutdown pattern.
Your turn
Use a buffered channel as a semaphore so that ten goroutines run with at most three of them inside the critical section at any moment. Print the total:
55
package main
import (
"fmt"
"sync"
)
func main() {
sem := make(chan struct{}, 3)
var wg sync.WaitGroup
var mu sync.Mutex
total := 0
for i := 1; i <= 10; i++ {
wg.Add(1)
// start a goroutine that acquires sem, adds i to total under mu,
// then releases sem
}
wg.Wait()
fmt.Println(total)
}
package main
import (
"fmt"
"sync"
)
func main() {
sem := make(chan struct{}, 3)
var wg sync.WaitGroup
var mu sync.Mutex
total := 0
for i := 1; i <= 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
mu.Lock()
total += n
mu.Unlock()
}(i)
}
wg.Wait()
fmt.Println(total)
}
Next: waiting on several channels at once.