Chapter 2: Goroutines

Chapter 1 established the conceptual foundations—what concurrency is, how Go approaches it with CSP, when it helps, and why goroutines are lightweight. Now we write concurrent code.

What you'll learn
  • How to create goroutines—and avoid the loop variable capture bug
  • Why program termination kills all goroutines—instantly
  • How to coordinate completion—with WaitGroups
  • How to prevent goroutine leaks—the most insidious concurrency bug
  • When to spawn freely vs limit concurrency
Building toward

Goroutines that communicate only through side effects are limited. Channels (Chapter 3) give goroutines the ability to coordinate, share results, and signal errors. Master goroutine lifecycle first; communication comes next.

Prerequisites

You should understand the concepts from Chapter 1—particularly the distinction between concurrency and parallelism, and why goroutines are lightweight compared to OS threads.


2.1 Creating Goroutines with go

The go keyword is deceptively simple: prefix any function call with go and it executes concurrently. But this simplicity hides important semantics that determine whether your program works correctly.

About time.Sleep in Examples

This section uses time.Sleep to demonstrate goroutine behavior. This is not production code. Sleeping to "wait for goroutines" is fragile—you're guessing at timing rather than coordinating explicitly.

Production code uses proper coordination: sync.WaitGroup (Section 2.3), channels (Chapter 3), or context.Context (Chapter 13). We use time.Sleep here only to keep examples focused on goroutine creation mechanics.

The Simplest Goroutine

simple_goroutine.go
package main

import (
    "fmt"
    "time"
)

func sayHello() {
    fmt.Println("Hello from goroutine")
}

func main() {
    go sayHello()  // Launch goroutine

    time.Sleep(100 * time.Millisecond) // Not for production—see §2.3
    fmt.Println("Main function ending")
}
Output
Hello from goroutine
Main function ending

That's it. The go keyword transforms a synchronous function call into concurrent execution.

What Happens When You Write go f()

Understanding the execution model prevents subtle bugs:

Sequential vs Concurrent Execution

Two timelines. Sequential: main calls f(), waits for it to finish, then continues. Concurrent: main issues `go f()` and continues immediately while the goroutine runs f() alongside it.

When you write go f(args), the runtime:

  1. Evaluates arguments (if any) in the current goroutine
  2. Creates a new goroutine with ~2KB initial stack
  3. Schedules the goroutine for execution (hands it to the runtime scheduler)
  4. Returns immediately to the caller—the new goroutine runs independently
Critical Insight

The go statement is non-blocking. It queues work for the scheduler and continues. The new goroutine may start immediately, later, or interleave with the caller—you cannot assume any ordering without explicit synchronization.

What go Does NOT Do

What go Does NOT Provide
  • Does NOT wait for the goroutine to start
  • Does NOT guarantee execution order with other goroutines
  • Does NOT provide a handle or ID to the goroutine
  • Does NOT establish parent-child relationship
  • Does NOT automatically propagate panics to the caller
  • Does NOT provide a way to cancel or stop the goroutine

All coordination is explicit—nothing is automatic.

This list of "does NOT" is precisely why you need a systematic approach to creating goroutines. Every one of these gaps must be addressed by your code.

The Four Questions: A Framework for Every Goroutine

Before writing go, stop and answer four questions. If you can't answer all four clearly, don't create the goroutine yet—redesign until you can.

The Four Questions
  1. How does this goroutine exit? Every goroutine must have a clear termination condition. What causes it to return? A completed task? A signal? A closed channel? A context cancellation?
  2. How does it communicate results? If the goroutine produces output, how does that output reach whoever needs it? Through a channel? A shared variable with synchronization? Written to a database?
  3. How are errors handled? If something goes wrong inside the goroutine, how does the rest of the program find out? Errors don't propagate automatically—you must design error flow explicitly.
  4. What data does it access? What variables does the goroutine read or write? Are any shared with other goroutines? If so, how is access coordinated to prevent data races?

Why These Questions Matter

Each question addresses a specific class of concurrency bug:

The Four Questions Framework
How does it exit?
Goroutine leaks (§2.4)
How does it communicate?
Lost results, deadlocks (Ch 3)
How are errors handled?
Silent failures, crashes (Ch 14)
What data does it access?
Data races (Ch 8)

Applying the Framework

Let's evaluate the simple example from earlier:

example.go
go sayHello()
Four Questions — sayHello
Exit?
Returns after fmt.Println completes
Communicate?
No results needed — side effect only (prints)
Errors?
fmt.Println can't fail here
Data?
No shared data — no parameters or captured variables

This goroutine is simple enough that the answers are trivial. That's fine—the questions scale to complexity. For complex goroutines, these answers require careful design.

A More Complex Example

Consider this goroutine that fetches a URL:

fetch_bad.go
// Illustrative snippet — not a complete program
go func() {
    resp, err := http.Get(url)
    if err != nil {
        log.Println(err)  // Question 3: Error handling
        return
    }
    defer resp.Body.Close()
    // Process response...
}()
Four Questions — fetchURL
Exit?
Returns after processing or error ⚠ Could hang (no timeout)
Communicate?
??? ✗ Results are lost!
Errors?
Logged but not propagated ⚠ Caller doesn't know
Data?
Captures url from enclosing scope ⚠ Is url modified elsewhere?

This analysis reveals problems. A better version:

fetch_good.go
// Illustrative snippet — Response and parseResponse
// are application-defined types/functions.
func fetchURL(
    url string,
    results chan<- Response,
    errs chan<- error,
) {
    resp, err := http.Get(url)
    if err != nil {
        errs <- err  // Q3: Errors go somewhere useful
        return
    }
    defer resp.Body.Close()

    // Q2: Results communicated via channel
    results <- parseResponse(resp)
}

// Q1: Exits after sending to results or errs
// Q4: url passed as parameter (no shared data)
go fetchURL(url, results, errs)

Now all four questions have clear answers. Note that if no goroutine is reading from results or errs, fetchURL will block indefinitely—the goroutine leak scenario covered in Section 2.4. In production, combine channel sends with context cancellation (Chapter 13).

When to Apply the Framework

Always. Even for simple goroutines, run through the questions mentally. For complex goroutines, write the answers down. The few seconds this takes prevents hours of debugging goroutine leaks, lost results, and data races.

This Framework Appears Throughout the Book

We'll reference the Four Questions repeatedly:

  • Section 2.2: Why exit matters (program termination)
  • Section 2.4: What happens when exit isn't answered (leaks)
  • Chapter 3: Communication patterns (channels)
  • Chapter 8: Data access coordination (races)
  • Chapter 14: Error handling in concurrent code

Internalize these questions now. They're your primary tool for reasoning about concurrent code.

Goroutine Cost Reference

Goroutine Creation Cost
  • Creation time: ~150 ns to issue, ~470 ns to create and finish (§2.5)
  • Initial stack: ~2 KB (grows dynamically as needed)
  • Context switch: ~200 nanoseconds typical (highly variable)

These figures are order-of-magnitude estimates referenced throughout this chapter and book. Actual values vary by hardware, Go version, and workload. For precise measurements, profile your specific use case (Chapter 19).

Forms of Goroutine Creation

Named Function

The simplest form—call an existing function:

named_function.go
package main

import (
    "fmt"
    "time"
)

func greet(name string) {
    fmt.Println("Hello,", name)
}

func main() {
    go greet("Alice")
    go greet("Bob")
    time.Sleep(10 * time.Millisecond) // Not for production—see §2.3
}

Four Questions check: Each goroutine exits after printing, communicates via side effect (stdout), has no error cases, and accesses only its parameter (no shared data).

Anonymous Function

Define and invoke a function inline:

anonymous_function.go
package main

import (
    "fmt"
    "time"
)

func main() {
    go func() {
        fmt.Println("Hello from anonymous goroutine")
    }() // Don't forget the () to invoke it

    time.Sleep(10 * time.Millisecond)
}

The trailing () is essential—it calls the function. Without it, you're not making a function call:

invoke_syntax.go
go func() {
    fmt.Println("This works")
}()  // ✓ Function call

go func() {
    fmt.Println("This won't compile")
}    // ✗ Syntax error: expression, not call

Anonymous Function with Arguments

Pass values explicitly to the anonymous function:

anonymous_with_args.go
package main

import (
    "fmt"
    "time"
)

func main() {
    name := "Charlie"

    go func(n string) {
        fmt.Println("Hello,", n)
    }(name) // Pass current value as argument

    time.Sleep(10 * time.Millisecond)
}

This pattern prevents the loop variable capture bug—one of the most common goroutine mistakes. We explore this critical issue next.

Method Call

Methods work identically to functions:

method_call.go
package main

import (
    "fmt"
    "time"
)

type Server struct {
    name string
}

func (s *Server) HandleRequest(id int) {
    fmt.Printf("%s handling request %d\n", s.name, id)
}

func main() {
    server := &Server{name: "API"}
    go server.HandleRequest(1)
    time.Sleep(10 * time.Millisecond)
}

The receiver (server) is evaluated at the go statement, just like any other argument.

Function Value

Any expression that evaluates to a function can be used:

function_value.go
package main

import (
    "fmt"
    "time"
)

func main() {
    greetFunc := func(name string) {
        fmt.Println("Hi,", name)
    }

    go greetFunc("Eve")
    time.Sleep(10 * time.Millisecond)
}
The Common Pattern

Regardless of form, go is always followed by a function call. Arguments and receivers are evaluated immediately at the go statement, then execution proceeds asynchronously.

The Loop Variable Capture Bug

Historically, this was the #1 goroutine mistake. Go 1.22 fixed it at the language level, but understanding closure capture remains essential—especially if you maintain pre-1.22 code or work with closures beyond loops.

The Bug

loop_capture_bug.go
// ✗ WRONG — but only if go.mod says `go 1.21` or lower.
// On go 1.22+ this prints all three URLs correctly.
urls := []string{
    "https://api.example.com/users",
    "https://api.example.com/orders",
    "https://api.example.com/products",
}

for _, url := range urls {
    go func() {
        fmt.Println(url)  // BUG: All goroutines see the same variable
    }()
}
time.Sleep(100 * time.Millisecond)
Likely Output — on Go 1.21 and earlier
https://api.example.com/products
https://api.example.com/products
https://api.example.com/products

All three goroutines print the last URL, not their respective URLs.

Why It Happens

Loop Variable Capture

Pre-Go-1.22 scoping. One `url` variable is reused across all three iterations, so all three closures capture the same variable and see whichever value the loop finished with.

The goroutines don't capture the value of url—they capture the variable itself. The closure and the loop body share the same url variable. By the time the goroutines execute, the loop has finished and url holds the last value.

Four Questions perspective: Question 4 asks "What data does it access?" The answer here reveals the bug—the goroutine accesses a shared, mutating variable (url).

Clarification: Closures vs Function Calls

The loop variable capture bug only occurs with closures—anonymous functions that reference variables from their enclosing scope without passing them as arguments.

closure_vs_call.go
// Captures url from enclosing scope (shared variable)
go func() { fmt.Println(url) }()

// Does not capture url: passed as argument instead
go func(u string) { fmt.Println(u) }(url)

// NOT A CLOSURE: named function call with argument
go fetch(url)

When you call a named function like go fetch(url), the value is passed as a normal argument—evaluated at the go statement—so there's no capture issue.

The Pre-1.22 Fix: Pass as Parameter

loop_capture_fix.go
// Pre-1.22 idiom. Still correct today, just unnecessary.
for _, url := range urls {
    go func(u string) {
        fmt.Println(u)  // Each goroutine has independent copy
    }(url)  // Pass current value as argument
}

When you pass url as an argument, the current value is copied into the parameter u. Each goroutine receives its own independent copy.

The Other Pre-1.22 Fix: Shadow the Variable

loop_capture_shadow.go
// Pre-1.22 idiom. On go 1.22+ `url := url` is a no-op
// that reviewers will flag.
for _, url := range urls {
    url := url  // Shadow creates new variable
    go func() {
        fmt.Println(url)  // Safe—refers to the shadowed copy
    }()
}

The url := url line creates a new variable in each iteration, giving each closure its own copy.

Go 1.22 Language Change

Starting in Go 1.22 (February 2024), loop variables declared by for loops are created per iteration rather than per loop. This eliminates the capture bug for modules that declare go 1.22 or later in their go.mod.

Important: the new semantics are controlled by the go directive in go.mod, not the toolchain version. Compiling with Go 1.22+ but targeting go 1.21 in go.mod still uses the old per-loop scoping.

In Go 1.22+ codebases, using the loop variable directly in a closure is correct. Passing as parameter is still a valid and clear alternative—especially if your codebase supports pre-1.22 versions or your team prefers explicit data flow at the function signature.

The parameter form does still document data flow at the signature, which is occasionally worth the extra characters:

documented_dataflow.go
go func(u string) { /* uses u */ }(url)
//      ^                          ^
//      |                          |
// parameter here           value passed here
Guideline

On Go 1.22+, capture the loop variable directlywg.Go(func() { fetch(url) }). It is correct, it is what §2.3 and Chapter 1 use, and the parameter form adds noise without adding safety. Reach for an explicit parameter in two cases: you are snapshotting a value that keeps changing within one iteration, or your module still declares go 1.21. Either way, Question 4 ("What data does it access?") is the one to keep asking — the answer is just easier to give now that the language answers it for you.

What go Requires

The go keyword must be followed by a function call—not a statement, expression, or function value alone:

go_requires.go
// ✓ Valid—function calls
go fmt.Println("hello")
go doWork(42)
go func() { /* ... */ }()
go obj.Method()

// ✗ Invalid—not function calls
go x + y           // expression, not a call
go if x { f() }    // statement, not a call
go myFunc          // function value, not a call (missing parentheses)

Arguments Are Evaluated Immediately

When you write go f(x, y), the arguments x and y are evaluated at the go statement, not when the goroutine runs:

argument_evaluation.go
package main

import (
    "fmt"
    "time"
)

func printValue(n int) {
    fmt.Println(n)
}

func main() {
    x := 1
    go printValue(x) // x evaluated NOW (value: 1)
    x = 2            // Too late for the goroutine

    time.Sleep(10 * time.Millisecond)
}
// Output: 1
Argument Evaluation Timing

Arguments to a `go` statement are evaluated in the calling goroutine, at the moment the statement runs — not later when the goroutine is scheduled.

This applies to all parts of the call expression:

evaluation_order.go
go getReceiver().method(computeArg())
//  ↑                    ↑
//  Both called in the launching goroutine,
//  before the new goroutine starts.

This is fundamental Go semantics: function arguments are always evaluated at the call site. The go keyword doesn't change this.

Value Types vs Reference Types

The example above uses int—a value type. The goroutine gets an independent copy. But if the argument is a pointer, slice, or map, the goroutine receives a copy of the pointer—both goroutines then share the same underlying data. Mutating that shared data without synchronization is a data race (Chapter 8).

Goroutines Cannot Return Values

A function called with go cannot return values to the caller:

no_return.go
func compute() int {
    return 42
}

func main() {
    result := go compute() // ✗ Syntax error
    go compute()         // ✓ Compiles—return value silently discarded
}

Why? The go statement returns immediately—before the goroutine runs. There's no one waiting to receive the result. Return values in a go call are silently discarded—the compiler does not warn.

This is why Question 2 ("How does it communicate results?") is essential. To get results from a goroutine, you must use explicit communication. A brief preview using channels (covered in Chapter 3):

channel_result.go
package main

import "fmt"

func compute(result chan<- int) {
    result <- 42
}

func main() {
    ch := make(chan int)
    go compute(ch)

    value := <-ch  // Receive result
    fmt.Println(value) // 42
}

We'll cover channels thoroughly in Chapter 3. For now, understand that goroutines communicate through explicit mechanisms, not return values.

Goroutine Characteristics

Key Goroutine Properties

Goroutines are anonymous. Unlike threads in some languages, goroutines have no exposed identity, ID, or name. There's no getGoroutineID() function. If you need to identify work units, pass an identifier explicitly via function parameters or context values (Chapter 13).

Goroutine lifecycle is implicit. A goroutine exits when its function returns. Unlike OS threads, there's no explicit "join" or "terminate" operation. You cannot force a goroutine to stop from outside—it must cooperate by checking for cancellation signals.

No goroutine-local storage. Go doesn't provide thread-local storage. Use context.Context (Chapter 13) to pass request-scoped values through your call chain.

A goroutine that never exits becomes a "goroutine leak"—we cover detection and prevention in Section 2.4.

Execution Is Non-Deterministic

The go statement provides no guarantees about when the goroutine will execute relative to other code:

nondeterministic.go
package main

import (
    "fmt"
    "time"
)

func main() {
    go fmt.Println("First")
    go fmt.Println("Second")
    go fmt.Println("Third")

    time.Sleep(10 * time.Millisecond)
}
One Possible Output (your order will differ)
Second
First
Third

Or you might see them in a different order—or see nothing at all if main exits before any goroutine runs.

The Go scheduler decides when each goroutine runs based on available processors, current goroutine states, and runtime heuristics. You control that goroutines exist, not when they execute.

Never assume execution order between goroutines without explicit synchronization.

When Goroutines Actually Start

The go statement schedules the goroutine immediately but executes it when the runtime decides:

start_timing.go
func main() {
    go fmt.Println("goroutine")
    fmt.Println("main")
}

Three possible outcomes:

Three Possible Execution Timelines

The same program produces different interleavings on different runs: the goroutine may finish before main continues, after, or partway through. No ordering is guaranteed without synchronization.

Yes, the third outcome is possible—main might exit before the goroutine runs. We explore this critical behavior in Section 2.2.

Panics Crash the Entire Program

A panic in any goroutine terminates the whole program:

panic_crash.go
package main

import (
    "fmt"
    "time"
)

func main() {
    go func() {
        panic("goroutine panic")
    }()

    time.Sleep(10 * time.Millisecond)
    fmt.Println("This never prints")
}

Unlike some languages where thread failures can be isolated, Go treats a goroutine panic as fatal unless explicitly recovered within that same goroutine. Recovery must happen in the same call stack—the launching goroutine cannot catch it:

recover_wrong.go
package main

import (
    "fmt"
    "time"
)

func main() {
    // ✗ recover in main does not cross goroutine boundaries
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Never reached")
        }
    }()

    go func() {
        panic("crashes the program")
    }()

    time.Sleep(10 * time.Millisecond)
}

To handle panics within a goroutine, recovery must be inside that goroutine:

recover_correct.go
package main

import (
    "fmt"
    "time"
)

func main() {
    go func() {
        // ✓ recover is inside the panicking goroutine
        defer func() {
            if r := recover(); r != nil {
                fmt.Println("Recovered:", r)
            }
        }()
        panic("handled")
    }()

    time.Sleep(10 * time.Millisecond)
    fmt.Println("Program continues")
}
Output
Recovered: handled
Program continues

Four Questions perspective: Question 3 asks "How are errors handled?" Panics are the extreme case—if you don't recover within the goroutine, the entire program crashes. This is why error handling in goroutines requires explicit design.

Whether to recover depends on whether the goroutine's failure should affect the rest of the program—Chapter 14 covers this decision in depth. For now, understand that goroutine failures are not isolated—unhandled panics bring down the entire program.

A Complete Example

Putting it together—concurrent URL fetching:

concurrent_fetch.go
package main

import (
    "fmt"
    "time"
)

// Note: Error handling simplified for clarity. Production code needs
// proper error handling—see Chapter 14.
func fetch(url string) {
    fmt.Printf("Fetching %s\n", url)
    time.Sleep(100 * time.Millisecond)  // Simulate network delay
    fmt.Printf("Done: %s\n", url)
}

func main() {
    urls := []string{
        "https://api.example.com/users",
        "https://api.example.com/orders",
        "https://api.example.com/products",
    }

    for _, url := range urls {
        go fetch(url)  // No closure—url is copied
    }

    // Not for production—see §2.3
    time.Sleep(200 * time.Millisecond)
    fmt.Println("All fetches complete")
}

Four Questions analysis:

Four Questions — Concurrent Fetch
Exit?
Each goroutine returns after simulated fetch completes
Communicate?
Side effect only (prints to stdout) — no results returned
Errors?
None handled (simplified example)
Data?
url passed as parameter — each goroutine has own copy
Possible Output (order varies)
Fetching https://api.example.com/products
Fetching https://api.example.com/users
Fetching https://api.example.com/orders
Done: https://api.example.com/users
Done: https://api.example.com/products
Done: https://api.example.com/orders
All fetches complete

Notice the non-deterministic ordering—goroutines execute concurrently with no guaranteed sequence.

Key Mental Model

When you write go f(args):

go f(x, y) Mental Model

The runtime evaluates the arguments, creates a goroutine with a small stack, hands it to the scheduler, and returns to the caller immediately.

That last point—"maybe never if main exits"—is critical enough that Section 2.2 is devoted entirely to understanding program termination and goroutine lifecycle.

Common Mistakes

Forgetting () on anonymous function
Problem

Doesn't compile

Fix

go func() { }() not go func() { }

Loop variable capture (pre-Go 1.22)
Problem

All goroutines see same value

Fix

Pass as parameter: go func(u string) { }(url)

Expecting return values
Problem

go can't return

Fix

Use channels or shared state with synchronization

Assuming execution order
Problem

Non-deterministic

Fix

Use synchronization primitives

Using time.Sleep for coordination
Problem

Fragile, wasteful

Fix

Use WaitGroup or channels

Not coordinating completion
Problem

main may exit early

Fix

Use proper synchronization (Section 2.3)

Ignoring panics
Problem

Crashes entire program

Fix

Recover within goroutine or let it crash intentionally

Not asking the Four Questions
Problem

Leaks, races, lost results

Fix

Always analyze before writing go

Section Summary

Go Statement Behavior
Syntax
go functionCall()
Argument eval
Immediate (at go statement)
Return behavior
go returns immediately; goroutine runs independently
Return values
None — use channels or shared state
Execution order
Non-deterministic without sync
Start timing
Scheduled immediately, executes when runtime decides
Panic behavior
Crashes entire program unless recovered in same goroutine
Identity
Anonymous — no ID, no handle, no parent-child relationship
Coordination
Explicit — nothing automatic

The Four Questions: Quick Reference

Before every go statement:

The Four Questions — Quick Reference
1. How does it exit?
Goroutine leaks
2. How does it communicate?
Lost data, deadlocks
3. How are errors handled?
Silent failures, crashes
4. What data does it access?
Data races

If you can't answer all four clearly, stop and redesign before writing the go statement.

Key Takeaways

  1. go launches and returns—no automatic waiting
  2. Arguments evaluate immediately at the go statement
  3. No return values—use channels for results
  4. Execution order is non-deterministic—never assume ordering
  5. Loop variable capture is the #1 bug—pass values as parameters (or use Go 1.22+)
  6. Panics are not isolated—unhandled panics crash the program
  7. Ask the Four Questions—before every go statement, every time

Next: Section 2.2 examines what happens when main returns—and why proper coordination is fundamental to correct concurrent programs.

Section 2.1 — in one line

The go keyword is one word and four questions: when does this goroutine exit, who waits for it, what happens if it fails, and what data does it touch? Arguments are evaluated at the go statement; nothing else about the ordering is promised. And since Go 1.22 the loop variable is no longer one of the things that can bite you.


2.2 The Main Goroutine and Program Termination

Section 2.1 showed how to create goroutines. Every example ended with time.Sleep—a placeholder we acknowledged was wrong. Now we explore why.

The Problem

Consider this program:

termination_problem.go
package main

import "fmt"

func main() {
    go fmt.Println("Hello from goroutine")
    fmt.Println("Main function ending")
}

What's the output?

Typical Output
Main function ending

The goroutine almost certainly never printsmain exits before the scheduler gives the goroutine CPU time. Now add a tiny delay:

with_delay.go
package main

import (
    "fmt"
    "time"
)

func main() {
    go fmt.Println("Hello from goroutine")
    fmt.Println("Main function ending")

    time.Sleep(1 * time.Millisecond)
}
Typical Output
Main function ending
Hello from goroutine

Same goroutine, different timing, different result. This isn't a bug—it's fundamental to how Go programs terminate.

The Main Goroutine

When a Go program starts, the runtime creates exactly one goroutine: the main goroutine, which executes the main function in the main package.

Program Startup

The runtime starts, creates the main goroutine, and runs main(). Every other goroutine descends from it.

The main goroutine is special in exactly one way:

Main Goroutine vs Other Goroutines
Row
Creation
Termination
Special status

Program Termination: The Iron Rule

The Iron Rule of Program Termination

When main() returns, the program exits immediately. All other goroutines are terminated—regardless of what they're doing.

No exceptions. No grace period. No cleanup for other goroutines.

workers_killed.go
package main

import (
    "fmt"
    "time"
)

func worker(id int) {
    fmt.Printf("Worker %d: starting\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Worker %d: done\n", id)  // May never execute
}

func main() {
    go worker(1)
    go worker(2)
    go worker(3)

    fmt.Println("Main exiting")
    // Program terminates HERE—workers are killed mid-execution
}
Most Likely Output
Main exiting

The workers most likely never print anything. They were created and scheduled, but main returned before the scheduler ran them.

Visualizing Termination: Three Scenarios

Scenario 1: Goroutines Never Start

never_start.go
package main

import "fmt"

func main() {
    go fmt.Println("Task 1")
    go fmt.Println("Task 2")
    go fmt.Println("Task 3")

    fmt.Println("Main exiting")
}
Scenario 1: Goroutines Never Start

main returns before the scheduler ever runs the new goroutines, so none of their output appears.

The goroutines were scheduled but main exited before the scheduler ran them.

Scenario 2: Goroutines Killed Mid-Execution

killed_mid_execution.go
package main

import (
    "fmt"
    "time"
)

func worker(id int) {
    fmt.Printf("Worker %d: starting\n", id)
    time.Sleep(100 * time.Millisecond)
    fmt.Printf("Worker %d: done\n", id)
}

func main() {
    go worker(1)
    go worker(2)

    time.Sleep(10 * time.Millisecond)
    fmt.Println("Main exiting")
}
Scenario 2: Goroutines Killed Mid-Execution

main returns while goroutines are partway through their work; they are terminated where they stand, with no chance to finish or clean up.

Workers started but were killed mid-sleep. Their "done" messages never print.

Scenario 3: Goroutines Complete (By Luck)

complete_by_luck.go
package main

import (
    "fmt"
    "time"
)

// Uses worker() from the previous example.

func main() {
    go worker(1)
    go worker(2)

    time.Sleep(200 * time.Millisecond)  // Guessing "long enough"
    fmt.Println("Main exiting")
}

This might work—but only because we guessed correctly. This is fragile, not a solution.

Deferred Functions Don't Run

When main exits, goroutines don't get a chance to clean up:

defers_dont_run.go
package main

import (
    "fmt"
    "os"
    "time"
)

func worker() {
    defer fmt.Println("Worker cleanup")  // Never runs!

    f, err := os.Create("important.txt")
    if err != nil {
        return
    }
    defer f.Close()  // Never runs!

    fmt.Println("Worker starting")
    time.Sleep(time.Second)
}

func main() {
    go worker()
    time.Sleep(100 * time.Millisecond)
    fmt.Println("Main exiting")
}
Output
Worker starting
Main exiting

The worker's deferred cleanup never executes.

Production Critical: Abandoned Goroutines Don't Clean Up

