A panic ends the whole program¶
Level: 201 · anyone who has put a recover in main and expected it to protect the program
One line: A panic that reaches the top of any goroutine's stack ends the whole process with exit status 2, however healthy main is; a deferred recover stops a panic only in the goroutine that is panicking, so the one in main never sees a worker's.
A bug in a worker¶
Verified output of panic_in_a_worker_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go build prices.go
$ ./prices
main: starting the worker
worker: deferred call runs as the panic unwinds
panic: runtime error: index out of range [3] with length 3
exit status 2
Reading the output¶
sumPrices goes one index too far, and indexing a slice out of range is a run-time panic ↗. The panic happened in the worker goroutine. What ran and what did not:
- The worker's deferred call ran. A panic stops the function it happens in and runs deferred calls on its way up the panicking goroutine's stack — the spec's Handling panics ↗.
- Nothing more of
mainran.mainhad nothing left to do but receive fromtotals. Its deferredPrintlnnever printed, and neither did its deferredrecover: the spec says that when the unwinding reaches the top-level function of the goroutine that panicked, the program is terminated and the panic reported. Terminated then and there, with no turn formain's deferred calls. - Exit status 2. That is what the
runtimedocumentation ↗ says an unrecovered panic exits with by default, after a stack trace of the current goroutine. The script keeps the first line of that report and drops the trace, which names a goroutine number and addresses that change between runs.
Even if main's deferred function had run, its recover would have returned nil: recover returns the panic's value only when called directly by a deferred function in a goroutine that is panicking, and main's goroutine was not.
panic_in_a_worker_sh.sh
panic_in_a_worker_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# A panic in a goroutine that main started. main is healthy -- it is waiting
# for a total -- and it has a deferred recover, yet the whole process exits
# with status 2. The stack trace under the panic line names a goroutine number
# and addresses that change between runs, so the script keeps stdout, the
# `panic:` line from stderr, and the exit status.
set -u
export GOTOOLCHAIN=local
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cat >"$dir/prices.go" <<'GO'
package main
import "fmt"
func sumPrices(prices []int) (cents int) {
for i := 0; i <= len(prices); i++ { // one step too far
cents += prices[i]
}
return cents
}
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("main: recovered:", r)
}
}()
defer fmt.Println("main: deferred call")
totals := make(chan int)
fmt.Println("main: starting the worker")
go func() {
defer fmt.Println("worker: deferred call runs as the panic unwinds")
totals <- sumPrices([]int{1999, 450, 1200})
}()
fmt.Println("main: total", <-totals)
}
GO
say() { printf '$ %s\n' "$*"; }
cd "$dir" || exit 1
say 'go build prices.go'
go build prices.go || exit 1
say './prices'
./prices 2>stderr.txt
status=$?
grep '^panic: ' stderr.txt
echo "exit status $status"
Recover where the panic is¶
Verified output of panic_recovered_in_the_worker_go.go — regenerated by tools/run_examples.py, never hand-typed.
main: received err: worker panicked: runtime error: index out of range [3] with length 3
main: still running; returning normally
Reading the output¶
The same off-by-one, and this time the program lives. The worker defers its own recover, at the top of its function and so before anything in it can panic. When sumPrices panics, the unwinding reaches that deferred function, recover returns the panic's value, and the worker turns it into an error and sends it on results — the same road a result takes in A goroutine has no handle. main receives an error, not a crash, and returns normally.
The error still says panicked. An index out of range is a bug, and recovering from it keeps the other goroutines alive; it does not make the total correct.
Two places in the standard library make the opposite choices for you. net/http recovers a panic in a request handler, logs a stack trace, and closes that connection, on the assumption that the damage stayed within one request (http.Handler ↗). And WaitGroup.Go ↗ says the function passed to it must not panic, so a function that might has to recover inside itself.
panic_recovered_in_the_worker_go.go
panic_recovered_in_the_worker_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// The recover that works is the one in the goroutine that panicked. Deferred
// at the top of the worker, it turns the panic into an error, and the error
// travels back on the channel like any other result.
//
// go run panic_recovered_in_the_worker_go.go
package main
import "fmt"
type total struct {
cents int
err error
}
func sumPrices(prices []int) (cents int) {
for i := 0; i <= len(prices); i++ { // one step too far
cents += prices[i]
}
return cents
}
func main() {
results := make(chan total)
go func() {
defer func() {
if r := recover(); r != nil {
results <- total{err: fmt.Errorf("worker panicked: %v", r)}
}
}()
results <- total{cents: sumPrices([]int{1999, 450, 1200})}
}()
r := <-results
fmt.Println("main: received err:", r.err)
fmt.Println("main: still running; returning normally")
}
What to do¶
- Treat a panic in any goroutine as a crash of the program, because it is one.
mainbeing fine does not help. - When a goroutine must survive a panic — a request handler, a job runner, one worker of many — defer the
recoverat the top of that goroutine's function, and send the value back as an error, like any other result. With several workers, that is onerecoverper worker: A worker pool. - Do not put a
recoverinmainfor the goroutines' sake. It can only stop a panic inmain's own goroutine. - Recovering is not fixing. Report the recovered value loudly; if it is a bug, the program's answer is still wrong.
- If one failure should stop the others, send the error and cancel the rest: First error cancels the rest.
In other languages¶
- The Rust library's Spawning a thread ↗: a panicking Rust thread does not take the process with it.
joinreturnsErrholding the panic's payload, andmaincarries on. Go has no join to hand a panic to, so an unrecovered one has nowhere to go but the whole process. - The Concurrency library's Getting a result back ↗: C++'s
get()and Python'sresult()rethrow the exception on the waiting thread, and Java'sget()throws anExecutionExceptionwith it as the cause. In Go, therecover-and-send above is the version you write yourself. - The Concurrency library's 01 Threads chapter plans a lesson on this very question, A failure nobody is waiting for.
Sources¶
- The Go Programming Language Specification: Handling panics ↗, Run-time panics ↗.
- The
runtimepackage's environment variables ↗, underGOTRACEBACK: the default report, and exit code 2. sync.WaitGroup.Go↗,net/http.Handler↗.- Effective Go: Recover ↗, and the Go blog's Defer, Panic, and Recover ↗.
- Burak Serdar, Effective Concurrency in Go (Packt, 2023), chapter 6, "Error Handling" — errors and panics in concurrent programs.
- Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 9, "Errors" — the section "panic and recover".