Golang Concurrency · Part 2 / 11

Channels
in Depth

Don't communicate by sharing memory, share memory by communicating
— how a single pipe also does your synchronization

Unbuffered Buffered Direction close · range
life of a channel make → send → close
make(chan T, n) opens a typed pipe ├─ n = 0 unbuffered · handshake └─ n > 0 buffered · queue ch <- v the sending side v, ok := <-ch the receiving side close(ch) declares: no more sends └─ range stops on this signal
the sender makes · sends · closes the receiver only receives
Greeting + part 2 of 11. Part 1 got as far as "launching" goroutines, so connect today as the story of moving values between them. The tree on the right is both the outline and the conclusion — say that once those six lines are second nature, 90% of channel code reads itself.
problemwhy channel

You launched a goroutine, but there's no way to get the result

  • Part 1 only got us to the unit of execution — no way to pass values yet
  • A shared variable + mutex works, but a human has to remember the lock order
  • Go's answer is CSP — hand the value over and ownership goes with it
  • A channel solves communication and synchronization at once. Nothing to lock
// shared memory — humans keep the lock order
mu.Lock()
result = 42
mu.Unlock()

// channel — hand it over, ownership goes too
ch := make(chan int)
go func() { ch <- 42 }()
v := <-ch
Go Proverb Don't communicate by sharing memory; share memory by communicating.
Opening with "Ever launched three goroutines and tried to collect the results?" works well. This is not a claim that mutexes are bad (part 4 covers sync properly) — only that for handing values over, a channel is the better tool. Don't linger here — one minute.
01

Channel Basics

How to make one, and how to put values in and take them out. The syntax takes ten minutes, but without training your eye here, blocking and close later will all blur together.

The goal is "read the direction straight off the code". The syntax itself is short, so cut it at 3–4 minutes and move to blocking.
01 · Basicsmake

A channel is a pipe with a fixed type

  • make(chan T) — only T flows. Anything else is a compile error
  • The second argument is the buffer size. Omit it and it's 0
  • That one number changes the synchronization: 0 is a handshake, 1+ is a queue
  • The zero value is nil. A nil channel blocks send and receive forever
ch := make(chan int)        // unbuffered
ch := make(chan string, 5) // buffered (5 slots)

ch := make(chan Result)     // structs work too
ch := make(chan chan int)   // channels of channels too

var ch chan int             // nil — never proceeds
the weight of one number make(chan int) and make(chan int, 1) have the same type. Yet their synchronization behavior is entirely different.
Keep the nil channel to "such a thing exists" for now. It's worth teasing that it comes back in part 3 on select, as "a switch that turns a case off".
01 · Basics<-

The arrow's position alone splits send from receive

  • Arrow on the right of the channel is a send — ch <- v
  • Arrow on the left of the channel is a receive — v := <-ch
  • You needn't take the value. A bare <-ch means "wait until one arrives"
  • In the two-value form v, ok := <-ch, ok tells you if the channel is still alive
func TestChannelSendReceive(t *testing.T) {
    ch := make(chan int)

    go func() {
        ch <- 42 // send
    }()

    value := <-ch // blocks until the send
    assert.Equal(t, 42, value)
}
how to read it The arrow always points the way the value flows. Into the channel is a send, out of it is a receive.
It's fine to pause here and have the audience read the code out loud. Landing that the value := <-ch line means "stop here until 42 arrives" is the whole of this section.
01 · Basicsany type

Send a struct and value and error arrive together

func TestChannelStructType(t *testing.T) {
    type Result struct {
        Value int
        Err   error
    }
    ch := make(chan Result)

    go func() {
        ch <- Result{Value: 100, Err: nil}
    }()

    result := <-ch
    assert.Equal(t, 100, result.Value)
    assert.NoError(t, result.Err)
}
why write it this way A separate error channel creates a new job: pairing the two up. Bundle value and error into one struct and that problem disappears.
This is the shape you'll use most in production. Asking "anyone split it into two channels, result and err?" always gets a reaction — in that case ordering problems appear in select over which arrives first. Say that much and hand it to part 3.
02

Blocking and Buffers

A channel's essence is not "it moves values" but "it waits". Know when it stops and the unbuffered/buffered difference follows on its own.

The heart of today's talk. If time runs short, cut other chapters and spend it here. 8–12 minutes.
02 · Blockingby design

Blocking isn't a side effect, it's the synchronization

Unbuffered — meet to proceed

Send and receive must be ready at the same time for the value to cross.

