Goroutines
This is the module where Go stops looking like "a stricter Python" and starts looking like a language actually designed for concurrent programs from day one — not with a library bolted on later, but with a keyword.
Launching a goroutine
func sayHello() {
fmt.Println("hello from a goroutine")
}
func main() {
go sayHello()
fmt.Println("hello from main")
}go sayHello() starts sayHello running concurrently with the rest of main — Go doesn't wait for it to finish before moving to the next line. Run this and you might see either line print first, or — very likely — see only "hello from main" at all:
hello from mainThat's not a bug. main returning ends the whole program immediately, goroutines and all, the same way closing a terminal kills every child process running in it. A goroutine isn't guaranteed to run to completion unless something makes the rest of the program wait for it — which is exactly the problem channels (next lesson) and sync.WaitGroup exist to solve.
Why goroutines aren't threads (even though they look like it)
If you've used threads in another language, go someFunc() looks a lot like spawning a thread. The similarity is intentional, but the cost isn't: an OS thread typically reserves a megabyte or more of stack space and costs real time for the OS scheduler to context-switch. A goroutine starts with a stack of only a few kilobytes that grows and shrinks as needed, and Go's own runtime scheduler — not the OS — multiplexes many goroutines onto a much smaller number of real OS threads.
The practical result: spinning up thousands, even hundreds of thousands, of goroutines is normal and cheap in Go. Spinning up that many OS threads would bring most machines to their knees. This is why Go code reaches for go someFunc() as casually as other languages reach for a plain function call — handling every incoming HTTP request in its own goroutine, for instance, is the default way Go's standard net/http server works, not an optimization someone had to opt into.
WaitGroup: waiting for goroutines to finish
The most common way to make main (or any function) wait for a batch of goroutines is sync.WaitGroup:
import "sync"
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println("worker", n)
}(i)
}
wg.Wait()
fmt.Println("all workers done")
}Three things worth naming:
wg.Add(1)before launching each goroutine — tell the WaitGroup how many things it's waiting for.defer wg.Done()inside the goroutine — mark this one as finished when the function returns. (You'll seedeferproperly in a later lesson; for now, read it as "run this when the surrounding function exits.")wg.Wait()— blocks until the count returns to zero.
Why n int is a parameter, not a captured variable
Notice the goroutine is func(n int) { ... }(i) — an anonymous function immediately called with i passed in — rather than just referencing the loop variable i directly from inside the closure. This is deliberate: it's a classic Go bug to write go func() { fmt.Println(i) }() inside a loop and have every goroutine print the same final value of i, because they'd all be sharing and reading the same loop variable, not a snapshot of it at the time they were launched. Passing i in as an argument gives each goroutine its own independent copy. (Go 1.22 changed loop variable semantics so this specific bug is less likely today, but passing it explicitly is still the clearer, more portable habit — and you'll see it in plenty of existing code.)
Goroutines are the foundation. The next lesson covers channels — the primary way goroutines safely communicate with each other, instead of reaching into shared memory directly the way threads usually do.