Skip to content
Software Atlas

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.

⏱ ~35 min read14 sections6 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceA channel is a pipe between goroutines. One goroutine puts a value in, another takes it out — and if the other side isn't ready, they wait for each other.

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:

Channel playground
Each button starts a new goroutine that does one thing. Watch who blocks and who wakes up.
ch := make(chan int)
Blocked sendersch <- v
none
The channelcapacity 0
No storage at all. A sender and a receiver must meet — whoever arrives first waits.
Blocked receiversv := <-ch
none
received: —
Try it: with unbuffered, press “go send” first — the sender blocks until a receiver shows up.

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.

Foundations§02

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.

main.go
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)
    }
}
Foundations§03

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?

QuestionUnbuffered 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 arrivesOnly when the buffer is empty
After a send finishes, you know…The receiver has the valueOnly that it's in the buffer — maybe nobody has read it yet

Here's the same difference on a timeline. Striped = waiting:

Unbuffered vs buffered, on a timeline
make(chan int) — unbuffered
sender
work
ch<-1
blocked: waits for receiver
✓ sent
work…
receiver
busy with something else
<-ch ✓
uses 1
make(chan int, 3) — buffered
sender
work
ch<-1 ✓
ch<-2 ✓
ch<-3 ✓
full
ch<-4 ✓
work…
receiver
busy with something else
<-ch
<-ch
…
buffer
1
1 2
1 2 3
2 3 4
3 4
…
Striped = blocked, waiting for the other side. With an unbuffered channel, the sender and receiver meet; with a buffer, the sender can run ahead until the buffer is full.

Try the exact scenarios

Use the playground below with these recipes:

  1. Unbuffered, send first: press go send(). The sender is stuck (⏳) until you press go receive().
  2. Unbuffered, receive first: the receiver waits; the next send hands the value straight to it.
  3. Buffer 3: press go send() four times. Three go into slots; the fourth waits until a receive frees a slot.
Channel playground
Each button starts a new goroutine that does one thing. Watch who blocks and who wakes up.
ch := make(chan int, 3)
Blocked sendersch <- v
none
The channelcapacity 3
→
·
·
·
→
Blocked receiversv := <-ch
none
received: —
Try it: with unbuffered, press “go send” first — the sender blocks until a receiver shows up.

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).
Practitioner§04

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 range loop. 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

Operationnil channelopen channelclosed channel
send ch <- vwaits foreverwaits until received / a slot is freepanic
receive <-chwaits foreverwaits until a value arrivesbuffered values first, then zero value + ok == false, instantly
close(ch)panic✅ closes itpanic

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.

Practitioner§05

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:

  1. If exactly one case is ready, it runs that one.
  2. If several are ready, it picks one at random — not the first one listed. (That stops one busy channel from starving the others.)
  3. If none is ready, it waits until one is — unless there's a default case, which runs immediately instead.

Play with all three rules:

select simulator
Mark which channels are ready, then run the select. Run it several times when two are ready.
select {
case o := <-orders:
handle(o)
case <-time.After(time.Second):
log.Println("slow…")
case <-ctx.Done():
return ctx.Err()
}
Result appears here.

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:

Merging two channels
Closed vs nil channels inside select, step by step.
Code
1for a != nil || b != nil {
2 select {
3 case v, ok := <-a:
4 if !ok { a = nil; continue } // a closed → switch this case off
5 out <- v
6 case v, ok := <-b:
7 if !ok { b = nil; continue }
8 out <- v
9 }
10}
11close(out)
channel a
open · has 1, 2
channel b
open · has x
out (received so far)
nothing yet
Goal: copy everything from 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.
1 / 8
Practitioner§06

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.

A pipeline, live
Each stage is a goroutine; the arrows are unbuffered channels (they store nothing). A value at the right edge of a stage is that stage trying to send it. Turn on the slow printer and watch.
generate()
square()
print()
chan▶
chan▶
printed: —Values hop one stage per tick.

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.

pipeline.go
// 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.

Practitioner§07

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.

Fan-out, then fan-in
jobsresultsworker 1worker 2worker 3fan-outfan-in
One channel of jobs feeds several workers (fan-out). Their results are merged back into one channel (fan-in). The order of results is not guaranteed.

A worker pool, end to end

