The problem channels solve
When you start a goroutine to do some work, you hit a problem straight away. It can do the work perfectly well, but it has no way of giving you the answer.
go compute() // whatever compute returns, it's gone
go f() launches f and moves straight on to the next statement. By the time
f finishes, the line that started it is long past, so there is nowhere for a
return value to go. A goroutine has no caller waiting to receive one. Go won’t
even let you pretend otherwise: x := go compute() doesn’t compile.
So how do you get a value out of a goroutine?
The obvious answer is to have it write somewhere you can both see. Say you have a batch of jobs to get through, and you split them across two workers. You’d like to know how many got done, so both workers bump a counter.
Here’s the version everyone writes first:
package main
import (
"flag"
"fmt"
"sync"
)
// stands in for the real work: validate a record, resize an image, parse a line
func process(job int) {
_ = job * job
}
func main() {
n := flag.Int("n", 10, "how many jobs to process")
flag.Parse()
jobs := make([]int, *n)
for i := range jobs {
jobs[i] = i + 1
}
var processed int // shared by both workers
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for _, job := range jobs[:*n/2] {
process(job)
processed++ // 🚩
}
}()
go func() {
defer wg.Done()
for _, job := range jobs[*n/2:] {
process(job)
processed++ // 🚩
}
}()
wg.Wait()
fmt.Printf("processed: %d (want %d)\n", processed, *n)
}
Run it and you’ll get 10. Run it enough times, or with enough jobs, and eventually you won’t, because nothing in this program guarantees that number. Run it with Go’s race detector and you get told exactly why:
$ go run -race ./01-race source $ go run -race ./01-race
==================
WARNING: DATA RACE
Read at 0x00c0000181c8 by goroutine 9:
main.main.func2()
/home/you/code/01-race/main.go:51 +0x13d
Previous write at 0x00c0000181c8 by goroutine 8:
main.main.func1()
/home/you/code/01-race/main.go:43 +0x11a
==================
processed: 10 (want 10)
(correct this time, which is exactly why races are dangerous)
Found 1 data race(s)
exit status 66
Go’s race detector reports a read racing with a previous write, at the same address, from two different goroutines. Notice that the count still came out at 10. The detector flags the unsafe access pattern rather than a wrong answer, which means it finds the bug on the run where you got lucky. That is what makes it worth using.
Scale it up with -n and the wrongness becomes visible without any tooling:
$ go run ./01-race -n 1000000
processed: 549740 (want 1000000)
^ lost updates: this is what a data race looks like from the outside
Nearly half the jobs it finished were never counted.
The reason is that processed++ isn’t one step. It reads processed, adds one,
and writes the result back. Two workers can read the same old value, each add
one, and each write back, and one of those writes lands on top of the other. The
increment that got overwritten is simply gone. The work itself still happened,
which is the nasty part: only the count is wrong, and that is the kind of bug
that survives code review.
That is a data race: two goroutines touching the same memory at the same time, with at least one of them writing. Races are the reason concurrent code has the reputation it does, and they have a habit of turning up once a week in production and never once on your laptop.
There are two ways out. One is to lock the variable so a single goroutine
touches it at a time, which is what sync.Mutex and sync/atomic are there
for. The other is to stop sharing it: give each worker its own count, and have
it send the number back when it has finished.
For a counter, take the lock. atomic.Int64 has an Add that compiles to a
single instruction, and routing two numbers through a channel to add them up
would be overkill. The counter earns its place here by being the shortest
program that goes visibly wrong.
Sending is the option that keeps working as the problem grows. A goroutine that can hand back a finished number can hand back anything else it produced, a parsed row or an error just as easily. That is the handoff channels are built for.
What a channel is
A channel is a typed pipe between goroutines. One goroutine puts values in, another takes them out.
ch := make(chan int) // a channel that carries ints
ch <- 42 // send: put 42 in
v := <-ch // receive: take a value out
The arrow always points in the direction the data moves. ch <- 42 pushes into
the channel, and <-ch pulls out of it. That is the whole syntax, and once you
read it as an arrow it stops being cryptic.
chan int is a real type, like []string or map[string]int. A channel of
strings won’t accept an int, and the compiler stops you.
Here’s the same batch of jobs, rewritten:
package main
import "fmt"
func main() {
jobs := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
results := make(chan int)
go func() {
mine := 0 // private to this worker
for _, job := range jobs[:5] {
process(job)
mine++
}
results <- mine // hand the finished count over
}()
go func() {
mine := 0 // private to this worker
for _, job := range jobs[5:] {
process(job)
mine++
}
results <- mine
}()
first := <-results
second := <-results
fmt.Println("processed:", first+second) // always 10
}
Run this a million times with -race and it stays clean. Nothing is shared. Each
worker owns its own mine, and the only thing that crosses between goroutines is
a finished value handed over deliberately. That crossing answers the question we
started with: the result got out of the goroutine, and it never lived in memory
two goroutines could reach at once.
Compare it to the earlier diagram. That one had a shared cell in the middle that both workers read and wrote. This one has no middle lifeline at all, because there is no longer anything in the middle to share:
Notice that the WaitGroup is gone too. <-results blocks until a value
arrives, so once we have received two values, both workers have finished their
jobs. The channel did the waiting for us, which brings us to the part that
actually matters.
Channels block by design
One rule sits underneath all of this. Once it clicks, most of what channels do stops being surprising, including the parts that look like limitations:
On an unbuffered channel, a send blocks until another goroutine receives, and a receive blocks until another goroutine sends.
”Blocks” means the goroutine stops and waits. It isn’t spinning or burning CPU: the runtime parks it until the other side shows up, and wakes it then.
The word “pipe” suggests storage, which is why this catches people out. An unbuffered channel has no storage at all. It is a meeting point rather than a container you drop things into.
The everyday version: an unbuffered channel is handing someone a package in person. You both have to be there. Whoever arrives first waits for the other, the handoff happens at one instant, and afterwards you both walk away.
You can watch it happen. This program prints timestamps:
func receiverWaits() {
ch := make(chan string)
at := stopwatch() // returns [ 2.0s] style timestamps, counting from now
go func() {
fmt.Println(at(), "sender: sleeping 2s before sending")
time.Sleep(2 * time.Second)
fmt.Println(at(), "sender: about to send")
ch <- "hello"
fmt.Println(at(), "sender: send completed")
}()
fmt.Println(at(), "receiver: about to receive (this will block)")
msg := <-ch
fmt.Println(at(), "receiver: got", msg)
}
The program runs this case and its mirror image back to back, so one command prints both. Here’s the first half:
$ go run ./03-blocking -case receiver source 1. receiver arrives first, so the receiver waits
------------------------------------------------
[ 0.0s] receiver: about to receive (this will block)
[ 0.0s] sender: sleeping 2s before sending
[ 2.0s] sender: about to send
[ 2.0s] sender: send completed
[ 2.0s] receiver: got hello
The receiver sat at <-ch for two full seconds. Look at the last two lines: the
send completing and the receive completing happen at the same moment. They are
two halves of one event.
It works identically in reverse. If the sender is ready first then the sender waits, which is the second half of the same run, with the sleeps swapped:
$ go run ./03-blocking -case sender source 2. sender arrives first, so the sender waits
--------------------------------------------
[ 0.0s] sender: about to send (this will block)
[ 0.0s] receiver: sleeping 2s before receiving
[ 2.0s] receiver: about to receive
[ 2.0s] receiver: got hello
[ 2.0s] sender: send completed
Why blocking is the good part
New Go programmers usually read blocking as a limitation to work around. It is closer to the opposite. Blocking is the feature you are paying for.
It gives you synchronization for free. When <-results returns a value, you know
with certainty that the sending goroutine got far enough to produce it. You
didn’t write any locking, any flag, or any “is it ready yet” polling. The
ordering is guaranteed by the language: everything the sender did before the send
is visible to the receiver after the receive. (That guarantee has a formal name,
the happens-before relationship, and it is the same one a mutex gives you.)
It also gives you backpressure, which matters as soon as you have real volume. A fast producer physically cannot outrun a slow consumer, because the send won’t complete until the consumer takes the value. Memory stays flat with no effort.
Buffered channels
Sometimes a strict rendezvous is more coupling than you want. make takes an
optional capacity:
ch := make(chan int, 3) // room for 3 values
Now the rule changes:
A send blocks only when the buffer is full. A receive blocks only when the buffer is empty.
A buffered channel holds up to that many values on its own, so a sender can hand one over and carry on without a receiver being ready. That only lasts while there is room. Once the buffer is full the sender waits again, exactly as it did before.
func main() {
ch := make(chan int, 3)
for i := 1; i <= 3; i++ {
ch <- i // returns immediately while the buffer has room
fmt.Printf("sent %d, returned immediately (len=%d cap=%d)\n", i, len(ch), cap(ch))
}
ch <- 4 // 🚩 the buffer is full and nobody is receiving, so this blocks forever
}
$ go run ./04-buffered -case overfill source one send past capacity, with nobody receiving
---------------------------------------------
buffer full (len=3 cap=3)
sending a 4th value with nobody receiving...
fatal error: all goroutines are asleep - deadlock!
len(ch) is how many values are sitting in the buffer right now, and cap(ch)
is the capacity. len on an unbuffered channel is always 0, because there is
nowhere for a value to sit.
Start unbuffered. It is the stronger guarantee: when your send completes you know somebody actually took the value, whereas a buffered send only tells you the value was accepted for later.
Add a buffer when you have a specific reason. A small buffer absorbs bursts, so a producer that arrives in clumps doesn’t stall when the average rate is fine. A buffer of exactly N lets a sender finish a known-size handoff when the receiver only shows up later, which avoids a deadlock. Anything beyond that wants a measurement behind it, not a hunch that bigger sounds faster.
Remember that a buffer is memory you are committing. make(chan Request, 100_000)
where each request is 2KB is a 200MB decision.
Closing a channel
Sending values is half the story. The other half is saying “that’s all.”
close(ch)
close doesn’t destroy the channel or free anything. It is a one-time
announcement that no more values will ever be sent. Two things follow from it,
and both are useful.
The first is that receiving from a closed channel returns immediately with the zero value, and keeps doing so forever after. The two-value form tells you whether the value was real:
v, ok := <-ch
// ok == true → v is a genuine value
// ok == false → the channel is closed and drained; v is just the zero value
The second is that range over a channel loops until it is closed.
func main() {
ch := make(chan int)
go func() {
for i := 1; i <= 3; i++ {
ch <- i
}
close(ch) // without this, the range below hangs forever
}()
for v := range ch { // ends when ch is closed and empty
fmt.Println("got", v)
}
fmt.Println("channel closed, loop finished")
}
$ go run ./05-close-range -case range source 1. range over a channel
-----------------------
got 1
got 2
got 3
channel closed, loop finished
Delete the close(ch) line and the program prints the three values, then hangs,
then dies with all goroutines are asleep - deadlock!. The range loop is still
waiting for a fourth value that will never come. Forgetting to close is an easy
mistake to make, and the symptom is a hang after the work is already done.
Two conventions about closing
Neither of these is enforced by the compiler. Any goroutine holding a channel is allowed to close it, and nothing checks how many times. They are conventions because of what the runtime does when you break them.
Only the sender closes. Nothing stops a receiver from calling close, but if it
does, a sender that is still going will panic the moment it sends again. The
sender is the only party that knows there is nothing more coming, so it is the
only one in a position to say so.
Close exactly once, because closing an already-closed channel panics as well.
Both conventions come free when there is one sender that closes right after its send
loop, ideally with defer:
go func() {
defer close(ch)
for _, v := range values {
ch <- v
}
}()
With multiple senders it is genuinely harder, because nobody individually knows
when the last value has been sent. The standard answer is a sync.WaitGroup and
a small goroutine whose only job is to close after everyone is done:
go func() {
wg.Wait() // all senders have finished
close(ch)
}()
You usually don’t need to close at all
Closing is not cleanup, which surprises people. An unclosed channel is garbage collected like anything else once nothing references it. You close a channel to communicate “no more values”, so if nobody needs that signal, you can skip it.
If you send exactly one result and the receiver takes exactly one value, don’t bother closing.
select: waiting on more than one thing
A plain receive waits on exactly one channel. select waits on several and
proceeds with whichever is ready first:
// after(d, v) returns a channel that delivers v once d has elapsed
fast := after(100*time.Millisecond, "fast")
slow := after(500*time.Millisecond, "slow")
select {
case v := <-fast:
fmt.Println("got:", v)
case v := <-slow:
fmt.Println("got:", v)
}
That prints got: fast, and the second case never runs at all.
It is switch for channel operations. Some details worth knowing up front:
- It blocks until one case can proceed, runs that case, and moves on. One case
per
select, not all of them. Above, theslowvalue is simply never received. - If several are ready simultaneously, it picks one at random. That is deliberate, and it stops a busy channel from starving a quiet one.
- A
defaultcase makes it non-blocking. If nothing else is ready,defaultruns immediately. - A case can be a send as well as a receive (
case ch <- v:), which fires when a receiver is ready. It is far rarer than the receive form, and not used again here.
Two patterns cover most real uses. The first is a timeout. time.After returns a
channel that delivers a value after a delay, so racing it against your real work
gives you a deadline:
select {
case result := <-work:
fmt.Println("finished:", result)
case <-time.After(2 * time.Second):
fmt.Println("timed out")
}
The second is a non-blocking check. With default you can peek without
committing:
select {
case v := <-ch:
fmt.Println("got", v)
default:
fmt.Println("nothing ready right now")
}
That’s useful, but if you find yourself putting it in a for loop to poll a
channel, stop. That is a spin loop burning a CPU core. Just block on the receive,
which is what blocking is for.
The complete behavior table
Every combination of operation and channel state, in one table. A lot of channel confusion turns out to be a cell in here that you had not run into yet.
| Operation | nil channel | closed channel | open, empty | open, full |
|---|---|---|---|---|
send ch <- v | blocks forever | panic | unbuffered: blocks until a receiver is ready. buffered: completes immediately | blocks until a receiver frees space |
receive <-ch | blocks forever | returns zero value immediately, ok == false | blocks until a value | returns a value |
close close(ch) | panic | panic | ok | ok |
len / cap | 0 / 0 | works normally | works normally | works normally |
Three rows people meet the hard way.
Sending on a closed channel panics, and it is a real crash rather than an error you can check. That is why “only the sender closes” matters.
Receiving from a closed channel never blocks. It hands out zero values forever.
If you range over a closed channel you get no iterations, and if you receive in
a loop without checking ok, you get an infinite stream of zeros. Check ok
when it matters.
Every operation on a nil channel blocks forever. That sounds like a pure footgun, and it does cause plenty of mystery hangs, since the zero value of a channel is nil:
var ch chan int // nil! make() was never called
ch <- 1 // blocks forever
It is also, surprisingly, useful. Since a nil channel is never ready, a nil case
in a select is effectively disabled, which gives you a clean way to stop
listening to one source while continuing with others:
for ch1 != nil || ch2 != nil {
select {
case v, ok := <-ch1:
if !ok {
ch1 = nil // disable this case; the loop keeps serving ch2
continue
}
use(v)
case v, ok := <-ch2:
if !ok {
ch2 = nil
continue
}
use(v)
}
}
Without that trick, a closed channel in a select is always ready, so the loop
spins at full speed receiving zero values.
What channels are actually for
Those are the mechanics. The shapes below are the ones you will actually reach for.
1. Get a result back from a goroutine
This is the opening problem in its smallest form. A goroutine has no caller to return to, so a channel is the return path:
func main() {
result := make(chan int)
go func() {
result <- expensiveComputation()
}()
// ... do something else useful here ...
fmt.Println("answer:", <-result) // blocks until it's ready
}
If there’s nothing useful to do in between, don’t start a goroutine. Just call the function.
2. Signal that something happened
When you don’t need to send data, only a notification, use chan struct{}. The
empty struct occupies zero bytes, and you care about the event rather than the
value:
done := make(chan struct{})
go func() {
defer close(done) // announce completion, however we exit
doWork()
}()
<-done // blocks until doWork finishes
fmt.Println("worker finished")
Closing is the signal here rather than sending, which leads directly to the next one.
3. Broadcast to many goroutines at once
A send goes to exactly one receiver. Closing a channel wakes up every goroutine waiting on it, so one event reaches every listener at once:
func main() {
quit := make(chan struct{})
for i := 1; i <= 3; i++ {
go func() {
fmt.Printf("worker %d: waiting\n", i)
<-quit // all three are parked here
fmt.Printf("worker %d: shutting down\n", i)
}()
}
time.Sleep(100 * time.Millisecond)
close(quit) // one close, three wakeups
time.Sleep(100 * time.Millisecond)
}
You cannot do this with sends. Three sends would be needed, and you’d have to
know there were three. close needs to know nothing about its audience.
This is exactly how context.Context works internally. ctx.Done() hands you a
channel, and cancelling the context closes it, so every goroutine holding that
context wakes up at once.
4. Distribute work across N workers
Multiple goroutines receiving from one channel is a work queue, and the runtime guarantees each value goes to exactly one of them. No locking required:
jobs := make(chan int)
for w := 1; w <= 3; w++ {
go func() {
for job := range jobs { // three workers, one channel
process(job)
}
}()
}
for i := 1; i <= 100; i++ {
jobs <- i
}
close(jobs) // all three range loops end
That is a worker pool, and it is the shape most production channel code ends up in.
5. Build a pipeline
A goroutine that receives from one channel and sends to another is a stage. Chain them and each stage runs concurrently:
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
func main() {
for v := range square(generate(1, 2, 3, 4)) {
fmt.Println(v) // 1 4 9 16
}
}
Each stage is an ordinary loop, and each closes its output when its input dries
up, so close cascades down the chain automatically. Because the channels are
unbuffered, the whole pipeline processes one item at a time in constant memory,
no matter how many items flow through it.
Note the return types. <-chan int is a receive-only channel, and you can also
declare send-only (chan<- int), with the same arrow direction rule as always.
Using directional types in signatures documents the flow and lets the compiler
enforce it, so a stage that accidentally sends to its input won’t build.
6. Time out an operation
This one appeared earlier, but it belongs on the list because it comes up so often:
select {
case result := <-slowOperation():
use(result)
case <-time.After(5 * time.Second):
return errors.New("timed out")
}
One caveat for beginners: this doesn’t stop the slow operation, it only stops you
waiting for it. The goroutine keeps running. To actually cancel work you need
context.
7. Limit concurrency (a semaphore)
A buffered channel is a permit dispenser: a send takes a permit, a receive gives
it back. The values never matter, only how full the buffer is, so the element
type is struct{}, which is zero bytes wide.
sem := make(chan struct{}, 3) // 3 permits
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
sem <- struct{}{} // acquire, and block here once 3 are out
go func() {
defer wg.Done()
defer func() { <-sem }() // release, however we exit
handle(task)
}()
}
wg.Wait()
Three things are easy to get wrong here.
The acquire sits outside the goroutine, and that is what bounds anything. With
three permits out, the send blocks the loop. Move it inside the go func() and
you have spawned 10,000 goroutines to queue for permits.
defer <-sem doesn’t compile, because defer needs a function call and a
receive is an expression, so the receive goes inside a closure. Using defer
also returns the permit if handle panics, and a leaked permit shrinks the limit
for good.
The WaitGroup isn’t optional. The loop ends when the last task is launched,
with up to three still running.
Ten tasks go through that loop, and never more than three run at a time:
$ go run ./08-usecases -case semaphore source 7. limit concurrency (semaphore)
--------------------------------
ran 10 tasks, peak concurrency 3 (limit 3)
That is how you avoid opening 10,000 simultaneous connections when you have 10,000 URLs.
8. Rate limiting
time.Tick gives you a channel that delivers on a schedule. Receiving from it
before each operation paces your loop:
limiter := time.Tick(200 * time.Millisecond) // 5 per second
for _, req := range requests {
<-limiter // wait for the next tick
go handle(req)
}
Common advice says time.Tick leaks and you should always use time.NewTicker
with defer ticker.Stop(). That stopped being true in Go 1.23: the garbage
collector now recovers unreferenced tickers whether or not they were stopped,
and go doc time.Tick states there is no longer any reason to prefer
NewTicker. Calling Stop to stop the ticking is a behaviour choice now, and
no longer a leak.
When not to use a channel
Go’s slogan is “share memory by communicating,” and beginners often take it to mean channels are always the right answer. They aren’t.
To protect shared state, use a mutex. If several goroutines need to read and
write the same map or struct, a sync.Mutex is simpler, faster, and clearer than
routing every access through a channel:
type Counter struct {
mu sync.Mutex
n map[string]int
}
func (c *Counter) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.n[key]++
}
The rule of thumb is channels for transferring ownership of a value, mutexes for protecting shared state. If the value’s owner changes, use a channel. If it is a thing many goroutines legitimately share, lock it.
To wait for N goroutines to finish, use sync.WaitGroup. You can do it with a
channel, but a WaitGroup says what it means.
For a simple counter, use sync/atomic. atomic.Int64.Add(1) is a single atomic
operation, where a channel round trip involves the scheduler and costs orders of
magnitude more.
For sequential code, just call the function. Concurrency costs you goroutine
creation, scheduling and channel operations, so if step B needs step A’s result
and there’s nothing else to do in between, b(a()) is faster and much easier to
debug.
For very high-frequency tiny operations, batch. A channel handoff costs somewhere in the tens to low hundreds of nanoseconds, depending on the machine and on whether the channel is buffered, so once the work you are passing is comparable to that, you spend more time coordinating than working. Send slices of 1,000 items rather than 1,000 individual sends.
The mistakes you will make
These are the ones that catch people out, roughly in the order they tend to.
1. Unbuffered send with no receiver, in the same goroutine.
ch := make(chan int)
ch <- 1 // deadlock: nobody can receive, we're the only goroutine
The classic first-day error. Either start a goroutine to receive, or use a buffered channel.
2. Forgetting to close, with a range on the other end. The loop hangs
after the last value. Use defer close(ch) in the sender.
3. Closing from the receiver, or closing twice. Panic. Only the sender closes, exactly once.
4. Sending on a nil channel. Blocks forever, silently. It usually means you
declared var ch chan int and never called make.
5. Ignoring ok on a closed channel. You get an infinite stream of zero
values instead of a hang, which is arguably worse, because the program looks like
it’s working.
6. Goroutine leaks. A goroutine blocked on a channel nobody will ever use again never exits and never gets collected:
func leak() {
ch := make(chan int)
go func() {
ch <- expensive() // if nobody receives, this goroutine is stuck forever
}()
return // we left. the goroutine is still parked.
}
Give it a buffer of 1, or make sure someone always receives, or add a select
with a cancellation case.
7. Assuming order across multiple senders. Values from a single sender arrive in order. Values from several senders interleave arbitrarily.
The mental model
Channels in seven sentences:
- A channel is a typed pipe between goroutines:
ch <- vsends,<-chreceives. - An unbuffered channel has no storage. It is a meeting point, and both sides wait for each other.
- That blocking is the synchronization, which is why you don’t need a lock.
- A buffer adds slack: sends only block when it’s full, receives only when it’s empty.
closeannounces “no more values.” It endsrangeloops and wakes every waiting receiver at once.- Only the sender closes, exactly once. Sending on a closed channel panics.
selectwaits on several channels, and adefaultcase makes it non-blocking.
The judgment call in one line: use a channel to hand a value from one goroutine to another, and use a mutex to protect something they share.
Where to go next
- Go Concurrency Patterns: Pipelines and cancellation. The canonical article on chaining stages.
- Effective Go: Concurrency. Short, and from the source.
- The Go Programming Language Specification: channel types. The exact rules for send, receive and close, including what panics and what blocks forever.
- The Go Memory Model. The guarantee underneath “the blocking is the synchronization”: a send happens before the corresponding receive completes.
- C. A. R. Hoare, Communicating Sequential Processes, CACM 21(8) (1978). Where this model of concurrency comes from, forty years before you typed
ch <- v. - Bryan C. Mills, Rethinking Classical Concurrency Patterns (GopherCon 2018). When a channel is the wrong tool, argued carefully by a Go team member.