The problem with waiting
You are building a service that fetches user data from three different microservices. Each call takes 200 milliseconds. If you run them one after another, the total latency is 600 milliseconds. You want the user to wait 200 milliseconds, not 600. You need the calls to happen at the same time.
In many languages, you reach for OS threads. OS threads are heavy. They consume megabytes of memory and take time to create. Spawning thousands of threads crashes the process. Go offers a different tool. You reach for goroutines. Goroutines let you run functions concurrently with minimal overhead. You can spawn thousands of goroutines on a single machine without running out of memory.
What a goroutine is
A goroutine is a function running concurrently with other functions. It is not an OS thread. The Go runtime manages goroutines using a scheduler. The scheduler maps many goroutines onto a smaller set of OS threads. This is often called M:N scheduling.
Think of an OS thread as a dedicated worker with a fixed desk and a heavy uniform. Creating one costs time and memory. A goroutine is more like a task card on a shared board. The runtime manages a pool of workers and shuffles task cards between them. You can have thousands of task cards for a handful of workers. The runtime decides which card gets picked up next.
Goroutines are lightweight because they start with a tiny stack, typically 2 kilobytes. The stack grows and shrinks automatically as needed. OS threads usually have a fixed stack of 1 to 8 megabytes. This difference lets you scale concurrency without exhausting resources.
Goroutines are cheap. Leaks are expensive.
Minimal example
Here is the syntax: prefix a function call with go to run it concurrently.
package main
import "fmt"
// worker performs a background task.
func worker() {
fmt.Println("Worker is running")
}
func main() {
// go keyword starts worker concurrently. main does not wait.
go worker()
fmt.Println("Main continues immediately")
}
When the program runs, main calls go worker(). The runtime schedules worker to run eventually. The go statement returns immediately. main prints "Main continues immediately". Then main returns. When main returns, the entire program exits. The runtime stops all goroutines. worker likely never prints anything.
This is the first trap: spawning a goroutine does not guarantee it finishes. You must coordinate. If you write go fmt.Println without parentheses, the compiler rejects it with go statement must be function or method call. You must invoke the function.
How the runtime manages goroutines
The scheduler uses a work-stealing algorithm. Each OS thread has a local queue of goroutines. When a goroutine blocks on I/O, the runtime parks it and runs another goroutine from the queue. If one OS thread runs out of work, it can steal goroutines from another thread's queue. This keeps all CPU cores utilized.
You don't manage the threads. You just spawn goroutines. The runtime handles context switching. This abstraction removes the complexity of thread management from your code. You focus on logic, not scheduling.
Goroutines communicate by sharing memory. Go encourages sharing memory by communicating. Instead of locking a variable, you pass values through channels. This reduces race conditions. Channels are the pipes that connect goroutines.
Don't share memory by communicating; communicate by sharing memory.
Realistic example
Here is a realistic pattern: use a sync.WaitGroup to wait for multiple goroutines to finish before proceeding.
package main
import (
"fmt"
"sync"
"time"
)
// fetchService simulates an API call with latency.
func fetchService(id string, wg *sync.WaitGroup) {
// Decrement the wait group counter when the function returns.
defer wg.Done()
// Simulate network delay. In real code, this is an HTTP call.
time.Sleep(100 * time.Millisecond)
fmt.Printf("Fetched service %s\n", id)
}
func main() {
var wg sync.WaitGroup
// Add three tasks to the wait group.
wg.Add(3)
// Launch three goroutines to fetch data concurrently.
go fetchService("users", &wg)
go fetchService("orders", &wg)
go fetchService("inventory", &wg)
// Block until all goroutines call Done.
wg.Wait()
fmt.Println("All services fetched")
}
The sync.WaitGroup tracks how many goroutines are still running. You call wg.Add(3) before launching the goroutines. Each goroutine calls wg.Done() when it finishes. The defer statement ensures Done runs even if the function panics. wg.Wait() blocks main until the counter reaches zero.
The community accepts the boilerplate of if err != nil because it makes the unhappy path visible. In production code, fetchService would return an error. You would capture it and handle it explicitly.
Wait for your goroutines. If main exits, the work is lost.
Pitfalls and errors
Goroutines introduce concurrency bugs. The most common issue is a race condition. If two goroutines write to the same variable without synchronization, the result is unpredictable. The runtime can detect this. Run your code with go run -race. The race detector instruments the binary and reports data races at runtime.
If two goroutines write to a map without synchronization, the runtime panics with concurrent map writes. Maps are not safe for concurrent access. Use a mutex or a channel to protect shared state.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. If a goroutine blocks forever, it consumes memory and prevents garbage collection. The leak grows over time until the service crashes.
A panic in a goroutine crashes the whole program unless you recover. Use recover in a deferred function to catch panics. This is essential for long-running servers. If a handler panics, the request fails, but the server stays up.
Context is plumbing. Run it through every long-lived call site. When spawning long-running goroutines, pass a context.Context as the first argument. This lets the caller cancel the work if the parent request is aborted. Functions that take a context should respect cancellation and deadlines.
The race detector is your friend. Run go run -race before you ship.
When to use goroutines
Use a goroutine when you have independent I/O calls that can run while others wait.
Use a worker pool when you need bounded concurrency to protect a downstream service from overload.
Use a single goroutine plus a channel when one task feeds another in a pipeline.
Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.
Concurrent code is harder to debug. Keep it simple until you need speed.