Golang Concurrency · Part 3 / 11

Select & Advanced
Channel Patterns

The one construct that waits on many channels at once
— timeout · fan-in/out · nil channel

select timeout fan-in · fan-out nil channel
five faces of select keyword × 1
select ├─ case only waits until one is ready ├─ + default never waits ├─ + time.After wakes when time is up ├─ case <-nilCh that case is off └─ select {} blocks forever
one case = plain <-ch several ready = random
Greeting + part 3 of an 11-part series. The first two parts covered goroutines and channels separately, so frame today as the story of combining them. The right-hand panel is everything we cover — there is only one keyword, select, and it grows five faces depending on what you attach.
problemwhy select

One channel is easy. Two is the problem

  • You want whichever of two APIs answers first
  • But write <-ch1 first and no matter how fast ch2 is, you sit waiting on ch1
  • And if ch1 never comes? The program stops on that line
  • The moment you fix an order, the requirement whichever comes first is broken
// one channel: this is enough
msg := <-ch1

// the moment there are two —
a := <-ch1   // even if ch2 is first
b := <-ch2   // we never get here

// select: takes whichever is ready
select {
case m := <-ch1:
case m := <-ch2:
}
Go's answer select is the syntax that lines up several channel operations and picks the one that becomes possible first. Every pattern today derives from it.
Opening with "how did you write the code that fires two requests and takes the first reply?" gets a good reaction. Nail down that the problem with sequential receives is not slowness but that they force an order. If timeout comes up here, push it to Chapter 3.
series11 parts

Where today sits in the series

PartTitleFocus
Part 1Overview and Goroutine Basicsunit of execution
Part 2Channels in Depthcommunication
Part 3Select and Advanced Channel Patternschoice · composition
Part 4 · 5The sync Package / The Context Packagesync · lifecycle
Part 6 · 7Concurrency Patterns / Error Handlingdesign patterns
Part 8 · 9Memory Model and Atomic / Race Detectorlow level · debugging
Part 10 · 11Real Project / go tool tracepractice · tooling

Part 1 gave the unit of execution, Part 2 the means of communication. Today weaves the two together.

We assume goroutines and channels are known. If someone is not there yet, point them at parts 1 and 2 and keep going. Do not spend time here — 30 seconds.
contentsagenda

What we cover today

01
Select Statement Basics
How it differs from switch · anatomy of a case · the random pick when several are ready
02
Default and Non-blocking
Receive / send without waiting · the five shapes of select · the busy-wait trap
03
Handling Timeouts
time.After · context.WithTimeout · which one to pick
04
Fan-out / Fan-in
One out to many · many back into one · the two joined into a pipeline
05
The Nil Channel Trick
Using blocks-forever as a switch · turning off drained sources
Chapters 1 and 2 are syntax; the real work starts at 3. If time runs short, skip the fan-out code slide (18), show only the diagram, and save Chapter 5. Flag Chapter 5 (nil channel) up front as today's highlight.
01

Select Statement Basics

It looks like switch, but what it chooses over is different. We look at what decides which case runs, and what happens when several are ready at once.

A syntax chapter, so move fast. Three slides (06 · 07 · 08), and of those only 08 (the random pick) has to stick.
01 · basicsselect vs switch

switch looks at values, select at readiness

  • Each case is exactly one channel operation — receive or send
  • The criterion is not a value but whether that op can proceed now
  • If no case can proceed, it waits right there
  • Exactly one case runs. There is no fallthrough
  • A select with a single case is just <-ch
select {
case msg := <-ch1: // if ch1 is first
    fmt.Println("ch1:", msg)
case msg := <-ch2: // if ch2 is first
    fmt.Println("ch2:", msg)
}
// neither ready? it waits right here
in one line select is the syntax that picks whichever becomes ready first. Which one that is, the code does not decide.
Setting it next to switch makes it click fast. "Can I put a value comparison in a case?" → No. It must be a channel operation. Mention here that send cases work too and slide 10 follows naturally.
01 · basicsanatomy

One case line is made of four pieces

case v, ok := <-ch:
caseOne candidate. Only one of them runs
vValue taken from the channel. Omit if unused
okfalse means the channel is closed
<-chWhether this op can proceed is the criterion
watch the ok A closed channel keeps handing out zero values without ever blocking — so that case is ready forever. Catching that through ok is where the Chapter 5 nil channel trick starts.
The point here is to plant ok early. Say the sentence "a closed channel does not block" out loud once before moving on — it comes back on slide 24.
01 · basicspseudo-random

