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.

What you'll learn
  • 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
Building toward

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.

Prerequisites

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

— Rob Pike, Concurrency Is Not Parallelism (2012)

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:

Sequential Execution — Neither concurrent nor parallel

One processor runs Task A to completion, then starts Task B. Nothing overlaps.

Concurrent Execution, Single Processor — Concurrent but not parallel

One processor alternates between Task A and Task B in short slices. Both make progress; neither runs at the same instant as the other.

Concurrent Execution, Multiple Processors — Concurrent and parallel

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.

Key Insight

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.

Concurrency Enables Parallelism

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:

Concurrency × Parallelism in Go
Row
Not Parallel
Parallel

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.

Sequential Coffee Shop

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.

Concurrent Coffee Shop (Single Worker)

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.

Parallel but Poorly Structured

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.

Concurrent and Parallel (The Goal)

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.

Too Many Workers, One 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:

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:

Common Misconceptions

Misconception

"Concurrency means faster"

Reality

Only with parallelism or I/O overlap. On CPU-bound work with one core, it adds overhead.

Misconception

"Goroutines = parallel execution"

Reality

Goroutines provide concurrent structure. They may execute in parallel.

Misconception

"Parallelism is always better"

Reality

Parallelism has costs: synchronization, complexity, cache effects. Sometimes sequential wins.

Section Summary

Concurrency vs Parallelism Summary
Row
Definition
Property of
Controlled by
Single-core
Primary benefit
Section 1.1 — in one line

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.

Shared Memory with Locks

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:

Approach 2: Message Passing

Independent processes have private state. They coordinate by sending messages through channels.

Message Passing

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.

CSP Unpacked
Sequential
Each process is straightforward sequential code
Processes
Independent units of execution with private state
Communicating
Processes coordinate by sending and receiving messages
Channels
The conduits through which messages flow

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.

CSP Model

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:

CSP to Go Mapping
Sequential Process
Goroutine
Channel
chan T
Send / Receive
ch <- value and value := <-ch
Guarded Choice
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

bank_unsafe.go
// 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
}
What a Data Race Actually Costs You

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

bank_mutex.go
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

Why Show This?

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.

bank_channel.go
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 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.

We removed the data race, not the race condition

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.

Ownership Transfer

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.

Ownership Transfer Is Convention, Not Enforcement

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:

Pipeline Data Flow

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

A Mental Model Shift
Row
Data protection
Access mechanism
Reasoning about others
Operation pattern

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:

Go's Concurrency Toolkit

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.

Decision Rule: Channels vs Mutexes

Ask: "Are goroutines communicating (passing data between them) or protecting state (guarding internal fields of a struct)?"

  • Communicating → Channels
  • Protecting → Mutexes

Common Misconceptions

Misconception

"Go forbids shared memory"

Reality

Go provides mutexes and atomics. The philosophy is guidance, not restriction.

Misconception

"Channels are always better"

Reality

Channels excel at coordination; mutexes often simplify protecting internal state.

Misconception

"CSP eliminates all concurrency bugs"

Reality

CSP prevents data races on properly transferred data, but deadlocks, goroutine leaks, and race conditions in application logic remain possible.

Misconception

"Go invented CSP"

Reality

CSP predates Go by 30+ years. Go made CSP-style channels accessible as a built-in language feature.

Misconception

"Sending data through a channel always deep-copies it"

Reality

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.

Section 1.2 — in one line

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:

The Two Workload Types
Row
I/O-bound
CPU-bound

I/O-Bound Workloads

An I/O-bound program spends most of its time waiting for input/output operations.

I/O-Bound Workload

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.

I/O-Bound: Sequential vs Concurrent

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.
Key Insight

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.

CPU-Bound Workload

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.

CPU-Bound: Single Core vs Multi-Core

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:

Amdahl's Law

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

fetch.go
// 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

checksum.go
// 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

Concurrency and Parallelism Benefits
Row
I/O-Bound (waiting)
CPU-Bound (computing)

Identifying Your Workload

The Diagnostic Question

"If I had infinitely fast I/O, would my program be dramatically faster?"

Symptoms

Workload Symptoms
Row
CPU usage
Profile shows
Adding concurrency (1 core)
Illustrative Typical ranges, not thresholds. Read your own numbers from top or a CPU profile.

Decision Framework

Should I Add Concurrency?

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

Misconception

"Concurrency always speeds things up"

Reality

Only for I/O-bound work. For CPU-bound work on a single core, concurrency adds overhead and makes things slower.

Misconception

"CPU-bound work can't benefit from goroutines"

Reality

It can—with multiple cores. Set GOMAXPROCS > 1 (the default) and split work across goroutines for true parallelism.

Misconception

"I should profile after adding concurrency"

Reality

Profile before. Identify the bottleneck first, then choose the right strategy. Adding concurrency to a CPU-bound hot loop wastes effort.

Practical Guidelines

Practical Guidelines
Row
I/O-bound
CPU-bound
Mixed (HTTP+resize)
Section 1.3 — in one line

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

The Cost of OS Threads
Row
Stack memory
Creation time
Context switch
Kernel resources
Illustrative Order-of-magnitude figures for typical Linux on x86-64. Stack sizes are reserved address space; exact values vary by platform and ulimit.

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.

M:N Scheduling

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

Stack Comparison
Row
Initial stack
Growth
10,000 units
Illustrative Goroutine initial stack is 2 KB — a runtime constant. Thread stack is the platform default, commonly 8 MB on Linux.
Key Insight

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.

