Concurrency
Channels, select and Pipelines That Cannot Deadlock
Unbuffered and buffered channels, the receive-from-closed rule, select with default and timeouts, and a pipeline whose closing rules make deadlock impossible.
Channels fail differently from goroutines. A leaked goroutine is quiet, but a channel
mistake either stops the program with a stack trace or silently returns zero values where
you expected data. The compiler checks that you send an int on a
chan int, and it has no opinion about which goroutine closes the channel,
whether a buffer is big enough, or whether the value you just read was real. What is left
is a short set of rules.
An unbuffered channel is a rendezvous, not a queue
make(chan T) has no capacity. A send completes only when a receiver is
ready to take the value, and a receive completes only when a sender is ready to give one.
Until both sides arrive, whichever got there first sits parked.
// producer sends each item and reports how long the send blocked.
func producer(out chan<- string) {
for _, item := range []string{"a", "b", "c"} {
start := time.Now()
out <- item
fmt.Printf("sent %q after %v\n", item, time.Since(start).Round(time.Millisecond))
}
close(out)
}
func main() {
ch := make(chan string) // unbuffered
go producer(ch)
for item := range ch {
time.Sleep(100 * time.Millisecond) // pretend the consumer is busy
fmt.Println("received", item)
}
fmt.Println("channel closed, range finished")
}
sent "a" after 0s
received a
sent "b" after 101ms
received b
sent "c" after 101ms
received c
channel closed, range finished
The first send takes no measurable time because the producer and the range loop arrive together. Every send after that waits, and the printed duration is exactly how long the consumer spent working. The sender never gets more than one value ahead of the receiver, which is what an unbuffered channel buys: it synchronises as well as moves data.
A buffer changes when the sender finishes, not how long the work takes
make(chan T, n) holds up to n values. A send stores the value
and returns if there is room, and blocks only when the buffer is full. The same producer
and consumer with capacity zero and with capacity three:
received a
received b
producer finished after 202ms
received c
buffer 0: total 303ms
producer finished after 0s
received a
received b
received c
buffer 3: total 303ms
With no buffer the producer finishes at 202 ms, because the last send waits for the consumer to come back for it. With room for three it finishes immediately. The consumer still takes 303 ms for three items, because it is the slow side in both runs. A buffer decides who waits when the two sides run at different speeds; it does not divide the work.
That is the mistake to name before you make it. Slack is worth having when a producer
must not be slowed by a consumer that stutters. It is not worth having when you use it to
hide the fact that nobody drains the channel: a channel with capacity 1,000 and no reader
blocks exactly as capacity zero would have. A full check is
len(ch) == cap(ch).
Buffers delay leaks, they do not prevent them
A buffered channel lets a goroutine finish work that nobody collects. That is sometimes deliberate, for a best-effort metric or a log line. It is also the shape of a leak that a goroutine count will not reveal until the buffer fills, days later.
The sender closes, and a closed channel hands out zero values
Closing is the signal that no more values are coming, and it is a send-side operation, because only the side that knows it has finished sending can say so honestly. The receiver sees closure through the two-result form of a receive:
closed := make(chan struct {
ID int
Name string
}, 1)
closed <- struct {
ID int
Name string
}{ID: 1, Name: "first"}
close(closed)
for i := 0; i < 3; i++ {
v, ok := <-closed
fmt.Printf("receive %d: %+v ok=%v\n", i, v, ok)
}
receive 0: {ID:1 Name:first} ok=true
receive 1: {ID:0 Name:} ok=false
receive 2: {ID:0 Name:} ok=false
The buffered value survives the close and is still delivered. After that, every receive
succeeds immediately with the zero value and ok false: 0 for
integers, "" for strings, nil for pointers. A loop that reads a
closed channel without checking ok has an endless supply of zeroes, and the
loop never ends.
Both channel panics are runtime faults, and the only way to see their messages without crashing is a recover:
send to a closed channel: send on closed channel
close a closed channel: close of closed channel
Both mean the same thing: two goroutines think they own the closing, and the fix is one
owner. A generator closes the channel it created, and a function that receives a channel as
a parameter never closes it, because the caller may still be sending. One
close releases every receiver at once, which is why the idiomatic "stop
everything" channel is chan struct{}.
range drains until close, and makes a deadlock visible
The comma-ok form is for when you want one more value and need to know whether it was
real. For draining everything, range is shorter and safer, because the loop
condition is the thing that decides when to stop:
for v := range in {
total += v
}
The loop ends when in is closed and drained, and no version of it spins on a
closed channel or drops a value. Written by hand it is a receive, a check of
ok and a break, which is three chances to put the break in the
wrong place.
range also makes the common deadlock detectable: a range over a channel
nobody will ever close blocks forever, and when every other goroutine is parked too, the
runtime says so:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
/tmp/goconc/l2j/main.go:8 +0x78
exit status 2
That message is a gift, and it also explains why production deadlocks are often something else. The runtime only panics when every goroutine is blocked. One goroutine parked forever while others serve traffic is the leak from the previous lesson with a different resting place.
select waits on several channels, and default refuses to wait
select is a switch whose cases are channel operations. It
blocks until one case can proceed, then runs it, and when several are ready it picks one
at random so no channel starves the others. The two forms you will write most are a
timeout and a non-blocking check:
// head returns the first value produced, giving up after wait.
func head(ch <-chan string, wait time.Duration) (string, bool) {
select {
case v := <-ch:
return v, true
case <-time.After(wait):
return "", false
}
}
head: gave up after 50ms
head got late
The default case runs when nothing else can, which turns
select into a non-blocking check:
func tryHead(ch <-chan int) (int, bool) {
select {
case v := <-ch:
return v, true
default:
return 0, false
}
}
The bool in that signature is doing real work. A non-blocking read that
returns only a value cannot tell you whether it got a real zero or nothing at all. Return
the second result and let the caller decide.
The trap with default is the loop. for { select { case v :=
<-ch: ...; default: } } never blocks, so it spins at full speed, burning a core
while it waits. If you poll, sleep in the default branch:
func waitFor(ch <-chan string, limit time.Duration) (string, error) {
deadline := time.Now().Add(limit)
for {
select {
case v := <-ch:
return v, nil
default:
}
if time.Now().After(deadline) {
return "", fmt.Errorf("nothing arrived within %v", limit)
}
time.Sleep(10 * time.Millisecond) // without this the loop burns a core
}
}
got: ready <nil>
quiet: nothing arrived within 50ms
Polling is still a compromise: the goroutine wakes ten times a second whether or not
there is work, and a value arriving mid-sleep waits up to ten milliseconds. Most of the
time a blocking receive plus a context is the right answer. Reach for default
when the work is genuinely optional.
Channel direction appears in every signature above and costs nothing at runtime.
chan<- T is send-only, <-chan T is receive-only, and
chan T is both. A send-only parameter cannot be received from and a
receive-only parameter cannot be closed.
A mutex is the right tool when there is no handoff
Channels move data between goroutines. When the thing you are protecting is a shared structure that many goroutines read and write, a channel becomes a request queue with one owner, which is more code and more latency. A mutex is the smaller tool:
type Cache struct {
mu sync.Mutex
items map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.items[key]
return v, ok
}
func (c *Cache) Put(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
}
Take the two Lock pairs out, run a hundred goroutines that read and write
the map, and the detector finds it on the first run:
==================
WARNING: DATA RACE
Read at 0x00c0000a20f0 by goroutine 7:
runtime.mapassign_fast64ptr()
/opt/homebrew/Cellar/go/1.26.3/libexec/src/internal/runtime/maps/runtime_fast64.go:374 +0x38c
main.main.func1()
/tmp/goconc/l2race/main.go:15 +0x80
Previous write at 0x00c0000a20f0 by goroutine 8:
runtime.mapaccess2_faststr()
/opt/homebrew/Cellar/go/1.26.3/libexec/src/internal/runtime/maps/runtime_faststr.go:161 +0x2ac
main.main.func1()
/tmp/goconc/l2race/main.go:15 +0xb0
==================
exit status 66
Read to the bottom of each stack block rather than stopping at the top, or you will conclude the bug is in the map runtime. The last two lines are your code, both at line 15: a map write and a map read with no lock between them.
sync.Once covers the case a channel handles badly: initialisation that
must happen exactly once however many goroutines race for it.
sync.OnceValue wraps the pattern and returns the value:
var loadSchema = sync.OnceValue(func() map[string]string {
fmt.Println("loading schema from disk (this prints once)")
time.Sleep(20 * time.Millisecond)
return map[string]string{"orders": "id, customer_id, total"}
})
cache has 100 entries, key-7 -> value-7
loading schema from disk (this prints once)
schema loaded, callers: 20 columns: id, customer_id, total
Twenty goroutines called loadSchema, the disk was read once, and the other
nineteen waited and got the same map. The channel equivalent needs a goroutine holding the
channel, a sync.Once for the launch, and a broadcast close for the waiters.
The rule of thumb: a channel passes a value or a signal between goroutines, and a mutex
protects state that several goroutines touch.
A pipeline is generate, transform, collect, with one owner per close
Now the pieces fit together. Each stage takes a receive-only channel and returns a
receive-only channel. It starts one goroutine, reads until its input closes, writes its
results, and closes its own output. The defer close(out) immediately after
the goroutine starts is what makes the closing unconditional:
// generate owns out and closes it when there is nothing left to send.
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
// square reads until in is closed, then closes its own channel.
func square(in <-chan int, delay time.Duration) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
time.Sleep(delay)
out <- v * v
}
}()
return out
}
Two channels exist at that point and each has exactly one owner, so no close can panic. The collector is a plain loop over the last channel. The interesting case is a stage that needs several workers, because several output channels have to become one:
// merge fans several channels into one and closes the result when all inputs end.
func merge(chans ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)
wg.Add(len(chans))
for _, c := range chans {
go func() {
defer wg.Done()
for v := range c {
out <- v
}
}()
}
go func() {
wg.Wait()
close(out)
}()
return out
}
merge is the one place a WaitGroup and a channel belong
together. Each copier goroutine owns nothing, the WaitGroup counts them, and
the single goroutine that waits is the single owner of close(out). The
collector's range ends exactly when the last copier finishes. Sharing one
input channel among three workers gives the fan-out:
single stage: [1 4 9 16]
three workers: [4 1 9 16 36 25 49 64] in 300ms
The order is not the input order, which is the honest consequence of fanning out: the workers take whichever value is next, so the sequence depends on who finishes first. If order matters the pipeline needs a sequence number and a reorder stage, or the ordering has to come from somewhere else.
Eight values through three workers takes about 300 ms when each value costs 100: the work divides, and the slowest worker sets the pace. A bottleneck stage wants more workers, not a bigger buffer.
Write a pipeline of three stages over a slice of order totals in cents:
generate sends the raw amounts, addTax multiplies each by
1.2 and rounds to the nearest cent, and collect sums them into a total
and a count.
Then add a filter stage between addTax and collect that
drops amounts below a threshold, and fan out the tax stage across three workers with a
merge.
Answer these in comments before you run anything:
- Which goroutine closes the channel that
filterwrites to, and why can no other goroutine close it? - What does
collectdo if the filter stage panics, and how would you find out that it happened? - You want the total as a
float64of dollars. Where does the conversion go, and what does that choice cost you?
Run the finished pipeline under go run -race and confirm the report is
empty.
Show the solution and the reasoning
package main
import (
"fmt"
"math"
"sync"
)
func generate(amounts ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, a := range amounts {
out <- a
}
}()
return out
}
func addTax(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
out <- int(math.Round(float64(v) * 1.2))
}
}()
return out
}
func filter(in <-chan int, min int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
if v >= min {
out <- v
}
}
}()
return out
}
func merge(chans ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)
wg.Add(len(chans))
for _, c := range chans {
go func() {
defer wg.Done()
for v := range c {
out <- v
}
}()
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func collect(in <-chan int) (total, count int) {
for v := range in {
total += v
count++
}
return total, count
}
func main() {
amounts := []int{95, 340, 1280, 55, 2100, 760}
total, count := collect(filter(addTax(generate(amounts...)), 100))
fmt.Printf("single: total=%d cents (%d orders)\n", total, count)
in := generate(amounts...)
outs := []<-chan int{}
for i := 0; i < 3; i++ {
outs = append(outs, addTax(in))
}
total, count = collect(filter(merge(outs...), 100))
fmt.Printf("fanned: total=%d cents (%d orders)\n", total, count)
}
single: total=5490 cents (5 orders)
fanned: total=5490 cents (5 orders)
The filter's own goroutine closes the channel it created, because that goroutine is
the only one that knows when it has stopped filtering. Nothing upstream can close it:
the stage signatures hand out <-chan int, so addTax and
merge do not have the send end. Both runs agree, and the run under
-race reports nothing. The three addTax workers share one
input channel, so the six amounts are divided between them, and the total does not
change when the division does.
If the filter stage panics, its defer close(out) still runs during the
unwind, so collect sees the channel close and returns a partial total with
a smaller count. The panic then propagates up the filter's own stack and kills the
process, which is the honest outcome: a partial total reported as a total is worse than
a crash.
The conversion to dollars belongs at the end, in collect. Money as a
float64 is a bug in the corner: 0.1 plus 0.2 is not 0.3 in binary floating
point, and a rounding error in a total is a customer complaint. Keep the pipeline in
integer cents, which is exact, and convert where the value is formatted for a human or
handed to a JSON encoder.
One check worth running on your own version: the fanned and single paths must report the same total and count. They agree here because the filter works per value, not per stream. The moment a stage aggregates, that stops being true and the order of the stages has to be chosen deliberately.
Progress
Mark this lesson done and it stays marked in this browser.