A goroutine isn't always running. Most of the time it's waiting, queued, or already finished. Knowing the stages it passes through, and what ends it, is what lets you reason about programs that have more than one thing happening at once. This lesson walks through the lifecycle, the three ways a goroutine can end, and the rule that costs most programs their first bug: when main returns, every other goroutine dies with it.
A goroutine has a small number of distinct stages. When you call go someFunc(), the runtime creates a new goroutine and hands it off to be scheduled. From that point, the goroutine moves between a few states until it finishes.
Five stages, each with a clear meaning:
Goexit or an unrecovered panic). The runtime cleans it up.A goroutine bounces between Runnable, Running, and Blocked many times during its life. It only enters Done once.
Cost: A blocked goroutine uses no CPU, but it does hold onto its stack memory. Stacks start small (a few KB) and grow as needed. Thousands of idle goroutines are cheap. Millions of leaked goroutines that never reach Done eat memory steadily, which is why leaks matter.
"Runnable" and "Blocked" both look like "not running" from the outside, but the runtime treats them very differently.
A runnable goroutine wants the CPU. It's ready to run. Nothing is stopping it except that some other goroutine is currently on the CPU. The scheduler will pick it up as soon as a slot opens.
A blocked goroutine doesn't want the CPU. It's waiting for something else: a time.Sleep to expire, a file to be read, a mutex to be unlocked, a network packet to arrive. Until that external event happens, giving it CPU time wouldn't help, it has nothing to do.
The practical reason this distinction matters: a program with 10,000 blocked goroutines isn't slow. The scheduler skips them entirely. The same program with 10,000 runnable goroutines all fighting for a small number of CPUs is a different story, that's where contention and latency live.
For now, the high-level picture above is enough.
A goroutine reaches the Done state in exactly three ways:
go someFunc() runs through to the end of someFunc, hits a return (or falls off the bottom), and the goroutine is done.Here's case 1, the normal return:
Each processOrder runs from start to finish, then its goroutine reaches Done. The order between the two goroutines isn't guaranteed, you might see 101 before 102 on another run. We're using time.Sleep to keep main alive long enough for the goroutines to finish. That's a teaching shortcut, the proper way to wait is sync.WaitGroup, covered in the next chapter.
The goroutine running main is special: when it returns, the Go runtime shuts the program down immediately. Every other goroutine, no matter what state it's in, is killed on the spot.
Here's the classic example. We launch a goroutine that prints after a one-second sleep, and main returns right away.
The goroutine was created. It started sleeping. Half a millisecond later, main returned and the runtime tore down the process. The goroutine was blocked in time.Sleep, it never got a chance to print anything.
Now give main a reason to wait:
The goroutine had time to finish before main returned. The sleep in main is a crude tool, you don't know exactly how long the goroutine will take in real code, so you'd never use a fixed sleep in production. The next chapter shows sync.WaitGroup, which lets main wait for a specific number of goroutines to finish.
Visually, the "main returns" event is a hard stop for the whole program:
The lesson: a goroutine's lifetime is bounded by main's lifetime. You have to make main wait, otherwise you'll launch work that never gets to run.
runtime.Goexit: Ending a Goroutine ExplicitlyThe function runtime.Goexit() terminates the goroutine that calls it. It's not common in everyday code, but it has one useful property: it runs all the deferred functions in the goroutine before exiting. This makes it the cleanup-friendly way to bail out from deep inside a goroutine.
Notice that the defer still ran. Goexit is different from return in two ways: it can be called from any function deep in the call stack, and it ends the entire goroutine, not just the function it's in.
A small but important rule: if runtime.Goexit() is called from the main goroutine, it ends that goroutine after running its deferred functions, and then the runtime detects that there are no user-level goroutines left to run (or that the main goroutine has exited) and the program terminates. Most of the time you won't call Goexit from main, it's mainly useful inside spawned goroutines.
Goexit is rare in production Go. Most code ends goroutines by letting their function return. It exists for the cases where you need to bail out from several layers down without threading a special return value back up the stack.
Here's a behavior that surprises a lot of people: if a goroutine panics and the panic isn't recovered inside that goroutine, the entire program crashes, not just the one goroutine.
The exact output varies by run, you might see "USB-C Cable" or not depending on timing. Once any goroutine panics without recovery, Go prints the panic and exits the whole process. The other goroutines, including main, are terminated.
This is a deliberate design choice. The Go team's reasoning is that an unrecovered panic represents a bug, and silently continuing while one part of the program is in a broken state hides problems. Better to crash loudly and fix the bug.
There's a crucial detail in the diagram: recovery has to happen inside the same goroutine that panics. A defer recover() in main does not catch a panic from a goroutine main started. Each goroutine is responsible for its own recovery.
defer recover() Inside the GoroutineIf you want a single bad input to fail one goroutine without killing the rest, wrap the work in a defer recover():
The deferred function in each goroutine runs even when a panic happens. recover() returns the panic value (and resets the panic), so the goroutine ends normally instead of taking down the program. The other two goroutines process their items as usual, and main finishes cleanly.
This pattern is common at the top of any goroutine that runs user-supplied or external work, request handlers, job processors, background workers. For our purposes here, the rule is: a panic that escapes the goroutine that started it is fatal to the whole program, and defer recover() inside the goroutine is the fix.
Cost: recover only works inside a deferred function. Putting recover() directly in the function body returns nil and does nothing.
Go's runtime package exposes a count of how many goroutines exist at this instant. runtime.NumGoroutine() returns that number. It's a useful sanity check during development:
The "1" at the start is main itself. After launching five processOrder goroutines, the count jumps to six. After the sleep, all five have finished and the count is back to one. That arc, up to six, back down to one, is the lifecycle made visible.
NumGoroutine is good for a quick health check. If you see the count grow without bound while your program is doing the same amount of work, you have a goroutine leak.
A short program that exercises every stage in one place, including a panic that gets recovered, makes the picture concrete.
The print order between goroutines isn't guaranteed, what matters is the shape. Each processOrder goroutine moves from Created to Runnable to Running, blocks on time.Sleep, comes back to Runnable, runs again, and ends. Order 2 panics during its run, the deferred recovery catches it, and the goroutine ends normally instead of crashing the program. The goroutine count returns to 1 (main) once all three children are Done.
10 quizzes