When main exits, other goroutines' deferred functions never execute:

  • Buffered writes are never flushed — the kernel closes the fd, but data still sitting in a bufio.Writer is lost
  • Database transactions are not committed or rolled back
  • Connections close abruptly — no graceful shutdown frame, so the peer sees a reset rather than a clean goodbye
  • Temporary files are not deleted
  • In-process locks are irrelevant (the process is gone), but distributed locks and leases are not — they stay held until they expire
  • Metrics, traces and audit records buffered in memory never leave the process

This is why graceful shutdown patterns (Chapter 15) are essential. Never assume cleanup will happen—coordinate explicitly.

When main() Returns

main returning terminates the process immediately. Remaining goroutines are not signalled, not unwound, and their deferred functions never run.

Blocked Goroutines Are Also Terminated

Goroutines blocked on channels, mutexes, or other synchronization primitives are abandoned when main exits. The process terminates, and all goroutine state is discarded by the operating system. The blocking operation never completes—the goroutine simply ceases to exist.

blocked_goroutine.go
package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int)

    go func() {
        fmt.Println("Waiting for value...")
        val := <-ch  // Blocked—killed when main exits
        fmt.Println("Received:", val)
    }()

    time.Sleep(100 * time.Millisecond)
    fmt.Println("Main exiting")
}
Typical Output
Waiting for value...
Main exiting

The goroutine is waiting on the channel when main exits. It doesn't get a chance to "notice" the program is terminating—it's simply deleted mid-operation.

This is NOT the same as:

The goroutine is simply removed from memory mid-operation.

This applies to all blocking operations:

Blocking Operations at Termination
Channel receive (<-ch)
Receive never completes
Channel send (ch <-)
Send never completes
Mutex (mu.Lock())
Lock never acquired
WaitGroup (wg.Wait())
Wait never returns
Select
Selected case never executes

The goroutine doesn't wake up, doesn't error, doesn't know it's being terminated. It ceases to exist.

The Runtime Deadlock Detector

Go's runtime can detect one specific type of deadlock: when all goroutines are blocked and none can make progress.

deadlock_detected.go
package main

func main() {
    ch := make(chan int)

    go func() {
        <-ch  // Blocked waiting for send
    }()

    go func() {
        <-ch  // Also blocked
    }()

    <-ch  // Main blocked too—deadlock
}
Output
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
    /path/to/main.go:14 +0x8c
goroutine 6 [chan receive]:
main.main.func1()
    /path/to/main.go:8 +0x24
created by main.main in goroutine 1
    /path/to/main.go:7 +0x2c
goroutine 7 [chan receive]:
main.main.func2()
    /path/to/main.go:12 +0x24
created by main.main in goroutine 1
    /path/to/main.go:11 +0x5c
exit status 2

The runtime detects that no goroutine can proceed and panics with helpful information showing where each goroutine is stuck.

Reading Goroutine Stack Traces
  • [chan receive] — The state tells you what it's blocked on
  • main.main.func1 — The function where execution stopped (anonymous closures are named funcN; named functions show their name)
  • created by main.main — Where to look for the spawning code

The "created by" line is often the most useful—it tells you where the goroutine originated, which helps trace the bug to its source.

stack_trace.txt
goroutine 6 [chan receive]:    ← ID and state
main.main.func1()              ← function (anonymous)
    /path/to/main.go:8 +0x24  ← file:line
created by main.main           ← spawn site
    /path/to/main.go:7 +0x2c
Deadlock Detector Limitations

The detector only catches complete deadlocks—when ALL goroutines are blocked. It does NOT detect:

  • Partial deadlocks: Some goroutines blocked forever while others run
  • Goroutine leaks: Goroutines blocked but main hasn't exited
  • Livelocks: Goroutines running but making no progress
undetected_leak.go
package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int)

    go func() {
        <-ch  // Blocked—no deadlock detected!
    }()

    // Main is NOT blocked, so no deadlock
    for {
        time.Sleep(time.Second)
        fmt.Println("Still running...")
    }
}

This is a goroutine leak, not a detected deadlock. Main is running, so the program continues. We cover leak detection in Section 2.4.

Why This Design?

This behavior is intentional. The alternatives are worse:

Why Not Wait Automatically?
Wait for all goroutines
Leaked goroutines would hang the program forever
Timeout then kill
How long? Wrong choice wastes time or kills legitimate work
Force cleanup
Cleanup code might block or deadlock

Go's design philosophy: explicit coordination is better than implicit magic. You know which goroutines must complete; the runtime doesn't.

Design Rationale

Go chooses immediate exit over waiting for stragglers, because waiting on an arbitrary goroutine that may never finish would make every program vulnerable to one stuck task.

The Coordination Problem

Go's goroutine model creates a deliberate asymmetry:

The Coordination Asymmetry
Creating goroutines
Built into language (go keyword)
Waiting for goroutines
Must be explicit (no auto joining)

Go doesn't track parent-child relationships between goroutines. There's no implicit "wait for children" behavior.

The Coordination Problem

main has no built-in way to know when its goroutines are done — that gap is what WaitGroups, channels and context fill.

Blocking Keeps the Program Alive

A critical distinction: the program exits when main returns, not when main blocks. A blocked main goroutine keeps the program alive:

blocking_main.go
package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int)

    go func() {
        time.Sleep(100 * time.Millisecond)
        ch <- 42
    }()

    value := <-ch  // Main blocks here, waiting
    fmt.Println(value)
}

Here, main blocks on a channel receive. The program doesn't exit because main hasn't returned—it's waiting. The goroutine completes its work and sends a value, allowing main to continue and eventually return.

Blocking Forever

Sometimes you want main to run indefinitely (servers, daemons):

block_forever.go
package main

import "time"

func serve() {
    // stand-in for an http.Server, a worker pool…
    for { time.Sleep(time.Second) }
}

func main() {
    // select{} only parks main if something else is actually running.
    go serve()

    select {}  // Park main forever, at zero CPU
}

// Careful: with no other runnable goroutine, select{} does not block
// "forever" — the detector from the previous section fires:
//
//   fatal error: all goroutines are asleep - deadlock!
//   goroutine 1 [select (no cases)]:

The empty select blocks indefinitely—main never returns, so the program runs until killed externally. Do not confuse this with for {}, which spins the CPU at 100% without yielding:

Blocking Forever: select{} vs for{}

An empty for loop spins and burns a core. An empty select parks the goroutine at zero CPU — but only if some other goroutine is still runnable, otherwise the deadlock detector fires.

For production services, block until an OS signal requests termination:

signal_handling.go
package main

import (
    "fmt"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    // Start server goroutines...
    go runServer()

    // Set up signal handling
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
    // os.Interrupt is portable (works on Windows)
    // syscall.SIGTERM is Unix-only but common for containers

    fmt.Println("Server running. Press Ctrl+C to stop.")
    sig := <-sigCh  // Block until signal received
    fmt.Println("Received signal:", sig)

    // Graceful shutdown (covered in Chapter 15)
    fmt.Println("Shutting down...")
}

func runServer() {
    // Server implementation...
    select {}  // Placeholder
}

This pattern lets your program run indefinitely until explicitly stopped, without consuming CPU or preventing proper cleanup.

We cover graceful shutdown thoroughly in Chapter 15.

What About Test Functions?

In tests, goroutines spawned by a test function continue running after the test returns—they are not killed. Unlike main returning (which terminates the process), leaked test goroutines persist and can interfere with subsequent tests through shared state or race conditions.

leak_test.go
package worker

import (
    "testing"
    "time"
)

func doWork() {
    time.Sleep(5 * time.Second)  // outlives the test
}

// The file MUST be named *_test.go — `go test` ignores anything else,
// so a Test function sitting in worker.go simply never runs.
func TestSomething(t *testing.T) {
    go doWork()  // leaks: the test returns long before this does
    // Coordinate with a WaitGroup, a channel, or goleak (below).
}

This is why tests that spawn goroutines must coordinate completion. The goleak package (covered in Section 2.4) detects exactly these test-time leaks.

Why time.Sleep Is Never the Answer

time.Sleep appears throughout Section 2.1. Here's why it's fundamentally broken:

Problem 1: You're Guessing

guessing.go
// Illustrative snippet — processFile is application-defined.

func processFile(filename string) {
    // How long does this take?
    // 10ms? 100ms? 10 seconds?
    // Depends on file size, disk speed, system load...
}

func main() {
    go processFile("data.csv")
    time.Sleep(500 * time.Millisecond)  // Enough? Who knows.
}

Problem 2: Wasted Time

wasted_time.go
package main

import (
    "fmt"
    "time"
)

func quickTask() {
    fmt.Println("Done in 1ms")
}

func main() {
    go quickTask()
    time.Sleep(time.Second)  // Wastes 999ms
}

Problem 3: Variable Execution Time

variable_time.go
// Illustrative snippet — not a complete program.

func fetchURL(url string) {
    resp, _ := http.Get(url)  // 50ms? 5s? Timeout?
    // ...
}

func main() {
    go fetchURL("https://slow-server.example.com")
    time.Sleep(time.Second)  // Might not be enough
}

Problem 4: No Feedback

no_feedback.go
// Illustrative snippet — processData is application-defined.

func main() {
    go processData()
    time.Sleep(5 * time.Second)
    // Did it succeed? Fail? Still running?
}
Why time.Sleep Is Not Coordination

Sleeping guesses at a duration: too short and work is cut off, too long and the program idles. Either way the program's correctness depends on timing rather than on a signal.

When Fire-and-Forget Is Acceptable

Not every goroutine needs coordination. Some are designed to be abandoned:

fire_and_forget.go
// Illustrative snippet — sendToLoggingService is application-defined.

func logAsync(message string) {
    go func() {
        // Best-effort—acceptable to lose on shutdown
        sendToLoggingService(message)
    }()
}

The Decision Framework:

Ask: "What happens if this goroutine is killed mid-execution?"

When to Coordinate
Data loss or corruption
Yes — must complete
Inconsistent state
Yes — must complete or roll back
Resource leak (files, conns)
Yes — must clean up
Nothing bad happens
No — fire-and-forget acceptable

Examples of acceptable fire-and-forget:

Examples that need coordination:

Most production goroutines need coordination. Be deliberate about exceptions.

Four Ways Programs Terminate

Go programs can terminate in four ways, each with different behavior:

1. main Returns (Normal)

main_returns.go
package main

import (
    "fmt"
    "time"
)

func main() {
    defer fmt.Println("Main's defer runs")  // Runs

    go func() {
        defer fmt.Println("Goroutine's defer")  // Does NOT run
        time.Sleep(time.Second)
    }()

    time.Sleep(100 * time.Millisecond)
}
Output
Main's defer runs

Main's deferred functions execute. Other goroutines are terminated without cleanup.

2. Panic in main

main_panic.go
package main

import (
    "fmt"
    "time"
)

func main() {
    defer fmt.Println("Main's defer runs")  // Runs

    go func() {
        defer fmt.Println("Goroutine's defer")  // Does NOT run
        time.Sleep(time.Second)
    }()

    time.Sleep(100 * time.Millisecond)
    panic("something went wrong")
}
Output
Main's defer runs
panic: something went wrong
goroutine 1 [running]:
main.main()
    /path/to/main.go:14 +0x...
exit status 2

Main's deferred functions execute (stack unwinding), then a stack trace prints, then the program exits with a non-zero code. Other goroutines are still terminated without cleanup.

3. os.Exit (Immediate)

os_exit.go
package main

import (
    "fmt"
    "os"
)

func main() {
    defer fmt.Println("This never prints")  // Does NOT run

    os.Exit(0)  // Immediate termination
}
Output
(nothing)

No deferred functions run—not even main's. The program terminates immediately.

Watch out for log.Fatal: the log.Fatal, log.Fatalf, and log.Fatalln functions call os.Exit(1) internally—all deferred functions are skipped. This surprises many Go developers when a database connection or file isn't closed.

4. Unhandled Goroutine Panic

Section 2.1 established that an unrecovered panic in any goroutine crashes the entire program. Unlike a panic in main, no deferred functions in main execute—the runtime prints a stack trace and exits immediately:

Goroutine Panic vs Main Panic

A panic in any goroutine crashes the whole process. Recovering in main does not help, because recover only works inside the goroutine that panicked.

This is the most dangerous termination mode because it bypasses main's cleanup entirely. See Section 2.1 for recovery patterns.

Summary: Termination Behavior

Termination Behavior Summary
Row
main returns
panic in main
os.Exit()
Goroutine panic

Note: For goroutine panics, the panicking goroutine's own deferred functions do run (stack unwinding). The table shows that main's defers and all other goroutines' defers are skipped.

The pattern is clear: Other goroutines never get cleanup, regardless of how the program terminates. Goroutine panics and os.Exit() are the most abrupt—even main's defers are skipped.

Use os.Exit sparingly. Prefer returning from main to allow proper cleanup.

Solutions Preview

Go provides proper coordination mechanisms. Section 2.3 covers the first in detail:

sync.WaitGroup (Section 2.3)

Wait for a known set of goroutines to complete:

waitgroup_preview.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            fmt.Printf("Worker %d complete\n", id)
        }(i)
    }

    wg.Wait()  // Blocks until all Done()
    fmt.Println("All workers finished")
}
Output (order varies, completion guaranteed)
Worker 0 complete
Worker 2 complete
Worker 1 complete
All workers finished

Key difference: "All workers finished" prints after all workers complete—guaranteed, regardless of timing.

Additional coordination mechanisms covered later:

Common Mistakes

Using time.Sleep for coordination
Problem

Unreliable, wasteful

Fix

Use WaitGroup or channels

No coordination at all
Problem

Goroutines killed mid-work

Fix

Explicit synchronization

Expecting defers to run in abandoned goroutines
Problem

They don't

Fix

Coordinate before main exits

Assuming blocked goroutines will "wake up"
Problem

They're deleted instantly

Fix

Coordinate or accept loss

Assuming goroutines will finish
Problem

main might exit first

Fix

Always coordinate