Whoever arrives first waits for the other. That meeting instant becomes the sync point of the two goroutines.

Buffered — room means pass

If the buffer has a free slot, the send finishes immediately.

Once it's full the sender waits; once it's empty the receiver waits. A queue has slipped in between.

in one line The waiting itself creates order. That's why channels guarantee "B after A" without any lock.
Break the "blocking = bad" preconception right here. Saying up front that the next two slides (09 and 10) are these two cards drawn out makes them easy to follow.
02 · Blockingunbuffered

Unbuffered — the value crosses only when both sides meet

Goroutine Ach <- 42 — waits here
Handshakeboth proceed at once
Goroutine Bv := <-ch — waits here

The middle is the meeting point. Whichever side arrives first stands still until the other shows up.

why that's useful The moment the value crosses, both sides know it happened. That property is exactly a "work completed" acknowledgment.
The "handshake" metaphor lands well — one hand out is not a handshake. Mention that the next slide deliberately reuses the same diagram frame so buffered can be compared against this.
02 · Blockingbuffered

Buffered — the buffer absorbs the speed gap

Producer3 sends pass instantly
Buffer (size 3)4th send blocks
Consumerreceive waits when empty

The lines show where values flow. The buffer is FIFO — out in the order they went in.

easy to get wrong A buffer absorbs momentary spikes only. If average production outruns consumption, it clogs no matter the size.
"Does a bigger buffer make it faster?" comes up often. The answer is in the callout — unless the average rates flip, a buffer only postpones the problem.
02 · Blockingin code

Unbuffered — the sender stands there waiting for a receiver

func TestUnbufferedChannel(t *testing.T) {
    ch := make(chan int) // buffer size 0

    go func() {
        ch <- 42 // blocks here until a receiver is ready
    }()

    time.Sleep(100 * time.Millisecond) // sender is already waiting
    value := <-ch                      // receiving frees the sender too
    assert.Equal(t, 42, value)
}
drop the go here? The main goroutine ends up waiting on itself — fatal error: all goroutines are asleep - deadlock!
Be sure to note the Sleep is for illustration, not production code. Everyone has seen the deadlock message in the callout at least once, so it gets a laugh — close by saying they can now explain why it appears.
02 · Blockingin code

Buffered — with no receiver, buffer-many sends still pass

func TestBufferedChannel(t *testing.T) {
    ch := make(chan int, 3)

    // with no receiver, the first 3 sends finish instantly
    ch <- 1
    ch <- 2
    ch <- 3
    // ch <- 4 → blocks here (buffer full)

    assert.Equal(t, 1, <-ch)
    assert.Equal(t, 2, <-ch)
    assert.Equal(t, 3, <-ch)
}
what changed There is no goroutine at all. Unbuffered, the very first line ch <- 1 would deadlock.
Put it next to the previous slide and point out that the goroutine is gone — that's the core of this slide. Ask what happens if the commented-out fourth send is enabled and take answers; it connects to quiz Q2.
02 · Blockingcap · len

cap is the size of the bowl, len what's in it now

  • cap(ch) — the buffer capacity fixed at creation. It never changes
  • len(ch) — how many values are queued in the buffer right now
  • An unbuffered channel is always cap 0, len 0
  • Fine for monitoring and logs. But never use them for flow control
ch := make(chan string, 5)

assert.Equal(t, 5, cap(ch)) // capacity
assert.Equal(t, 0, len(ch)) // values waiting

ch <- "a"
ch <- "b"

assert.Equal(t, 5, cap(ch))
assert.Equal(t, 2, len(ch))
don't branch on len if len(ch) < cap(ch) { ch <- v } — another goroutine can fill it between the check and the send. That job belongs to select's default (part 3).
The value is already stale the instant you read it — a textbook TOCTOU. Lay down one more teaser for part 3 here.
02 · Blockingchoosing

Which one to pick, and when

SituationPickWhy
Handshake between goroutinesUnbufferedThe meeting instant is the sync point
Done · quit signalsUnbufferedSignals have no reason to pile up
Producer / consumer speed gapBufferedAbsorbs short bursts
Producer / ConsumerBufferedNeither waits on the other every time
Bulk data transferBufferedFewer context switches
in doubt, unbuffered Buffers work by hiding problems. Better to size one once you can actually see the bottleneck.
Don't read the table out; say the callout line only. To "what buffer size?" the right answer is "don't pick one without measuring" — most codebases have an unjustified 1 or core-count baked in.
03

Direction

Carve the direction into the type and wrong usage is caught at compile time. The runtime cost is zero and the syntax is one arrow moved.

