The problem with waiting
You are writing a script to check the status of five different microservices. You loop through the list, send an HTTP request, wait for the response, print the result, and move to the next one. The code works. It also takes forever. Each request waits for the network round-trip before the next one starts. Your CPU sits idle while the network does the heavy lifting. You know these requests are independent. You want them to run at the same time.
In other languages, you might reach for OS threads. That feels heavy. You worry about thread limits, context switching overhead, and managing a thread pool. Go offers a different approach. You launch a goroutine for each request. The runtime handles the rest. Goroutines let you express concurrency with minimal overhead, turning sequential bottlenecks into parallel flows without the mental tax of thread management.
What a goroutine actually is
A goroutine is a function running concurrently with other functions. It is not an OS thread. The Go runtime manages a pool of OS threads and schedules goroutines onto them. This is M:N scheduling. M goroutines map to N OS threads. When a goroutine blocks on I/O, the runtime parks it and runs another goroutine on the same thread. The thread stays busy.
Goroutines start with a tiny stack, usually 2KB, and grow dynamically on the heap as needed. This makes them cheap. You can spawn thousands of goroutines without exhausting memory or hitting OS limits. An OS thread typically reserves 1MB to 8MB of stack space upfront. Goroutines pay for stack only when they use it.
Convention aside: The Go community accepts the boilerplate of explicit synchronization. You do not get implicit parallelism. If you want concurrency, you write go. If you want to wait, you use WaitGroup or channels. The code tells you exactly what is happening. There are no hidden threads running in the background.
Minimal example
The go keyword is all you need to start a goroutine. It takes a function call and runs it concurrently. The call returns immediately.
package main
import (
"fmt"
"time"
)
// greet prints a message after a short delay.
func greet(name string) {
time.Sleep(1 * time.Second) // Simulate work.
fmt.Println("Hello,", name)
}
func main() {
// Launch greet in a new goroutine.
// The main function continues immediately without waiting.
go greet("Alice")
fmt.Println("Main continues immediately")
// Wait for the goroutine to finish.
// Without this, main exits and kills the goroutine.
time.Sleep(2 * time.Second)
}
The go greet("Alice") call starts the function in a new goroutine. The main function prints "Main continues immediately" and then sleeps. The sleep is a hack for this example. In real code, you use synchronization primitives, not sleep. If main exits, the entire program stops. Any running goroutines die instantly. The compiler does not warn you about this. It is a logic error.
Goroutines are cheap. Waiting for them is not automatic.
How the runtime schedules work
The Go runtime uses the GMP model to schedule goroutines. G stands for goroutine. M stands for machine, which is an OS thread. P stands for processor, which is a logical resource that holds a local run queue of goroutines.
When you call go, the runtime adds the goroutine to a run queue. An M picks up a P and executes goroutines from the P's queue. If a goroutine blocks on a system call, the M parks the goroutine and hands the P to another M. The new M continues running other goroutines. The blocked goroutine resumes when the I/O completes and the runtime reattaches it to a thread.
This design keeps CPU utilization high. Threads are never idle while goroutines are waiting. You do not control this scheduling. The runtime makes decisions based on load, blocking, and fairness. Trust the scheduler. Focus on writing correct concurrent logic.
Convention aside: gofmt is mandatory. Do not argue about indentation or brace placement. Let the tool decide. Most editors run gofmt on save. Consistent formatting reduces cognitive load when reviewing concurrent code.
Trust the runtime. Argue logic, not scheduling.
Realistic pattern: Fan-out with WaitGroup
A common pattern is fan-out. You have a list of tasks. You launch a goroutine for each task. You wait for all tasks to complete. The sync.WaitGroup is the standard tool for this.
package main
import (
"context"
"fmt"
"sync"
"time"
)
// checkService simulates checking a service status.
// It respects context cancellation to avoid leaks.
func checkService(ctx context.Context, service string, results chan<- string) {
// Simulate network latency.
select {
case <-time.After(500 * time.Millisecond):
results <- fmt.Sprintf("%s is up", service)
case <-ctx.Done():
// Context cancelled. Exit immediately.
return
}
}
func main() {
services := []string{"auth", "payments", "inventory"}
var wg sync.WaitGroup // WaitGroup tracks active goroutines.
// Channel to collect results.
// Buffered channel prevents goroutines from blocking on send.
results := make(chan string, len(services))
for _, svc := range services {
wg.Add(1) // Increment counter before launching.
// Launch a goroutine for each service.
go func(s string) {
defer wg.Done() // Decrement counter when goroutine finishes.
checkService(context.Background(), s, results)
}(svc) // Pass svc as argument to capture loop variable correctly.
}
// Close results channel when all goroutines finish.
go func() {
wg.Wait()
close(results)
}()
// Collect results.
for result := range results {
fmt.Println(result)
}
}
The sync.WaitGroup tracks the number of active goroutines. Call Add(1) before launching each goroutine. Call Done() inside the goroutine when it finishes. Use defer wg.Done() to ensure the counter decrements even if the goroutine panics. Call Wait() to block until the counter reaches zero.
The loop variable capture requires attention. In Go 1.22 and later, the loop variable is scoped per iteration, so closures capture the correct value. Passing the variable as an argument, as shown here, is still the idiomatic pattern. It is explicit, works on all Go versions, and makes the data flow clear.
Convention aside: context.Context always goes as the first parameter. Name it ctx. Functions that accept a context should check for cancellation and deadlines. Propagate context to child goroutines. This allows you to cancel a whole tree of work with a single signal.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and runtime errors
Goroutines introduce concurrency bugs that are hard to reproduce. The compiler cannot catch all of them. You need to understand the failure modes.
Goroutine leaks happen when a goroutine waits on a channel that never gets closed. The goroutine blocks forever. It holds references to memory, preventing garbage collection. The process grows until it runs out of memory. The worst goroutine bug is the one that never logs. Always provide a cancellation path. Use context.Context or a done channel to signal goroutines to exit.
Deadlocks occur when goroutines wait on each other in a cycle. The runtime detects this and panics.
The runtime stops the program with
fatal error: all goroutines are asleep - deadlock!if no goroutine can make progress.
This error usually means you are waiting on a channel that no one is sending to, or you have a circular dependency. Check your channel directions and ensure senders and receivers are balanced.
Data races happen when multiple goroutines access the same memory without synchronization. One goroutine writes while another reads. The behavior is undefined. The program might crash, produce wrong results, or appear to work until you deploy to production.
The compiler does not catch data races. Run your code with the
-raceflag to detect them. The race detector reportsWARNING: DATA RACEwith stack traces showing the conflicting accesses.
Protect shared state with sync.Mutex or communicate via channels. Do not share memory by communicating. Communicate by sharing memory only when you have a lock.
The worst goroutine bug is the one that never logs.
When to use goroutines
Concurrency adds complexity. Use it only when it solves a real problem. Follow these guidelines to keep your code maintainable.
Use a goroutine when you have independent tasks that can run concurrently, like fetching multiple URLs or processing a batch of files. The tasks should not depend on each other's results.
Use a worker pool when you need to limit concurrency to protect a downstream service or manage resources. A pool prevents thousands of goroutines from overwhelming a database or exhausting file descriptors.
Use a channel when goroutines need to pass data or signal completion. Channels enforce communication without shared memory. They make the flow of data explicit.
Use sequential code when tasks depend on each other or the overhead of concurrency outweighs the benefit. The simplest thing that works is usually the right thing.