Using os.Exit unnecessarily
Problem

Skips all defers

Fix

Return from main instead

Expecting runtime to detect partial deadlocks
Problem

Only complete deadlocks detected

Fix

Use leak detection tools

Section Summary

Section 2.2 Summary
Main goroutine
Created by runtime; executes main()
Program termination
When main returns, program exits
Other goroutines
Killed immediately — no cleanup
Blocked goroutines
Blocking op never completes — deleted mid-wait
Deferred functions
Only main's execute (except with os.Exit)
Blocking in main
Program stays alive while blocked
os.Exit()
Most abrupt — skips all defers
time.Sleep
Never appropriate for coordination
Deadlock detection
Runtime detects only when ALL goroutines blocked
Proper coordination
WaitGroup, channels, context

Key Takeaways

  1. When main returns, everything stops—all goroutines are killed instantly
  2. Blocked goroutines don't wake up—they're deleted mid-operation
  3. Abandoned goroutines don't clean up—their defers never run
  4. Blocking keeps the program alive—main must return to terminate
  5. time.Sleep is not coordination—it's guessing, not synchronizing
  6. This is intentional design—forces explicit lifecycle management
  7. Most goroutines need coordination—fire-and-forget should be a deliberate exception
  8. Deadlock detector has limits—only catches complete deadlocks, not leaks

Next: Section 2.3 covers WaitGroups—Go's fundamental tool for waiting on goroutine completion.

Section 2.2 — in one line

When main returns the process ends — no signal, no unwinding, no deferred functions. That is a deliberate design choice, not an oversight. time.Sleep is not coordination; it is a guess that happens to work on your laptop.


2.3 WaitGroups

Section 2.2 established the problem: main exits immediately when it returns, killing all other goroutines. time.Sleep is unreliable—we need coordination that waits exactly as long as necessary.

sync.WaitGroup is Go's fundamental tool for waiting on goroutine completion. It answers a simple question: "Have all these goroutines finished?"


The Core Concept

A WaitGroup is a counter with blocking:

WaitGroup Model

A counter. Add raises it, each Done lowers it, and Wait blocks until it reaches zero.


Basic Usage

waitgroup_basic.go
package main

import (
    "fmt"
    "sync"
    "time"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done()  // Decrement counter when function returns

    fmt.Printf("Worker %d starting\n", id)
    time.Sleep(100 * time.Millisecond)  // Simulate work
    fmt.Printf("Worker %d done\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        wg.Add(1)           // Increment BEFORE starting goroutine
        go worker(i, &wg)   // Pass pointer to WaitGroup
    }

    wg.Wait()  // Block until counter reaches 0
    fmt.Println("All workers completed")
}

Output (order of workers varies):

Possible Output (order varies)
Worker 3 starting
Worker 1 starting
Worker 2 starting
Worker 1 done
Worker 3 done
Worker 2 done
All workers completed

"All workers completed" always prints last—that's the guarantee WaitGroup provides.


The Three Methods

Add(delta int)

Adds delta to the counter. Typically called with positive values before starting goroutines.

add_examples.go
wg.Add(1)   // Add one (most common)
wg.Add(3)   // Add three at once
wg.Add(-1)  // Equivalent to Done() (rarely used directly)

Done()

Decrements the counter by 1. Called when a goroutine completes its work.

done_example.go
func worker(wg *sync.WaitGroup) {
    defer wg.Done()  // Always use defer
    // ... work ...
}

Done() is equivalent to Add(-1) but more expressive.

Wait()

Blocks until the counter reaches zero.

wait_example.go
wg.Wait()  // Returns immediately if counter is already 0
           // Otherwise blocks until all Done() calls complete

Critical Rule: Add Before Go

Warning

Always call Add() BEFORE the go statement, never inside the goroutine.

This is the most common WaitGroup mistake:

add_inside_goroutine_wrong.go
// ✗ WRONG: Add inside goroutine—race condition
for i := 0; i < 3; i++ {
    go func(id int) {
        wg.Add(1)       // BUG: May execute after Wait() is called
        defer wg.Done()
        process(id)
    }(i)
}
wg.Wait()  // Might return immediately if no Add() has executed yet!
Add Inside Goroutine: Race Condition

Calling Add inside the goroutine races with Wait: Wait may see a counter of zero and return before the goroutine has registered itself.

The correct pattern:

add_before_go_correct.go
// ✓ CORRECT: Add before go
for i := 0; i < 3; i++ {
    wg.Add(1)  // Increment in main goroutine
    go func(id int) {
        defer wg.Done()
        process(id)
    }(i)
}
wg.Wait()  // Counter is 3; waits for all Done() calls

Why this matters: The go statement returns immediately, but the goroutine might not start executing for some time. If Add() is inside the goroutine, Wait() might be called before any Add() executes, causing it to return immediately with counter 0.


Always Use defer wg.Done()

Warning

Always use defer wg.Done()—never call Done() directly at the end of a function.

Without defer, early returns and panics cause deadlocks:

defer_done_comparison.go
// ✗ WRONG: Done() might not be reached
func worker(wg *sync.WaitGroup) {
    if someCondition {
        return  // BUG: Done() never called → Wait() blocks forever
    }

    // ... work that might panic ...

    wg.Done()  // Never reached on early return or panic
}

// ✓ CORRECT: Done() always called
func worker(wg *sync.WaitGroup) {
    defer wg.Done()  // Guaranteed to run on ALL exit paths

    if someCondition {
        return  // Done() still called via defer
    }

    // ... work that might panic ...
    // Done() still called via defer (before panic propagates)
}
Why defer wg.Done() Is Essential

Without defer, an early return or a panic skips Done, the counter never reaches zero, and Wait blocks forever.

Even panics are covered: defer executes during stack unwinding, so Done() is called before the panic propagates. This prevents orphaned WaitGroups. (This matters most when a recover() is in place—without recovery, a goroutine panic terminates the whole program anyway.)


Pass WaitGroup by Pointer

WaitGroups must be passed by pointer. Passing by value creates a copy with an independent counter:

pass_by_value_wrong.go
// ✗ WRONG: Passing by value copies the WaitGroup
func worker(wg sync.WaitGroup) {  // Receives a COPY
    defer wg.Done()  // Decrements copy, not original
    // ...
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go worker(wg)  // Passes a copy
    wg.Wait()      // Original counter still 1 → deadlock!
}
Pass by Value vs Pass by Pointer

Copying a WaitGroup copies its counter, so the copy's Done calls never reach the original. Always pass a pointer.

The correct pattern:

pass_by_pointer_correct.go
// ✓ CORRECT: Pass by pointer
func worker(wg *sync.WaitGroup) {  // Pointer to original
    defer wg.Done()  // Decrements the original's counter
    // ...
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go worker(&wg)  // Passes pointer
    wg.Wait()       // Waits on same WaitGroup
}
Tooling

The go vet tool catches this mistake automatically. Its copylocks analyzer flags passing sync.WaitGroup (and other sync types like sync.Mutex) by value: copies lock value: sync.WaitGroup.


Zero Value Is Ready to Use

Unlike many synchronization primitives in other languages, WaitGroup requires no initialization:

zero_value.go
var wg sync.WaitGroup  // Ready to use—counter starts at 0
wg.Add(1)              // Works immediately

This is idiomatic Go: zero values should be useful.


Adding Multiple at Once

If you know the count upfront, you can add all at once:

add_multiple.go
func main() {
    urls := []string{"url1", "url2", "url3", "url4", "url5"}

    var wg sync.WaitGroup
    wg.Add(len(urls))  // Add all at once

    for _, url := range urls {
        go func(u string) {
            defer wg.Done()
            fetch(u)
        }(url)
    }

    wg.Wait()
}

Trade-off:

Recommendation

Use Add(1) per iteration unless performance profiling shows it matters (it rarely does).


Multiple Goroutines Can Wait

Multiple goroutines can call Wait() on the same WaitGroup. All will block until the counter reaches zero, then all will proceed:

multiple_waiters.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    var gate sync.WaitGroup
    gate.Add(1)

    var done sync.WaitGroup

    // Multiple waiters
    for i := 0; i < 3; i++ {
        done.Add(1)
        go func(id int) {
            defer done.Done()
            gate.Wait()  // All three block here
            fmt.Printf("Waiter %d: proceeding\n", id)
        }(i)
    }

    fmt.Println("Releasing waiters...")
    gate.Done()  // All three waiters unblock

    done.Wait()  // Wait for all to finish printing
}

Output:

Possible Output (order varies)
Releasing waiters...
Waiter 0: proceeding
Waiter 2: proceeding
Waiter 1: proceeding

This is useful for broadcast-style coordination: "everyone wait until the setup is complete."


Negative Counter Panics

If Done() is called more times than Add(), the counter goes negative and the program panics:

negative_counter.go
package main

import "sync"

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    wg.Done()
    wg.Done()  // Panic: negative WaitGroup counter
}
Output
panic: sync: negative WaitGroup counter
goroutine 1 [running]:
sync.(*WaitGroup).Add(0x...?)
...

Common causes:

Prevention

The defer wg.Done() pattern with Add(1) per goroutine makes mismatches nearly impossible.


Reusing WaitGroups

A WaitGroup can be reused after Wait() returns:

reuse_waitgroup.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup

    // First batch
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            fmt.Printf("Batch 1, worker %d\n", id)
        }(i)
    }
    wg.Wait()
    fmt.Println("Batch 1 complete")

    // Second batch—same WaitGroup
    for i := 0; i < 2; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            fmt.Printf("Batch 2, worker %d\n", id)
        }(i)
    }
    wg.Wait()
    fmt.Println("Batch 2 complete")
}
Reuse Rule

A WaitGroup can only be reused after Wait() has fully returned. Do not call Add() for a new batch while a previous Wait() may still be executing—the new Add() could race with the completion of the wait.

Safe: Wait() returns → Add() for next batch
Unsafe: Add() for new batch while previous Wait() hasn't returned yet


Nested Goroutines

When goroutines spawn other goroutines, pass the WaitGroup through:

nested_goroutines.go
func processTree(node *Node, wg *sync.WaitGroup) {
    defer wg.Done()

    process(node)

    for _, child := range node.Children {
        wg.Add(1)
        go processTree(child, wg)  // Pass same WaitGroup
    }
}

func main() {
    var wg sync.WaitGroup

    wg.Add(1)
    go processTree(root, &wg)

    wg.Wait()  // Waits for entire tree
    fmt.Println("All nodes processed")
}
Nested Goroutine Coordination

An outer goroutine that spawns inner ones must wait for them itself, or the outer Done fires while inner work is still running.

Key insight: The parent calls Add() for each child before spawning it, maintaining the "Add before go" rule even in nested scenarios.


Separating Business Logic from Concurrency

Keep your business logic unaware of WaitGroups:

separate_concerns.go
// Two alternative designs (not both in the same file)

// ✗ LESS IDEAL: Business logic coupled to concurrency
func processOrder(order Order, wg *sync.WaitGroup) {
    defer wg.Done()
    validate(order)
    charge(order)
    fulfill(order)
}

// ✓ BETTER: Business logic is pure
func processOrder(order Order) {
    validate(order)
    charge(order)
    fulfill(order)
}

// Orchestration layer handles concurrency
func processOrdersConcurrently(orders []Order) {
    var wg sync.WaitGroup

    for _, order := range orders {
        wg.Add(1)
        go func(o Order) {
            defer wg.Done()
            processOrder(o)  // Pure function, no WaitGroup awareness
        }(order)
    }

    wg.Wait()
}
Separation of Concerns

Business logic stays in an ordinary function; the WaitGroup lives in the caller. The function stays testable and knows nothing about concurrency.

Why this matters:


wg.Go() — Go 1.25+

Go 1.25 added the WaitGroup.Go() method. It takes a func() (no parameters, no return values) and combines Add(1), launching the goroutine, and ensuring Done() is called:

wg_go_new.go
// Go 1.25+ pattern
var wg sync.WaitGroup

for _, url := range urls {
    wg.Go(func() {
        fetch(url)  // No Add or Done needed—handled automatically
    })
}

wg.Wait()

This achieves the same result as:

wg_go_old.go
// Pre-Go 1.25 pattern (still works in all versions)
var wg sync.WaitGroup

for _, url := range urls {
    wg.Add(1)
    go func(u string) {
        defer wg.Done()
        fetch(u)
    }(url)
}

wg.Wait()

Benefits of wg.Go()

  1. Eliminates boilerplate—no Add(1) or defer wg.Done()
  2. Prevents "Add before go" mistakesAdd(1) runs before the goroutine starts, so Wait can never observe a counter that hasn't been raised yet
  3. Prevents missing Done()—on the normal path and on runtime.Goexit. Not on panic, and that is deliberate.
wg.Go does not call Done on a panic

It would be reasonable to assume the deferred Done always runs. It doesn't, and the standard library explains why:

Calling Done will unblock Wait in the main goroutine, allowing it to race with the fatal panic and possibly even exit the process (os.Exit(0)) before the panic completes. This is almost certainly undesirable, so instead avoid calling Done and simply panic.
$GOROOT/src/sync/waitgroup.go

A panicking goroutine will kill the process anyway (§2.1). Releasing Wait first would let main return cleanly and swallow the crash report. So wg.Go chooses the loud failure over the tidy counter—the right trade, and a concrete reminder that you cannot recover a panic from outside the goroutine that raised it.

Limitations

wg.Go takes a func()—no parameters and no return value. Calling an existing function isn't actually a limitation; you wrap it, which costs one line. The return value is the real constraint:

wg_go_limitations.go
func worker(id int) {
    // ... an existing function; its signature has no WaitGroup
}

// Not a limitation — just wrap it. One line.
for i := 0; i < 3; i++ {
    wg.Go(func() { worker(i) })
}

