Skip to main content

Goroutines: Lightweight Concurrency in Go

Published: December 17, 2025 Updated: August 29, 2026 Larry Qu 7 min read

A goroutine is a function executing concurrently with other functions in the same address space. You start one with the go keyword — that’s the entire syntax. But what makes goroutines interesting is what happens underneath: Go’s runtime multiplexes thousands of goroutines onto a small number of OS threads using a cooperative, work-stealing scheduler. A freshly created goroutine uses only 2–8 KB of stack, which grows and shrinks automatically. You can run hundreds of thousands simultaneously without the per-thread overhead of other languages.

The flip side is that goroutines are cheap enough to misuse. The most common bugs are goroutine leaks — goroutines that block forever and are never garbage collected — and unsynchronized access to shared data. This guide covers the mechanics, the correct patterns, and how to avoid the pitfalls.

For related topics see Go sync package, Go channels, and Go worker pools.

Starting a Goroutine

Prefix any function call with go to run it concurrently. The caller continues immediately — it does not wait for the goroutine to finish:

func fetchUser(id int) {
    // imagine a DB call here
    fmt.Printf("fetched user %d\n", id)
}

func main() {
    go fetchUser(1)  // starts concurrently
    go fetchUser(2)
    fmt.Println("requests dispatched")
    // WARNING: main may exit before the goroutines finish
}

The warning matters. When main returns, the Go runtime tears everything down — all running goroutines are killed instantly, regardless of what they were doing. If you fire goroutines and return from main without waiting, you have a race between the goroutines and the exit.

Waiting for Goroutines: sync.WaitGroup

sync.WaitGroup is the standard tool for waiting until a known number of goroutines finish. It’s a counter: Add(n) increments it, Done() decrements it, Wait() blocks until it reaches zero.

func processItem(id int, wg *sync.WaitGroup) {
    defer wg.Done()  // always use defer — runs even if the function panics
    fmt.Printf("processing item %d\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1)          // increment BEFORE starting the goroutine
        go processItem(i, &wg)
    }

    wg.Wait()  // block until all 5 call Done()
    fmt.Println("all done")
}

Two rules that prevent subtle bugs: call wg.Add(1) before go, not inside the goroutine (there’s a race if the goroutine starts, calls Done, and Wait sees zero before your Add runs). Always pass &wg — the WaitGroup must not be copied.

Goroutine Lifecycle and Cancellation

A goroutine runs until its function returns. To stop one early, you need a signal. The idiomatic Go approach uses context.Context — it propagates cancellation through a call tree and handles timeouts cleanly:

func worker(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("worker %d stopping: %v\n", id, ctx.Err())
            return
        default:
            // do a unit of work
            fmt.Printf("worker %d working\n", id)
            time.Sleep(100 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 350*time.Millisecond)
    defer cancel()

    var wg sync.WaitGroup
    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            worker(ctx, id)
        }(i)
    }

    wg.Wait()
    fmt.Println("all workers stopped")
}

When the deadline expires, ctx.Done() is closed simultaneously for all goroutines watching it. This is far cleaner than managing individual done channels per goroutine.

Goroutine Leaks

A goroutine leak occurs when a goroutine blocks on a channel receive or send that will never complete, or waits on a lock that’s never released. The goroutine stays alive for the entire program lifetime, consuming stack memory.

The classic example — a goroutine blocked on a channel with no one to unblock it:

// ❌ Goroutine leak: the goroutine blocks on results forever if the caller abandons it
func search(query string) <-chan string {
    results := make(chan string)
    go func() {
        result := expensiveSearch(query)
        results <- result  // blocks if no one is reading
    }()
    return results
}

If the caller stops reading (e.g., due to a timeout), the goroutine in search is stuck trying to send forever. Fix it with a buffered channel or by passing a context:

// ✅ Buffered channel: goroutine can always send and exit
func search(query string) <-chan string {
    results := make(chan string, 1)  // buffer of 1 means the send never blocks
    go func() {
        results <- expensiveSearch(query)
    }()
    return results
}

// ✅ Context: goroutine exits when the caller cancels
func searchWithCtx(ctx context.Context, query string) <-chan string {
    results := make(chan string, 1)
    go func() {
        select {
        case results <- expensiveSearch(query):
        case <-ctx.Done():
        }
    }()
    return results
}

To detect leaks in tests, use the goleak package from Uber: it records the goroutine count before and after a test and fails if any leaked.

Closures in Goroutines: A Common Trap

Goroutines that use loop variables via closures share the variable by reference, not by value. By the time the goroutine runs, the loop may have finished:

// ❌ All goroutines print the same (final) value of i
for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i)  // captures i by reference
    }()
}

// ✅ Pass i as an argument — each goroutine gets its own copy
for i := 0; i < 5; i++ {
    go func(id int) {
        fmt.Println(id)
    }(i)
}

This is one of the most frequent bugs in concurrent Go code. The Go vet tool catches it as of Go 1.22 (the loop variable semantics changed), but it’s worth understanding the underlying cause.

Goroutines vs Threads

OS Thread Goroutine
Stack size 1–8 MB (fixed) 2–8 KB (grows dynamically)
Creation time ~10–100 µs ~1 µs
Context switch OS kernel call Go runtime (user space)
Typical count Hundreds Hundreds of thousands
Scheduling Preemptive (OS) Cooperative + preemptive (Go 1.14+)

The Go scheduler uses an M:N model — M goroutines run on N OS threads (N = GOMAXPROCS, defaults to CPU count). Goroutines yield at function calls, channel operations, and system calls, allowing other goroutines to run without kernel involvement.

Checking Goroutine Count

During development, you can inspect running goroutines with runtime.NumGoroutine(). In production, expose it via a metrics endpoint or the net/http/pprof handler:

import (
    "net/http"
    _ "net/http/pprof"  // registers /debug/pprof/ handlers
    "runtime"
)

// Check count programmatically
before := runtime.NumGoroutine()
// ... do work ...
after := runtime.NumGoroutine()
if after > before+expectedNew {
    log.Printf("possible goroutine leak: %d → %d", before, after)
}
# Live goroutine dump from a running service
curl http://localhost:6060/debug/pprof/goroutine?debug=2

The dump shows every goroutine’s stack trace, which makes it straightforward to identify where leaked goroutines are blocked.

Key Patterns Summary

Fan-out: start multiple goroutines to process work in parallel, collect results via a channel.

Bounded concurrency: use a semaphore (buffered channel) or worker pool to cap how many goroutines run simultaneously — see Go semaphores and rate limiting for the full pattern.

Pipeline: chain goroutines where each stage reads from the previous stage’s output channel — see Go worker pools for implementation.

One-shot background task: use go func() { ... }() with a WaitGroup if you need to wait for it, or a context if you need to cancel it. Never “fire and forget” if the goroutine touches shared state or external resources.

Summary

  • A goroutine costs ~2 KB of stack and starts in ~1 µs — cheap, but not free
  • Always wait for goroutines with sync.WaitGroup or a done channel before returning from main
  • Use context.Context for cancellation and timeouts — it propagates cleanly through call trees
  • Goroutine leaks happen when a goroutine blocks on a channel or lock that is never resolved — use buffered channels or contexts to prevent them
  • Pass loop variables as arguments to goroutines, never capture them by closure reference
  • Use runtime/pprof or goleak to detect leaks in tests and production

Resources

Comments

👍 Was this article helpful?