Skip to content

Concurrency: The Go Way

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
Shared memory under a lock vs CSP: a channel moves the data itself, so there is nothing left to lock
LessonTopic
This pageModel overview and first runnable example
Goroutinesgo f(), the scheduler, sync.WaitGroup
ChannelsCreate, send, receive, buffered vs unbuffered, close, range
SelectMultiplexing channels, default, timeouts
Sync primitivessync.Mutex, sync.Once, sync/atomic
Contextcontext.Context, cancellation, propagation
PatternsWorker pool, fan-out/fan-in, pipeline

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")
}
What you getBenefitCost
goroutine vs OS thread2-8 KB stack vs 1-8 MBscheduler overhead
CSP (channels) vs shared memorydata flow is explicitchannel overhead is higher than a mutex
M:N schedulingfew OS threads neededscheduler complexity
  • 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

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 a WaitGroup, 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 exits
main exits
goroutine 0 says hello

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

What does the Go proverb 'share memory by communicating' mean in practice?
What does GOMAXPROCS control?
Why is wg.Add(1) called BEFORE go func()?
What can this program print?
func main() {
    for i := 0; i < 3; i++ {
        go func(id int) {
            fmt.Println("goroutine", id, "says hello")
        }(i)
    }
    fmt.Println("main exits")
}
Which statement about goroutines vs OS threads is accurate?