Keep it to 4 minutes. The syntax is simple enough to fly through, but be sure to leave the message "the signature is the documentation".
03 · Directionchan<- · <-chan

Arrow after chan means put in, before means take out

  • chan<- intsend-only. Receiving is a compile error
  • <-chan intreceive-only. Sending or closing is a compile error
  • The runtime cost is zero. Purely a compile-time check
  • Mostly used on function parameters and return types. Rarely on variables
chan int     // bidirectional
chan<- int   // send-only — put in only
<-chan int   // receive-only — take out only

func produce(ch chan<- int) { ch <- 1 } // OK
func consume(ch <-chan int) { <-ch }   // OK

func bad(ch <-chan int) { close(ch) } // compile error
the type is the doc From the signature alone you know whether a function puts in or takes out. A stronger contract than any comment.
A mnemonic: arrow pointing at chan means going in, arrow leaving chan means coming out. If confused, recall the two expressions ch <- v and <-ch.
03 · Directioncontract

The signature is a declaration of role

// producer — send-only. cannot receive here
func produce(ch chan<- int, values []int) {
    for _, v := range values {
        ch <- v
    }
    close(ch) // the sender closes
}
// consumer — receive-only. cannot close here
func consume(ch <-chan int) []int {
    var out []int
    for v := range ch {
        out = append(out, v)
    }
    return out
}
this pair is the base form The sending function closes, and the receiving one reads to the end with range. Every remaining example today is a variation on this shape.
Point out that the caller just passes a bidirectional ch := make(chan int, 5) — the conversion is automatic, so calling code doesn't change at all. That's the next slide.
03 · Directionone way only

Permission flows only toward the narrower side

Allowed — implicit conversion

chan intchan<- int

chan int<-chan int

Assignment and argument passing both just work. No cast needed.

Not allowed — compile error

chan<- intchan int

<-chan intchan int

Once narrowed, permission can't be undone. To widen you must still hold the original bidirectional channel.

how to use it in design Keep the bidirectional channel only where it was made and expose only narrowed types outward. Then there is no way left to break the rules.
Comparing it to "exposing a private field through a getter only" lands well with an object-oriented audience. The generator pattern on 23 is exactly this idea in code.
04

Close

close doesn't destroy the channel; it announces "I will send no more". Break one of three rules and it blows up as a runtime panic, not a compile error.

This is where production incidents come from most. Spend the time on the rules table (22) and the generator pattern (23). 6–8 minutes.
04 · Closedeclaration

close is not destruction, it's a declaration

  • It doesn't remove the channel. Values left in the buffer all still come out
  • It means "no more sends", not "all values have arrived"
  • So only the sender can close — the receiver has no way to know the end
  • You needn't always close. It's needed when someone reads with range
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch) // no more sends

fmt.Println(<-ch) // 1 — leftovers intact
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 0 — all drained
nothing to do with memory Even unclosed, once the references go the GC reclaims it. close is a signal, not cleanup.
"Must I always close it, like a file?" is guaranteed to come up. The answer is the callout — it's a protocol, not resource release. The only reason to close is to tell the receiver it's over.
04 · Closev, ok

A closed channel instantly returns zero value + false

func TestReceiveFromClosedChannel(t *testing.T) {
    ch := make(chan int, 1)
    ch <- 42
    close(ch)

    // values left in the buffer come back normally
    val, ok := <-ch
    assert.Equal(t, 42, val)
    assert.True(t, ok) // a valid value

    // empty and closed → zero value + false
    val, ok = <-ch
    assert.Equal(t, 0, val) // zero value of int
    assert.False(t, ok)     // closed marker
}
without ok you can't tell Whether that 0 came from a close or from a sender is known only to ok. And it doesn't block — it returns at once.
"Won't receiving from a closed channel loop forever?" — yes, zero values come out endlessly without blocking. That's why you need an ok check or range. It leads naturally into the next slide.
04 · Closefour rules

Three panic, one is safe

RuleIf broken
The sender closesIf the receiver closes, the sender sends on a closed channel → panic
Never close twicepanic: close of closed channel
Never send on a closed channelpanic: send on closed channel
Receiving from a closed channel is safeAllowed — returns zero value + false at once
the compiler can't catch these All three blow up at runtime. So "who closes, and when" has to be nailed down by design, not by code.
A real incident story fits well here — retry logic calling close twice is a common shape. The fix is sync.Once or "funnel closing into a single owner", and the latter is the next slide.
04 · Closegenerator