// The REAL limitation: func() has no error return, so a failing
// goroutine has nowhere to report to. That is exactly why
// errgroup.Go takes a func() error — Chapter 14.
//
//   g, ctx := errgroup.WithContext(ctx)
//   g.Go(func() error { return fetch(ctx, url) })
//   if err := g.Wait(); err != nil { ... }

The same shape, spelled out:

wg_go_wrap.go
for i := 0; i < 3; i++ {
    wg.Go(func() {
        worker(i)  // Safe: i is per-iteration in Go 1.22+
    })
}

Loop Variable Handling with wg.Go()

Note that wg.Go() was introduced in Go 1.25, well after the Go 1.22 loop variable fix. With both features, this is safe:

wg_go_loop_safe.go
// Go 1.25+: Both wg.Go() and per-iteration loop variables
for _, url := range urls {
    wg.Go(func() {
        fetch(url)  // Safe: url is per-iteration in Go 1.22+
    })
}

For code that must support older Go versions, continue using the explicit parameter pattern.

When to Use Which

wg.Go() vs Manual Pattern

wg.Go collapses Add, go and defer Done into one call. The manual pattern remains for pre-1.25 toolchains.


Complete Example: Concurrent URL Fetcher

url_fetcher.go
package main

import (
    "fmt"
    "io"
    "net/http"
    "sync"
    "time"
)

func fetchURL(url string) (int, error) {
    resp, err := http.Get(url)
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return 0, err
    }

    return len(body), nil
}

func main() {
    urls := []string{
        "https://go.dev",
        "https://pkg.go.dev",
        "https://go.dev/blog",
    }

    var wg sync.WaitGroup
    start := time.Now()

    for _, url := range urls {
        // wg.Go (Go 1.25) replaces Add(1) + defer Done().
        // url is per-iteration since Go 1.22 — capture it directly.
        wg.Go(func() {
            size, err := fetchURL(url)
            if err != nil {
                fmt.Printf("%-25s error: %v\n", url, err)
                return
            }
            fmt.Printf("%-25s %d bytes\n", url, size)
        })
    }

    wg.Wait()

    fmt.Printf("\nFetched %d URLs in %v\n",
        len(urls), time.Since(start))
}

Output (order varies, byte counts depend on current page sizes):

Possible Output (order varies)
https://go.dev/blog 18392 bytes
https://go.dev 45231 bytes
https://pkg.go.dev 31456 bytes
Fetched 3 URLs in 234ms

Four Questions analysis:

Four Questions — URL Fetcher
Exit?
Returns after fetch completes/errors
Communicate?
Prints to stdout (side effect)
Errors?
Printed, not propagated (acceptable for this example)
Data?
u passed as parameter; no shared mutable state
Note

This example prints results directly. For production code that needs to collect results, you'd use channels (Chapter 3) or a synchronized data structure (Chapter 9). To propagate errors from concurrent goroutines, see errgroup (Chapter 14).


Common Mistakes

Add() inside goroutine
Problem

Race condition—Wait() may return early

Fix

Add() before go

Passing WaitGroup by value
Problem

Creates copy with independent counter

Fix

Pass *sync.WaitGroup

Forgetting defer on Done()
Problem

Early returns/panics cause deadlock

Fix

Always defer wg.Done()

More Done() than Add()
Problem

Panic: negative counter

Fix

Match counts exactly

Add() while Wait() is blocking
Problem

Race condition

Fix

Reuse only after Wait() returns

Coupling business logic to WaitGroup
Problem

Hard to test, inflexible

Fix

Separate orchestration from logic


WaitGroup vs Other Coordination

WaitGroup is one tool among several. Here's when to use it:

Choosing the Right Coordination Tool
Wait for N to complete
sync.WaitGroup
Get results from goroutines
Channels (Ch 3)
Cancel goroutines
context.Context (Ch 13)
Wait for first of N
Channels + select (Ch 3/4)
Coordinate with errors
errgroup (Ch 14)
One-time initialization
sync.Once (Ch 12)

WaitGroup answers "have they all finished?" but not "what did they produce?" or "did they succeed?" For those, you need additional mechanisms.


Section Summary

WaitGroup Summary
Purpose
Wait for goroutines to complete
Add(n)
Increment counter (call before go)
Done()
Decrement counter (always defer)
Wait()
Block until counter reaches 0
Go() (1.25+)
Combines Add(1), go, defer Done()
Zero value
Ready to use (counter starts at 0)
Passing
By pointer (*sync.WaitGroup)
Negative counter
Panics
Reuse
Safe only after Wait() returns
Multiple waiters
All unblock when counter hits 0

Key Takeaways

  1. Add before go—always increment the counter before starting the goroutine
  2. Always defer wg.Done()—handles all exit paths including panics
  3. Pass by pointer—value copies create independent counters
  4. Use wg.Go() in Go 1.25+—eliminates boilerplate and common mistakes
  5. Separate concerns—keep business logic unaware of WaitGroups
  6. WaitGroup answers "all done?"—use channels for results, context for cancellation

Next: Section 2.4 covers what happens when goroutines don't exit—goroutine leaks, one of the most insidious bugs in concurrent Go programs.

Section 2.3 — in one line

A WaitGroup is a counter: Add before the go, Done in a defer, and always pass it by pointer. On Go 1.25 wg.Go collapses all three into one call — and deliberately skips Done on panic, so the crash stays loud.


2.4 Goroutine Leaks

Section 2.3 showed how to wait for goroutines to complete. But what if a goroutine never completes? What if it blocks forever, waiting for something that will never happen?

This is a goroutine leak—one of the most insidious bugs in concurrent Go programs. Unlike memory leaks in languages without garbage collection, goroutine leaks are invisible to Go's GC. They accumulate silently until your program crashes from resource exhaustion.


The Fundamental Principle

Goroutines Are Not Garbage Collected

The Go runtime will NOT:

  • Detect that a goroutine is "stuck"
  • Terminate goroutines that have been blocked "too long"
  • Clean up goroutines that are no longer "useful"
  • Warn you that goroutines are accumulating

A goroutine only exits when its function returns. If that never happens, the goroutine exists forever.

Goroutine Lifecycle

A goroutine is created, becomes runnable, alternates between running and blocked, and finally exits when its function returns — or never exits, which is a leak.


Why Leaks Matter

Each leaked goroutine consumes:

A single leak might be negligible. Thousands accumulating over hours or days will crash your service:

leak_demo.go
// Leak demonstration: each request leaks one goroutine
func handleRequest(w http.ResponseWriter, r *http.Request) {
    ch := make(chan Result)

    go func() {
        result := expensiveComputation()
        ch <- result  // Blocks if handler returns
    }()

    select {
    case result := <-ch:
        writeResponse(w, result)
    case <-time.After(100 * time.Millisecond):
        http.Error(w, "timeout", http.StatusGatewayTimeout)
        return  // Handler returns; goroutine still blocked
    }
}

At 100 requests/second with 10% timeouts, you leak 10 goroutines/second = 36,000 goroutines/hour = 864,000 goroutines/day.

Leak Accumulation

Leaked goroutines never exit, so their stacks and everything they reference stay live. Under steady traffic the count climbs until the process runs out of memory.


Common Leak Patterns

Pattern 1: Blocked Receive—No Sender

A goroutine waits for a value that will never arrive:

blocked_receive.go
// ✗ LEAK: Nothing ever sends on this channel
func leak() {
    ch := make(chan int)

    go func() {
        val := <-ch  // Blocks forever—no sender
        fmt.Println(val)
    }()

    // ch goes out of scope, but goroutine still exists, waiting
}

Fix: Ensure every receive has a corresponding send, or use a done channel for cancellation:

blocked_receive_fixed.go
// ✓ FIXED: Cancellation path provided
func noLeak(done <-chan struct{}) {
    ch := make(chan int)

    go func() {
        select {
        case val := <-ch:
            fmt.Println(val)
        case <-done:
            return  // Exit path when cancelled
        }
    }()
}

Pattern 2: Blocked Send—No Receiver

A goroutine tries to send, but no one is receiving:

blocked_send.go
// ✗ LEAK: Nothing ever receives from this channel
func leak() {
    ch := make(chan int)

    go func() {
        ch <- 42  // Blocks forever—no receiver
    }()

    // Function returns without receiving from ch
}

Fix: Use a buffered channel when the receiver might not exist:

blocked_send_fixed.go
// ✓ FIXED: Buffer allows send without blocking
func noLeak() {
    ch := make(chan int, 1)  // Buffer size 1

    go func() {
        ch <- 42  // Succeeds immediately (buffered)
    }()

    // Even if we don't receive, goroutine completes
}

For a single goroutine, buffer size 1 suffices. When multiple goroutines send on the same channel, the buffer must match the sender count—see Buffer Size Must Match Sender Count below.


Pattern 3: Nil Channel Operations

Operations on nil channels block forever:

nil_channel.go
// ✗ LEAK: Nil channel blocks forever
func leak() {
    var ch chan int  // nil—not initialized

    go func() {
        val := <-ch  // Blocks forever on nil channel
        fmt.Println(val)
    }()
}
Nil Channel Operations

Send and receive on a nil channel both block forever, and no close can ever unblock them.

Fix: Always initialize channels before use:

nil_channel_fixed.go
// ✓ FIXED: Channel properly initialized
func noLeak() {
    ch := make(chan int, 1)  // Initialized (not nil), buffered

    go func() {
        select {
        case val := <-ch:
            fmt.Println(val)
        case <-time.After(time.Second):
            return  // Timeout exit
        }
    }()

    ch <- 42  // Buffered — never blocks
}
Note

Nil channels block forever—a bug when unintentional, but deliberately useful in select statements for dynamically disabling cases. Chapter 4 covers this technique.


Pattern 4: Timeout Abandons Sender

This is the most common real-world leak pattern—and the most subtle:

Note

The select statement waits on multiple channel operations simultaneously—whichever is ready first executes. We cover its full semantics in Chapter 4, but these patterns can be used as-is for timeouts and cancellation.

timeout_leak.go
// ✗ LEAK: Timeout abandons the goroutine
func fetchWithTimeout(url string) ([]byte, error) {
    result := make(chan []byte)  // Unbuffered

    go func() {
        data := fetch(url)  // Takes 200ms
        result <- data      // BLOCKS if main returned due to timeout
    }()

    select {
    case data := <-result:
        return data, nil
    case <-time.After(100 * time.Millisecond):
        return nil, errors.New("timeout")
        // Still running, blocks on send forever
    }
}
Timeout Abandons Sender

The receiver gives up on a timeout and returns; the sender is still holding a value for an unbuffered channel nobody will ever read, so it blocks forever.

Fix: Use a buffered channel so the send never blocks:

timeout_fixed.go
// ✓ FIXED: Buffer allows send even if no receiver
func fetchWithTimeout(url string) ([]byte, error) {
    result := make(chan []byte, 1)  // Buffer size 1

    go func() {
        data := fetch(url)
        result <- data  // Never blocks—buffer absorbs it
    }()

    select {
    case data := <-result:
        return data, nil
    case <-time.After(100 * time.Millisecond):
        return nil, errors.New("timeout")
        // Goroutine can still send to buffer and exit cleanly
    }
}
Buffer Prevents Leak, Not Wasted Work

The buffered channel prevents the goroutine from blocking forever on the send. However, fetch(url) still runs to completion—we just discard its result.

If cancelling the work itself matters (saving CPU, network, etc.), you need context-based cancellation (Chapter 13):

func fetchWithTimeout(
    ctx context.Context, url string,
) ([]byte, error) {
    ctx, cancel := context.WithTimeout(
        ctx, 100*time.Millisecond)
    defer cancel()

    return fetchWithContext(ctx, url)
}

Buffer Size Must Match Sender Count

Buffer size 1 rescues only one sender — a second is saved by the receive itself, so this leaks exactly one goroutine. With N potential senders you need buffer size N: count the senders, not the receives.

// ✗ LEAK: 3 senders, buffer 1, one receive → exactly 1 leaks
func leak() {
    result := make(chan int, 1)

    for i := 0; i < 3; i++ {
        go func(n int) {
            result <- n * n  // 1 buffered + 1 received = 2 succeed
        }(i)                 // The third blocks forever
    }

    <-result  // Read one value and return
}

// ✓ FIXED: Buffer matches sender count
func noLeak() {
    result := make(chan int, 3)  // Buffer for all 3

    for i := 0; i < 3; i++ {
        go func(n int) {
            result <- n * n  // All sends succeed
        }(i)
    }

    <-result  // Read one value and return
    // Other 2 goroutines also exit cleanly
}

Pattern 5: Infinite Loop Without Exit

A goroutine that loops forever without checking for cancellation:

infinite_loop.go
// ✗ LEAK: No way to stop this goroutine
func leak() {
    go func() {
        for {
            doPeriodicWork()
            time.Sleep(time.Second)
        }
        // No exit condition—leaks for the server's lifetime
    }()
}

Fix: Check a done channel or context:

infinite_loop_fixed.go
// ✓ FIXED: Respects cancellation
func noLeak(done <-chan struct{}) {
    go func() {
        ticker := time.NewTicker(time.Second)
        defer ticker.Stop()

        for {
            select {
            case <-done:
                return  // Clean exit when signaled
            case <-ticker.C:
                doPeriodicWork()
            }
        }
    }()
}
select with default Can Spin

A common mistake when adding exit checks:

// ✗ BAD: Spins at 100% CPU
for {
    select {
    case <-done:
        return
    default:
        // Executes immediately if done isn't ready
        // Loop repeats instantly—no blocking
    }
    doWork()
}

// ✓ GOOD: Blocks until something happens
for {
    select {
    case <-done:
        return
    case <-ticker.C:
        doWork()
    }
}

Without default, select blocks until a case is ready. With default, it never blocks—potentially spinning at 100% CPU.


Pattern 6: Waiting on External Resource

A goroutine waiting on a resource that never responds:

external_resource.go
// ✗ LEAK: No timeout on external call
func leak() {
    go func() {
        conn, err := db.Connect()  // Hangs if DB is down
        if err != nil {
            return
        }
        // ...
    }()
}

Fix: Always use timeouts for external resources:

external_resource_fixed.go
// ✓ FIXED: Context with timeout
func noLeak(ctx context.Context) {
    go func() {
        ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
        defer cancel()

        conn, err := db.ConnectContext(ctx)  // Respects timeout
        if err != nil {
            return  // Returns on timeout or error
        }
        // ...
    }()
}

Real-World Example: HTTP Handler Leak

A complete example showing how leaks occur in production code:

handler_leak.go
// ✗ LEAKING VERSION
func handleSearch(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query().Get("q")
    results := make(chan []Result)

    // Search multiple backends concurrently
    go func() { results <- searchDatabase(query) }()
    go func() { results <- searchCache(query) }()
    go func() { results <- searchExternal(query) }()

    // Return first result, with timeout
    select {
    case res := <-results:
        json.NewEncoder(w).Encode(res)
    case <-time.After(100 * time.Millisecond):
        http.Error(w, "timeout", http.StatusGatewayTimeout)
    }
    // BUG: 2 goroutines leak on success; all 3 on timeout!
}

Problems:

  1. Three goroutines send, but we only receive once
  2. On timeout, all three goroutines block forever on send
  3. Even on success, two goroutines still block forever

Fixed version:

handler_fixed.go
// ✓ FIXED VERSION
func handleSearch(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query().Get("q")
    results := make(chan []Result, 3)  // Buffer for all senders

    // Search multiple backends concurrently
    go func() { results <- searchDatabase(query) }()
    go func() { results <- searchCache(query) }()
    go func() { results <- searchExternal(query) }()

    // Return first result, with timeout
    select {
    case res := <-results:
        json.NewEncoder(w).Encode(res)
    case <-time.After(100 * time.Millisecond):
        http.Error(w, "timeout", http.StatusGatewayTimeout)
    }
    // All goroutines can send and exit cleanly
}

Even better—use context for cancellation:

handler_context.go
// ✓ BEST: Context-based cancellation
func handleSearch(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(
        r.Context(), 100*time.Millisecond)
    defer cancel()

    query := r.URL.Query().Get("q")
    results := make(chan []Result, 3)

    // Search with context—can be cancelled
    go func() { results <- searchDatabaseCtx(ctx, query) }()
    go func() { results <- searchCacheCtx(ctx, query) }()
    go func() { results <- searchExternalCtx(ctx, query) }()

    select {
    case res := <-results:
        json.NewEncoder(w).Encode(res)
    case <-ctx.Done():
        http.Error(w, "timeout", http.StatusGatewayTimeout)
    }
    // cancel() tells goroutines to stop (if they respect ctx)
}

Context-based cancellation (Chapter 13) not only prevents leaks but also stops wasted work.


Detecting Leaks

Method 1: runtime.NumGoroutine()

Monitor goroutine count over time:

monitor_goroutines.go
func monitorGoroutines(done <-chan struct{}) {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()

    var baseline int
    for {
        select {
        case <-done:
            return
        case <-ticker.C:
            count := runtime.NumGoroutine()
            if baseline == 0 {
                baseline = count
            }
            growth := count - baseline
            if growth > 100 {
                log.Printf(
                    "WARNING: +%d (base=%d cur=%d)",
                    growth, baseline, count)
            }
        }
    }
}

For production, expose as a metric:

prometheus_metric.go
// Prometheus-style metric
// Process-lifetime goroutine — exits when program exits
var goroutineGauge = prometheus.NewGauge(prometheus.GaugeOpts{
    Name: "go_goroutines_current",
    Help: "Current number of goroutines",
})

func init() {
    go func() {
        for {
            goroutineGauge.Set(float64(runtime.NumGoroutine()))
            time.Sleep(10 * time.Second)
        }
    }()
}

This goroutine intentionally has no exit path—it starts once in init() and runs for the program's entire lifetime. This is an acceptable exception to the "always provide an exit path" rule: process-lifetime goroutines that start exactly once don't accumulate and don't leak.

Method 2: goleak in Tests

Uber's goleak package detects leaks in tests:

goleak_test.go
package myapp_test

import (
    "testing"
    "go.uber.org/goleak"
)

func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)  // Check for leaks after all tests
}

// Or per-test:
func TestSomething(t *testing.T) {
    defer goleak.VerifyNone(t)  // Check for leaks after this test

    // ... test code ...
}

goleak inspects goroutines still running after the test, filtering known runtime goroutines, and fails if any unexpected goroutines remain.

Installation:

Terminal
$ go get go.uber.org/goleak

Example failure:

Test Output
--- FAIL: TestLeakyFunction (0.10s)
leaks.go:78: found unexpected goroutines:
[Goroutine 19 in state chan receive,
with main.leakyFunction.func1
on top of the stack:
goroutine 19 [chan receive]:
main.leakyFunction.func1()
/path/to/main.go:15 +0x34
created by main.leakyFunction
/path/to/main.go:14 +0x52
]

Method 3: pprof

For production debugging, use the goroutine profile:

pprof_setup.go
package main

import (
    "log"
    "net/http"
    _ "net/http/pprof"
)

func main() {
    // Exposes /debug/pprof/goroutine
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()

    // ... rest of application ...
}

Access the profile:

Terminal
# Text summary
$ curl http://localhost:6060/debug/pprof/goroutine?debug=1
# Full stack traces
$ curl http://localhost:6060/debug/pprof/goroutine?debug=2
# Interactive analysis
$ go tool pprof http://localhost:6060/debug/pprof/goroutine

The output shows where goroutines are blocked:

pprof Output
goroutine profile: total 1847
1823 @ 0x43e20e 0x44f486 0x44f45b 0x47a19a 0x47a1ec 0x4806e1 0x46c661
# 0x47a199 main.handleSearch.func1+0x79 /app/handlers.go:42
# 0x47a1eb main.handleSearch.func2+0x4b /app/handlers.go:43

1823 goroutines stuck in handleSearch—clear evidence of a leak.


Goroutine States

When debugging with pprof or stack traces, you'll see goroutine states:

Goroutine States

Runnable, running, blocked (on a channel, mutex, syscall or timer), and dead. A leaked goroutine is one stuck in blocked with nothing that can ever wake it.

Large numbers of goroutines in chan receive or chan send often indicate leaks.

Reading Stack Traces

A goroutine stack trace tells you exactly where it's stuck and who created it. Here's how to read one:

goroutine 42 [chan send, 3 minutes]:
main.worker(0xc0000b4000)
        /app/worker.go:28 +0x45
created by main.startWorkers
        /app/main.go:15 +0x85

Prevention Patterns

Pattern 1: Always Provide Exit Paths

Every goroutine should have a way to exit:

exit_paths.go
// Two alternative approaches (not both in the same file)

// ✓ Done channel for cancellation
func workerWithDone(done <-chan struct{}, interval time.Duration) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()
    for {
        select {
        case <-done:
            return  // Exit when signaled
        case <-ticker.C:
            doWork()
        }
    }
}

// ✓ Context for cancellation (preferred)
func workerWithContext(ctx context.Context, interval time.Duration) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return  // Exit when context cancelled
        case <-ticker.C:
            doWork()
        }
    }
}

Pattern 2: Buffer for Potential Abandonment

When a receiver might not exist, buffer the channel:

buffer_pattern.go
// ✓ Buffer prevents blocking if receiver is gone
result := make(chan Result, 1)
go func() {
    result <- computeResult()  // Never blocks
}()

Pattern 3: The Creator Is Responsible

The code that creates a goroutine is responsible for ensuring it can exit:

creator_responsibility.go
// ✓ Creator provides cleanup mechanism
func startWorkers(count int, interval time.Duration) (stop func()) {
    done := make(chan struct{})

    for i := 0; i < count; i++ {
        go func(id int) {
            ticker := time.NewTicker(interval)
            defer ticker.Stop()
            for {
                select {
                case <-done:
                    return
                case <-ticker.C:
                    doWork(id)
                }
            }
        }(i)
    }

    // Return function that stops all workers
    // Note: stop() must only be called once
    return func() {
        close(done)
    }
}

// Usage:
stop := startWorkers(10, time.Second)
// ... later ...
stop()  // All workers exit cleanly

Pattern 4: Timeouts on All External Operations

Never block indefinitely on external resources:

timeout_pattern.go
// ✓ Timeout prevents permanent blocking
ctx, cancel := context.WithTimeout(
    context.Background(), 30*time.Second)
defer cancel()

result, err := externalService.CallWithContext(ctx, request)

Prevention Checklist

Before creating any goroutine, verify:

Goroutine Creation Checklist

Exit Path:

  • Does the goroutine have a clear termination condition?
  • Can it be signaled to stop? (done channel, context)
  • Do all loops have exit conditions?

Blocking Operations:

  • Are channels buffered appropriately?
  • Do external calls have timeouts?
  • What happens if the receiver/sender disappears?

Failure Modes:

  • What if the operation takes forever?
  • What if the collaborating goroutine panics?
  • What if the context is cancelled?

Testing:

  • Is goleak enabled in tests?
  • Are leak scenarios tested explicitly?

The Four Questions Connection

Remember the Four Questions from Section 2.1:

Four Questions — Leak Prevention
Exit?
Must have a clear exit path
Communicate?
Buffer channels prevent blocking
Errors?
Error paths must also exit cleanly
Data?
Channels must be properly managed

Question 1 is paramount for leak prevention. If you can't clearly articulate how a goroutine exits, you likely have a leak.


Common Mistakes

Unbuffered channel with timeout
Problem

Sender blocks forever after timeout

Fix

Buffer matches sender count

No cancellation mechanism
Problem

Goroutine runs forever

Fix

Use done channel or context

Nil channel operations
Problem

Blocks forever

Fix

Always initialize channels

select with default in loop
Problem

Spins at 100% CPU

Fix

Remove default or add sleep

Ignoring goroutine in tests
Problem

Leaks go unnoticed

Fix

Use goleak

No timeout on external calls
Problem

Blocks if service is down

Fix

Always use context with timeout

Fire-and-forget with blocking ops
Problem

Silent leaks accumulate

Fix

Track and test all goroutines


Section Summary

Goroutine Leaks — Complete Summary
What is a leak?
Goroutine that never exits
Why harmful?
Memory growth, scheduler overhead, eventual crash
Detection
NumGoroutine(), goleak, pprof
Common causes
Blocked send/receive, no exit path, nil channels, abandoned timeouts
Prevention
Exit paths, buffered channels, timeouts, context cancellation
Responsibility
Creator ensures goroutine can exit
Testing
Use goleak in all test suites

Key Takeaways

  1. Goroutines are not garbage collected—they exist until they return
  2. Leaks accumulate silently—no warnings until resource exhaustion
  3. Buffer size must match sender count—one buffer slot per potential sender
  4. Every goroutine needs an exit path—done channels or context
  5. The creator is responsible—whoever starts a goroutine must ensure it can stop
  6. Test for leaks—use goleak to catch leaks early
  7. Monitor in production—track goroutine count as a metric
  8. Timeout abandons sender is the #1 leak—always buffer or use context

Next: Section 2.5 covers goroutine costs and practical limits—when to spawn freely versus when to limit concurrency.

Section 2.4 — in one line

A goroutine leaks when nothing can ever wake it. Six shapes cause almost all of them, and they share one cure: every goroutine needs a guaranteed exit path. Whoever starts one owns making sure it can finish.


2.5 Goroutine Cost and Practical Limits

Sections 2.1–2.4 taught you how to create goroutines, coordinate them, and prevent leaks. One question remains: how many goroutines should you create?

The answer depends on what those goroutines do. "Goroutines are cheap" is true but incomplete—cheap doesn't mean free, and the work goroutines perform often matters more than the goroutines themselves.


Performance Numbers Are Approximate

All numbers in this section are order-of-magnitude estimates based on typical modern hardware (2020s era x86-64). Actual values vary significantly based on:

  • CPU architecture and generation
  • Memory speed and configuration
  • Go version
  • Workload characteristics
  • Operating system

Use these numbers for mental models and back-of-envelope calculations. For precise measurements in your specific context, profile your actual workload (Chapter 19).


The Three Categories of Cost

Goroutine Cost Breakdown

Three costs: memory for the stack, scheduler time to create and switch, and whatever the goroutine keeps reachable on the heap.

Illustrative Orders of magnitude, not a measurement. Stack figures are runtime constants; the rest varies by platform.

Memory Cost

Stack Memory

Each goroutine starts with a small stack that grows as needed:

Goroutine Stack Memory
Row
Initial stack
Growth
Maximum
Shrinking
Stack Growth

A goroutine starts on a small stack and the runtime grows it by copying to a larger one when it runs out, so deep recursion costs memory but does not crash.

Illustrative Initial stack is a runtime constant (2 KB). Growth steps are implementation detail and may change between releases.

Memory Per Goroutine Count

Memory Per Goroutine Count
Row
1,000
10,000
100,000
1,000,000

But stacks are just the beginning. Each goroutine also holds references to heap objects, preventing garbage collection:

heap_reference_leak.go
package main

import (
    "fmt"
    "runtime"
    "time"
)

// The goroutine still USES data after it blocks, so the slice stays
// reachable from its stack and the collector cannot touch it.
func leakyWorker(data []byte) {
    ch := make(chan struct{})
    <-ch                   // Blocks forever
    fmt.Println(len(data)) // ...and data is still live at this point
}

func main() {
    const workers, size = 20, 100 << 20 // 20 × 100 MB

    for i := 0; i < workers; i++ {
        data := make([]byte, size)
        go leakyWorker(data)
    }

    time.Sleep(150 * time.Millisecond)
    runtime.GC()  // give the collector every chance

    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("goroutines: %d\n", runtime.NumGoroutine())
    fmt.Printf("heap held:  %.2f GB\n", float64(m.HeapAlloc)/(1<<30))
}

