Chapter 1: Understanding Concurrency
You've written go func(). You've watched things break in
production that worked perfectly in testing. You've debugged race
conditions that appear only under load and disappear when you add
logging. The problem isn't Go's concurrency model—it's that
goroutines are
deceptively simple to create and surprisingly hard to
master. This chapter builds the mental models that prevent expensive
mistakes.
- Concurrency vs parallelism—the essential distinction that prevents confusion about what goroutines provide
- CSP and message passing—Go's philosophical approach to structuring concurrent programs
- I/O-bound vs CPU-bound workloads—the key to knowing when concurrency improves performance
- Goroutines vs OS threads—why Go can do what other languages can't
- GOMAXPROCS—the single knob for controlling parallelism
This chapter is pure concepts—the foundation that makes the mechanics intuitive. We show select code examples to illustrate ideas, but hands-on concurrent programming begins in Chapter 2. First, we establish precise understanding of what concurrency actually is, how Go approaches it differently, and when it helps.
Basic Go syntax familiarity. No prior concurrency experience required—we start from first principles.
1.1 Concurrency vs Parallelism: The Essential Distinction
Many developers conflate "concurrent" and "parallel," leading to confusion about goroutines, wrong performance expectations, and poor architecture. Let's separate these concepts.
The Definitions
Concurrency is about structure. A concurrent program is structured so that multiple tasks can be in progress at overlapping times—whether or not they physically execute at the same instant.
Parallelism is about execution. Parallel execution means multiple tasks run at the same instant on different processors. It requires hardware capable of simultaneous computation.
"Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once."
Concurrency is structure; parallelism is execution. A program is concurrent by design. It runs in parallel (or not) based on hardware and runtime decisions.
Visualizing the Difference
Consider two independent tasks, A and B:
One processor runs Task A to completion, then starts Task B. Nothing overlaps.
One processor alternates between Task A and Task B in short slices. Both make progress; neither runs at the same instant as the other.
The same program structure as the previous figure, now on two processors: Task A runs on processor 1 and Task B on processor 2, simultaneously and for the full duration.
The second and third scenarios use identical program structure. Only the execution environment changes. You design for concurrency once; the runtime adapts to available hardware.
The Relationship
Concurrency enables parallelism, but does not guarantee it.
A box labeled CONCURRENCY (structure), which you control as a design decision, points via an arrow labeled “enables” to a box labeled PARALLELISM (execution), which the runtime and hardware control.
Four combinations exist on paper. Three of them apply to Go:
Note: "Parallel but not concurrent" exists in other domains (SIMD, GPU computing) but doesn't apply to Go's goroutine model, where parallelism requires concurrent structure.
A Concrete Analogy: The Coffee Shop
This analogy illustrates all combinations and reveals why the distinction matters.
Scenario 1: Sequential (Neither Concurrent Nor Parallel)
A solo barista takes one order, makes the entire drink, delivers it, then takes the next order.
A single barista completes Order A end to end — take, make, deliver — before beginning Order B. Simple, and slow once orders queue up.
Scenario 2: Concurrent, Not Parallel (One Worker, Smart Structure)
Still one barista, but work is restructured. Take Customer A's order and start the espresso pulling. While the machine runs, take Customer B's order. When A's espresso finishes, steam A's milk while B's espresso pulls. Switch between tasks based on what's waiting.
One barista interleaves three orders: taking A's order, then B's while A's espresso pulls, then steaming and serving in the gaps. One worker, one action at a time, but no idle waiting.
This is concurrency without parallelism. One person, one action at a time—but structured to make progress on multiple orders by interleaving tasks during natural wait times.
Scenario 3: Parallel but Poorly Structured
Two baristas working simultaneously, but with poor structure—each serves only their own line. No shared queue, no load balancing, no collaboration.
Two baristas work simultaneously but each serves only their own line. Barista 1 handles orders A then C while Barista 2, having finished B, sits idle because there is no shared queue to pull from.
Parallel but poorly structured. Both baristas operate in parallel—they execute simultaneously—but without a shared queue, the system can't balance load. Barista 2 sits idle while Barista 1 is overwhelmed.
Scenario 4: Concurrent AND Parallel (The Goal)
Two baristas with good organization: shared order queue, collaborative task handling.
Two baristas draw from one shared queue. Barista 1 takes and pours A then moves to C; Barista 2 takes B, steams A's milk, serves B and starts D. Neither is idle.
Concurrent design executing in parallel. Work is structured to handle multiple orders efficiently (concurrency), and multiple workers execute simultaneously (parallelism). Notice that the two baristas overlap on the same order: in the middle of the figure Barista 2 is steaming A's milk while Barista 1 pours A's shot. Only once that hand-off finishes does Barista 1 start C, while Barista 2 serves B. Neither is ever idle, because neither owns a private line—that is what the shared queue buys you.
Scenario 5: Too Many Workers, One Bottleneck (The Anti-Pattern)
Ten baristas, one espresso machine. Everyone competes for the shared bottleneck.
Ten baristas all wait on a single espresso machine that serves one at a time. Barista 1 eventually uses the machine and serves; baristas 2 through 10 remain waiting. Adding workers changes nothing.
Adding workers doesn't help when they all compete for a shared resource. This mirrors spawning 1,000 goroutines to query a database that maintains only 10 connections—990 goroutines wait on the connection pool, adding overhead without improving throughput.
In Go terms: The solution isn't more goroutines; it's either:
- Increasing the bottleneck capacity (more database connections)
- Limiting goroutines to match the bottleneck (worker pool pattern—see Chapter 7)
- Reducing bottleneck contention (caching, batching)
The Crucial Insight
Compare Scenarios 1 and 2: for tasks with natural wait times—like I/O—the solo barista working concurrently dramatically outperforms sequential operation, despite zero parallelism.
Then compare Scenarios 3 and 4: the same number of workers, but concurrent structure makes parallel execution far more efficient. And Scenario 5 shows the limit: more workers past the bottleneck add cost, not throughput.
Why This Distinction Matters
Three consequences follow from this distinction:
- You design concurrency; the runtime provides parallelism. Your program must be correct whether it runs on one core or many—you cannot assume parallelism.
- Concurrency has value without parallelism. Responsiveness, modularity, and I/O efficiency all improve from concurrent structure alone—even on a single core.
- Parallelism on top of a bad concurrent structure is wasteful. Scenario 3 proved this. Two baristas each working their own queue is a concurrent structure—just a poor one, with no shared queue to balance load. Adding workers to it yields diminishing returns. Structure comes first, and structure can be wrong.
Common Misconceptions
"Concurrency means faster"
Only with parallelism or I/O overlap. On CPU-bound work with one core, it adds overhead.
"Goroutines = parallel execution"
Goroutines provide concurrent structure. They may execute in parallel.
"Parallelism is always better"
Parallelism has costs: synchronization, complexity, cache effects. Sometimes sequential wins.
Section Summary
Concurrency is structure; parallelism is execution. You design the first and the runtime supplies the second, so the same program must be correct on one core and fast on twelve. Concurrency pays on its own wherever there is waiting to overlap — and past the system's real bottleneck, more workers buy cost, not throughput.
1.2 Go's Philosophy: CSP and "Share Memory by Communicating"
Go didn't invent a new concurrency model—it adopted and refined one of the most elegant ideas in computer science. Understanding this philosophy shapes how you think about every concurrent program you write.
Two Approaches to Concurrent Communication
When concurrent tasks need to coordinate or share data, there are two fundamental approaches:
Approach 1: Shared Memory with Locks
Multiple threads access common data structures. Locks (mutexes) protect that data from simultaneous modification.
Three threads, A, B and C, all point at one block of shared data guarded by a lock. Communication happens by mutating that shared block.
This model seems intuitive—it mirrors shared physical resources. But it creates problems:
- Lock management is error-prone: Forget a lock? Data race. Wrong lock order? Deadlock.
- Reasoning is difficult: To understand Thread A, you must consider what Threads B and C might be doing simultaneously.
- The default is unsafe: You must carefully add safety through correct locking.
Approach 2: Message Passing
Independent processes have private state. They coordinate by sending messages through channels.
Three processes, each holding private state, pass messages to their neighbours in both directions. No memory is shared.
Both approaches can produce correct concurrent programs, but they differ in structure, failure modes, and reasoning about correctness.
CSP: Communicating Sequential Processes
Go's concurrency model is rooted in Communicating Sequential Processes (CSP), a formal model published by Tony Hoare in his 1978 CACM paper.
The core insight:
Build concurrent systems from independent sequential processes that communicate through channels.
Communication is synchronization. With an unbuffered channel, send and receive operations block until both parties are ready—the act of communication synchronizes the goroutines at that exact moment. Buffered channels decouple the timing but still guarantee that a send happens before the corresponding receive.
Process A and Process B, each ordinary sequential code, are joined by a channel. A dot on the channel marks the synchronization point where an unbuffered send and receive meet.
Go translates CSP concepts into practical primitives:
chan Tch <- value and value := <-ch
select statement (wait on multiple channels)
The Go Proverb
Go's philosophy is captured in one line:
"Do not communicate by sharing memory; instead, share memory by communicating."
You will also see this quoted in its shorter form—“Don't communicate by sharing memory, share memory by communicating”—which is Go Proverb #1, Rob Pike's condensed phrasing from his 2015 Gopherfest talk. Same idea, different sentence; we quote Effective Go because that is where it was written down first (go-proverbs.github.io).
This inverts the traditional approach. Let's see what it means.
A Real Problem: The Bank Account
Imagine a bank account where multiple operations happen concurrently. The balance starts at $100, and two withdrawals of $60 each arrive simultaneously.
Traditional: Communicate by Sharing Memory
// UNSAFE — data race. This program is incorrect.
var balance = 100
func withdraw(amount int) bool {
if balance >= amount {
// One possible interleaving:
// Both goroutines read balance (100) before either writes
// Both see 100 >= 60, both proceed, both write balance = 40
// Result: BOTH WITHDRAWALS SUCCEED — $120 withdrawn
// from a $100 balance
balance -= amount
return true
}
return false
}
Go is stricter here than C or C++. The Go memory model deliberately constrains racy programs: a read of a single machine word always observes some value that was actually written—never a value invented out of thin air—so a racy counter gives you a real-but-wrong number rather than arbitrary garbage.
That guarantee stops at one word. A race on a multi-word value—an interface, slice, string, or map—can tear: the reader sees one field from before the write and another from after. A half-updated slice header yields an out-of-range pointer; a concurrently-written map is detected by the runtime and terminates the process outright.
So the rule is not "anything can happen." It is narrower and more useful: a racy program is incorrect, and its outcome is not something you are allowed to reason about. Don't reason about the interleaving—fix the race. Chapter 8 makes these guarantees precise.
The Straightforward Fix: Mutex
var balance = 100
var mu sync.Mutex
func withdraw(amount int) bool {
mu.Lock()
defer mu.Unlock()
if balance >= amount {
balance -= amount
return true
}
return false
}
This works, and for this specific problem, a mutex is the right tool—it's simple, clear, and correct.
Demonstrating the Philosophy: Channels
For a single bank account, the mutex version is clearly simpler—use it. We show the channel version to illustrate ownership transfer, the concept that becomes powerful when the account manager must also rate-limit, audit, batch operations, or maintain transaction history.
type WithdrawRequest struct {
amount int
response chan bool
}
// Single goroutine owns the balance
func accountManager(requests chan WithdrawRequest) {
balance := 100 // Private state
for req := range requests {
if balance >= req.amount {
balance -= req.amount
req.response <- true
} else {
req.response <- false
}
}
}
// Clients send requests via channel
func withdraw(requests chan WithdrawRequest, amount int) bool {
response := make(chan bool)
requests <- WithdrawRequest{amount: amount, response: response}
return <-response
}
What changed:
-
The balance isn't shared—it's owned by the
accountManagergoroutine -
Other goroutines can't directly access it; they must
send requests (hence
withdrawnow takes arequestschannel parameter) - No data race possible, because only one goroutine touches the balance
- No locks needed—channel operations provide synchronization
The caller launches accountManager as a goroutine (go accountManager(requests)) and sends WithdrawRequest values through the shared
channel. Chapter 2 covers goroutine lifecycle management, and Chapter
4 introduces select for multiplexing channel operations.
The outcome is still nondeterministic. If both $60 withdrawals are sent at nearly the same moment, which one succeeds depends on which request reaches the channel first—and that ordering isn't guaranteed. What's gone is the corruption: the balance can never be read and written concurrently, so it can never end up at an impossible value.
That difference has a name. A data race is two goroutines touching the same memory unsynchronized; a race condition is a correct-looking program whose answer depends on timing. Channels and mutexes remove the first. Only your design removes the second—and sometimes, as here, you don't want to: first-come-first-served is the intended banking behavior. Chapter 8 draws the line properly.
Ownership Transfer
When you send data through a channel, you're conceptually transferring ownership.
Two contrasting models. Above: goroutine A writes to shared memory guarded by a lock and goroutine B reads from it — ownership is ambiguous. Below: goroutine A sends the data over a channel to goroutine B, and ownership travels with the message so only one goroutine holds it at a time.
Go cannot prevent you from using data after sending it. When sending pointers, slices, or maps through channels, you're sending a reference, not a copy. After sending, treat the data as belonging to the receiver. The race detector (Chapter 8) catches violations at runtime.
Why This Philosophy Matters
1. Sequential Reasoning
Each goroutine is internally sequential. You can reason about it line-by-line without considering what other goroutines might be doing at that instant.
2. Explicit Data Flow
Channel operations make data flow visible in code structure:
Input flows into a Parse stage, through channel 1 into a Validate stage, through channel 2 into a Process stage, and out. Each stage communicates only through its channels.
3. Natural Composition
CSP-style components compose cleanly. Because each goroutine communicates only through its input and output channels, you can replace, rearrange, or add stages to a pipeline without affecting the rest of the system—much like connecting Unix pipes.
4. A Mental Model Shift
The shift is from defensive programming to structured communication.
A Realistic Balance
Go's philosophy expresses a preference, not a prohibition. Go provides both paradigms:
Two panels. Message passing — channels and select — preferred for transferring data, distributing work, communicating results, event notification and pipelines. Shared memory — mutex, RWMutex and atomics — appropriate for protecting struct internal state, simple counters, caches and read-heavy data.
Ask: "Are goroutines communicating (passing data between them) or protecting state (guarding internal fields of a struct)?"
- Communicating → Channels
- Protecting → Mutexes
Common Misconceptions
"Go forbids shared memory"
Go provides mutexes and atomics. The philosophy is guidance, not restriction.
"Channels are always better"
Channels excel at coordination; mutexes often simplify protecting internal state.
"CSP eliminates all concurrency bugs"
CSP prevents data races on properly transferred data, but deadlocks, goroutine leaks, and race conditions in application logic remain possible.
"Go invented CSP"
CSP predates Go by 30+ years. Go made CSP-style channels accessible as a built-in language feature.
"Sending data through a channel always deep-copies it"
Channel sends copy the value, but for slices, maps, and pointers that means copying the header or address—the underlying data is shared. Ownership transfer is a discipline, not a mechanism.
Go gives you both paradigms and a preference: channels to move data between goroutines, mutexes to protect state inside one. CSP’s value is that each goroutine stays sequential and reasonable on its own — the question shifts from “which lock guards this?” to “who owns this right now?” Ownership transfer is a discipline you keep, not a rule the compiler enforces.
1.3 When Concurrency Helps: I/O-Bound vs CPU-Bound Workloads
Understanding when concurrency helps is as important as understanding how it works. Not every problem benefits—adding concurrency to the wrong problem makes things worse.
Before using goroutines, ask:
"What is my program waiting for?"
The answer determines whether concurrency will help, how much, and whether you need parallelism.
The Fundamental Distinction
Most tasks are primarily limited by one of two bottlenecks:
I/O-Bound Workloads
An I/O-bound program spends most of its time waiting for input/output operations.
A CPU timeline showing brief blocks of computation separated by long stretches of waiting for input and output. Most of the elapsed time is spent waiting.
How Concurrency Helps: Overlap the Waiting
For I/O-bound work, concurrency lets you do useful work while waiting.
Sequentially, three tasks each compute briefly then wait, one after another, and total time is the sum. Concurrently, all three launch immediately so their waits overlap and total time is roughly that of a single task — about three times faster, on one core.
Illustrative Schematic timeline. The exercise at the end of the chapter measures the real thing.This speedup happens on a single CPU core. No parallelism required. Tasks aren't running simultaneously—they're interleaved. Because most time is spent waiting anyway, interleaving dramatically reduces total time.
CPU-Bound Workloads
A CPU-bound program spends most of its time performing computations.
A CPU timeline that is solid computation from end to end, with no waiting.
How Concurrency Helps: Only With Parallelism
For CPU-bound work, concurrency without parallelism provides no speedup—it can make things slower due to context-switching overhead.
Three cases. Sequential on one core sets the baseline. Concurrent on one core is slightly slower, because context switches add overhead with no waiting to overlap. Parallel across four cores runs four equal blocks simultaneously and finishes about four times sooner.
Illustrative Schematic timeline. Real speedup is bounded by Amdahl's law, below.In practice, speedup is typically less than the core count. Synchronization overhead, memory bandwidth and—above all—the part of the work that simply cannot be split all pull it down.
That last one has a name and an equation.
Amdahl's law says that if a fraction
s of the work is inherently sequential and the remaining
p = 1 − s parallelizes perfectly across
N cores, your best possible speedup is:
Speedup equals one divided by the quantity s plus p over N, where s is the sequential fraction, p the parallel fraction, and N the core count. With five percent sequential work, sixteen cores yield about 9.1 times speedup and sixty-four cores 15.4 times; the ceiling as cores approach infinity is twenty.
Derived Arithmetic from the formula above — check any row yourself.Read the last line again. Five percent sequential work caps you at 20× no matter how many cores you buy. This is why “add more goroutines” stops helping so much earlier than people expect—and why the honest first question is always how much of the work is genuinely parallel, not how many cores are available. Profile before assuming linear scaling.
Code Examples
I/O-Bound: Fetching URLs
// Sequential: fetch one at a time
func fetchSequential(urls []string) {
for _, url := range urls {
resp, err := http.Get(url)
if err != nil {
continue
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
// Concurrent: fetch all at once (waits overlap)
func fetchConcurrent(urls []string) {
var wg sync.WaitGroup // Covered in Chapter 2
for _, url := range urls {
// Go 1.25: wg.Go replaces the Add / defer Done pair.
wg.Go(func() {
// url is a new variable each iteration (Go 1.22+),
// so capturing it is correct — no parameter needed.
resp, err := http.Get(url)
if err != nil {
return
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
})
}
wg.Wait()
}
// NOTE: This spawns one goroutine per URL — fine for small
// slices. For large inputs, bound concurrency with a worker
// pool (Chapter 7) to avoid overwhelming the target server.
// Expected shape, for N URLs of latency L:
// sequential ≈ N × L concurrent ≈ L
// You measure this yourself — including on a single
// core — in the exercise at the end of the chapter.
CPU-Bound: Computing Checksums
// CPU-intensive computation (no I/O)
func computeChecksum(data []byte) uint64 {
var sum uint64
for _, b := range data {
sum = sum*31 + uint64(b)
}
return sum
}
// The parallel version: split the data, one goroutine per chunk.
func checksumParallel(data []byte, chunks int) []uint64 {
out := make([]uint64, chunks)
size := (len(data) + chunks - 1) / chunks
var wg sync.WaitGroup
for i := range chunks {
lo := i * size
if lo >= len(data) {
// ceil() can leave trailing chunks with no work
break
}
hi := min(lo+size, len(data))
wg.Go(func() {
// out is shared, but the writes never conflict: each
// goroutine owns exactly one element. No mutex needed.
out[i] = computeChecksum(data[lo:hi])
})
}
wg.Wait()
return out
}
// Run it yourself: go test -bench=Checksum ./ch01
// Then again with: GOMAXPROCS=1 go test -bench=Checksum ./ch01
//
// The shape to expect — and the point of the exercise:
// GOMAXPROCS=1 concurrent ≈ sequential, or a little worse
// (context switches, no waiting to overlap)
// GOMAXPROCS=N concurrent ≈ sequential / N, up to core count
The Complete Picture
Identifying Your Workload
The Diagnostic Question
"If I had infinitely fast I/O, would my program be dramatically faster?"
- Yes → I/O-bound
- No → CPU-bound
Symptoms
top or a CPU profile.
Decision Framework
A decision tree. If performance is not a problem, do not add concurrency. If it is, identify the bottleneck: I/O-bound work benefits from concurrency even on one core; CPU-bound work benefits from parallelism if cores are available; and if you do not know, profile first with go tool pprof.
Common Misconceptions
"Concurrency always speeds things up"
Only for I/O-bound work. For CPU-bound work on a single core, concurrency adds overhead and makes things slower.
"CPU-bound work can't benefit from goroutines"
It can—with multiple cores. Set
GOMAXPROCS > 1 (the default) and split work
across goroutines for true parallelism.
"I should profile after adding concurrency"
Profile before. Identify the bottleneck first, then choose the right strategy. Adding concurrency to a CPU-bound hot loop wastes effort.
Practical Guidelines
Ask what your program is waiting for. I/O-bound work gets a large speedup from concurrency alone, on a single core, because the waiting overlaps. CPU-bound work gets nothing from concurrency without parallelism — and Amdahl’s law caps even that. Profile first; the bottleneck picks the strategy.
1.4 The Go Runtime: Goroutines vs OS Threads
The previous sections established what concurrency is, how Go structures it, and when it helps. Now we explore why Go's approach is practical.
Why can Go programs create thousands of goroutines when traditional programs struggle with hundreds of threads?
The Traditional Model: OS Threads
Most languages use OS threads (operating system threads) as their unit of concurrency. Creating a thread in Java, C++, or C# asks the OS to create and manage a new execution context.
The Cost of OS Threads
Go's Model: Goroutines
Go introduces goroutines—lightweight execution contexts managed by the Go runtime in user space, not by the operating system.
M:N Scheduling
Go uses M:N scheduling: M goroutines multiplexed onto
N OS threads, where M >> N. More precisely, N is bounded by
GOMAXPROCS processor contexts, not raw thread
count—the runtime may create additional OS threads when
goroutines block on syscalls. Section 1.5 explores this in detail.
A warning about the letter M. In “M:N”, M and N are just counts—M goroutines, N threads. Chapter 21 introduces the runtime's GMP model, where the same letter means something else entirely: M is the machine—an OS thread, G is a goroutine and P is a scheduling context. Same letter, opposite side of the ratio. Read “M:N” here as “many : few” and you will not get tangled later.
Ten thousand goroutines feed into the Go runtime scheduler, which multiplexes them onto roughly four OS threads — about one per CPU core.
Why Goroutines Are Lightweight
1. Small Initial Stack
Against a 1 MB thread stack that is ~500× less memory; against the 8 MB default on many Linux systems it is closer to 4,000×. Either way, it is the difference between hundreds of thousands of concurrent operations and exhausting the machine.
Thread figures are reserved virtual address space, not resident memory—the kernel commits physical pages on demand, so 1,000 threads do not actually consume a gigabyte of RAM. What really caps thread counts is kernel bookkeeping and scheduler overhead, and those bite long before the address space does. Goroutine figures are initial stack only; actual per-goroutine overhead is slightly higher. The ratio is what matters.
2. User-Space Scheduling
OS thread context switches require kernel mode transitions. Goroutine switches happen entirely in user space.
How Blocking Works
A critical feature: when a goroutine blocks on most I/O operations, it doesn't block the OS thread.
Goroutine A works, then blocks; the OS thread immediately picks up goroutine B, then C, then returns to A when it is ready. The thread is never idle.
Why Network I/O Doesn't Block Threads
Go achieves efficient I/O through the network poller—integration with OS-level async I/O mechanisms (epoll on Linux, kqueue on macOS/BSD, IOCP on Windows).
This is why Go can write simple, synchronous-looking code:
resp, err := http.Get(url) // Looks blocking, doesn't waste threads
Don’t block OS threads: Network I/O (via the network poller) and channel operations (goroutine parks; thread stays free).
Do block OS threads:
-
Synchronous file I/O (
os.Fileoperations on regular files) - CGO calls (C function calls)
- Certain syscalls
When a goroutine does block an OS thread, the runtime detects this and creates an additional thread so other goroutines keep running.
What This Enables
Goroutine-Per-Connection (It Works!)
// With OS threads: breaks at ~10,000 connections
// With goroutines: handles 100,000+ connections easily
func handleConnection(conn net.Conn) {
defer conn.Close()
// ... handle connection
}
func main() {
listener, err := net.Listen("tcp", ":8080")
if err != nil {
log.Fatal(err)
}
for {
conn, err := listener.Accept()
if err != nil {
log.Println(err)
continue
}
go handleConnection(conn) // One goroutine per connection
}
}
Goroutine Characteristics
Beyond being lightweight, goroutines have several properties that distinguish them from threads in other languages:
Goroutines are anonymous. Unlike threads in some
languages, goroutines have no exposed identity, ID, or name. There's
no goroutine-local storage in Go—context.Context
carries request-scoped values, cancellation signals, and deadlines
across goroutine boundaries (Chapter 13).
Goroutine lifecycle is implicit. A goroutine exits
when its function returns. There's no goroutine handle—you can't
call goroutine.Join() or goroutine.Kill().
Coordination is done through channels and sync primitives like
sync.WaitGroup (Chapter 2).
You cannot kill a goroutine from outside—it
must exit voluntarily (e.g., by checking a
context.Context
cancellation). This makes goroutine leak prevention a key design
concern (Chapter 2).
An unrecovered panic in any goroutine crashes the entire program—a key difference from languages where thread failures can be isolated.
Common Misconceptions
"Goroutines are OS threads"
Goroutines are user-space; many multiplex onto few OS threads
"One goroutine = one core"
Unrelated. You can have 10,000 goroutines on 1 core.
"Blocking a goroutine blocks a thread"
Not for network I/O or channels. File I/O and CGO calls do block a thread, but the runtime compensates by creating new ones.
"Goroutines are completely free"
They cost ~2 KB+ memory and scheduling overhead. Cheap, not free.
"I can get a goroutine's ID"
Goroutines are anonymous. Use context.Context for
request-scoped data.
Goroutines are cheap because they start on a ~2 KB growable stack and are scheduled in user space, so switching never enters the kernel. The runtime multiplexes many of them onto few OS threads, and the network poller means a goroutine blocked on I/O parks without holding a thread hostage. Cheap, not free — and you cannot name one, kill one, or join one.
1.5 GOMAXPROCS and Controlling Parallelism
Section 1.4 explained that Go multiplexes many goroutines onto few OS threads. Now:
How many goroutines can run simultaneously—and can you control it?
The answer: GOMAXPROCS, Go's primary knob for controlling parallelism.
What GOMAXPROCS Controls
GOMAXPROCS sets the maximum number of P (processor) contexts—the scheduling contexts that can execute user-level Go code simultaneously.
With GOMAXPROCS set to 4, thousands of goroutines pass through the scheduler into four processor contexts P1 to P4, each bound to an OS thread. At most four goroutines execute Go code simultaneously; extra threads may exist for blocking syscalls.
Key distinction:
- GOMAXPROCS limits how many goroutines run in parallel (simultaneously)
- It does not limit how many goroutines can exist (you can have millions)
The Default Value
If the GOMAXPROCS environment variable is set to a
positive number, that wins. Otherwise the runtime picks a default from
three inputs:
- the number of logical CPUs on the machine;
- the process's CPU affinity mask;
- and, on Linux, the average CPU throughput allowed by the cgroup CPU quota, if there is one.
GOMAXPROCS is the minimum of those — with two rounding rules that matter more than they look:
- a fractional cgroup limit is rounded up to a whole number;
- and the result is never below 2, unless the machine itself has fewer than two logical CPUs.
Those two rules decide the most common Kubernetes case. A container
with cpu: 500m — half a core — does
not get GOMAXPROCS=1. The fraction rounds up,
the floor of 2 applies, and you get
GOMAXPROCS=2. Derive it from “minimum” alone and you will predict the
wrong number.
And since Go 1.25 the runtime keeps watching: if the logical CPU count, the affinity mask or the cgroup quota changes while the process runs, the default is updated automatically.
Before Go 1.25 the default was simply
runtime.NumCPU(), which ignored cgroup quotas — the
source of a well-known class of container problem we come back to
below. The old behavior is still available as
GODEBUG=containermaxprocs=0 (and
GODEBUG=updatemaxprocs=0 to stop the automatic updates);
both are the default when your go.mod declares language
version 1.24 or below.
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("CPUs available:", runtime.NumCPU())
// Passing 0 queries the current value
// without changing it.
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
}
The default is almost always correct. The runtime matches parallelism to the CPU you are actually allowed to use—which, as of Go 1.25, is not the same thing as the CPU the machine has.
NumCPU() and GOMAXPROCS(0) can disagree
They answer different questions.
runtime.NumCPU() reports the logical CPUs usable by
the process, sampled once at startup and never revised.
runtime.GOMAXPROCS(0) reports how many goroutines may
run Go code simultaneously right now—which additionally
respects the cgroup quota and can change while the process runs.
Inside a two-CPU container on a 64-core host you will see
NumCPU() = 64 and GOMAXPROCS(0) = 2.
When you want to size a worker pool, ask
GOMAXPROCS(0).
Impact on Different Workloads
I/O-Bound Work: GOMAXPROCS Matters Little
One thousand goroutines fetching URLs take about the same time at GOMAXPROCS 1, 4 and 8. The network, not the CPU, is the limit.
Illustrative Representative shape, not a measurement. You reproduce this yourself in the chapter exercise.CPU-Bound Work: GOMAXPROCS Is Critical
Eight CPU-intensive tasks on a four-core machine: GOMAXPROCS 1 is slowest, GOMAXPROCS 4 is roughly four times faster, and GOMAXPROCS 8 gives no further gain because only four physical cores exist.
Illustrative Representative shape, not a measurement. Benchmark checksumParallel from section 1.3 to produce your own numbers.When to Change GOMAXPROCS
The default is correct for most programs. Change it only for specific reasons:
1. Containers — Mostly Handled For You Now
This used to be the canonical GOMAXPROCS bug, and you will still find it in most blog posts. It is worth understanding, because you will meet it in any service that hasn't moved to Go 1.25:
A 64-core host runs a container limited to 2 CPUs. Before Go 1.25 the runtime read 64 logical CPUs and set GOMAXPROCS to 64, so 64 threads contended for 2 CPUs of quota. Since Go 1.25 the runtime also reads the cgroup quota and sets GOMAXPROCS to 2.
Illustrative Representative numbers chosen to show the mechanism, not measurements from a specific host.
So on a current toolchain, do nothing. The case for
reaching for
go.uber.org/automaxprocs
— for a decade the standard fix — has largely gone away;
its own README now says the same. Keep it only while you still ship
binaries built with Go 1.24 or earlier.
Two cases the runtime still can't read your mind about:
-
CPU shares without a quota. Kubernetes
requestswith nolimitsset no bandwidth cap, so there is nothing for the runtime to detect. SetGOMAXPROCSyourself to match the request. - You want a value other than the limit. A latency- sensitive service sharing a node may deliberately want fewer Ps than its quota allows.
When you do need to set it in code — and to hand control back afterwards:
// Pin it. Note this also switches OFF the runtime's automatic
// updates — from here on the value is yours to maintain.
runtime.GOMAXPROCS(2)
// Go 1.25: hand it back. Restores the container-aware default
// and re-enables automatic updates.
runtime.SetDefaultGOMAXPROCS()
2. Isolating Concurrency Behavior
Setting GOMAXPROCS=1 eliminates parallelism, which can simplify reasoning about goroutine interleavings. However, use the race detector for finding actual data races:
Some races only manifest with parallel execution. Setting
GOMAXPROCS=1 may hide bugs rather than reveal
them. Always rely on -race for detection.
3. Shared Server Resources
On a shared system, reserve cores for other services:
Common Misconceptions
"Higher GOMAXPROCS = faster program"
Only helps CPU-bound work, only up to core count
"GOMAXPROCS limits goroutine count"
No. Millions of goroutines with GOMAXPROCS=1 is fine
"GOMAXPROCS = total OS threads"
No. Runtime creates additional threads for syscalls
"I should always tune GOMAXPROCS"
No. The runtime-selected default is right for most programs—and since Go 1.25 it already accounts for container limits.
"GOMAXPROCS=1 means no concurrency"
No. Concurrency still exists; parallelism doesn't
Practical Guidelines
Trust the default. Only adjust when:
-
Running under CPU shares with no quota — Kubernetes
requestswithoutlimits, where there is no cgroup bandwidth cap for the runtime to read - Still shipping on Go 1.24 or earlier, where the default ignores cgroup quotas
- Isolating concurrency behavior during debugging
- Deliberately reserving headroom on a shared node, below what your quota allows
- Benchmarking specific configurations
On Go 1.25 and later, in a container with a CPU limit, the correct action is no action.
GOMAXPROCS caps how many goroutines run Go code
simultaneously; it says nothing about how many can exist.
Since Go 1.25 the default already respects CPU affinity and cgroup
quotas and re-reads them as they change, so
in a container the right move is to leave it alone.
Common Mistakes
Only helps with parallelism or I/O overlap; adds overhead to CPU-bound work on one core
Profile first. Use concurrency for I/O overlap; add parallelism (more cores) for CPU-bound speedup
1000 goroutines on 4 cores adds overhead without speedup
For CPU-bound work, use approximately core count goroutines
Ownership transfer is convention, not enforcement—causes data races
After sending, treat data as belonging to receiver
Overcomplicating simple state protection
Use channels for communication, mutexes for protecting internal state
Launching a goroutine per request without limits can exhaust memory under load
Use a worker pool or semaphore to cap concurrent goroutines (covered in Chapter 7)
Exercise 1.1 — Prove It On One Core
Make the waits overlap
This chapter has made one claim you should not take on trust: for I/O-bound work, concurrency alone delivers a large speedup — on a single core, with no parallelism at all. Every performance number in this chapter follows from that. So measure it.
The test below stands up a local HTTP server that sleeps 50 ms per request, then asks for ten URLs. Sequential fetching takes about 500 ms. Your job is to get it under 150 ms without making the server any faster.
You have already seen the shape you need:
fetchConcurrent in section 1.3. Chapter 2
explains why it works — goroutine lifecycle,
WaitGroup, and how to leak one. Here you only have
to make it run.
package ch01
import (
"io"
"net/http"
)
// fetchOne retrieves a single URL and discards the body.
// Provided for you — the exercise is about structure, not HTTP.
func fetchOne(url string) {
resp, err := http.Get(url)
if err != nil {
return
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
}
func FetchSequential(urls []string) {
for _, url := range urls {
fetchOne(url)
}
}
// TODO(reader): make these fetches run concurrently and wait for
// all of them before returning.
func FetchConcurrent(urls []string) {
FetchSequential(urls) // <- your move
}
package ch01
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
const (
urlCount = 10
latency = 50 * time.Millisecond
)
// A server that is slow on purpose. Provided for you.
func slowServer(t *testing.T) *httptest.Server {
t.Helper()
handler := func(w http.ResponseWriter, r *http.Request) {
time.Sleep(latency)
}
s := httptest.NewServer(http.HandlerFunc(handler))
t.Cleanup(s.Close)
return s
}
// n copies of the same URL. Provided for you.
func urls(base string, n int) []string {
out := make([]string, n)
for i := range out {
out[i] = base
}
return out
}
// The waits overlap, so N requests should cost about as much
// as one — and this holds on a single core, because nothing
// here is CPU-bound.
func TestConcurrentOverlapsWaiting(t *testing.T) {
s := slowServer(t)
u := urls(s.URL, urlCount)
start := time.Now()
FetchConcurrent(u)
elapsed := time.Since(start)
// Sequential would be ~500ms. Anything under 150ms means the
// waits are genuinely overlapping, not merely faster.
budget := latency * 3
if elapsed > budget {
t.Fatalf("FetchConcurrent took %v for %d URLs of %v "+
"latency; want < %v.\nSequential would be ~%v. "+
"The waits are not overlapping yet.",
elapsed.Round(time.Millisecond), urlCount, latency,
budget, latency*urlCount)
}
}
Run it. It fails:
Fix FetchConcurrent. Then — and this is the part
that matters — run it again with parallelism switched off
entirely:
One core. One goroutine executing at any instant. The test only demands 3× — a deliberately loose gate so it isn't flaky on a busy machine — but watch the actual number it prints. It lands near 10×, because ten 50 ms waits collapse into roughly one. That is the whole argument of section 1.3, and you have now measured it rather than read it.
GOMAXPROCS and at GOMAXPROCS=1,
with -race clean. If it only passes on multiple cores,
you have accidentally written something CPU-bound.
checksumParallel from section 1.3 at
GOMAXPROCS=1 and at your core count. It behaves the
opposite way: no speedup without parallelism, then near-linear gains
until Amdahl's law bites. Two workloads, two entirely different
answers to “will concurrency help?”
Self-Check Questions
At most 4 goroutines execute in parallel at any moment (limited by GOMAXPROCS=4). The other ~9,996 goroutines still exist but are parked—waiting on network I/O. This is the key insight: for I/O-bound servers, concurrency (structure) matters far more than parallelism (cores), because most goroutines spend their time waiting, not computing.
Slower. CPU-bound work without parallelism gains nothing from concurrency—goroutine context switches add overhead without overlapping useful work. There's no waiting time to exploit; the CPU is already fully utilized.
Change GOMAXPROCS when:
-
Under CPU shares with no quota — Kubernetes
requestswithoutlimits— because there is no cgroup cap for the runtime to detect -
On Go 1.24 or earlier, where the default is
NumCPU()and ignores cgroup quotas - On shared systems where you want to reserve cores for other processes
- Benchmarking to isolate the effect of parallelism
Note what is not on that list any more: a container with a CPU limit. Since Go 1.25 the runtime reads the cgroup quota itself and re-reads it if it changes, so the right answer there is to leave the default alone.
And for race detection, always use
go test -race rather than adjusting GOMAXPROCS.
No. Ownership transfer is a convention, not a mechanism Go enforces. The channel sends a reference to the underlying array, not a copy. If the sender modifies the slice after sending, both goroutines access the same memory—that's a data race. After sending, treat the data as belonging to the receiver.
Concurrency only. With one core there is no parallelism—only one goroutine executes at a time. But the program is highly concurrent: while one goroutine waits on a network response, others run. This is I/O-bound work, so concurrency alone delivers nearly all the speedup.
CPU-bound. Image resizing is pure computation. Adding goroutines helps only if you also have multiple cores (parallelism). On a 4-core machine with GOMAXPROCS=4, ~4 goroutines can resize images simultaneously. Adding 100 goroutines won't be faster than 4—it just adds scheduling overhead.
Key Takeaways
- Concurrency is structure; parallelism is execution—design concurrent programs that adapt to any hardware
- Share memory by communicating—prefer channels for transferring data; use mutexes for protecting state
- Know your bottleneck—I/O-bound benefits from concurrency alone; CPU-bound needs parallelism
- Goroutines are cheap, not free—~2 KB each enables hundreds of thousands, but they're not zero-cost
- Trust the defaults—on Go 1.25+ GOMAXPROCS already accounts for CPU affinity and cgroup quotas, and updates itself; adjust only for CPU shares without a quota, older toolchains, or benchmarking
Further reading
- Rob Pike, Concurrency Is Not Parallelism (2012) — the talk the distinction in section 1.1 comes from. Thirty minutes, and still the clearest statement of it.
- The Go Memory Model — what Go does and does not guarantee for racy programs. Chapter 8 works through it properly; the “Implementation Restrictions for Programs Containing Data Races” section is what section 1.2's callout is based on.
- C. A. R. Hoare, Communicating Sequential Processes, CACM 21(8), 1978 — the paper Go's channels descend from.
- Effective Go — Concurrency and the Go Proverbs.
-
Go 1.25 release notes
— container-aware
GOMAXPROCSandsync.WaitGroup.Go, both used in this chapter. The authoritative current statement isgo doc runtime.GOMAXPROCSon your own toolchain. -
Fixing For Loops in Go 1.22
— why
FetchConcurrentcan captureurldirectly instead of passing it as a parameter.
You now know what concurrency is, why Go structures it the way it
does, and when it will actually pay. That was the reasoning.
Chapter 2 spends it: starting goroutines with
go, waiting for them properly with
sync.WaitGroup and wg.Go—and the first
way to leak one and never notice.