Make, send and close, gathered in one place

func generator() <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch) // the sender closes
        for i := range 5 {
            ch <- i
        }
    }()
    return ch // receive-only — callers can't close
}

for v := range generator() {
    fmt.Println(v) // 0 1 2 3 4
}
no way left to break the rules The return type is narrowed to <-chan, so callers can neither close nor send. All three rules from 22 have turned into compile errors.
It's fair to say that if you photograph one slide today, make it this one. Note that defer close(ch) sits on the first line inside the goroutine — it closes even on panic. It's also the basic brick of pipelines (part 3).
05

Range and Signals

The receiver's idiom range, and the value-less channel chan struct{}. Both sit on top of the close we just learned.

We just did the close rules, so this follows naturally. 5–6 minutes.
05 · Rangefor range ch

range ends on one thing only: close

  • for v := range ch — waits for values and pulls them out one by one
  • It ends in exactly one case: the channel closing. It doesn't count values
  • Forget the close and it blocks forever — the classic cause of goroutine leaks
  • Same as writing v, ok := <-ch, but it does the ok check for you
ch := make(chan int, 5)
go func() {
    for i := 1; i <= 5; i++ {
        ch <- i
    }
    close(ch) // without it range never ends
}()

for v := range ch { // 1 2 3 4 5
    out = append(out, v)
}
they come as a pair The moment you write code that reads with range, you must also decide who closes that channel, and when. They're one set.
If you can answer "how many times does range loop?" with "until it's closed", this slide is done. Tease that the infinite wait gets cut off by select + ctx.Done() in part 3.
05 · Signalschan struct{}

When you want to send "it's done", not data

  • struct{} has no fields, so it's 0 bytes — the type shows you mean to carry no value
  • With chan bool, "what does true mean, and false?" always follows
  • Signals have no reason to pile up, so they're usually unbuffered
  • By convention they're named done, quit, ready
done := make(chan struct{})

go func() {
    // do the work...
    close(done) // done signal
}()

<-done // wait until it's done
cost unsafe.Sizeof(struct{}{}) is 0 — the values crossing cost nothing. (The channel itself is a separate cost.)
"Why struct{}, why not bool?" always comes up. Frame the answer as expressing intent, not performance — pinning down in the type that the value carries no meaning.
05 · Signalsbroadcast

close wakes everyone, send wakes one

done <- struct{}{}

Wakes exactly one waiting receiver.

Three waiting means sending three times. That is, the sender must know the receiver count.

close(done)

Everyone waiting wakes at once.

Any number is fine, and receivers that arrive after the close pass through immediately. The signal can't be missed.

so use close for cancel and shutdown You needn't know the receiver count, and latecomers don't miss it. context.Done() works exactly this way — we meet it again in part 5.
"The signal can't be missed" is the key — with a send, a receiver that arrives late never gets a signal that already passed. This is where you convince them why context was designed around closing a channel.
05 · SignalsFAQ

struct{} is the blueprint, struct{}{} the thing

ExpressionMeaningAnalogy
intthe int typeblueprint
42an int valuethe thing
struct{}empty struct typeblueprint — with no fields
struct{}{}empty struct valuethe thing — with no contents
how to parse it struct{} + {} — type first, empty literal second. Exactly like Person{} being Person + {}. So make(chan struct{}) takes a type and done <- struct{}{} takes a value.
This slide shows that only the four braces jammed together look odd — the rule itself is ordinary. Thirty seconds is plenty, so move on quickly.
06

Practice — Producer / Consumer

Everything so far, gathered into one piece of code. The moment there are several producers, "who closes" turns into a real design question.

From here it's assembling the earlier pieces. The only new concept is WaitGroup, and draw the line early that it's a part 4 topic.
06 · Practice1 : 1

One producer, one consumer — the base form

func TestProducerConsumer(t *testing.T) {
    ch := make(chan int, 10)
    var results []int

    go func() { // Producer — makes squares
        for i := 1; i <= 10; i++ {
            ch <- i * i
        }
        close(ch) // declares: all sent
    }()
    for val := range ch { // Consumer — collects until closed
        results = append(results, val)
    }
    assert.Len(t, results, 10)
}
what buffer 10 buys The producer needn't wait for the consumer every time. Set it to 0 and the result is the same, but then they shake hands on every value.
Every earlier piece is in here — make, send, close, range, and the reason for buffering. Walking the code top to bottom once makes a good review.
06 · PracticeN : 1

With many producers, nobody can close alone

