Concurrency: The Go Way
The Go concurrency model
Section titled “The Go concurrency model”Most languages bolt concurrency on top of shared-memory threads. Go was designed from the ground up around a different idea: Communicating Sequential Processes (CSP), formalized by Tony Hoare in 1978. The Go proverb that captures it best is:
Do not communicate by sharing memory; instead, share memory by communicating.
In practice this means:
- Goroutines are independently executing functions. They are cheap — a few kilobytes of stack that grows on demand — so you can have hundreds of thousands running concurrently.
- Channels are typed conduits. Goroutines send values into a channel and receive values from another, transferring both data and synchronization in one operation.
- The scheduler multiplexes goroutines onto a pool of OS threads (GOMAXPROCS threads by default, one per CPU core). You never manage threads directly.
This design makes concurrent programs easier to reason about. When data flows through channels, ownership is clear: the sender had it, now the receiver has it. No mutex needed for that transfer.
flowchart LR
subgraph SharedMemory["Shared-memory style (most languages)"]
T1["Thread A"] -.Lock/Unlock.-> Mu["mutex"]
T2["Thread B"] -.Lock/Unlock.-> Mu
Mu --> Data["shared variable"]
end
subgraph CSP["Go's CSP style"]
G1["Goroutine A"] -->|send| Ch["channel"]
Ch -->|receive| G2["Goroutine B"]
end What this module covers
Section titled “What this module covers”| Lesson | Topic |
|---|---|
| This page | Model overview and first runnable example |
| Goroutines | go f(), the scheduler, sync.WaitGroup |
| Channels | Create, send, receive, buffered vs unbuffered, close, range |
| Select | Multiplexing channels, default, timeouts |
| Sync primitives | sync.Mutex, sync.Once, sync/atomic |
| Context | context.Context, cancellation, propagation |
| Patterns | Worker pool, fan-out/fan-in, pipeline |
Your first concurrent Go program
Section titled “Your first concurrent Go program”The snippet below launches three goroutines in parallel. Each goroutine writes its result into a pre-allocated slice indexed by its own id, so output is always in order. sync.WaitGroup blocks main until all three are finished — the Goroutines lesson covers WaitGroup itself in depth (including the classic wg.Add placement bug); this page only needs it to keep main alive long enough to see the output.
package main
import ( "fmt" "sync")
func main() { var wg sync.WaitGroup results := make([]string, 3)
for i := 0; i < 3; i++ { wg.Add(1) go func(id int) { defer wg.Done() results[id] = fmt.Sprintf("goroutine %d says hello", id) }(i) }
wg.Wait() for _, r := range results { fmt.Println(r) } fmt.Println("main exits")} Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| goroutine vs OS thread | 2-8 KB stack vs 1-8 MB | scheduler overhead |
| CSP (channels) vs shared memory | data flow is explicit | channel overhead is higher than a mutex |
| M:N scheduling | few OS threads needed | scheduler complexity |
Common Misconceptions
Section titled “Common Misconceptions”- A goroutine is an OS thread — a goroutine is far lighter than a thread; you can create hundreds of thousands of them
- Concurrent means parallel — concurrent means juggling multiple tasks; parallel means actually running at the same time on multiple cores
- Channels are always better than mutexes — a mutex fits simple shared state; a channel fits data flow
Reading AI-generated code
Section titled “Reading AI-generated code”Every lesson in this module ends with its own detailed version of this section; here is the survey-level version worth checking on sight, before diving into a generated snippet’s details:
- Does
main(or the calling goroutine) wait for every goroutine it starts — with aWaitGroup, a channel, or by returning a channel the caller drains — or can it return while goroutines are still running? - Does every goroutine have a way to stop, and does anything protect state that more than one goroutine touches?
- If the snippet is presented as deterministic, is the printed order actually forced (sorted, or read back through an indexed slice), or could two runs legitimately print in a different order?
🐛 Spot the bug
func main() { for i := 0; i < 3; i++ { go func(id int) { fmt.Println("goroutine", id, "says hello") }(i) } fmt.Println("main exits")}Show the answer
Nothing here waits for the three goroutines to run before main returns. The Go runtime does not keep a process alive for background goroutines — the instant main returns, the process exits, whether or not any goroutine has been scheduled yet. Real captured output from two separate runs of this exact program:
main exitsmain exitsgoroutine 0 says helloBoth are legitimate outcomes of the same binary — sometimes zero goroutines get a chance to run, sometimes one or two do, entirely depending on scheduling. The fix is the sync.WaitGroup this page’s own runnable example uses: main must not return until it has confirmed every goroutine it started has finished.