Both ready? The one written first does not win

  • When several cases are ready the runtime picks one at random
  • It does not scan top-down — never rely on source order
  • That is what prevents starvation, where one channel is served forever
  • If you need priority you have to build it outside select
ch1Count, ch2Count := 0, 0
for range 1000 {
    ch1 <- 1  // make both ready
    ch2 <- 2

    select {
    case <-ch1:
        ch1Count++
    case <-ch2:
        ch2Count++
    }
    drain(ch1, ch2) // clear leftovers
}
// ch1: 516, ch2: 484 — roughly 50:50
Stress that the numbers shift a little on every run if you try it yourself. "So how do I give priority?" comes up often — describe the two-level structure (an outer select checking the priority channel non-blocking, then the inner select) in words only and move on.
02

Default and Non-blocking

One default line changes the whole character of a select. From a structure that waits to one that only checks what is possible now.

A short chapter. Two slides (10 · 11), and the busy-wait warning on 11 is the mine people step on most often in production.
02 · defaultnon-blocking

With default, select never blocks

non-blocking receive
select {
case val := <-ch:
    fmt.Println("received:", val)
default:
    // empty? straight to here
    fmt.Println("no data")
}
non-blocking send
ch := make(chan int, 1)
ch <- 1 // buffer is full

select {
case ch <- 2:
    fmt.Println("sent")
default:
    fmt.Println("buffer full")
}
mnemonic default = "if it can't happen now, skip it". Use it where a value must be received and you get code that silently drops data.
The send example is the more useful one — dropping instead of blocking on a full queue is common (metrics, log shipping). Advise asking "is this data safe to drop?" first.
02 · defaultone-page recap

Five faces, by what you attach

ShapeBehaviorWhere
case onlyBlocks until one is readythe basic form
+ defaultReturns at once if nothing is readypolling · drop-tolerant send
+ time.AfterWakes up when the time is uptimeout
case <-nilChThat case is never chosendynamic disabling
select {}Blocks foreverdeadlock — almost always a bug
most common mistake Put a select with default inside a for loop and you have busy-wait. Nothing is happening, yet one CPU core spins at 100%. Where you must wait, drop default and add a timeout case.
This table is the summary of today's syntax part. Do not read it row by row — point only at row 2 (default) and row 4 (nil); row 4 also teases Chapter 5. If you have a real busy-wait story, add it briefly.
03

Handling Timeouts

Go has no separate timeout syntax. You just add one more case — a channel that delivers a value once the time passes. Where that channel comes from is the difference between time.After and context.

Production territory starts here. Three slides (13 · 14 · 15), and the comparison table on 15 is the conclusion. Even if time is tight, keep 15.
03 · timeouttime.After

Timeout is not special syntax — it is one more case

ch := make(chan string)

go func() {
    time.Sleep(200 * time.Millisecond) // slow work
    ch <- "result"
}()

select {
case msg := <-ch: // result first: normal path
    t.Log("received:", msg)
case <-time.After(50 * time.Millisecond): // over 50ms
    t.Log("timeout!")
}
// → timeout!  (work 200ms > limit 50ms)
what actually happens time.After(d) returns a channel that receives one value after d. To select it is simply one more ordinary receive case.
If someone says "I expected a timeout keyword, and it's just a channel", you have succeeded. One caveat — before Go 1.23 the timer was not reclaimed until it fired, even if you left through the timeout. Used in a loop at short intervals it piled up memory. It is better now, but worth knowing.
03 · timeoutthe production default

In production you use context.WithTimeout

func simulateAPICall(ctx context.Context, d time.Duration) (string, error) {
    ch := make(chan string, 1) // buffer 1 — reason below

    go func() {
        time.Sleep(d)
        ch <- "api response"
    }()

    select {
    case result := <-ch:  // if the response wins
        return result, nil
    case <-ctx.Done(): // deadline hit · cancelled upstream
        return "", ctx.Err() // context.DeadlineExceeded
    }
}
buffer 1 is the point Even after we leave on timeout, the goroutine stores its value and exits cleanly. Unbuffered, nobody would receive it and the send would block forever — a goroutine leak.
The caller side is ctx, cancel := context.WithTimeout(...) + defer cancel(). Point out that forgetting cancel leaves the timer alive even when the work finishes early. The buffer-1 story connects to the leak slide in part 1 — a concrete case of "build the exit path when you build the thing".
03 · timeoutA vs B