ch := make(chan int, 20)
var wg sync.WaitGroup

for p := range 3 { // 3 producers
    wg.Add(1)
    go func() {
        defer wg.Done()
        for i := range 5 {
            ch <- p*100 + i
        }
    }()
}

go func() { // watcher — closes only when all are done
    wg.Wait()
    close(ch)
}()
give closing its own role None of the three producers knows it is "the last one". A fourth goroutine waiting on the WaitGroup takes that role. The receiving side is unchanged from the previous slide.
"Can't I close inside the producer?" — that's a double close and a panic. This is where rule 22 collides with reality. Draw the line that WaitGroup itself is covered properly in part 4.
06 · Practicestep by step

What happens, in order, up to the close

1
wg.Add(1) — before launching the goroutine
Add inside the goroutine and Wait may slip past first
2
defer wg.Done() — each producer reports its own exit
defer keeps the counter dropping even on a panic
3
wg.Wait() → close(ch) — closes only after everyone is done
this job needs its own goroutine so the range below can run
4
for range ch — the loop ends itself on the close signal
the consumer never needs to know how many producers there are
why a separate watcher Call wg.Wait() in main and the producers stall the moment the buffer fills while main stalls on Wait — each waiting on the other, forever.
Step 4 is the real value of this pattern — the consumer is fully decoupled from the producer count. It's the prototype of fan-in, and part 3 extends this exact shape.
wrap-uprecap

What to take away

  • A channel is a type-safe pipe between goroutines — communication and synchronization at once
  • Unbuffered is a handshake, buffered is a queue — one number in make changes the behavior
  • Narrow the direction with chan<- and <-chan and the compiler blocks wrong usage
  • close declares "no more sends" and must be done by the sender — closing twice panics
  • A closed channel instantly returns zero value + false. range ends on that signal
  • For signals use chan struct{} — to tell everyone, don't send, close
Reading the six lines aloud is itself the summary. It's fine to start taking questions from here.
check ①click to reveal

Answer for yourself — buffers and direction

The sender keeps blocking until it meets a receiver. If every goroutine is in that state, the runtime kills it with all goroutines are asleep - deadlock! → Slide 09 · 11
The first three pass instantly and the fourth blocks. A buffer only absorbs momentary speed gaps; it is not an infinite queue. → Slide 10 · 12
Another goroutine can fill the buffer between the check and the send. It's stale the instant you read it. Non-blocking send needs select + default. → Slide 13
Both send and close. Receiving only. It's caught at compile time, not runtime, so no test is needed. → Slide 16 · 17
No. Conversion happens automatically only in the narrowing direction. To widen, you must hold the original bidirectional channel. → Slide 18
Let them answer first, then click to open. If time is short, Q2 and Q3 alone are enough.
check ②click to reveal

Answer for yourself — close and signals

If a sender is still sending you get panic: send on closed channel. Only the sender knows the end, so closing is the sender's job too. → Slide 22 · 23
Only the ok of v, ok := <-ch tells them apart. A closed channel returns zero value and false at once, without blocking. → Slide 21
close(ch). range ends on close alone, and how many values were sent makes no difference. → Slide 25
close wakes everyone waiting and lets late receivers through. send wakes one, so the sender must know the receiver count. → Slide 26 · 27
No producer knows it is the last. A separate goroutine waiting on wg.Wait() closes once all are done. → Slide 31 · 32
Q1 and Q5 are the two that blow up most often in production. If time is left, have everyone recall who calls close in their own codebase.
nextpart 3

Next — select and channels in depth

Today we handled one channel. In practice you wait on a value channel and an error channel, plus timeout and cancellation — several at once. That's the next part's topic.

what we deferred today Non-blocking send (default), timeouts that cut off endless waits, switching a case off with a nil channel — everything brushed past today falls out of one construct, select. fan-in / fan-out too.

References

  • Full code — github.com/kenshin579/tutorials-go /golang/concurrency
  • Go Tour — Channels go.dev/tour/concurrency/2
  • Effective Go — Channels go.dev/doc/effective_go#channels
  • Go Blog — Pipelines and cancellation go.dev/blog/pipelines
Thank you Questions welcome — advenoh.pe.kr
Leave the GitHub repository address up while taking Q&A.
Speaker notes
← Article Channels · Go Concurrency 2
01 / 36

Shortcuts

→ · Space · PgDn
Next slide
← · PgUp
Previous slide
Home · End
First · Last
O
Slide overview
N
Speaker notes
T
Light / dark theme
F
Fullscreen
Esc
Close