DEV Community

Cover image for Panic and Recover in the Go Runtime
Serge Radinovich
Serge Radinovich

Posted on

Panic and Recover in the Go Runtime

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
Enter fullscreen mode Exit fullscreen mode

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()))
Enter fullscreen mode Exit fullscreen mode

The goroutine is assigned a panic reference from the panic struct’s start() method here:

gp._panic = (*_panic)(noescape(unsafe.Pointer(p)))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

The panic process then calls all the deferred funcs from the relevant stack frame here.

for {
    fn, ok := p.nextDefer()
    if !ok {
        break
    }
    fn()
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)