Which one, and when

Aspecttime.Aftercontext.WithTimeout
Cancel propagationNone — only I bail outReaches child goroutines
Scopeone selectthe whole call chain
CleanupNone neededdefer cancel() required
ReasonNonectx.Err() — deadline or cancel
Wheretests · one-off waitsAPI calls · server handlers
how to choose If you alone can stop waiting, time.After. If everyone you called must stop too, context. Production code is mostly the latter.
If only one line survives: "do you bail out alone, or do you tell others?" Announce that context itself gets a whole part (5) and do not go deeper here.
04

Fan-out / Fan-in

Hand the work out to many (fan-out), then gather the scattered results back into one (fan-in). Join the two and that is a concurrent pipeline.

The most practical chapter today. Five slides (17–21), so it runs long. Leave the two code slides (18 · 20) on screen and talk through the comments — that keeps the timing.
04 · patternsfan-out

Fan-out — many share one queue

jobschan int, shared
Worker 1go func()
Worker 2go func()
Worker 3go func()
results[0]chan int
results[1]chan int
results[2]chan int

The arrows show where the data flows. All three workers compete to pull from the same jobs channel, and who takes how many is not fixed — whoever finishes sooner takes more (load balancing for free).

The key is not "split the work into three" but "everyone drinks from one queue". With the former a slow worker holds up its whole share; with the latter it does not. Worker count usually follows the CPU count or how many concurrent requests the downstream system tolerates.
04 · patternsfan-out code

What stops the workers is one close(jobs)

jobs, results := make(chan int, 10), make([]chan int, 3)
for i := range results { results[i] = make(chan int, 10) }
var wg sync.WaitGroup
for i := range results {
    wg.Add(1)
    go func() {                   // all three share one jobs
        defer wg.Done()
        for job := range jobs {   // pulls until jobs is closed
            results[i] <- job * job
        }
        close(results[i])
    }()
}
for i := 1; i <= 9; i++ { jobs <- i }
close(jobs)                        // without it, workers wait forever
wg.Wait()
two things easy to miss ① Without close(jobs) the range never ends and all three workers stay alive. ② Capturing the loop variable i straight into the goroutine is only safe on Go 1.22+.
Do not read line by line — point at three blocks: setup / launching workers / pushing jobs and closing. The 1.22 loop-variable note matters most to people on older codebases. Mention the old style of passing func(i int) once.
04 · patternsfan-in

Fan-in — scattered results back into one stream

source1chan string
source2chan string
source3chan string
mergedchan string
consumerfor v := range merged

One goroutine per source forwards values into merged. The consumer only has to watch one channel. In exchange, arrival order is not guaranteed — if you need order, pick a design other than fan-in.

The real gain of fan-in is that the consumer code gets simple. Compare it with handling three channels directly in a select — with fan-in you never touch the select cases as sources are added.
04 · patternsfan-in code

The WaitGroup decides when to close

func fanIn(channels ...<-chan string) <-chan string {
    var wg sync.WaitGroup
    merged := make(chan string)
    for _, ch := range channels {
        wg.Add(1)
        go func() {              // one goroutine per source
            defer wg.Done()
            for v := range ch { merged <- v } // ends on close
        }()
    }
    go func() {
        wg.Wait()                 // wait for all of them, then
        close(merged)            // close merged at that point
    }()
    return merged
}
why wg.Wait() sits in a goroutine Waiting right here means fanIn never returns. Values only drain once the caller runs range merged, and the function is stuck before that — deadlock.
This slide asks one question — "who closes?" Answer: the sender. But there are many senders, so none can close alone. That is why the WaitGroup acts as the referee that rules "everyone is done".
04 · patternspipeline

Join the two and you get a pipeline

inputchan Job
Worker 1process
Worker 2process
Worker 3process
outputchan Result
different jobs Fan-out raises throughput; fan-in folds the scattered results back into one. Joined, from the outside it becomes a plain function that takes one channel and returns one channel.
In practice you wrap this whole picture in a single function — func Process(in <-chan Job) <-chan Result. Then you can chain several such blocks into a multi-stage pipeline and tune the worker count of each stage separately. Say part 6 covers more.
05

