Channels & select
How Go goroutines talk to each other: unbuffered vs buffered channels, close and range, select, pipelines, fan-out/fan-in, cancellation — and how to build the same thing in JavaScript.
The 30-second version
That second half is the important part. A channel isn't just a way to pass data; it's also a way to coordinate: "don't continue until you've got my result", "stop when I say so", "only 3 of you at a time".
Try both. Each button starts a new goroutine that does exactly one thing:
Why not just share variables?
You could let goroutines read and write the same variables, protected by locks. Go supports that too (§10). But Go's motto points the other way:
Don't communicate by sharing memory; share memory by communicating.— Go proverb
In plain words: instead of several goroutines poking at the same variable (and needing to take turns carefully), pass the value along so only one goroutine owns it at a time. Whoever holds the baton is the only one allowed to touch it. (That's a habit you follow, not something Go enforces — §12, pitfall #8.) This style has a name, CSP, and it's why channels are built into the Go language itself.
The five operations
There are only five things you can do with a channel. Here they are in one small program: one goroutine produces three words, main consumes them.
package main
import "fmt"
func main() {
words := make(chan string) // 1. make: a channel that carries strings
go func() {
words <- "hello" // 2. send: arrow points INTO the channel
words <- "from"
words <- "a goroutine"
close(words) // 4. close: "no more values are coming"
}()
first := <-words // 3. receive: arrow points OUT of the channel
fmt.Println(first) // hello
for w := range words { // 5. range: receive until the channel is closed
fmt.Println(w) // from, a goroutine
}
}"Did I get a real value?" — the comma-ok form
After a channel is closed and empty, receiving doesn't wait — it immediately gives you the type's Zero value ("" for strings, 0 for ints). To tell a real "" from "the channel is closed", ask for a second value:
w, ok := <-words
if !ok {
fmt.Println("channel closed — no more words")
}Channel directions in function signatures
When you pass a channel to a function, you can say whether the function may only send, or only receive. The compiler then stops you from using it the wrong way:
func produce(out chan<- string) { // chan<- : send-only (the arrow points into chan)
out <- "data"
// v := <-out ← compile error: can't receive from a send-only channel
}
func consume(in <-chan string) { // <-chan : receive-only (arrow points out of chan)
for v := range in {
fmt.Println(v)
}
}Unbuffered vs buffered
make(chan int) and make(chan int, 3) look almost the same, but they behave very differently. The only question that matters is: when does each side have to wait?
| Question | Unbuffered make(chan T) | Buffered make(chan T, N) |
|---|---|---|
| Send waits when… | Until a receiver is there (no wait if one is already waiting) | Only when all N slots are full |
| Receive waits when… | Until a sender arrives | Only when the buffer is empty |
| After a send finishes, you know… | The receiver has the value | Only that it's in the buffer — maybe nobody has read it yet |
Here's the same difference on a timeline. Striped = waiting:
Try the exact scenarios
Use the playground below with these recipes:
- Unbuffered, send first: press go send(). The sender is stuck (⏳) until you press go receive().
- Unbuffered, receive first: the receiver waits; the next send hands the value straight to it.
- Buffer 3: press go send() four times. Three go into slots; the fourth waits until a receive frees a slot.
Which one should I use?
- Default to unbuffered. It's the simplest to reason about: when the send returns, you know the other side has the value. The two goroutines are guaranteed to be "in sync" at that moment.
- Use a buffer when you have a concrete reason and can name the number: "the producer is bursty and we accept up to 100 queued jobs", or "exactly one result will ever be sent, so 1 slot lets the sender exit even if nobody reads it" (you saw that one in Async/Await §06).
Closing channels (and nil ones)
Closing a channel is a broadcast message to every receiver: "nothing more is coming". It's how a for v := range ch loop knows to stop.
The one rule: only the sender closes
- Receivers never close. A receiver can't know whether some sender is about to send — and sending on a closed channel crashes the program (a panic / recover).
- Close exactly once. Closing an already-closed channel also panics.
- You don't have to close every channel. Closing is only needed to tell receivers "you can stop now" — e.g. to end a
rangeloop. A channel nobody refers to anymore is cleaned up by the garbage collector either way. - Several senders? Then none of them can close it alone. Let a separate goroutine wait for all of them (with a WaitGroup) and close it afterwards — you'll see this in §07.
The complete rules
| Operation | nil channel | open channel | closed channel |
|---|---|---|---|
send ch <- v | waits forever | waits until received / a slot is free | panic |
receive <-ch | waits forever | waits until a value arrives | buffered values first, then zero value + ok == false, instantly |
close(ch) | panic | ✅ closes it | panic |
Closing with range: the standard producer
func countTo(n int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // ← closes no matter how this goroutine exits
for i := 1; i <= n; i++ {
out <- i
}
}()
return out
}
for v := range countTo(3) { // 1, 2, 3 — then the loop ends because out was closed
fmt.Println(v)
}defer close(out) at the top of the producing goroutine is a habit worth copying: the channel gets closed even if the loop returns early.
select: waiting on several channels
So far each receive waited on one channel. Real code often needs "whichever comes first": a result or a timeout or a cancellation. That's what select does. It looks like a switch, but every case is a channel operation:
select {
case o := <-orders: // an order arrived
handle(o)
case <-time.After(time.Second): // 1 second passed first
log.Println("no order in 1 s")
case <-ctx.Done(): // our caller cancelled us
return ctx.Err()
}How it decides:
- If exactly one case is ready, it runs that one.
- If several are ready, it picks one at random — not the first one listed. (That stops one busy channel from starving the others.)
- If none is ready, it waits until one is — unless there's a
defaultcase, which runs immediately instead.
Play with all three rules:
select {case o := <-orders:handle(o)case <-time.After(time.Second):log.Println("slow…")case <-ctx.Done():return ctx.Err()}
Common select recipes
select {
case res := <-results:
return res, nil
case <-time.After(2 * time.Second):
return Result{}, errors.New("timed out") // Result{} = an empty Result (all zero values)
}Turning a case off: the nil-channel trick
A closed channel is always ready (it returns zero values forever), but a nil channel is never ready. So when one input of a select closes, set that variable to nil and its case simply stops firing. This is how you merge two channels into one:
1for a != nil || b != nil {2 select {3 case v, ok := <-a:4 if !ok { a = nil; continue } // a closed → switch this case off5 out <- v6 case v, ok := <-b:7 if !ok { b = nil; continue }8 out <- v9 }10}11close(out)
a and b into out, and close out when both inputs are finished. The loop keeps going while at least one input is still non-nil.Pipelines
A pipeline is a chain of stages. Each stage is a goroutine that receives values from the channel before it, does one job, and sends results to the channel after it — like stations on an assembly line.
Notice what happened with the slow printer: nobody wrote "slow down" code, yet the whole line slowed to the printer's pace. Each unbuffered send simply waits until the next stage is ready. That automatic "wait until downstream can take more" is called Backpressure, and with channels you get it for free.
// Stage 1: emit numbers.
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // tell the next stage when we're done
for _, n := range nums {
out <- n
}
}()
return out
}
// Stage 2: square each number it receives.
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in { // ends when stage 1 closes its channel
out <- n * n
}
}()
return out
}
func main() {
for v := range square(generate(1, 2, 3, 4)) { // stage 3: print
fmt.Println(v) // 1 4 9 16
}
}Every stage follows the same shape: make an output channel, start a goroutine that loops over the input and defer close(out), return the output channel (receive-only). Because each close ends the next stage's range, shutting down happens in order, from the front to the back.
Fan-out, fan-in and worker pools
Some stages are slow (a network call, a resize). Run several copies of that stage reading from the same channel — fan-out — and then merge their outputs back into one channel — fan-in.
A worker pool, end to end
func processAll(jobs []Job) []Result {
jobCh := make(chan Job)
resCh := make(chan Result)
// Fan-out: 4 workers all read from the same jobs channel.
// Whichever worker is free takes the next job.
const workers = 4
var wg sync.WaitGroup
wg.Add(workers)
for w := 0; w < workers; w++ {
go func() {
defer wg.Done()
for j := range jobCh { // ends when jobCh is closed
resCh <- handle(j)
}
}()
}
// Feed the jobs, then close: "no more work".
go func() {
for _, j := range jobs {
jobCh <- j
}
close(jobCh)
}()
// Fan-in: close the results channel only after EVERY worker is done.
go func() {
wg.Wait()
close(resCh)
}()
var results []Result
for r := range resCh { // ends when resCh is closed
results = append(results, r)
}
return results
}Three kinds of goroutine, each with one job: workers (4 of them) do the work, a feeder sends jobs and closes jobCh, and a closer waits for all workers and then closes resCh. That last one is the answer to "who closes a channel that has several senders?" (§04): nobody among the senders — a separate goroutine closes it once all senders are finished.
Why can't main just feed the jobs itself?
It's tempting to drop the feeder goroutine and loop over jobs directly in processAll. That deadlocks. (So does calling wg.Wait() before the results loop: the workers are stuck sending results that nobody is reading yet, so they never call Done().) Step through the broken version and the working one:
1for w := 0; w < 2; w++ {2 go worker(jobCh, resCh) // range jobCh { resCh <- handle(j) }3}4for _, j := range jobs { // main feeds the jobs itself…5 jobCh <- j6}7close(jobCh)8for r := range resCh { // …and reads results only after9 results = append(results, r)10}
main feeds jobs. W1 takes job 1.The rule behind it: the sending side and the receiving side must be able to run at the same time. With unbuffered channels, any design where one goroutine must finish all its sends before anyone receives will eventually lock up.
Stopping goroutines: cancellation and leaks
A goroutine that's blocked on a channel nobody will ever use again is leaked: it waits forever, holding its memory, and the garbage collector can't free it. The pipeline in §06 leaks if the reader stops early. Picture it inside a server that handles requests for days:
func firstSquare() int {
for v := range square(generate(1, 2, 3, 4)) {
return v // stop after the first value…
}
return 0
}
// …but generate() and square() are still blocked on their sends. Forever.
// Called once per request, that's 2 more stuck goroutines per request.(If this were directly in main, it wouldn't matter: the program exits when main returns. Leaks hurt in long-running programs like servers.)
The fix: give every goroutine a second thing to listen for — a "stop" signal. The simplest version is a done channel that you close when you want everyone to stop:
done := make(chan struct{}) // struct{} = an empty type: this channel carries a signal, not data
go worker(done)
// … later …
close(done) // every goroutine doing <-done wakes up at onceIn real code you'll almost always use the standard version of the same idea: ctx.Done(), from context.Context. Every send becomes a select between "send my value" and "someone told me to stop":
func generate(ctx context.Context, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
select {
case out <- n: // downstream took it
case <-ctx.Done(): // downstream is gone → exit instead of waiting forever
return
}
}
}()
return out
}
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
func main() {
// context.Background() = an empty starting context; WithCancel adds a cancel button
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // when main is done, every stage gets the stop signal
for v := range square(ctx, generate(ctx, 1, 2, 3, 4)) {
fmt.Println(v)
break // safe now: the deferred cancel() runs, both stages return
}
}Build a channel in JavaScript
JavaScript has no channels, but we can build one from promises. Doing it is the best way to understand Go's rules, because every rule becomes a line of code you can read. A pending promise plays the role of a "blocked goroutine": await on it waits until someone resolves it. The key trick is to save the promise's resolve function so that someone else can call it later:
let wake; // will hold a resolve function
const waiting = new Promise((resolve) => { wake = resolve; });
setTimeout(() => wake("hi"), 1000); // someone else calls it later…
console.log(await waiting); // …and this line wakes up: "hi"The channel below keeps lists of these saved resolve functions — one list for waiting receivers, one for waiting senders. (shift() removes and returns the first item of an array; splice(0) removes and returns all of them.) It follows the exact same rules as the playground in §01: blocked senders = #sendWaiters, slots = #buffer, blocked receivers = #recvWaiters.
class Channel {
#buffer = []; // values waiting in slots
#recvWaiters = []; // receivers waiting for a value (their resolve functions)
#sendWaiters = []; // senders waiting for space: { value, resolve }
#closed = false;
constructor(capacity = 0) { this.capacity = capacity; }
async send(value) {
if (this.#closed) throw new Error("send on closed channel"); // Go: panic
const receiver = this.#recvWaiters.shift();
if (receiver) return receiver({ value, ok: true }); // hand straight over
if (this.#buffer.length < this.capacity) { // a free slot
this.#buffer.push(value);
return;
}
await new Promise((resolve) => this.#sendWaiters.push({ value, resolve })); // wait
}
async recv() {
if (this.#buffer.length) { // take from the buffer…
const value = this.#buffer.shift();
const sender = this.#sendWaiters.shift(); // …and let one waiting sender in
if (sender) { this.#buffer.push(sender.value); sender.resolve(); }
return { value, ok: true };
}
const sender = this.#sendWaiters.shift(); // unbuffered: take directly from a sender
if (sender) { sender.resolve(); return { value: sender.value, ok: true }; }
if (this.#closed) return { value: undefined, ok: false }; // Go: zero value, ok=false
return new Promise((resolve) => this.#recvWaiters.push(resolve)); // wait
}
close() {
if (this.#closed) throw new Error("close of closed channel");
this.#closed = true;
for (const r of this.#recvWaiters.splice(0)) r({ value: undefined, ok: false }); // wake all
}
// Lets you write: for await (const v of ch) { … } (Go: for v := range ch)
async *[Symbol.asyncIterator]() {
while (true) {
const { value, ok } = await this.recv();
if (!ok) return;
yield value;
}
}
}And the same producer/consumer as §02:
const words = new Channel(); // unbuffered
(async () => { // the "goroutine": an async function we don't await
await words.send("hello"); // waits until main receives it
await words.send("from");
await words.send("a producer");
words.close();
})();
for await (const w of words) console.log(w); // hello, from, a producerChannels or a mutex?
The Go proverb from §01 is a guideline, not a law. Go also has mutexes (sync.Mutex), and for some jobs they're simply the better tool. Here's the same job — a shared counter — both ways:
type Counter struct {
mu sync.Mutex
n map[string]int
}
func NewCounter() *Counter {
return &Counter{n: map[string]int{}} // maps must be created before use
}
func (c *Counter) Inc(key string) {
c.mu.Lock() // only one goroutine past this line at a time
defer c.mu.Unlock()
c.n[key]++
}
// Usage — any goroutine can call it:
// c := NewCounter()
// go c.Inc("a")
// Short, fast, obvious.For a counter, the mutex wins: less code, faster, easier to read. The channel version shines when the "owner" does more than guard data — for example, it batches writes, or it's the one place that talks to a slow device.
A rule of thumb
| Reach for a channel when… | Reach for a mutex when… |
|---|---|
| You're passing work or results between goroutines | Several goroutines read/update a shared value (cache, counter, map) |
| You need to wait for something, or signal "done/stop" | The critical section is short: lock, update, unlock |
| Data changes owner (producer → consumer) | Data stays in one place and you just need turns |
You want pipelines, fan-out/fan-in, timeouts with select | Performance matters and there's no "flow" of data |
Under the hood: what a channel really is
A Go channel is a regular struct in the runtime (runtime/chan.go). Simplified:
type hchan struct {
buf unsafe.Pointer // the slots (unsafe.Pointer = a raw memory address)
dataqsiz uint // capacity (0 = unbuffered)
qcount uint // how many slots are filled right now
sendx uint // where the next send writes
recvx uint // where the next receive reads
closed uint32
recvq waitq // goroutines blocked receiving
sendq waitq // goroutines blocked sending
lock mutex // channels are safe to use from many goroutines
}The slots form a Ring buffer, so receiving never has to shift values around. That's exactly what the playground in §01 draws: the slots in the middle (buf), blocked senders on the left (sendq), blocked receivers on the right (recvq). Every operation is "take the lock, look at these fields, decide":
- Send: if a receiver is waiting in
recvq→ copy the value directly to that receiver and wake it (the buffer is skipped entirely). Else if there's a free slot → copy intobuf. Else → put this goroutine insendqand park it (the scheduler runs another goroutine on this thread). - Receive: if the buffer has a value → take it, and if a sender is waiting, move its value into the freed slot and wake it. Else if a sender is waiting (unbuffered) → take its value directly. Else if closed → zero value,
ok=false. Else → joinrecvqand park. - Close: set
closed, wake every waiting receiver (they get zero values) and every waiting sender (they panic).
"Park" is the same thing you saw in the Async/Await scheduler visualizer (§09): the goroutine's task card is set aside, and the worker thread picks up another one. A blocked channel operation never blocks an OS thread.
The guarantee you get: happens-before
Here's a surprise: when one goroutine writes a variable, another goroutine is not automatically guaranteed to see that write — CPUs keep values in per-core caches, and compilers reorder instructions to go faster. Without some synchronization, another goroutine may see an old or half-written value.
Channels fix this: they don't just move values, they also make memory visible between goroutines. Go's memory model guarantees (this is the Happens-before relation):
- Everything a goroutine did before sending a value is visible to the goroutine after it receives that value.
- For an unbuffered channel, it also works the other way: everything the receiver did before receiving is visible to the sender after its send completes — because they met.
- Everything a goroutine did before
close(ch)is visible to any goroutine whose receive returns because the channel is closed.
var config Config
ready := make(chan struct{})
go func() {
config = loadConfig() // 1. write…
close(ready) // 2. …then signal
}()
<-ready // 3. wait for the signal…
use(config) // 4. …now guaranteed to see the write from step 1Step 4 is safe because of that third rule: the write happened before close(ready), and <-ready returned because of the close. Without the channel, step 4 might see an empty or half-written config — a Data race. struct{} is an empty type that takes no memory: the idiom for "this channel carries a signal, not data".
Pitfalls gallery
Sending and receiving in the same goroutine
On an unbuffered channel, a send waits for a receiver. If the only possible receiver is the same goroutine — a few lines later — it never gets there. When every goroutine is stuck like this, Go stops the program: fatal error: all goroutines are asleep - deadlock! (a Deadlock).
ch := make(chan int)
ch <- 1 // waits for a receiver… forever
fmt.Println(<-ch)ch := make(chan int)
go func() { ch <- 1 }() // send from another goroutine
fmt.Println(<-ch)Forgetting to close before range
for v := range ch only ends when ch is closed. If the producer just stops sending, the loop waits for a value that never comes — in a small program Go reports a deadlock; in a server (where other goroutines are alive) it just hangs silently.
go func() {
for _, v := range items {
ch <- v
}
// forgot close(ch)
}()
for v := range ch { … } // hangs after the last itemgo func() {
defer close(ch) // always, at the top
for _, v := range items {
ch <- v
}
}()
for v := range ch { … } // ends cleanlyClosing from the receiving side (or twice)
A receiver that closes "to signal it's done" will crash the sender on its next send (panic: send on closed channel). To tell a sender to stop, use a separate signal: ctx.Done() (§08). Only the side that sends closes, and only once. With several senders, use the WaitGroup-then-close goroutine from §07.
Forgetting to make the channel
var results chan int // declared, never made → nil
results <- 1 // waits forever: sends on a nil channel never completevar ch chan T gives you a nil channel. Always create one with make(chan T) before use. (Nil channels are useful in one place: inside select, to switch a case off — see §05.)
Using a buffer to make a deadlock 'go away'
Adding capacity until the program stops hanging hides the real question: who was supposed to receive? The program will hang again in production when the buffer fills. Choose buffer sizes for a reason you can write in a comment (§03).
time.After inside a busy loop
for {
select {
case msg := <-msgs:
handle(msg)
case <-time.After(time.Minute): // creates a new timer on every loop iteration
log.Println("idle for a minute")
}
}Before Go 1.23, each of those timers stayed in memory until it fired — a minute later — so a loop handling thousands of messages piled up thousands of timers. Go 1.23 fixed the garbage collection, but creating a timer per message is still wasteful. Create one timer outside the loop and reset it:
idle := time.NewTimer(time.Minute)
for {
select {
case msg := <-msgs:
handle(msg)
idle.Reset(time.Minute) // restart the "1 minute of silence" countdown
case <-idle.C: // .C is the timer's channel
log.Println("idle for a minute")
idle.Reset(time.Minute)
}
}(This simple Reset pattern relies on Go 1.23+ timers. On older Go versions — or a module whose go.mod says go 1.22 or lower — call if !idle.Stop() { <-idle.C } before each Reset, or a stale tick can fire right after a message.)
Goroutines with no way to stop
Every pattern on this page — pipelines, workers, fan-in — leaks goroutines if the consumer can quit early and the producers don't listen for a stop signal. Before writing go func(), answer: how does it exit? (§08)
Sending a pointer, then still using it
Sending a value through a channel copies it — but for a pointer, slice or map, only the reference is copied. Both goroutines now point at the same data. "The receiver owns it now" is a promise you have to keep by not touching it after the send.
buf := []byte("hello")
out <- buf // the receiver now reads buf…
buf[0] = 'J' // …while we change it: data racebuf := []byte("hello")
out <- buf // hand it over…
buf = nil // …and stop using it (or send a copy)Check yourself
Predict the answer before you click. Every one is covered above: Q1–Q2 in §03, Q3 in §04, Q4 in §05, Q5 in §04 and §12.
func main() {
ch := make(chan int)
ch <- 1
fmt.Println(<-ch)
}func main() {
ch := make(chan int, 1)
ch <- 1
fmt.Println(<-ch)
}ch := make(chan string, 2)
ch <- "a"
close(ch)
v1, ok1 := <-ch
v2, ok2 := <-ch
fmt.Printf("%q %v %q %v", v1, ok1, v2, ok2)a := make(chan string, 1)
b := make(chan string, 1)
a <- "A"
b <- "B"
select {
case v := <-a:
fmt.Println(v)
case v := <-b:
fmt.Println(v)
}ch := make(chan int)
go func() {
for i := 0; i < 3; i++ {
ch <- i
}
}()
for v := range ch {
fmt.Println(v)
}Cheat sheet
make(chan T): sender and receiver must meet. When a send returns, the receiver has the value.make(chan T, N): sends only wait when all N slots are full. Pick N for a reason.ch <- v sends into the channel; v := <-ch receives out of it. chan<- send-only, <-chan receive-only.ok == false. Sends panic.default makes it non-blocking.ctx.Done() in every send/receive that might wait forever.| I want to… | Use |
|---|---|
| Return one result from a goroutine | ch := make(chan T, 1) — sender never blocks |
| Wait for N goroutines | sync.WaitGroup (or errgroup for errors) |
| Signal "stop" to many goroutines | close(done) or cancel() → everyone's <-ctx.Done() fires |
| Timeout | select with case <-time.After(d) or context.WithTimeout |
| Limit concurrency | Worker pool (§07) or errgroup's SetLimit(n) |
| Stream values through stages | Pipeline: each stage defer close(out) and ranges over in |