The goroutine stacks total ~200KB. The heap references total ~10GB.


Scheduler Cost

Creation Time

Creating a goroutine costs a couple of hundred nanoseconds — the same order Chapter 1 quoted, and roughly 50× cheaper than an OS thread:

goroutine_creation_bench.go
// Two benchmarks, because they answer different questions.

// (a) What the `go` statement itself costs. This does NOT wait, so b.N
//     goroutines are still in flight when the timer stops — it measures
//     issuance plus scheduler backpressure, not completion.
func BenchmarkGoroutineCreation(b *testing.B) {
    for i := 0; i < b.N; i++ {
        go func() {}()
    }
}

// (b) Creation through to completion — what you actually pay when you
//     hand work to a goroutine and need it finished.
func BenchmarkGoroutineCreationWaited(b *testing.B) {
    for i := 0; i < b.N; i++ {
        done := make(chan struct{})
        go func() { close(done) }()
        <-done
    }
}

Typical result:

Benchmark Results
goos: darwin · goarch: amd64 · go1.26.1
cpu: Intel(R) Core(TM) i7-10700K CPU @ 3.80GHz
BenchmarkGoroutineCreation-16 15788530 155.8 ns/op
BenchmarkGoroutineCreationWaited-16 5088897 468.5 ns/op
Measured Measured on go1.26.1, darwin/amd64, Intel i7-10700K @ 3.80GHz. Your numbers will differ; the shape will not.

~156 ns to issue the go statement; ~469 ns to create and finish. Fast — but not zero, and the second figure is the one to budget with.

Context Switch Time

Switching between goroutines takes approximately 100-200 nanoseconds:

context_switch_bench.go
func BenchmarkContextSwitch(b *testing.B) {
    ch := make(chan struct{})
    done := make(chan struct{})
    go func() {
        defer close(done)
        for {
            v, ok := <-ch
            if !ok {
                return
            }
            ch <- v
        }
    }()

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        ch <- struct{}{}
        <-ch
    }
    b.StopTimer()
    close(ch)
    <-done
}

Typical result:

Benchmark Results
goos: darwin · goarch: amd64 · go1.26.1
cpu: Intel(R) Core(TM) i7-10700K CPU @ 3.80GHz
BenchmarkContextSwitch-16 6378932 363.7 ns/op
Measured Measured on go1.26.1, darwin/amd64, Intel i7-10700K @ 3.80GHz.

~366 ns per round-trip (two context switches per iteration, so ~183 ns per switch).

Comparison with OS Threads

Goroutines vs OS Threads
Row
Initial stack
Creation time
Context switch
Memory overhead

Goroutines are dramatically cheaper than OS threads—but the comparison that matters most is goroutine overhead versus the work being done.


The Break-Even Point

The critical question: When does goroutine overhead matter?

Overhead vs Work

A table matching work duration against how much goroutine overhead matters — from dominating below half a microsecond to negligible past fifty.

Derived Bands computed from the ~0.5 µs create-and-finish figure measured above. Re-measure on your own hardware.

Benchmark: Goroutine-per-Item vs Batching

batching_benchmark.go
// Approach 1: one goroutine per item (bad for trivial work)
func BenchmarkGoroutinePerItem(b *testing.B) {
    nums := make([]int, 1000)
    for i := range nums {
        nums[i] = i
    }

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        var wg sync.WaitGroup
        results := make([]int, len(nums))

        for j, n := range nums {
            wg.Go(func() {
                results[j] = n * n  // Trivial work
            })
        }
        wg.Wait()
    }
}

// Approach 2: single goroutine, process all (baseline)
func BenchmarkSequential(b *testing.B) {
    nums := make([]int, 1000)
    for i := range nums {
        nums[i] = i
    }

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        results := make([]int, len(nums))
        for j, n := range nums {
            results[j] = n * n
        }
    }
}

Results:

Benchmark Results
goos: darwin · goarch: amd64 · go1.26.1
cpu: Intel(R) Core(TM) i7-10700K CPU @ 3.80GHz
BenchmarkGoroutinePerItem-16 9189 269,521 ns/op
BenchmarkSequential-16 1312120 1,868 ns/op

Goroutine-per-item is 144× slower for trivial work. The overhead dominates completely.

Where the Overhead Goes

Goroutine-Per-Item Overhead Breakdown

For a thousand items of one-nanosecond work, goroutine creation and synchronization account for over 99% of elapsed time.

Derived Arithmetic from the two benchmarks above, on this machine. The ratio is the durable part, not the absolutes.

Practical Limits by Workload

Case 1: I/O-Bound Work

Characteristics: Work spends most time waiting—network calls, disk I/O, database queries.

io_bound_work.go
// Illustrative snippet — I/O-bound: 50ms network call
func fetchURL(url string) ([]byte, error) {
    resp, err := http.Get(url)  // ~50-500ms typically
    // ...
}

func fetchAll(urls []string) {
    var wg sync.WaitGroup
    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            fetchURL(u)
        }(url)
    }
    wg.Wait()
}

Analysis:

Verdict:Spawn freely. For I/O-bound work, goroutine overhead is negligible. The limiting factor is external resources (network bandwidth, database connections), not goroutines.

Practical limits:

Practical Limits — I/O-Bound
Network connections
10,000-100,000
Database pool
10-1,000
File descriptors
1,000-1,000,000
Memory (if responses are large)
Depends on response size

Case 2: CPU-Bound Work

Characteristics: Work keeps the CPU busy—computation, parsing, encoding.

cpu_bound_work.go
// Illustrative snippet — CPU-bound: heavy computation
func computeHash(data []byte) []byte {
    for i := 0; i < 10000; i++ {
        data = sha256.Sum256(data)[:]
    }
    return data
}

Analysis:

CPU-Bound Concurrency

CPU-bound work saturates at roughly the core count; goroutines beyond that add scheduling overhead without adding throughput.

Illustrative Schematic. Real saturation depends on cache behavior and memory bandwidth as much as core count.

Verdict:Limit to core count. Creating more CPU-bound goroutines than CPU cores adds overhead without improving throughput.

cpu_bound_worker_pool.go
// ✓ CORRECT: limit CPU-bound work to available cores
func processAllCPUBound(items []Item) {
    // what you may actually use, not NumCPU()
    numWorkers := runtime.GOMAXPROCS(0)

    // Buffered for EVERY item, so the send loop below can never block —
    // which is why this is safe to run from the calling goroutine.
    // Compare worker_pool_cpu.go, where a results channel makes that
    // same send loop deadlock unless it runs concurrently.
    jobs := make(chan Item, len(items))
    var wg sync.WaitGroup

    for i := 0; i < numWorkers; i++ {
        wg.Go(func() {
            for item := range jobs {
                cpuIntensiveWork(item)
            }
        })
    }

    for _, item := range items {
        jobs <- item
    }
    close(jobs)

    wg.Wait()
}

Case 3: Memory-Heavy Work

Characteristics: Each unit of work requires significant memory.

memory_heavy_work.go
// Illustrative snippet — Memory-heavy: each goroutine needs 100MB
func processLargeDataset(data []byte) []byte {
    buffer := make([]byte, 100*1024*1024)  // 100MB per goroutine
    copy(buffer, data)
    // ... process using buffer ...
    return buffer
}

Analysis:

Verdict:Limit by available memory.

memory_heavy_semaphore.go
// ✓ CORRECT: Limit memory-heavy work with semaphore
func processAllMemoryHeavy(datasets [][]byte) {
    maxConcurrent := 10  // Based on available memory
    sem := make(chan struct{}, maxConcurrent)

    var wg sync.WaitGroup
    for _, data := range datasets {
        wg.Add(1)
        sem <- struct{}{}  // Acquire before creating goroutine

        go func(d []byte) {
            defer wg.Done()
            defer func() { <-sem }()  // Release

            processLargeDataset(d)
        }(data)
    }
    wg.Wait()
}

Limiting Concurrency: Two Patterns

Pattern 1: Worker Pool (Chapter 7 goes deeper)

Fixed number of workers consuming from a shared queue:

worker_pool.go
func workerPool(
    jobs <-chan Job,
    results chan<- Result,
    numWorkers int,
) {
    var wg sync.WaitGroup

    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                results <- process(job)
            }
        }()
    }

    wg.Wait()
    close(results)  // OK: workerPool owns results' lifecycle
}
Worker Pool

A fixed set of workers draws from a shared job channel. Concurrency is capped by the number of workers, not the number of items.

Pattern 2: Semaphore (Chapter 17 goes deeper)

Limit concurrent operations without fixed workers:

semaphore_pattern.go
func semaphorePattern(items []Item, maxConcurrent int) {
    sem := make(chan struct{}, maxConcurrent)
    var wg sync.WaitGroup

    for _, item := range items {
        wg.Add(1)
        sem <- struct{}{}  // Acquire

        go func(it Item) {
            defer wg.Done()
            defer func() { <-sem }()  // Release

            process(it)
        }(item)
    }

    wg.Wait()
}
Semaphore

A buffered channel used as a counting semaphore: acquire by sending, release by receiving, and its capacity is the concurrency limit.

Semaphore Acquire Position

Where you acquire the semaphore changes behavior:

semaphore_acquire_position.go
// Option A: Acquire BEFORE go
for _, item := range items {
    sem <- struct{}{}  // Blocks here if at limit
    go func(it Item) {
        defer func() { <-sem }()
        process(it)
    }(item)
}
// At most maxConcurrent goroutines exist

// Option B: Acquire INSIDE goroutine
// All goroutines created, execution limited
for _, item := range items {
    go func(it Item) {
        sem <- struct{}{}  // Blocks here if at limit
        defer func() { <-sem }()
        process(it)
    }(item)
}
// All created, only maxConcurrent run
Semaphore Acquire Position

Acquiring before the `go` statement bounds how many goroutines exist. Acquiring inside bounds only how many run at once — every goroutine is still created.

Choosing Between Patterns

Worker Pool vs Semaphore
Row
Goroutine count
Best for
Job distribution
Complexity
Backpressure
Quick Decision Guide
  • CPU-bound work? → Worker pool with runtime.GOMAXPROCS(0) workers
  • I/O-bound with external limit? → Semaphore matching the limit (DB connections, API rate limit)
  • I/O-bound, no limit? → Spawn freely
  • Memory-heavy? → Semaphore, acquire before go

When NOT to Use Goroutines

Sometimes sequential code is better:

Case 1: Trivial Work

trivial_work.go
// ✗ BAD: Overhead dominates
func sumWithGoroutines(nums []int) int {
    results := make(chan int, len(nums))
    for _, n := range nums {
        go func(x int) {
            results <- x
        }(n)
    }

    total := 0
    for range nums {
        total += <-results
    }
    return total
}

// ✓ GOOD: Simple loop is faster
func sum(nums []int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

Case 2: Sequential Dependencies

sequential_dependencies.go
// ✗ BAD: Can't parallelize—each step needs previous result
func pipelineBad(data []byte) []byte {
    // These can't actually run concurrently!
    step1Result := step1(data)
    step2Result := step2(step1Result)
    step3Result := step3(step2Result)

    return step3Result
}

// ✓ GOOD: Just call sequentially
func pipeline(data []byte) []byte {
    return step3(step2(step1(data)))
}

Case 3: Shared State Dominates

shared_state.go
// ✗ BAD: Lock contention negates parallelism
func countWithGoroutinesBad(items []Item) int {
    var mu sync.Mutex
    count := 0

    var wg sync.WaitGroup
    for _, item := range items {
        wg.Add(1)
        go func(it Item) {
            defer wg.Done()
            if matches(it) {
                mu.Lock()
                count++  // All goroutines serialize here
                mu.Unlock()
            }
        }(item)
    }
    wg.Wait()
    return count
}

// ✓ GOOD: Count locally, combine at end
func countWithGoroutines(items []Item) int {
    numWorkers := runtime.GOMAXPROCS(0)
    chunkSize := (len(items) + numWorkers - 1) / numWorkers

    results := make(chan int, numWorkers)

    for i := 0; i < numWorkers; i++ {
        start := min(i*chunkSize, len(items))
        end := min(start+chunkSize, len(items))

        go func(chunk []Item) {
            localCount := 0
            for _, it := range chunk {
                if matches(it) {
                    localCount++
                }
            }
            results <- localCount
        }(items[start:end])
    }

    total := 0
    for i := 0; i < numWorkers; i++ {
        total += <-results
    }
    return total
}

Decision Framework

Concurrency Decision Flowchart

A decision tree: no need for concurrency means sequential code; short units get batched; then the choice between spawning freely for I/O-bound work and bounding it for CPU-bound work.


Practical Limits Summary

Practical Limits Summary
Row
I/O (free)
I/O (limited)
CPU-bound
Memory-heavy
Idle/blocked

Common Patterns Reference

Copy-paste-ready patterns for the concurrency limiting techniques covered in this section. Each snippet is self-contained.

Coordinating Completion (WaitGroup)

waitgroup_pattern.go
// Illustrative snippet — WaitGroup coordination
var wg sync.WaitGroup
for _, item := range items {
    wg.Add(1)
    go func(it Item) {
        defer wg.Done()
        process(it)
    }(item)
}
wg.Wait()

Preventing Timeout Leaks (Buffered Channel)

buffered_channel_timeout.go
// Illustrative snippet — buffered channel prevents timeout leak
result := make(chan Result, 1)  // Buffer matches sender count
go func() {
    result <- computeResult()
}()

select {
case r := <-result:
    use(r)
case <-time.After(timeout):
    return  // Goroutine can still send and exit
}

Cancellable Long-Running Goroutine (Done Channel)

done_channel_pattern.go
// Illustrative snippet — cancellable long-running goroutine
done := make(chan struct{})
go func() {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    for {
        select {
        case <-done:
            return
        case <-ticker.C:
            doPeriodicWork()
        }
    }
}()

// Later: close(done) to stop

Limiting Concurrency (Semaphore)

semaphore_concurrency.go
// Illustrative snippet — semaphore limits concurrency
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup

for _, item := range items {
    wg.Add(1)
    sem <- struct{}{}  // Acquire

    go func(it Item) {
        defer wg.Done()
        defer func() { <-sem }()  // Release
        process(it)
    }(item)
}
wg.Wait()

CPU-Bound Work (Worker Pool)

worker_pool_cpu.go
func processAll(items []Item) []Result {
    numWorkers := runtime.GOMAXPROCS(0)
    // 2× buffer keeps workers fed while the sender preps the next
    jobs := make(chan Item, numWorkers*2)
    results := make(chan Result, numWorkers*2)

    // Start workers
    var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Go(func() {
            for item := range jobs {
                results <- process(item)
            }
        })
    }

    // Send jobs CONCURRENTLY — the part that is easy to get wrong.
    // Fill `jobs` from main and the pool deadlocks: workers fill
    // `results`, block, stop draining `jobs`, it fills, main blocks.
    go func() {
        for _, item := range items {
            jobs <- item
        }
        close(jobs)
    }()

    // Close results once every worker has finished
    go func() {
        wg.Wait()
        close(results)
    }()

    var all []Result
    for r := range results {
        all = append(all, r)
    }
    return all
}

