Go

Go Goroutines👨‍💻

A goroutine is a function executing concurrently with other goroutines in the same address space. Unlike OS threads, goroutines are multiplexed onto a small number of kernel threads by the Go runtime scheduler, making them extraordinarily cheap to create — a fresh goroutine starts with a stack of just a few kilobytes that grows and shrinks on demand. Spawning ten thousand goroutines is routine; spawning a million is feasible on modern hardware.

The go keyword is the only syntax required. Prefix any function or method call with go and it runs asynchronously. There is no thread pool to configure, no executor to manage, no future object returned. The goroutine runs, and when the function returns, it silently exits. This simplicity is deceptive — coordinating goroutines requires channels, WaitGroups, or the context package, and failing to do so correctly leads to goroutine leaks, data races, and subtle deadlocks.

Under the hood, Go uses a work-stealing scheduler built on the GMP model: G (goroutines), M (OS threads), and P (logical processors). Each P maintains a local run queue of goroutines. When a goroutine blocks on I/O or a channel operation, the scheduler parks it and runs another goroutine on the same thread — no context switch to the kernel. This is why Go can handle hundreds of thousands of concurrent connections with a fraction of the memory that thread-per-request models require.

Key Takeaways

  • 1Goroutines start with a ~2-8 KB stack that grows dynamically — creating thousands or millions is practical and expected in Go programs
  • 2The `go` keyword fires-and-forgets: it does not return a handle, a future, or an error — coordination must happen through channels or sync primitives
  • 3The GMP scheduler (Goroutines, OS threads, Processors) multiplexes goroutines onto threads and performs work-stealing across logical processors for load balancing
  • 4A goroutine that blocks on I/O or a channel operation is parked by the scheduler at zero cost — the OS thread is reused for other goroutines immediately
  • 5`sync.WaitGroup` is the standard mechanism to wait for a batch of goroutines to complete — call `Add` before launching, `Done` inside each goroutine, and `Wait` to block until all finish
  • 6Goroutine leaks occur when a goroutine blocks forever on a channel or I/O that will never complete — every goroutine must have a clear exit path, typically via context cancellation or a done channel

Master go goroutines

Take the Go Programming course with hands-on lessons and challenges.

Examples

Launching goroutines with the go keyword

go

The go keyword launches each fetchData call as a concurrent goroutine. All three run simultaneously rather than sequentially. In production code, you would never rely on time.Sleep for synchronization — use sync.WaitGroup or channels.

Coordinating goroutines with sync.WaitGroup

go

WaitGroup tracks a count of active goroutines. Add(1) increments before each launch, Done() decrements when a goroutine finishes, and Wait() blocks until the count hits zero. The range-over-int syntax (Go 1.22+) replaces the classic for i := 0; i < 10; i++ loop.

Bounded concurrency with a worker pool

go

Instead of spawning a goroutine per task (which can overwhelm resources), a fixed pool of workers reads from a shared jobs channel. Closing the channel signals workers to exit their range loop. This pattern bounds memory and CPU usage regardless of how many tasks arrive.

Goroutine with closure — capturing loop variables

go

Since Go 1.22, each loop iteration gets its own copy of the loop variable, so closures capture the correct value. In earlier Go versions, all goroutines would share a single url variable and typically all print the last URL. This was one of the most common concurrency bugs in Go.

Detecting goroutine leaks with runtime.NumGoroutine

go

leakyProducer spawns a goroutine that blocks forever once the consumer stops reading. safeProducer uses context cancellation so the goroutine exits cleanly when the caller is done. runtime.NumGoroutine() is useful during testing and debugging to verify goroutines are being cleaned up.

Parallel computation with errgroup

go

errgroup from golang.org/x/sync combines WaitGroup semantics with error propagation and context cancellation. If any goroutine returns an error, the context is cancelled (signaling other goroutines to stop) and Wait() returns the first error. This is the production-grade alternative to bare WaitGroups when errors matter.

Common Mistakes

Mistake:

Launching goroutines in a loop without waiting for them to finish — the program exits (or the function returns) before goroutines complete their work, silently dropping results

Fix:

Use sync.WaitGroup, errgroup, or channel-based signaling to ensure all goroutines complete before proceeding. Every goroutine you launch must have a corresponding synchronization point.

Mistake:

Passing a WaitGroup by value instead of by pointer — the copy never decrements the original counter, causing Wait() to block forever (deadlock)

Fix:

Always pass *sync.WaitGroup as a pointer. The go vet tool catches this: run `go vet ./...` as part of your CI pipeline to detect WaitGroup copy errors.

Mistake:

Spawning an unbounded number of goroutines for incoming requests or tasks — under load, this exhausts memory and causes the process to crash with an out-of-memory error

Fix:

Use a worker pool pattern with a fixed number of goroutines reading from a buffered channel, or use a semaphore (buffered channel of struct{}) to cap concurrency. errgroup.SetLimit() also works.

Mistake:

Creating goroutines that have no exit path — a goroutine blocked on a channel send or receive that will never complete stays alive for the lifetime of the process, leaking memory

Fix:

Every goroutine must have a cancellation mechanism: context.Context, a done channel, or a channel that will eventually close. Test for leaks using runtime.NumGoroutine() in your test suite.

Best Practices

  • Prefer errgroup over bare WaitGroup when goroutines can fail. errgroup propagates the first error, cancels the shared context, and waits for all goroutines — handling the three concerns that WaitGroup leaves to you.
  • Always give goroutines a cancellation path via context.Context. Long-lived goroutines should select on ctx.Done() alongside their primary channel operations so they exit promptly when the parent scope ends.
  • Bound concurrency explicitly. Use worker pools, errgroup.SetLimit(), or a semaphore channel to prevent goroutine counts from scaling linearly with input size. Unbounded goroutine creation is the Go equivalent of an unbounded thread pool.
  • Use defer wg.Done() as the first line inside a goroutine to guarantee the WaitGroup counter decrements even if the goroutine panics. Placing Done() at the end of the function body risks skipping it on early returns.
  • Run the race detector (`go test -race ./...`) in CI. Data races between goroutines are undefined behavior in Go — the race detector instruments memory accesses and catches concurrent reads and writes that lack synchronization.

Summary

Goroutines are Go's fundamental concurrency primitive — lightweight, runtime-managed units of execution launched with the go keyword. The GMP scheduler multiplexes them onto OS threads efficiently, making it practical to run hundreds of thousands concurrently. Coordination happens through channels, sync.WaitGroup, and errgroup. The critical discipline is ensuring every goroutine has an exit path — typically context cancellation — so that goroutine leaks do not silently accumulate.

Practice Go with hands-on challenges

Learn go goroutines hands-on in your IDE

Interactive lessons and challenges on Stanza, practice in VS Code, Cursor, or the web.

Related Concepts

Related Cheatsheets

Master Go with Stanza

Interactive lessons and challenges, right in your code editor.

Check the free courses. No credit card.