The Nil Channel Trick

A nil channel can do nothing — send blocks, receive blocks. Inside a select, that bug-looking property becomes a switch that turns a case off.

Today's highlight. Two slides (23 · 24), but spend generous time on the code in 24. If "ah, that's why we took ok" clicks here, the talk is complete.
05 · nil channelbehavior

Blocking forever turns out to be useful here

  • Send to a nil channel blocks forever
  • Receive from a nil channel blocks forever too
  • So inside a select that case is never ready — effectively ignored
  • Meaning: assigning nil to the variable alone turns the case off
var ch chan int // nil

<-ch      // blocks forever
ch <- 1  // blocks forever

select {
case <-ch:    // never chosen
case <-other: // only this one lives
}
flip it around You cannot delete a case from a select — the code is fixed. Instead set the variable it reads to nil and it is as good as deleted.
"Doesn't sending on a nil channel panic?" is the standard question — panic is for sending on a closed channel. A nil one stops quietly. Separate the two clearly right here.
05 · nil channeldynamic disabling

Drained sources get switched off with nil

var a, b = (<-chan int)(ch1), (<-chan int)(ch2) // hold them as receive-only
for a != nil || b != nil {   // both nil = all drained
    select {
    case v, ok := <-a:
        if !ok {
            a = nil              // closed → turn this case off
            continue
        }
        results = append(results, v)
    case v, ok := <-b:
        if !ok {
            b = nil
            continue
        }
        results = append(results, v)
    }
}
if you skip the nil A closed channel hands out zero values instantly, forever. That case keeps winning, the loop piles up 0s and burns CPU. Turning it off is the only way.
This is today's climax. First ask "what happens if we delete the two nil assignments?" and then open the callout. Two places it is used — ① merging sources and switching off the ones that finish ② turning specific channel handling on and off by condition. In both, the select structure stays and only the variable changes.
wrap-uprecap

What to take away

  • select picks one ready case. Several ready means random — never rely on source order
  • Adding default makes it non-blocking. Put that in a loop and you get busy-wait
  • Timeout is not syntax but one more casetime.After or ctx.Done()
  • Need cancel to propagate? context.WithTimeout, and give the result channel buffer 1
  • Fan-out for throughput, fan-in to merge. The sender closes, and WaitGroup sets the moment
  • Assign nil to a channel variable and its case turns off — the idiom for drained sources
Reading the six lines out loud is the whole recap. If only one survives, take the last — the nil channel trick has no counterpart in other languages. Questions are welcome from here.
checkclick to reveal

Answer for yourself

No. The runtime picks at random. If you need priority, build it outside select. → Slide 08
With nothing ready it drops to default at once, so the loop never rests — busy-wait. If you must wait, drop default and add a time.After case. → Slide 11
context. Cancel propagates through the whole call chain and ctx.Err() tells deadline from cancel. time.After binds to that one select. → Slide 15
If the result channel is unbuffered, nobody receives and the send blocks forever — a goroutine leak. Give it buffer 1 and it stores the value and exits. → Slide 14
A closed channel always returns immediately with a zero value. That case keeps winning and the loop spins. Set the variable to nil when ok == false to turn the case off. → Slide 23 · 24
Let the audience answer first, then click to open. If time is short, Q4 and Q5 alone are enough — both are mines people really step on in production.
nextpart 4

Next — the sync package

So far it has all been about passing values. But moments come when many goroutines must touch the same memory. There, a lock beats a channel. Next up is the tool outside CSP — the sync package.

left for later We used WaitGroup three times today and never explained it. Mutex · RWMutex · Once get a proper look next time.

References

  • Full code — github.com/kenshin579/tutorials-go /golang/concurrency
  • Go Tour — Select go.dev/tour/concurrency/5
  • Go Blog — Pipelines and cancellation go.dev/blog/pipelines
  • Previous part — Channels in Depth advenoh.pe.kr
Thank you Questions welcome — advenoh.pe.kr
Take Q&A with the GitHub repo address on screen. If "channel or mutex?" comes up, answer that the first slide of the next part is exactly that.
Speaker notes
← Article Select & Channels · Go Concurrency 3
01 / 27

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