Language Internals
Part 7 of 11 · Go Language ProficiencyGoroutines, Channels & Select
Goroutines, channels, and select, including when a mutex is the smaller tool.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
What does the go keyword do?
Answer
It starts a goroutine. The caller continues. You still need a way to wait or to observe the result.
L2
Is a goroutine an OS thread?
Answer
No. The scheduler multiplexes goroutines onto threads. Thousands of goroutines are a normal design.
L3
Who closes a channel?
Answer
The sender - the only one, or a coordinator after all senders finish; never the receiver. A send on a closed channel panics. A receive drains buffered values first, then yields the zero value with ok == false.
L4
What is select?
Answer
select waits until a case can proceed and picks pseudo-randomly among ready cases (only loosely like Promise.race).
L5
When is a channel the wrong tool?
Answer
A shared struct or map wants a mutex. A counter wants a mutex or an atomic. Waiting with no payload wants a WaitGroup.
L6
Buffered or unbuffered?
Answer
Unbuffered rendezvous: the send completes when someone receives. A buffer lets the sender run ahead until the buffer fills.
L7
How do you notice a data race?
Answer
go test -race. The detector is not optional on packages that share memory.
Failure modes
Leaked goroutine
The goroutine blocks on a send nobody receives, and nothing cancels it.
Send on closed
A second sender closes, or the receiver closes, and the next send panics.
Shared map without a lock
Two goroutines write one map. The runtime crashes.
Channel used as a counter
A goroutine and a channel replace an atomic add and hide the real critical section.
Misconceptions
Goroutines are threads.
They are scheduled by the runtime onto a smaller set of OS threads.
Concurrency means parallelism.
Concurrency is the structure. Parallelism is execution on more than one core.
Channels are always cleaner than mutexes.
Channels hand off ownership by convention (the sender stops touching the value). Mutexes protect in-place state.
Closing is the receiver's job.
The sender closes - the only one, or a coordinator after all senders finish; never the receiver.
Interviewer traps
Comparing a goroutine to a Promise that starts on call.
A goroutine starts immediately. A Promise executor also starts immediately, but the await is the host loop, not an M:N scheduler.
Closing from the receiver.
Name the sender as the closer, and the panic on a late send.
Reaching for a channel to protect a map.
Put a mutex next to the map. Channels are for handoff.
Design scenario
Same prompt for every reader.
Requirements
A WaitGroup or a single result channel, select with a timeout, a mutex only if a map is shared.
Traffic / scale
Hundreds of requests, a handful of outbound calls each.
Latency
Unbuffered sends block the worker until the collector receives.
Consistency
One sender closes the result channel after the wait.
Availability
A blocked send with no receiver leaks the goroutine until process exit.
Failure assumptions
- Every shared integer becomes a channel.
- The receiver closes the jobs channel.
Constraints
- Do not paste a runtime or ownership lesson from another language.
- Name the closer.
Prompt
Fan out a few fetches and merge one result or a timeout, without a channel per counter.
Handoff versus a lock
Prefer
Channel for ownership
One sender, one value in flight, a clear closer. select waits on more than one operation.
- The sender closes - the only one, or a coordinator after all senders finish; never the receiver.
- WaitGroup waits with no payload.
- A mutex guards a map.
Alternative
Shared memory first
A Promise race or a Python thread often mutates a captured variable. That is a data race in Go.
- The host loop is cooperative.
- The GIL serializes Python bytecode.
- A channel is optional there.
Overview
go starts a goroutine now. It is not an OS thread, and it is not a lazy future. Channels hand off ownership by convention (the sender stops touching the value). select waits until a case can proceed and picks pseudo-randomly among ready cases (only loosely like Promise.race). Reach for sync.Mutex, sync.WaitGroup, or an atomic when you are not handing off a value.
Decisions
- 1
Step 1 go f() starts a goroutine
- nextStep 2 It sends results on a channel
- 2
Step 2 It sends results on a channel
- nextStep 3 select waits until a case is ready
- nobody ever receivesFailure path - goroutine leak, blocked forever
- 3
Step 3 select waits until a case is ready
- nextStep 4 Which case is ready
- ?
Step 4 Which case is ready
- valueStep 5a Handle the value
- ctx.Done or timerStep 5b Stop and return
- 5
Step 5a Handle the value
- nextStep 6 The sole sender closes the channel when done
- 6
Step 5b Stop and return
- 7
Step 6 The sole sender closes the channel when done
- nextStep 7 range over the channel ends after the buffer drains
- 8
Step 7 range over the channel ends after the buffer drains
- 9
Failure path - goroutine leak, blocked forever
Lesson map
Goroutines, Channels & Select
Goroutines, channels, and select, including when a mutex is the smaller tool.
Architecture. Step 1 go f() starts a goroutine Ready. Step 2 It sends results on a channel Ready. Step 3 select waits until a case is ready Ready. Step 4 Which case is ready Ready. Step 5a Handle the value Ready. Step 5b Stop and return Ready. Step 6 The sole sender closes the channel when done Ready. Step 7 range over the channel ends after the buffer drains Ready. Failure path - goroutine leak, blocked forever Ready
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB A["Step 1 go f() starts a goroutine Ready"] B["Step 2 It sends results on a channel Ready"] C["Step 3 select waits until a case is ready Ready"] D["Step 4 Which case is ready Ready"] E["Step 5a Handle the value Ready"] F["Step 5b Stop and return Ready"] G["Step 6 The sole sender closes the channel when done Ready"] H["Step 7 range over the channel ends after the buffer drains Ready"] X["Failure path - goroutine leak, blocked forever Ready"] A -->|continues| B B -->|continues| C C -->|continues| D D -->|value| E D -->|ctx.Done or timer| F E -->|continues| G G -->|continues| H B -->|nobody ever receives| X
Press Run. Snippets must be self-contained — no network, files, or native modules.
The sketch records that the work ran and was joined. A Go WaitGroup is that join without a message.
Rosetta — start and wait
package conc
import (
"fmt"
"sync"
)
func Demo() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("runs concurrently")
}()
wg.Wait()
}async function demo() {
const p = Promise.resolve().then(() => console.log("microtask"));
await p;
}import threading
def demo() -> None:
thread = threading.Thread(target=lambda: print("runs concurrently"))
thread.start()
thread.join()Goroutines are multiplexed. A TypeScript async function still rides one thread unless you start workers. A Python thread is an OS thread that shares objects under the GIL. asyncio is cooperative on one thread.
Rosetta — channel and select
package chans
import "time"
func Demo() string {
ch := make(chan string, 1)
ch <- "hi"
select {
case v := <-ch:
return v
case <-time.After(time.Second):
return "timeout"
}
}async function demo(signal: AbortSignal): Promise<string> {
const value = Promise.resolve("hi");
const timeout = new Promise<string>((_, reject) => {
const timer = setTimeout(() => reject(new Error("timeout")), 1000);
signal.addEventListener("abort", () => clearTimeout(timer));
});
return Promise.race([value, timeout]);
}import asyncio
async def demo() -> str:
task = asyncio.create_task(asyncio.sleep(0, result="hi"))
try:
return await asyncio.wait_for(task, timeout=1.0)
except TimeoutError:
return "timeout"A buffer of 1 lets the send finish before the receive. An unbuffered send waits for a receiver. select waits until a case can proceed and picks pseudo-randomly among ready cases (only loosely like Promise.race). asyncio.wait_for is the shape of a timeout, not the scheduler.
When not to use a channel
| Prefer | When |
|---|---|
| sync.Mutex | A shared struct or map |
| sync.WaitGroup | Wait for N goroutines and no message |
| atomic | A counter or a flag |
The sender closes - the only one, or a coordinator after all senders finish; never the receiver. A send after close panics. A receive drains buffered values first, then yields the zero value with ok == false. Do not close from the receiver.
Interview Q&A
Who closes a channel?
Answer
The sender - the only one, or a coordinator after all senders finish; never the receiver. Send on a closed channel panics. A receive drains buffered values first, then yields the zero value with ok == false.
Goroutine or thread?
Answer
A goroutine. The runtime maps many of them onto fewer OS threads.
Concurrency or parallelism?
Answer
The channel structure is concurrency. More than one core running goroutines is parallelism. You can have the first without the second.
Mutex or channel for a cache map?
Answer
Mutex. The map stays put. A channel would exist only to serialize access you can lock directly.
What does a WaitGroup add that a channel does not?
Answer
A count of unfinished goroutines and no payload. Use it when the only result is done.
What does a buffer change?
Answer
The sender can run ahead by that many values. A full buffer blocks. An unbuffered channel blocks until the receive.
How is this different from the JS event loop?
Answer
That hub owns microtasks and macrotasks. Here the go statement starts a goroutine on the Go scheduler immediately.
What does Go 1.27 add on this page?
Answer
The language rules for channels did not change. The goroutineleak profile, covered on the production page, is how you see a permanently blocked goroutine.
Pitfalls
gowith no wait and no cancellation.- Closing from the receiver.
- A channel that only protects a counter.
- A map write from two goroutines.
Find a channel that only increments a counter. Replace it in words with an atomic or a mutex, and say what you still need a channel for.