Memory Footprint Comparison
Row
100
1,000
10,000
100,000
Illustrative Arithmetic from the stack sizes above, at a 1 MB thread stack. Reserved address space, not resident memory.

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.

Scheduling Overhead Comparison
Row
Creation time
Create 10,000
Context switch
Illustrative Order-of-magnitude figures. Measure your own with go test -bench on the hardware you deploy to.

How Blocking Works

A critical feature: when a goroutine blocks on most I/O operations, it doesn't block the OS thread.

Go: Blocking Doesn't Waste Threads

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:

client.go
resp, err := http.Get(url) // Looks blocking, doesn't waste threads
When Goroutines DO Block OS 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.File operations 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!)

server.go
// 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

Misconception

"Goroutines are OS threads"

Reality

Goroutines are user-space; many multiplex onto few OS threads

Misconception

"One goroutine = one core"

Reality

Unrelated. You can have 10,000 goroutines on 1 core.

Misconception

"Blocking a goroutine blocks a thread"

Reality

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.

Misconception

"Goroutines are completely free"

Reality

They cost ~2 KB+ memory and scheduling overhead. Cheap, not free.

Misconception

"I can get a goroutine's ID"

Reality

Goroutines are anonymous. Use context.Context for request-scoped data.

Section 1.4 — in one line

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.

GOMAXPROCS In Action

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:

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:

GOMAXPROCS is the minimum of those — with two rounding rules that matter more than they look:

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.

main.go
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

I/O-Bound: GOMAXPROCS Impact

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

CPU-Bound: GOMAXPROCS Impact

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:

Containers and GOMAXPROCS

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:

When you do need to set it in code — and to hand control back afterwards:

tuning.go
// 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:

Terminal
$ go test -race ./...
GOMAXPROCS=1 Can Hide Bugs

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:

Terminal
$ GOMAXPROCS=6 ./myapp

Common Misconceptions

Misconception

"Higher GOMAXPROCS = faster program"

Reality

Only helps CPU-bound work, only up to core count

Misconception

"GOMAXPROCS limits goroutine count"

Reality

No. Millions of goroutines with GOMAXPROCS=1 is fine

Misconception

"GOMAXPROCS = total OS threads"

Reality

No. Runtime creates additional threads for syscalls

Misconception

"I should always tune GOMAXPROCS"

Reality

No. The runtime-selected default is right for most programs—and since Go 1.25 it already accounts for container limits.

Misconception

"GOMAXPROCS=1 means no concurrency"

Reality

No. Concurrency still exists; parallelism doesn't

Practical Guidelines

Trust the default. Only adjust when:

On Go 1.25 and later, in a container with a CPU limit, the correct action is no action.

Section 1.5 — in one line

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

Assuming concurrency means faster
Problem

Only helps with parallelism or I/O overlap; adds overhead to CPU-bound work on one core

Fix

Profile first. Use concurrency for I/O overlap; add parallelism (more cores) for CPU-bound speedup

Creating too many goroutines for CPU-bound work
Problem

1000 goroutines on 4 cores adds overhead without speedup

Fix

For CPU-bound work, use approximately core count goroutines

Modifying data after sending through a channel
Problem

Ownership transfer is convention, not enforcement—causes data races

Fix

After sending, treat data as belonging to receiver

Using channels when a mutex is simpler
Problem

Overcomplicating simple state protection

Fix

Use channels for communication, mutexes for protecting internal state

Spawning unbounded goroutines
Problem

Launching a goroutine per request without limits can exhaust memory under load

Fix

Use a worker pool or semaphore to cap concurrent goroutines (covered in Chapter 7)

Exercise 1.1 — Prove It On One Core

Your move

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.

ch01/workload.go
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
}
ch01/workload_test.go
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:

Terminal
$ go test -race ./ch01
--- FAIL: TestConcurrentOverlapsWaiting (0.51s)
workload_test.go:50: FetchConcurrent took 508ms for 10 URLs of 50ms latency; want < 150ms.
Sequential would be ~500ms. The waits are not overlapping yet.
FAIL corelabs/ch01 1.271s

Fix FetchConcurrent. Then — and this is the part that matters — run it again with parallelism switched off entirely:

Terminal
$ GOMAXPROCS=1 go test -race -count=1 ./ch01
ok corelabs/ch01 1.444s

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.

Done when: the test passes at the default GOMAXPROCS and at GOMAXPROCS=1, with -race clean. If it only passes on multiple cores, you have accidentally written something CPU-bound.
Going further — the other half of the chapter: benchmark 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

1. How many goroutines execute in parallel with 10,000 connections on 4 cores?

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.

2. Adding goroutines to CPU-intensive work on single-core: faster, slower, or same?

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.

3. When should you change GOMAXPROCS from its default?

Change GOMAXPROCS when:

  • Under CPU shares with no quota — Kubernetes requests without limits — 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.

4. Can you safely modify a slice after sending it through a channel?

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.

5. A single-core machine runs a web scraper with 50 goroutines fetching URLs. Is this concurrency, parallelism, or both?

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.

6. You have a program that resizes images. Is it I/O-bound or CPU-bound, and how does adding goroutines help?

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

  1. Concurrency is structure; parallelism is execution—design concurrent programs that adapt to any hardware
  2. Share memory by communicating—prefer channels for transferring data; use mutexes for protecting state
  3. Know your bottleneck—I/O-bound benefits from concurrency alone; CPU-bound needs parallelism
  4. Goroutines are cheap, not free—~2 KB each enables hundreds of thousands, but they're not zero-cost
  5. 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

Next

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.