Goroutines Basics
Go's lightweight concurrency primitive, and why it's cheap enough to spin up by the thousand.
3 min read
Goroutines are the reason Go has a reputation for making concurrency approachable rather than terrifying. A goroutine is a function that runs concurrently with the rest of your program, managed by Go's runtime rather than the operating system.
Starting a goroutine
Just prefix a function call with go:
func sayHello() {
fmt.Println("hello from a goroutine")
}
func main() {
go sayHello()
fmt.Println("hello from main")
}That's the entire syntax — no thread objects to construct, no thread pool to configure. But run this and you'll likely only see "hello from main" print. That's the first goroutine gotcha worth understanding.
The program doesn't wait for goroutines
main returning ends the whole program immediately, regardless of what goroutines are still running. In the example above, main starts the goroutine and then finishes before the goroutine gets a chance to run at all. A crude fix for demonstration purposes:
func main() {
go sayHello()
time.Sleep(100 * time.Millisecond) // give it time to run
fmt.Println("hello from main")
}time.Sleep is never the real answer in production code — it's a guess, and guesses about timing are exactly what causes flaky concurrent programs. The next lesson (channels) and the one after that (sync.WaitGroup) cover the actual correct tools for waiting on goroutines to finish.
Why goroutines instead of OS threads
An OS thread typically reserves a few megabytes of stack space and involves the kernel in every context switch — spin up tens of thousands of them and a machine grinds to a halt. A goroutine starts with a stack of just a few kilobytes that grows and shrinks as needed, and the Go runtime schedules many goroutines onto a much smaller number of OS threads itself. Spinning up 100,000 goroutines is completely unremarkable in Go; the equivalent with OS threads would crash most systems.
func worker(id int) {
fmt.Printf("worker %d starting\n", id)
}
func main() {
for i := 0; i < 100; i++ {
go worker(i)
}
time.Sleep(time.Second) // again, just for this demo
}Closures over loop variables
As covered in the closures lesson, Go 1.22+ gives each loop iteration its own copy of the loop variable, so passing i directly into a goroutine closure works correctly:
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // safe in Go 1.22+: each iteration has its own i
}()
}On pre-1.22 Go, this required explicitly passing i as an argument (go func(n int) { fmt.Println(n) }(i)) to avoid every goroutine sharing and racing over the same loop variable.
Goroutines need a way to communicate
Starting a goroutine is the easy part. The hard part — coordinating results back, avoiding two goroutines stomping on the same variable at once, knowing when everything's actually done — is what channels and sync.WaitGroup exist for, covered in the next two lessons. Go's philosophy here is captured in a well-known proverb from the language's own documentation: "don't communicate by sharing memory; share memory by communicating." Channels are how that plays out in code.
Test what you just learned
4 quick questions. Get all of them right to unlock the next lesson.
You can take the quiz without an account — logging in just lets your result count toward your progress.