Goroutines: Lightweight Concurrency
Goroutines: Lightweight Concurrency
If Go's error handling reflects its engineering philosophy, then goroutines are its technical soul. Go's concurrency primitives — goroutines and channels — aren't library features added after the fact; they're core mechanisms built into the language from day one. Rob Pike said in his 2012 talk "Concurrency is not Parallelism":
"Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once."
This distinction is crucial. Go's design goal isn't to make programs run faster (that's parallelism's job), but to help programs better organize concurrent logic (that's concurrency's job). Goroutines are the foundational building block for achieving this goal.
This chapter dives deep into the goroutine mechanism: from basic usage to leak prevention, from a scheduling model preview to concurrency control patterns.
Level 1: What You Need to Know
What Is a Goroutine
A goroutine is a lightweight execution unit managed by the Go runtime. From a programmer's perspective, it's simply a concurrently-executing function:
func main() {
go sayHello() // start a new goroutine
time.Sleep(time.Second) // wait (crude approach)
}
func sayHello() {
fmt.Println("Hello from goroutine!")
}
The go keyword is the only syntax needed — place it before a function call, and that function executes concurrently in a new goroutine.
Core characteristics:
- Extremely lightweight: Initial stack is only 2KB (compare: OS threads typically 1-8MB)
- Dynamic stack: Automatically grows when more space is needed, shrinks when not needed
- User-space scheduling: Scheduled by the Go runtime, not 1:1 mapped to OS threads
- Minimal creation cost: Creating a goroutine takes ~0.3us, creating an OS thread takes ~10us
Starting Goroutines
Basic Usage
// Start a named function
go processRequest(req)
// Start an anonymous function
go func() {
fmt.Println("anonymous goroutine")
}()
// Start an anonymous function with parameters
go func(msg string) {
fmt.Println(msg)
}("hello")
Note the trailing () — what follows go is a function call expression, not just a function name.
Common Pitfall: Loop Variable Capture
// Bug (before Go 1.22): all goroutines share loop variable i
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // might all print 5
}()
}
// Correct: pass by value through parameter
for i := 0; i < 5; i++ {
go func(n int) {
fmt.Println(n) // prints 0,1,2,3,4 (order undefined)
}(i)
}
Good news: Starting with Go 1.22, loop variables create a new copy per iteration by default, eliminating this pitfall. But you still need to know this for maintaining older code.
Main Goroutine Exit Terminates Everything
func main() {
go func() {
time.Sleep(time.Second)
fmt.Println("done") // never executes
}()
// main returns, program ends, all goroutines forcibly terminated
}
A Go program's lifetime is bound to the main goroutine. When the main function returns, all other goroutines are immediately terminated — no defers execute, no cleanup opportunity.
Waiting for Goroutines to Complete
sync.WaitGroup
The most common waiting mechanism:
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // counter +1
go func(id int) {
defer wg.Done() // counter -1
fmt.Printf("goroutine %d done\n", id)
}(i)
}
wg.Wait() // blocks until counter reaches zero
fmt.Println("all done")
}
WaitGroup's three methods:
Add(delta int): Increment counter (usually called before launching goroutine)Done(): Decrement counter (equivalent toAdd(-1), typically used with defer)Wait(): Block until counter is 0
Critical rule: Add() must be called before the corresponding go statement, otherwise there's a race — Wait() might return before Add().
Channel Synchronization
func main() {
done := make(chan struct{})
go func() {
// do some work...
fmt.Println("work complete")
close(done) // signal completion
}()
<-done // blocks until channel is closed
fmt.Println("main exits")
}
context.Context for Lifecycle Control
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
go worker(ctx)
<-ctx.Done()
fmt.Println("timeout, shutting down")
}
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("worker: context cancelled")
return
default:
// do work...
time.Sleep(500 * time.Millisecond)
fmt.Println("worker: tick")
}
}
}
The Lightweight Nature of Goroutines
Why are goroutines called "lightweight"? The numbers:
| Property | Goroutine | OS Thread |
|---|---|---|
| Initial stack size | 2 KB | 1-8 MB |
| Creation time | ~0.3 us | ~10 us |
| Context switch | ~100 ns (user space) | ~1-10 us (kernel) |
| Memory overhead | ~4 KB (including runtime metadata) | ~1 MB+ |
| Maximum count | Millions | Thousands |
This means you can confidently create hundreds of thousands of goroutines, which would be impossible with OS threads. The following code is perfectly legal:
func main() {
var wg sync.WaitGroup
for i := 0; i < 1_000_000; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
time.Sleep(time.Second)
}(i)
}
wg.Wait()
}
One million goroutines consume approximately 4GB of memory (~4KB each) — entirely feasible on modern servers. One million OS threads would require 1TB+ of memory — simply impossible.
Common Mistakes and Fixes
Mistake 1: Forgetting to wait for goroutines
// Wrong: main may exit before goroutine completes
func main() {
go doWork()
}
// Right: use WaitGroup or channel to wait
func main() {
done := make(chan struct{})
go func() {
doWork()
close(done)
}()
<-done
}
Mistake 2: Using t.Fatal inside a goroutine
func TestSomething(t *testing.T) {
go func() {
if err := doSomething(); err != nil {
t.Fatal(err) // panic! t.Fatal must be called from the test goroutine
}
}()
}
// Right: pass error through channel
func TestSomething(t *testing.T) {
errCh := make(chan error, 1)
go func() {
errCh <- doSomething()
}()
if err := <-errCh; err != nil {
t.Fatal(err)
}
}
Mistake 3: Launching goroutines without limits
// Wrong: may cause OOM (Out of Memory)
func handleRequests(requests []Request) {
for _, req := range requests {
go process(req) // what if requests has millions of entries?
}
}
// Right: use worker pool to limit concurrency
func handleRequests(requests []Request) {
sem := make(chan struct{}, 100) // max 100 concurrent
var wg sync.WaitGroup
for _, req := range requests {
wg.Add(1)
sem <- struct{}{} // acquire token
go func(r Request) {
defer wg.Done()
defer func() { <-sem }() // release token
process(r)
}(req)
}
wg.Wait()
}
Level 2: How It Works Under the Hood
Internal Representation of Goroutines
In the Go runtime, each goroutine is represented by a runtime.g struct (defined in runtime/runtime2.go):
// Simplified runtime.g struct
type g struct {
stack stack // goroutine stack space descriptor
stackguard0 uintptr // stack overflow check sentinel
m *m // currently bound M (OS thread)
sched gobuf // scheduling context (SP, PC, BP registers)
atomicstatus uint32 // goroutine state
goid int64 // goroutine ID
gopc uintptr // PC of the go statement that created this goroutine
// ... more fields
}
type gobuf struct {
sp uintptr // stack pointer
pc uintptr // program counter
g guintptr
ret uintptr
bp uintptr // frame pointer (for stack unwinding)
}
Goroutine State Machine
A goroutine goes through these states during its lifecycle:
_Gidle → _Grunnable → _Grunning → _Gsyscall / _Gwaiting → _Gdead
- _Gidle: Just allocated, not initialized
- _Grunnable: In run queue waiting for execution
- _Grunning: Currently executing on some M
- _Gsyscall: Executing a system call (M is blocked)
- _Gwaiting: Blocked on some operation (channel, lock, timer, etc.)
- _Gdead: Finished execution, awaiting recycling
Dynamic Stack (Stack Growth)
Goroutine stacks grow dynamically. They start at only 2KB (defined as _StackMin = 2048 in runtime/stack.go), but can grow up to 1GB by default (configurable via runtime/debug.SetMaxStack).
Stack Growth Mechanism
The Go compiler inserts a stack check prologue at each function entry:
// Compiler-generated pseudo-assembly
TEXT ·someFunction(SB), NOSPLIT, $frameSize
MOVQ (TLS), CX // get current g
CMPQ SP, 16(CX) // compare SP with g.stackguard0
JLS morestack // if SP < stackguard0, need to grow stack
// normal function body...
RET
morestack:
CALL runtime·morestack_noctxt(SB)
JMP ·someFunction(SB) // re-execute function after stack growth
When insufficient stack space is detected:
- Allocate a larger stack (typically 2x current size)
- Copy old stack contents to new stack
- Adjust all pointers pointing to the old stack (this is why you can't take addresses of goroutine stack variables and pass them to cgo)
- Free old stack
This mechanism is called contiguous stacks — introduced in Go 1.4, replacing the previous segmented stacks. Segmented stacks had the "hot split" problem: when function calls repeatedly crossed stack boundaries, it caused repeated allocation and deallocation of stack segments, leading to performance jitter.
Stack Shrinking
Stacks don't just grow; they also shrink. When GC finds a goroutine's stack utilization is below 25%, it shrinks the stack by half at the next scheduling point. This prevents long-running goroutines from holding unnecessary memory.
Goroutine Leaks
Goroutine leaks are the most common form of resource leak in Go programs. Leaked goroutines never terminate, continuously consuming memory.
Cause 1: Unclosed/Unread Channels
// Leak: sender blocks forever
func leak1() {
ch := make(chan int)
go func() {
val := doExpensiveWork()
ch <- val // if nobody reads ch, blocks forever
}()
// function returns, ch unreachable, but goroutine still waiting to send
}
// Fix: use buffered channel or select + context
func fixed1(ctx context.Context) {
ch := make(chan int, 1) // buffered: won't block even without reader
go func() {
val := doExpensiveWork()
select {
case ch <- val:
case <-ctx.Done():
return
}
}()
}
// Leak: receiver blocks forever
func leak2() {
ch := make(chan int)
go func() {
for val := range ch { // ch never gets closed
process(val)
}
}()
// sender stops sending but doesn't close(ch)
}
// Fix: ensure sender closes channel
func fixed2() {
ch := make(chan int)
go func() {
for val := range ch {
process(val)
}
}()
// close after sending completes
for _, item := range items {
ch <- item
}
close(ch) // signal receiver to end
}
Cause 2: Uncancelled Context
// Leak: context never gets cancelled
func leak3() {
ctx := context.Background() // never expires
go worker(ctx) // worker runs forever
}
// Fix: use cancellable context
func fixed3() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // cancel context when function exits
go worker(ctx)
// ... do some work ...
} // cancel() executes here, worker receives cancellation signal and exits
Cause 3: Deadlock
// Leak: mutual waiting
func leak4() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
val := <-ch1 // wait for ch1
ch2 <- val // then send to ch2
}()
go func() {
val := <-ch2 // wait for ch2
ch1 <- val // then send to ch1
}()
// two goroutines waiting for each other, never complete
}
Detecting Goroutine Leaks
Method 1: runtime.NumGoroutine()
func TestNoLeak(t *testing.T) {
before := runtime.NumGoroutine()
// execute code under test
doSomething()
time.Sleep(100 * time.Millisecond) // wait for goroutines to finish
after := runtime.NumGoroutine()
if after > before {
t.Errorf("goroutine leak: before=%d, after=%d", before, after)
}
}
Method 2: goleak library (Uber open source)
import "go.uber.org/goleak"
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
func TestSomething(t *testing.T) {
defer goleak.VerifyNone(t)
// test code...
}
Method 3: pprof goroutine profile
import _ "net/http/pprof"
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// Visit http://localhost:6060/debug/pprof/goroutine?debug=2
// to see all goroutine stack traces
}
Goroutine Scheduling Model Preview (GMP Model)
Go's scheduler uses the GMP Model (detailed in Chapter 14); here's an overview:
- G (Goroutine): The goroutine itself, containing stack, PC, state, etc.
- M (Machine): OS thread, the carrier that executes goroutine code
- P (Processor): Logical processor, holding local run queue and resources needed to run G
┌─────────────────────────────────────────────┐
│ Global Run Queue (GRQ) │
│ [G5] [G6] [G7] ... │
└─────────────────────────────────────────────┘
↓ steal ↓ steal
┌──────────────┐ ┌──────────────┐
│ P0 │ │ P1 │
│ Local queue: │ │ Local queue: │
│ [G1][G2][G3] │ │ [G4] │
│ │ │ │
│ ↓ execute │ │ ↓ execute │
│ M0 │ │ M1 │
│ (OS Thread) │ │ (OS Thread) │
│ Current: G1 │ │ Current: G4 │
└──────────────┘ └──────────────┘
Key design decisions:
- P count defaults to CPU core count (
GOMAXPROCS), determining maximum parallelism - Each P has a local queue, reducing lock contention (local queue operations are lock-free)
- Work Stealing: When P's local queue is empty, steal G from other Ps or global queue
- M detaches from P during syscalls: When a goroutine enters a syscall, M blocks in kernel, P can bind to a new M to continue executing other Gs
Set P count with runtime.GOMAXPROCS(n):
import "runtime"
func main() {
// Set to use 4 logical processors
runtime.GOMAXPROCS(4)
// Query current setting
fmt.Println(runtime.GOMAXPROCS(0)) // 0 means query without changing
fmt.Println(runtime.NumCPU()) // CPU core count
}
Cooperative vs Preemptive Scheduling
Before Go 1.14, goroutine scheduling was cooperative — goroutines only yielded execution at specific points:
- Function calls (stack checks)
- Channel operations
- System calls
runtime.Gosched()
This caused a problem — tight loops without function calls would monopolize an M:
// Before Go 1.14: this goroutine would never be preempted
go func() {
for {
// tight loop, no function calls
// other goroutines starve
}
}()
Go 1.14 introduced asynchronous preemption — the runtime preempts by sending SIGURG signals to M, interrupting goroutines even in tight loops.
Level 3: What the Specification Says
Concurrency vs Parallelism (Rob Pike 2012)
Rob Pike gave the classic talk "Concurrency is not Parallelism" at Heroku's Waza conference in 2012. This talk defined how the Go community understands concurrency:
Concurrency:
- A program's structure
- The ability to deal with multiple things at once
- About composition
Parallelism:
- A program's execution
- Actually doing multiple things simultaneously
- About efficiency
Analogy: One person managing multiple email windows is concurrency — actually only one pair of hands (one CPU), but handling multiple things. Two people each writing a letter simultaneously is parallelism — two pairs of hands (two CPUs) working at once.
Go's goroutines are primarily a concurrency tool — they help you organize your program into independent, composable execution units. Parallelism is just one possible outcome of concurrency — when GOMAXPROCS > 1, multiple goroutines may execute simultaneously on different CPUs.
Rob Pike's core point:
"Concurrency is not parallelism, although it enables parallelism. If you have only one processor, your program can still be concurrent but it cannot be parallel."
The Go Specification on Goroutines
The Go Language Specification states:
A "go" statement starts the execution of a function call as an independent concurrent thread of control, or goroutine, within the same address space.
Key terms:
- Independent: No implicit communication mechanism between goroutines
- Concurrent: No guarantee of parallel execution
- Thread of control: Has its own execution sequence
- Same address space: Shared memory
The specification also states:
The function value and parameters are evaluated as usual in the calling goroutine, but unlike with a regular call, program execution does not wait for the invoked function to complete.
That is: the function value and parameters are evaluated in the calling goroutine, but execution doesn't wait for the function to complete. This explains why the following code is safe:
x := computeValue()
go process(x) // x is already fully evaluated before the go statement executes
The CSP Model (Communicating Sequential Processes)
Go's concurrency model is deeply influenced by CSP (Communicating Sequential Processes), proposed by Tony Hoare in his 1978 paper (later expanded into a book in 1985).
CSP's core ideas:
- Processes are the basic concurrent units (corresponding to Go's goroutines)
- Processes don't share memory, communicating through message passing (corresponding to Go's channels)
- Communication is synchronous — sender and receiver must both be ready (corresponding to Go's unbuffered channels)
The Go proverb (from Effective Go):
"Do not communicate by sharing memory; instead, share memory by communicating."
This doesn't mean Go forbids shared memory — sync.Mutex still exists and is widely used. But Go's encouraged default mindset is: pass data ownership through channels rather than protecting shared data with locks.
Historical Lineage: From CSP to Go
1978: Tony Hoare — Original CSP paper
↓
1985: Tony Hoare — CSP book
↓
1980s-90s: occam language (for Transputer parallel processors)
↓
1995: Rob Pike & Dennis Ritchie — alef language for Plan 9
↓
2000: Rob Pike — Limbo language (Inferno OS)
↓
2007: Rob Pike, Ken Thompson, Robert Griesemer — begin designing Go
↓
2009: Go open-sourced, goroutines + channels as core features
Rob Pike experimented with goroutine-like concurrency primitives in both alef (1995) and Limbo (2000). Go's goroutines and channels are the mature versions of these predecessors.
Specification Guarantees About Goroutines
The Go specification makes these guarantees about goroutines:
- Creation order: The effect of a
go f()statement — creating a new goroutine — happens before the new goroutine's execution begins - Unordered execution: Execution order of multiple goroutines is undefined unless happens-before relationships are established through synchronization primitives
- No preemption guarantee: The specification doesn't guarantee goroutines yield at any specific point (though in practice Go 1.14+ has asynchronous preemption)
- Memory visibility: Data visibility between goroutines follows the Go Memory Model (
sync,atomic, channel operations establish happens-before)
Level 4: Edge Cases and Pitfalls
Differences Between Goroutines and Threads (Common Interview Question)
| Dimension | Goroutine | OS Thread |
|---|---|---|
| Creator | Go runtime | OS kernel |
| Scheduling | Go scheduler (user space) | OS scheduler (kernel) |
| Stack size | 2KB initial, dynamically grows | Fixed 1-8MB |
| Switch cost | ~100ns (save/restore few registers) | ~1-10us (trap to kernel, TLB flush) |
| Creation cost | ~0.3us | ~10us (system call) |
| Communication | Channel (preferred), shared memory | Shared memory + locks, semaphores |
| Identity | No exposed goroutine ID | Has thread ID |
| Scale | Millions | Thousands |
| Preemption | Cooperative + async preemption (Go 1.14+) | Time-slice preemption |
Why doesn't Go expose goroutine IDs?
Go deliberately hides goroutine IDs, despite having an internal goid field. Reasons (Andrew Gerrand explained in the Go Blog):
- Prevent goroutine-local storage anti-pattern: With goroutine IDs, developers would create thread-local-storage-like things, breaking Go's concurrency model
- Encourage explicit passing: Values that need passing should go through function parameters or context.Context
- Avoid debugging dependencies: Goroutines should be anonymous and interchangeable
If you absolutely need tracking:
// Not recommended but possible: extract goroutine ID from runtime stack
func getGoroutineID() uint64 {
var buf [64]byte
n := runtime.Stack(buf[:], false)
// "goroutine 123 [...]" format
id, _ := strconv.ParseUint(strings.Fields(strings.TrimPrefix(string(buf[:n]), "goroutine "))[0], 10, 64)
return id
}
How to Control Goroutine Count
Method 1: Semaphore Pattern (Channel as Token Bucket)
func processItems(items []Item, maxConcurrency int) {
sem := make(chan struct{}, maxConcurrency)
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // acquire token, blocks if full
go func(it Item) {
defer wg.Done()
defer func() { <-sem }() // release token when done
process(it)
}(item)
}
wg.Wait()
}
Method 2: Worker Pool Pattern
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)
}
// Usage
func main() {
jobs := make(chan Job, 100)
results := make(chan Result, 100)
go workerPool(jobs, results, 10) // 10 workers
// Send jobs
for _, j := range allJobs {
jobs <- j
}
close(jobs)
// Collect results
for r := range results {
handleResult(r)
}
}
Method 3: errgroup (Recommended)
golang.org/x/sync/errgroup provides goroutine management with error propagation and concurrency limits:
import "golang.org/x/sync/errgroup"
func processAll(ctx context.Context, items []Item) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // max 10 concurrent goroutines
for _, item := range items {
item := item // needed before Go 1.22
g.Go(func() error {
return processItem(ctx, item)
})
}
return g.Wait() // returns first error (if any)
}
errgroup advantages:
- Error propagation: If any goroutine returns an error, ctx is cancelled, all others can detect it
- Concurrency limit:
SetLimit(n)controls maximum concurrency - Wait for all:
Wait()waits for all goroutines to complete - Context integration: Automatically manages context cancellation
Interview Questions
Question 1: What does the following code output?
func main() {
runtime.GOMAXPROCS(1)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Print(n)
}(i)
}
wg.Wait()
}
Answer: Outputs some permutation of 0-4 (e.g., 40132), order is undefined. Even with GOMAXPROCS=1 (only one P), goroutine execution order is not guaranteed — the order they enter the run queue and the order they're scheduled may differ.
Question 2: How do you gracefully stop a goroutine?
// Approach A: done channel
func worker(done <-chan struct{}) {
for {
select {
case <-done:
return
default:
doWork()
}
}
}
// Approach B: context (recommended)
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
doWork()
}
}
}
Answer: Context is recommended. Reasons:
- Context can carry cancellation reasons (
context.Cause) - Context can set timeouts (
WithTimeout) - Context propagates through the entire call chain
- Context is Go's standard cancellation signal propagation mechanism
Question 3: What are typical symptoms of goroutine leaks? How to diagnose?
Answer:
- Symptoms: Continuously growing memory (
runtime.NumGoroutine()monotonically increasing), OOM after running for a while - Diagnosis:
runtime.NumGoroutine()monitoringpprofgoroutine profile (/debug/pprof/goroutine)go.uber.org/goleakin tests- Production: Prometheus metric
go_goroutines
Question 4: 1 million goroutines each sleeping for 1 second — how much memory? How much time?
Answer:
- Memory: Approximately 4GB (~4KB per goroutine, including 2KB stack + runtime metadata)
- Time: Approximately 1 second. All goroutines sleep concurrently, not serially. The Go runtime manages sleeping with a timer heap; no OS thread needed per sleeping goroutine.
Real-World Cases
Case 1: Cloudflare's Goroutine Leak Incident (2019)
One of Cloudflare's DNS services experienced a memory leak. Investigation revealed it was due to an HTTP client not closing the response body after receiving error responses:
// Leaking code
resp, err := http.Get(url)
if err != nil {
return err
}
if resp.StatusCode != 200 {
return fmt.Errorf("bad status: %d", resp.StatusCode)
// Forgot resp.Body.Close()!
// The read goroutine in HTTP transport waits forever for body to be consumed
}
Fix:
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close() // always close body
// Even if not reading body, drain it
io.Copy(io.Discard, resp.Body)
Case 2: Go Runtime SIGURG Preemption Bug
When Go 1.14 introduced asynchronous preemption using SIGURG signals, some programs using signal.Notify(ch, syscall.SIGURG) started receiving unexpected signals. The Go team fixed this in 1.14.1 — SIGURG sent by the runtime is no longer delivered to user-registered signal handlers.
Case 3: Uncontrolled Goroutine Count Exhausting Database Connection Pool
// Problem code: each request launches goroutines to query DB without concurrency limit
func handler(w http.ResponseWriter, r *http.Request) {
var results []Result
var mu sync.Mutex
for _, id := range getIDs(r) {
go func(id int) {
// DB connection pool only has 100 connections
// If 1000 concurrent requests each query 10 IDs = 10000 concurrent queries
result, _ := db.Query("SELECT * FROM items WHERE id = ?", id)
mu.Lock()
results = append(results, result)
mu.Unlock()
}(id)
}
}
Fix using errgroup + SetLimit:
func handler(w http.ResponseWriter, r *http.Request) {
g, ctx := errgroup.WithContext(r.Context())
g.SetLimit(10) // max 10 concurrent queries per request
var results []Result
var mu sync.Mutex
for _, id := range getIDs(r) {
id := id
g.Go(func() error {
result, err := db.QueryContext(ctx, "SELECT * FROM items WHERE id = ?", id)
if err != nil {
return err
}
mu.Lock()
results = append(results, result)
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
http.Error(w, err.Error(), 500)
return
}
// use results...
}
Goroutine Best Practices Summary
- Always ensure goroutines have an exit path — use context or done channel
- Use errgroup to manage groups of goroutines — cleaner and safer than manual WaitGroup + error channel
- Control concurrency count — unlimited goroutine launching causes resource exhaustion
- Don't rely on goroutine execution order — even with GOMAXPROCS=1
- Every goroutine should have recover — at least long-running ones
- Prefer channel communication — over shared memory + locks
- Detect goroutine leaks in tests — use goleak or manually check NumGoroutine
- Always close HTTP response bodies — even if not reading the body content
- Long-running goroutines should periodically check context — don't let goroutines continue useless work after context cancellation
- GOMAXPROCS usually doesn't need manual setting — default (CPU count) is optimal for most scenarios