pool.go
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:

Why the feeder needs its own goroutine
Same workers, two ways to feed them. Step through both.
Code
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 <- j
6}
7close(jobCh)
8for r := range resCh { // …and reads results only after
9 results = append(results, r)
10}
main
sending job 1
workers
W1: got job 1
W2: idle
resCh (results)
empty
Only main feeds jobs. W1 takes job 1.
1 / 5

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.

Practitioner§08

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 once

In 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":

pipeline.go — with cancellation
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
    }
}
Practitioner§09

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.

channel.js
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 producer
Staff-level§10

Channels 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 goroutinesSeveral 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 selectPerformance matters and there's no "flow" of data
Staff-level§11

Under the hood: what a channel really is

A Go channel is a regular struct in the runtime (runtime/chan.go). Simplified:

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":

  1. 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 into buf. Else → put this goroutine in sendq and park it (the scheduler runs another goroutine on this thread).
  2. 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 → join recvq and park.
  3. 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 1

Step 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".

Practitioner§12

Pitfalls gallery

#1

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).

✕ Don't
ch := make(chan int)
ch <- 1          // waits for a receiver… forever
fmt.Println(<-ch)
✓ Do
ch := make(chan int)
go func() { ch <- 1 }() // send from another goroutine
fmt.Println(<-ch)
#2

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.

✕ Don't
go func() {
    for _, v := range items {
        ch <- v
    }
    // forgot close(ch)
}()
for v := range ch { … } // hangs after the last item
✓ Do
go func() {
    defer close(ch)   // always, at the top
    for _, v := range items {
        ch <- v
    }
}()
for v := range ch { … } // ends cleanly
#3

Closing 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.

#4

Forgetting to make the channel

var results chan int   // declared, never made → nil
results <- 1           // waits forever: sends on a nil channel never complete

var 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.)

#5

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).

#6

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.)

#7

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)

#8

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.

✕ Don't
buf := []byte("hello")
out <- buf      // the receiver now reads buf…
buf[0] = 'J'    // …while we change it: data race
✓ Do
buf := []byte("hello")
out <- buf      // hand it over…
buf = nil       // …and stop using it (or send a copy)
Practitioner§13

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.

QUESTION 1 / 5
What happens?
func main() {
    ch := make(chan int)
    ch <- 1
    fmt.Println(<-ch)
}
QUESTION 2 / 5
Same code, one change
func main() {
    ch := make(chan int, 1)
    ch <- 1
    fmt.Println(<-ch)
}
QUESTION 3 / 5
Receiving from a closed channel
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)
QUESTION 4 / 5
Two ready cases
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)
}
QUESTION 5 / 5
The forgotten close
ch := make(chan int)
go func() {
    for i := 0; i < 3; i++ {
        ch <- i
    }
}()
for v := range ch {
    fmt.Println(v)
}
Answered 0 / 5
Foundations§14

Cheat sheet

🤝
Unbuffered = handshakemake(chan T): sender and receiver must meet. When a send returns, the receiver has the value.
📬
Buffered = mailboxmake(chan T, N): sends only wait when all N slots are full. Pick N for a reason.
➡️
Arrows show the datach <- v sends into the channel; v := <-ch receives out of it. chan<- send-only, <-chan receive-only.
🔒
Only the sender closesClose once, from the sending side. Several senders → WaitGroup, then close.
📭
Closed = drained, then zerosReceives get buffered values first, then zero value with ok == false. Sends panic.
🎲
select picks randomlyAmong ready cases, a random one runs. default makes it non-blocking.
🛑
Every goroutine needs an exitListen to ctx.Done() in every send/receive that might wait forever.
⚖️
Channels vs mutexChannels move work between goroutines; mutexes protect data they share.
I want to…Use
Return one result from a goroutinech := make(chan T, 1) — sender never blocks
Wait for N goroutinessync.WaitGroup (or errgroup for errors)
Signal "stop" to many goroutinesclose(done) or cancel() → everyone's <-ctx.Done() fires
Timeoutselect with case <-time.After(d) or context.WithTimeout
Limit concurrencyWorker pool (§07) or errgroup's SetLimit(n)
Stream values through stagesPipeline: each stage defer close(out) and ranges over in
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Concurrency & AsyncRaces & Deadlocks
Read →