Art by Ashley McNamara
Go developers are well aware of how the built-in panic
and recover
funcs behave. But most of us haven’t dug into the Go source to understand why they behave the way they do. Why does recover
need to be deferred? Why is a panic only recoverable within the goroutine it occurred? To answer these questions, we need to understand the Go runtime.
The Go Runtime
The Go runtime is composed of code to be executed (goroutines), OS threads, and runtime resources such as scheduler and memory allocator state. The Go source refers to these in the runtime pkg as G, M, and P, respectively. Each M has a system stack associated with it (referred to as g0
in the source). The Go runtime executes on the user stack unless making explicit calls to mcall or systemstack funcs. The behaviour of getg
can also tell us when the runtime is executing on user or system stack.
Each goroutine has a user stack associated with it and, importantly, all values of the panic struct reside on this stack.
Digging into the Go Runtime Source
Panics are initiated via the gopanic func. Panic structs are simply initialized with the argument supplied to the panic()
call:
var p _panic
p.arg = e
If the panic occurred on a user stack, the panic procedure begins here with reference to the caller's program counter and stack pointer:
p.start(getcallerpc(), unsafe.Pointer(getcallersp()))
The goroutine is assigned a panic reference from the panic struct’s start() method here:
gp._panic = (*_panic)(noescape(unsafe.Pointer(p)))
After this, the user stack is unwound while looking for deferred function calls. If a stack frame with deferred func calls is found, the panic struct is assigned the relevant pointers for the continued unwinding and recovery process. The stack unwinding executes on the system stack here:
func (p *_panic) nextFrame() (ok bool) {
...
gp := getg()
...
systemstack(func() {
...
var u unwinder
u.initAt(p.lr, uintptr(p.fp), 0, gp, 0)
...
p.lr = u.frame.lr
p.sp = unsafe.Pointer(u.frame.sp)
p.fp = unsafe.Pointer(u.frame.fp)
The panic process then calls all the deferred funcs from the relevant stack frame here.
for {
fn, ok := p.nextDefer()
if !ok {
break
}
fn()
}
Eventually, the nextDefer func will return the builtin recover()
which is implemented here, as gorecover()
. The first thing that gorecover()
does is get a reference to the relevant panic:
func gorecover(argp uintptr) any {
...
gp := getg()
p := gp._panic
The panic struct contains an argument pointer of the topmost deferred function call (p.argp
, assigned in nextDefer()
here). The gorecover()
func checks that its caller has matched this pointer and executes the recover by returning the argument supplied to the panic call (p.arg
, which was assigned to the panic in gopanic()
here).
if p != nil && !p.goexit && !p.recovered && argp == uintptr(p.argp) {
p.recovered = true
return p.arg
}
Once the panic is in a recovered state (p.recovered
), it will begin a recovery process on the system stack whereby the Go runtime ensures that the caller of the recovered func receives results as if the panic had never happened.
Summarising the Go Runtime Source
The source we covered above is overwhelming on first pass. The key takeaways are the following:
The Go runtime executes goroutines (G) on OS threads (M);
Goroutines have their own user stack space;
The Go runtime manages panic instances with reference to values on the panicking goroutines’ stack space;
The Go runtime uses the system stack to unwind the user stack frames;
The Go runtime executes deferred funcs during the stack unwinding process; and
The Go runtime initiates a “recovery” process if one of the deferred funcs was the built-in
recover()
.
Now that we have an understanding of what happens under the hood, lets see it in action!
Using the Assembler
We can look at assembly of Go programs using godbolt.org. Here is a simple program that panics:
package main
func proc() {
defer func() {
if r := recover(); r != nil {
println(r)
}
}()
panic(1)
}
func main() {
proc()
}
If we look at the assembly of this program, we can see the expected runtime calls we covered above.
proc_pc0:
...
CALL runtime.gopanic(SB)
...
CALL runtime.deferreturn(SB)
proc_func1_pc0:
...
CALL runtime.gorecover(SB)
The compiler inserts a call to deferreturn at the end of all funcs make defer calls. This func uses the panic struct for its purposes, but it has a different flow to the one we covered above.
The SB (static-base pointer) symbol is a virtual register that points to the beginning of the address space of the program. This symbol can be used to jump to function addresses, which is what the CALL
instructions above are doing. If you are interested in understanding the Go assembler in greater detail, go.dev provides this primer.
Thanks!
Thanks for taking your time to read this article. This article was initially intended as an addendum to the Panic, Recover, and Relax article published beforehand. However, while digging into the implementation inside the Go runtime, it became clear that there was a lot to explain!
Top comments (0)