Common Mistakes

Goroutine per trivial item
Problem

Overhead dominates (100×+ slower)

Fix

Batch into chunks

Unlimited CPU-bound goroutines
Problem

Context switch overhead, no speedup

Fix

Worker pool matching core count

Unlimited memory-heavy goroutines
Problem

OOM crash

Fix

Semaphore limiting concurrency

Not measuring before optimizing
Problem

Premature optimization

Fix

Benchmark first, optimize if needed

Assuming more goroutines = faster
Problem

Often false for CPU-bound

Fix

Profile to find actual bottleneck

Ignoring external limits
Problem

Overwhelming databases, APIs

Fix

Semaphore matching external capacity


Section Summary

Performance and Practical Limits
Creation cost
~470 ns per goroutine (create + finish)
Memory cost
~2KB stack + heap references
Context switch
~100-200ns
Break-even
~1 µs; below this, batch
I/O-bound
Spawn freely (ext resources limit)
CPU-bound
Worker pool, GOMAXPROCS workers
Memory-heavy
Semaphore, acquire before go
Trivial work
Sequential or batched
External limits
Semaphore matching the limit

Key Takeaways

  1. Goroutines are cheap, not free—overhead matters for small work
  2. I/O-bound work: spawn freely—external resources are the limit
  3. CPU-bound work: match core count—more goroutines won't help
  4. Memory-heavy work: limit by memory—use semaphore
  5. Measure before optimizing—assumptions are often wrong
  6. Choose the right pattern—worker pool vs semaphore vs unlimited
  7. Consider not using goroutines—sequential code is sometimes faster
Section 2.5 — in one line

Goroutines cost ~2 KB and ~0.5 µs to create and finish — cheap enough to spawn thousands for I/O, expensive enough that below about a microsecond of work per item you should batch instead. Measure before you tune; the numbers on this page are from one machine, and yours is not that machine.


Next: Chapter 2 Self-Check consolidates the key concepts from all sections with questions to test your understanding.

Exercise 2.1 — Catch Your Own Leak

Your move

Make goleak go quiet

§2.4 catalogued six ways a goroutine leaks. Here is Pattern 2 — blocked send, no receiver — in code that looks completely reasonable, wearing the disguise it usually wears in production: a first-response-wins fetch.

ch02/fetcher.go
package ch02

import (
	"context"
	"time"
)

type Result struct {
	URL  string
	Size int
}

// Provided for you: a slow fetch that honours cancellation.
func fetchOne(ctx context.Context, url string) (Result, error) {
	select {
	case <-time.After(50 * time.Millisecond):
		return Result{URL: url, Size: len(url) * 10}, nil
	case <-ctx.Done():
		return Result{}, ctx.Err()
	}
}

// TODO(reader): this leaks. Every goroutine that loses the race is left
// holding a value for a channel nobody will ever read again. Fix it
// WITHOUT waiting for the slow fetches — the caller wants the first
// answer, fast.
func FetchFirst(ctx context.Context, urls []string) (Result, error) {
	results := make(chan Result)

	for _, url := range urls {
		go func() {
			r, err := fetchOne(ctx, url)
			if err != nil {
				return
			}
			results <- r // <- your move
		}()
	}

	return <-results, nil
}

The test is §2.4's detection Method 2, pointed at the chapter's own code:

ch02/fetcher_test.go
package ch02

import (
	"context"
	"testing"

	"go.uber.org/goleak"
)

// Fails the run if any goroutine outlives the test binary.
func TestMain(m *testing.M) {
	goleak.VerifyTestMain(m)
}

func TestFetchFirstDoesNotLeak(t *testing.T) {
	urls := []string{
		"https://go.dev",
		"https://pkg.go.dev",
		"https://go.dev/blog",
		"https://go.dev/doc",
	}

	got, err := FetchFirst(context.Background(), urls)
	if err != nil {
		t.Fatalf("FetchFirst returned %v", err)
	}
	if got.URL == "" {
		t.Fatal("FetchFirst returned an empty result")
	}
	// The losing goroutines are blocked on `results <- r` right now.
	// VerifyTestMain catches them when the binary exits.
}

Note what happens when you run it. The assertions all pass — the function returns a perfectly good result. It is goleak that fails the run:

Terminal
$ go test ./ch02
PASS
goleak: Errors on successful test run: found unexpected goroutines:
[Goroutine 8 in state chan send, with corelabs/ch02.FetchFirst.func1 on top of the stack:
corelabs/ch02.FetchFirst.func1()
fetcher.go:39 +0x73
created by corelabs/ch02.FetchFirst in goroutine 7
FAIL corelabs/ch02 0.795s
Measured Output from the unmodified starter in code/ch02/, go1.26.1. Goroutine numbers vary between runs; the chan send state does not.

state chan send is the whole diagnosis. Three goroutines finished their work and are stuck offering a value to a channel that will never be read again. Fix FetchFirst so they can finish and exit.

Done when: go test -race ./ch02 reports ok with goleak silent, and FetchFirst still returns as soon as the first fetch lands — it must not wait for the stragglers. If your fix made it slower, you solved a different problem.
Hint, if you want one: §2.4 lists four prevention patterns. One of them is a single word added to one line of FetchFirst.

Test your understanding of the concepts covered in Chapter 2. Click each question to reveal the answer.

Self-Check Questions

Goroutine Creation (Section 2.1)

1. What are the Four Questions you should ask before creating any goroutine?

The Four Questions:

  1. How does this goroutine exit?
  2. How does it communicate results?
  3. How are errors handled?
  4. What data does it access?
2. What is the output of this program? Why?
quiz_question.go
func main() {
    for i := 0; i < 3; i++ {
        go func() {
            fmt.Println(i)
        }()
    }
    time.Sleep(100 * time.Millisecond)
}

In Go < 1.22: Most likely output is 3 3 3 (all threes). This is the loop variable capture bug—all three closures capture the same variable i, and by the time they execute, the loop has completed with i == 3. Fix: pass i as a parameter: go func(n int) { fmt.Println(n) }(i).

In Go 1.22+: Output is 0 1 2 (in some order). The language now creates a new i per iteration, fixing this bug. The parameter pattern still works and remains recommended for clarity.

3. A goroutine panics. What happens to the rest of the program?

The entire program crashes. A panic in any goroutine terminates the whole program unless explicitly recovered within that same goroutine. Recovery in the launching goroutine does not catch panics in spawned goroutines.

4. When are function arguments to a go statement evaluated?

Arguments are evaluated immediately at the go statement, in the launching goroutine. The goroutine receives copies of those evaluated values.

Program Termination (Section 2.2)

5. What happens to other goroutines when main() returns?

They are terminated immediately. No cleanup, no deferred functions, no notification—they simply cease to exist. The operating system reclaims all process resources.

6. A goroutine is blocked on a channel receive when main() returns. Does the receive eventually return with an error?

No. The receive never returns at all—not with an error, not with a zero value. The goroutine is deleted mid-operation. From the goroutine's perspective, execution simply stops; it doesn't "notice" termination.

7. What's the difference between main() returning and calling os.Exit(0)?

When main() returns, its own deferred functions execute before the program exits—but deferred functions in other goroutines do not run; those goroutines are simply terminated. When os.Exit(0) is called, the program terminates immediately—no deferred functions run, not even those in main().

8. Under what conditions does the runtime deadlock detector fail?

The deadlock detector only catches complete deadlocks where ALL goroutines are blocked. It fails to detect:

  • Partial deadlocks (some goroutines blocked, others running)
  • Goroutine leaks (goroutines blocked forever but main is still running)
  • Livelocks (goroutines running but making no progress)

WaitGroups (Section 2.3)

9. What happens if you call wg.Wait() when the counter is already zero?

Wait() returns immediately. A zero counter means "nothing to wait for."

10. Why must you pass *sync.WaitGroup instead of sync.WaitGroup to a function?

Passing by value creates a copy with an independent counter. The original WaitGroup's counter never decrements, so Wait() blocks forever (deadlock). Passing by pointer ensures all goroutines operate on the same WaitGroup.

11. What happens if a goroutine panics before calling wg.Done() (without defer)?

The counter never decrements for that goroutine. If other goroutines complete, the counter never reaches zero, and Wait() blocks forever (deadlock). This is why defer wg.Done() is essential—it executes even when a panic occurs.

12. Why must wg.Add() be called before the go statement, not inside the goroutine?

Race condition. If Add() is inside the goroutine, Wait() might be called before any goroutine has executed its Add(). With counter at 0, Wait() returns immediately, even though goroutines were created.

13. What is the difference between wg.Done() and wg.Add(-1)?

They are functionally equivalent—both decrement the counter by 1. Done() is syntactic sugar for Add(-1). Use Done() for clarity; it signals intent ("this work is done").

Goroutine Leaks (Section 2.4)

14. What happens when you receive from a nil channel?

It blocks forever. A nil channel is not closed—it's uninitialized. Both send and receive on nil channels block indefinitely. (Closing a nil channel panics.)

15. This code leaks goroutines. How many can leak per call, and why?
func search(query string) Result {
    results := make(chan Result)

    go func() { results <- searchDB(query) }()
    go func() { results <- searchCache(query) }()
    go func() { results <- searchAPI(query) }()

    select {
    case r := <-results:
        return r
    case <-time.After(100 * time.Millisecond):
        return Result{}
    }
}

Up to 3 goroutines can leak per call:

  • On timeout: All 3 goroutines try to send on an unbuffered channel with no receiver → all 3 block forever
  • On success: 1 result is received, but 2 goroutines still try to send → 2 block forever

Fix: Use results := make(chan Result, 3) so all sends can complete.

16. How does a buffered channel prevent goroutine leaks in timeout scenarios?

A buffered channel allows the send to complete even when no receiver is waiting. The goroutine sends its result to the buffer and exits cleanly. Without the buffer, the send blocks forever waiting for a receiver that will never come.

17. Why doesn't the Go garbage collector clean up blocked goroutines?

A goroutine is a g struct plus its stack, so it is certainly memory. But the GC only collects what is unreachable, and a blocked goroutine is still reachable from the runtime's perspective (it's in the scheduler's data structures). The runtime has no way to know that a goroutine is "stuck forever" versus "legitimately waiting."

Costs and Limits (Section 2.5)

18. Should you create 1000 goroutines for 1000 items requiring ~500ns CPU work each?

No. At ~500 ns per item you are at parity with the ~0.5 µs it costs to create and finish a goroutine — so you would roughly double the total work to gain nothing. Batching is still the answer; the margin is just narrower than it looks. Process sequentially or batch items into larger chunks for worker goroutines.

19. Should you create 1000 goroutines for 1000 URLs taking ~200ms each to fetch?

Yes, this is reasonable. At 200ms per fetch, goroutine overhead (~0.5 µs) is negligible (0.0005%). The limiting factor is network/server capacity, not goroutine count. You might add a semaphore if you need to respect rate limits or connection pools.

20. For CPU-bound work, what's the ideal number of goroutines?

runtime.GOMAXPROCS(0) workers (typically equals CPU core count). More goroutines than cores means more context switches without more parallelism—same throughput, higher overhead, increased latency.

21. What's the difference between acquiring a semaphore before go versus inside the goroutine?
  • Acquire before go: Blocks the loop, limiting how many goroutines are created. Memory usage is O(maxConcurrent). Best for memory-heavy work.
  • Acquire inside goroutine: All goroutines are created immediately, but only maxConcurrent execute at once. Memory usage is O(N) goroutines. Best when goroutine memory is negligible and you want all work queued.

Further reading

  • Effective Go — Goroutines — the original short description, still the clearest statement of what go does and doesn't promise.
  • sync.WaitGroup.Go — and its Go 1.25 release note. The source is worth reading too: the panic comment in $GOROOT/src/sync/waitgroup.go is the clearest explanation of the trade-off in §2.3.
  • go.uber.org/goleak — the detector used in the exercise. Chapter 16 makes it part of the test suite properly.
  • Fixing For Loops in Go 1.22 — why the two “fixes” in §2.1 are history, and why the behavior is gated on your go.mod rather than your toolchain.
  • Profiling Go Programs — background for §2.4's third detection method. Chapter 19 goes properly into pprof.
Next

You can start goroutines, keep main alive long enough for them to finish, wait on them without racing, spot the six ways they leak, and say what they cost. Every one of those coordination tools so far has been a counter. Chapter 3 gives you the thing that actually carries values: channels — how they block, how they close, and why the sender is always the one who closes them.


Key Concepts Checklist

Before moving to Chapter 3, ensure you understand: