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.
- 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
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.
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.
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
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")
}
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:
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:
- Evaluates arguments (if any) in the current goroutine
- Creates a new goroutine with ~2KB initial stack
- Schedules the goroutine for execution (hands it to the runtime scheduler)
- Returns immediately to the caller—the new goroutine runs independently
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
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.
- 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?
- 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?
- 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.
- 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:
Applying the Framework
Let's evaluate the simple example from earlier:
go sayHello()
fmt.Println completes
fmt.Println can't fail here
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:
// 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...
}()
url from enclosing scope
⚠ Is url modified elsewhere?
This analysis reveals problems. A better version:
// 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.
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
- 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:
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:
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:
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:
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:
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:
package main
import (
"fmt"
"time"
)
func main() {
greetFunc := func(name string) {
fmt.Println("Hi,", name)
}
go greetFunc("Eve")
time.Sleep(10 * time.Millisecond)
}
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
// ✗ 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)
All three goroutines print the last URL, not their respective URLs.
Why It Happens
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).
The loop variable capture bug only occurs with closures—anonymous functions that reference variables from their enclosing scope without passing them as arguments.
// 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
// 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
// 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.
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:
go func(u string) { /* uses u */ }(url)
// ^ ^
// | |
// parameter here value passed here
On Go 1.22+,
capture the loop variable directly —
wg.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:
// ✓ 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:
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
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:
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.
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:
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):
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
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:
package main
import (
"fmt"
"time"
)
func main() {
go fmt.Println("First")
go fmt.Println("Second")
go fmt.Println("Third")
time.Sleep(10 * time.Millisecond)
}
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:
func main() {
go fmt.Println("goroutine")
fmt.Println("main")
}
Three possible outcomes:
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:
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:
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:
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")
}
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:
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:
url passed as parameter — each goroutine has
own copy ✓
Notice the non-deterministic ordering—goroutines execute concurrently with no guaranteed sequence.
Key Mental Model
When you write go f(args):
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
() on anonymous function
Doesn't compile
go func() { }() not go func() { }
All goroutines see same value
Pass as parameter: go func(u string) { }(url)
go can't return
Use channels or shared state with synchronization
Non-deterministic
Use synchronization primitives
time.Sleep for coordination
Fragile, wasteful
Use WaitGroup or channels
main may exit early
Use proper synchronization (Section 2.3)
Crashes entire program
Recover within goroutine or let it crash intentionally
Leaks, races, lost results
Always analyze before writing go
Section Summary
go functionCall()go statement)
go returns immediately; goroutine runs independently
The Four Questions: Quick Reference
Before every go statement:
If you can't answer all four clearly, stop and redesign before writing
the go statement.
Key Takeaways
-
golaunches and returns—no automatic waiting -
Arguments evaluate immediately at the
gostatement - No return values—use channels for results
- Execution order is non-deterministic—never assume ordering
- Loop variable capture is the #1 bug—pass values as parameters (or use Go 1.22+)
- Panics are not isolated—unhandled panics crash the program
-
Ask the Four Questions—before every
gostatement, every time
Next: Section 2.2 examines what happens when
main returns—and why proper coordination is
fundamental to correct concurrent programs.
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:
package main
import "fmt"
func main() {
go fmt.Println("Hello from goroutine")
fmt.Println("Main function ending")
}
What's the output?
The goroutine almost certainly never prints—main
exits before the scheduler gives the goroutine CPU time. Now add a
tiny delay:
package main
import (
"fmt"
"time"
)
func main() {
go fmt.Println("Hello from goroutine")
fmt.Println("Main function ending")
time.Sleep(1 * time.Millisecond)
}
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.
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:
go
Program Termination: The Iron Rule
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.
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
}
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
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")
}
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
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")
}
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)
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:
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")
}
The worker's deferred cleanup never executes.
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.Writeris 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.
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.
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")
}
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 blocking operation returning with an error
- The goroutine being signaled to exit
- The goroutine having a chance to clean up
The goroutine is simply removed from memory mid-operation.
This applies to all blocking operations:
<-ch)
ch <-)
mu.Lock())wg.Wait())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.
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
}
The runtime detects that no goroutine can proceed and panics with helpful information showing where each goroutine is stuck.
-
[chan receive]— The state tells you what it's blocked on -
main.main.func1— The function where execution stopped (anonymous closures are namedfuncN; 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.
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
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
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:
Go's design philosophy: explicit coordination is better than implicit magic. You know which goroutines must complete; the runtime doesn't.
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:
go keyword)
Go doesn't track parent-child relationships between goroutines. There's no implicit "wait for children" behavior.
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:
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):
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:
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:
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.
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.
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
// 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
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
// 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
// Illustrative snippet — processData is application-defined.
func main() {
go processData()
time.Sleep(5 * time.Second)
// Did it succeed? Fail? Still running?
}
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:
// 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?"
Examples of acceptable fire-and-forget:
- ✓ Best-effort metrics reporting (loss acceptable)
- ✓ Debug logging that can be dropped
- ✓ Cache warm-up that will retry anyway
- ✓ Non-critical notifications
Examples that need coordination:
- ✗ Database writes (data loss unacceptable)
- ✗ HTTP response handling (client needs answer)
- ✗ File operations (corruption risk)
- ✗ Resource cleanup (leaks accumulate)
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)
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)
}
Main's deferred functions execute. Other goroutines are terminated without cleanup.
2. Panic in main
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")
}
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)
package main
import (
"fmt"
"os"
)
func main() {
defer fmt.Println("This never prints") // Does NOT run
os.Exit(0) // Immediate termination
}
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:
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
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:
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")
}
Key difference: "All workers finished" prints after all workers complete—guaranteed, regardless of timing.
Additional coordination mechanisms covered later:
- Channels (Chapter 3)—for results and completion signals
- Context (Chapter 13)—for cancellation and timeouts
- Graceful shutdown patterns (Chapter 15)—for production systems
Common Mistakes
time.Sleep for coordination
Unreliable, wasteful
Use WaitGroup or channels
Goroutines killed mid-work
Explicit synchronization
They don't
Coordinate before main exits
They're deleted instantly
Coordinate or accept loss
main might exit first
Always coordinate
os.Exit unnecessarily
Skips all defers
Return from main instead
Only complete deadlocks detected
Use leak detection tools
Section Summary
main()
os.Exit)
Key Takeaways
-
When
mainreturns, everything stops—all goroutines are killed instantly - Blocked goroutines don't wake up—they're deleted mid-operation
- Abandoned goroutines don't clean up—their defers never run
- Blocking keeps the program alive—main must return to terminate
-
time.Sleepis not coordination—it's guessing, not synchronizing - This is intentional design—forces explicit lifecycle management
- Most goroutines need coordination—fire-and-forget should be a deliberate exception
- 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.
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:
A counter. Add raises it, each Done lowers it, and Wait blocks until it reaches zero.
Basic Usage
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):
"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.
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.
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.
wg.Wait() // Returns immediately if counter is already 0
// Otherwise blocks until all Done() calls complete
Critical Rule: Add Before Go
Always call Add() BEFORE the
go statement, never inside the goroutine.
This is the most common WaitGroup mistake:
// ✗ 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!
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:
// ✓ 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()
Always use defer wg.Done()—never call
Done() directly at the end of a function.
Without defer, early returns and panics cause deadlocks:
// ✗ 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)
}
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:
// ✗ 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!
}
Copying a WaitGroup copies its counter, so the copy's Done calls never reach the original. Always pass a pointer.
The correct pattern:
// ✓ 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
}
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:
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:
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:
-
Add(len(urls))once: Slightly cleaner, but if the loop exits early (break/error), you must handle the mismatch -
Add(1)per iteration: More robust—each goroutine paired with its own Add
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:
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:
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:
package main
import "sync"
func main() {
var wg sync.WaitGroup
wg.Add(1)
wg.Done()
wg.Done() // Panic: negative WaitGroup counter
}
Common causes:
-
Calling
Done()without correspondingAdd() - Calling
Done()twice in the same goroutine -
Incorrect
Add()count when adding multiple at once
The defer wg.Done() pattern with Add(1)
per goroutine makes mismatches nearly impossible.
Reusing WaitGroups
A WaitGroup can be reused after Wait() returns:
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")
}
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:
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")
}
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:
// 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()
}
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:
- Business logic becomes testable without goroutines
- Same logic works sequentially or concurrently
- Concurrency strategy can change without touching core logic
- Clearer separation of responsibilities
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:
// 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:
// 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()
-
Eliminates boilerplate—no
Add(1)ordefer wg.Done() -
Prevents "Add before go" mistakes—
Add(1)runs before the goroutine starts, soWaitcan never observe a counter that hasn't been raised yet -
Prevents missing
Done()—on the normal path and onruntime.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:
CallingDonewill unblockWaitin 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 callingDoneand simply panic.
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:
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:
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:
// 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 collapses Add, go and defer Done into one call. The manual pattern remains for pre-1.25 toolchains.
Complete Example: Concurrent URL Fetcher
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):
Four Questions analysis:
u passed as parameter; no shared mutable state
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 goroutineRace condition—Wait() may return early
Add() before go
Creates copy with independent counter
Pass *sync.WaitGroup
defer on Done()
Early returns/panics cause deadlock
Always defer wg.Done()
Done() than Add()
Panic: negative counter
Match counts exactly
Add() while Wait() is blocking
Race condition
Reuse only after Wait() returns
Hard to test, inflexible
Separate orchestration from logic
WaitGroup vs Other Coordination
WaitGroup is one tool among several. Here's when to use it:
sync.WaitGroupcontext.Context (Ch 13)
errgroup (Ch 14)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
go)
defer)
Add(1), go,
defer Done()
*sync.WaitGroup)
Wait() returns
Key Takeaways
- Add before go—always increment the counter before starting the goroutine
-
Always
defer wg.Done()—handles all exit paths including panics - Pass by pointer—value copies create independent counters
-
Use
wg.Go()in Go 1.25+—eliminates boilerplate and common mistakes - Separate concerns—keep business logic unaware of WaitGroups
- 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.
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
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.
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:
- Stack memory: Minimum ~2KB, grows with call depth
- Heap references: Everything the goroutine's stack points to cannot be garbage collected
- Scheduler overhead: Runtime must track the goroutine even if it's blocked
A single leak might be negligible. Thousands accumulating over hours or days will crash your service:
// 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.
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:
// ✗ 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:
// ✓ 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:
// ✗ 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:
// ✓ 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:
// ✗ 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)
}()
}
Send and receive on a nil channel both block forever, and no close can ever unblock them.
Fix: Always initialize channels before use:
// ✓ 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
}
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:
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.
// ✗ 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
}
}
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:
// ✓ 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
}
}
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 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:
// ✗ 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:
// ✓ 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:
// ✗ 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:
// ✓ 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:
// ✗ 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:
- Three goroutines send, but we only receive once
- On timeout, all three goroutines block forever on send
- Even on success, two goroutines still block forever
Fixed version:
// ✓ 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:
// ✓ 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:
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-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:
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:
Example failure:
Method 3: pprof
For production debugging, use the goroutine profile:
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:
The output shows where goroutines are blocked:
1823 goroutines stuck in handleSearch—clear
evidence of a leak.
Goroutine States
When debugging with pprof or stack traces, you'll see 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.
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
-
[chan send, 3 minutes]: State and duration—blocked for 3 minutes trying to send -
main.worker: The function where it's stuck -
created by main.startWorkers: Where to look for the bug
Prevention Patterns
Pattern 1: Always Provide Exit Paths
Every goroutine should have a way to exit:
// 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 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 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 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:
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
goleakenabled in tests? - Are leak scenarios tested explicitly?
The Four Questions Connection
Remember the Four Questions from Section 2.1:
Question 1 is paramount for leak prevention. If you can't clearly articulate how a goroutine exits, you likely have a leak.
Common Mistakes
Sender blocks forever after timeout
Buffer matches sender count
Goroutine runs forever
Use done channel or context
Blocks forever
Always initialize channels
select with default in loop
Spins at 100% CPU
Remove default or add sleep
Leaks go unnoticed
Use goleak
Blocks if service is down
Always use context with timeout
Silent leaks accumulate
Track and test all goroutines
Section Summary
NumGoroutine(), goleak, pprof
goleak in all test suites
Key Takeaways
- Goroutines are not garbage collected—they exist until they return
- Leaks accumulate silently—no warnings until resource exhaustion
- Buffer size must match sender count—one buffer slot per potential sender
- Every goroutine needs an exit path—done channels or context
- The creator is responsible—whoever starts a goroutine must ensure it can stop
-
Test for leaks—use
goleakto catch leaks early - Monitor in production—track goroutine count as a metric
- 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.
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.
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
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:
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
But stacks are just the beginning. Each goroutine also holds references to heap objects, preventing garbage collection:
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:
// 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:
~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:
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:
~366 ns per round-trip (two context switches per iteration, so ~183 ns per switch).
Comparison with OS Threads
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?
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
// 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:
Goroutine-per-item is 144× slower for trivial work. The overhead dominates completely.
Where the Overhead Goes
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.
// 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:
- Work duration: 50-500ms (50,000-500,000μs)
- Goroutine overhead: ~0.5 µs
- Overhead ratio: 0.0002-0.002%
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:
Case 2: CPU-Bound Work
Characteristics: Work keeps the CPU busy—computation, parsing, encoding.
// 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 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.
// ✓ 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.
// 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:
- 10 concurrent goroutines: 1GB memory
- 100 concurrent goroutines: 10GB memory
- 1000 concurrent goroutines: 100GB memory (likely OOM)
Verdict: ✗ Limit by available memory.
// ✓ 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:
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
}
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:
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()
}
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:
// 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
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
-
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
// ✗ 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
// ✗ 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
// ✗ 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
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
Common Patterns Reference
Copy-paste-ready patterns for the concurrency limiting techniques covered in this section. Each snippet is self-contained.
Coordinating Completion (WaitGroup)
// 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)
// 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)
// 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)
// 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)
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
Overhead dominates (100×+ slower)
Batch into chunks
Context switch overhead, no speedup
Worker pool matching core count
OOM crash
Semaphore limiting concurrency
Premature optimization
Benchmark first, optimize if needed
Often false for CPU-bound
Profile to find actual bottleneck
Overwhelming databases, APIs
Semaphore matching external capacity
Section Summary
go
Key Takeaways
- Goroutines are cheap, not free—overhead matters for small work
- I/O-bound work: spawn freely—external resources are the limit
- CPU-bound work: match core count—more goroutines won't help
- Memory-heavy work: limit by memory—use semaphore
- Measure before optimizing—assumptions are often wrong
- Choose the right pattern—worker pool vs semaphore vs unlimited
- Consider not using goroutines—sequential code is sometimes faster
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
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.
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:
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:
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.
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.
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)
The Four Questions:
- How does this goroutine exit?
- How does it communicate results?
- How are errors handled?
- What data does it access?
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.
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.
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)
They are terminated immediately. No cleanup, no deferred functions, no notification—they simply cease to exist. The operating system reclaims all process resources.
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.
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().
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)
Wait() returns immediately. A zero counter means
"nothing to wait for."
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.
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.
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.
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)
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.)
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.
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.
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)
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.
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.
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.
-
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
godoes 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.gois 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.modrather than your toolchain. -
Profiling Go Programs
— background for §2.4's third detection method.
Chapter 19 goes properly into
pprof.
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:
- The Four Questions framework for goroutine design
- Why loop variable capture is dangerous (and how Go 1.22+ changes this)
-
The Iron Rule:
main()returning kills all goroutines instantly - Why
time.Sleepis never proper coordination - WaitGroup mechanics: Add before go, defer Done, pass by pointer
- How
wg.Go()simplifies the pattern in Go 1.25+ - The six common goroutine leak patterns
- Why buffer size must match sender count
-
How to detect leaks:
runtime.NumGoroutine(),goleak, pprof - When to spawn freely vs. limit concurrency
- Worker pool vs. semaphore patterns
- The break-even point for goroutine overhead (~1 µs)