Featured image of post Decrypt Go: Three reasons for panic

Decrypt Go: Three reasons for panic

 

Why is panic worth thinking about?

When learning Go, many questions often arise. Sometimes, what seems to be understood is actually not. What exactly is panic? It seems obvious, but it can be difficult to explain. I’m going to use two articles to understand the concept of panic thoroughly:

  • Posture: Understanding the origins of panic. It doesn’t just come out of nowhere. There are three main postures to consider.
  • Principles: Fully comprehending the internal workings of panic and understanding its deeper principles.

This article is first published in the medium MPP plan. If you are a medium user, please follow me in medium. Thank you very much.

The Three Postures of Panic

When does panic occur? Let’s start with the “form.” From a developer’s perspective, panic can be categorized into active and passive.

Active posture:

  • Developers actively call the panic() function.

Passive posture:

  • The compiler triggers hidden code.
  • The kernel sends a signal to the process.

Hidden Code by the Compiler

Go is simple yet powerful, and the compiler plays a crucial role. It handles many tasks on behalf of programmers, such as logic supplementation and memory escape analysis. This includes the throwing of panics!

Let’s take a classic example: dividing by zero in integer arithmetic triggers a panic. How does this happen?

Consider the following minimal code snippet:

1
2
3
4
func divzero(a, b int) int { 
	c := a / b 
	return c 
}

This function has a risk of division by zero. When b is equal to 0, the program triggers a panic and exits, as shown below:

1
root@ubuntu:~/code/gopher/src/panic# ./test_zero panic: runtime error: integer divide by zero goroutine 1 [running]: main.zero(0x64, 0x0, 0x0) /root/code/gopher/src/panic/test_zero.go:6 +0x52

Now, the question is: How does the program trigger a panic?

Code reveals all secrets.

Looking at the code, it seems simple—just one line: c := a / b, right?

Well, it’s actually assembly code. The hidden logic added by the compiler is not visible in the source code.

By using the dlv debugger to set a breakpoint in the divzero function and executing disassemble, we can uncover the secret. Here is a snippet of the assembly code with annotations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
 (dlv) disassemble
TEXT main.zero(SB) /root/code/gopher/src/panic/test_zero.go
    // Check if b is equal to 0
    test_zero.go:6  0x4aa3c1    4885c9          test rcx, rcx
    // If it's not equal to 0, jump to 0x4aa3c8 and execute the instruction; otherwise, continue execution
    test_zero.go:6  0x4aa3c4    7502            jnz 0x4aa3c8
    // If execution reaches this point, it means b is 0, so jump to 0x4aa3ed, which is call $runtime.panicdivide
=>  test_zero.go:6  0x4aa3c6    eb25            jmp 0x4aa3ed
    test_zero.go:6  0x4aa3c8    4883f9ff        cmp rcx, -0x1
    test_zero.go:6  0x4aa3cc    7407            jz 0x4aa3d5
    test_zero.go:6  0x4aa3ce    4899            cqo
    test_zero.go:6  0x4aa3d0    48f7f9          idiv rcx
    // ...
    test_zero.go:7  0x4aa3ec    c3              ret
    // See the magical function?
    test_zero.go:6  0x4aa3ed    e8ee27f8ff      call $runtime.panicdivide

Do you see the hidden function?

The compiler secretly adds an if/else logic and even includes the code for runtime.panicdivide.

  1. If b is equal to 0, it jumps to the function runtime.panicdivide.
    Take a look at the panicdivide function. It’s a simplified wrapper:
1
2
3
4
5
// runtime/panic.go
func panicdivide() {
    panicCheck2("integer divide by zero")
    panic(divideError)
}

As you can see, it calls the panic() function.

This is how a panic is triggered when dividing by zero. It’s not something that magically appears out of nowhere; rather, it’s the additional logic added by the compiler to ensure that a panic is triggered when the divisor is 0.

Triggered by Process Signal

The most typical example is illegal memory access, such as accessing a nil pointer, which triggers a panic. How does this happen?

Consider this minimal example:

1
2
3
4
func nilptr(b *int) int { 
	c := *b 
	return c 
}

When calling nilptr(nil), it will cause the process to exit with an exception:

1
root@ubuntu:~/code/gopher/src/panic# ./test_nil panic: runtime error: invalid memory address or nil pointer dereference [signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x4aa3bc] goroutine 1 [running]: main.nilptr(0x0, 0x0) /root/code/gopher/src/panic/test_nil.go:6 +0x1c

Now, the question is: How does this panic occur?

When the Go process starts, it registers the default signal handler, sigtramp. When the CPU encounters a 0 address, it triggers a page fault exception, indicating an illegal address. The kernel sends a SIGSEGV signal to the process. When this signal is received, the sigtramp function handles it, ultimately calling the panic() function:

1
2
3
4
5
6
7
sigtramp (pure assembly code)
	-> sigtrampgo (signal_unix.go)
		-> sighandler (signal_sighandler.go)
			-> preparePanic (signal_amd64x.go)
				-> sigpanic (signal_unix.go)
					-> panicmem
						-> panic

In the sigpanic function, the panicmem function is called, which then calls the panic() function. This leads to Go’s panic handling.

Similar to panicdivide, panicmem is a minimal wrapper for panic():

1
2
3
4
func panicmem() {
	panicCheck2("invalid memory address or nil pointer dereference")
	panic(memoryError) 
}

This method triggers the panic function by using a software interrupt via a signal. It allows the Go registered signal handler to be invoked, enabling panic handling at the language level.
You may wonder when the logic for signal handling is registered.

During process initialization, when creating M0 (thread), the system call sigaction is used to register the signal handling function as sigtramp. The call stack looks like this:

1
2
3
mstartm0 (proc.go)
	-> initsig (signal_unix.go:113)
		-> setsig (os_linux.go)

As a result, when a software interrupt is triggered, the Go signal handling function is called, allowing panic handling at the language level.

Active Panic by Developers

The third way is when developers actively call panic themselves:

1
2
3
func main() { 
	panic("panic test") 
}

This is a simple function call—very straightforward.

Discussing the Essence of Panic

Now that we have explored the postures of panic, all three methods ultimately rely on the panic() function. So, one thing is clear: panic is a language-level handling mechanism.

By default, after a panic occurs, if Go doesn’t handle it in any specific way, the default behavior is to print the reason for the panic, print the stack trace, and exit the program.

Now, let’s go back to the fundamental question: What exactly is panic?

I won’t delve into the concept, but I’ll describe a few simple facts:

  1. The panic() function internally creates a crucial data structure called _panic and associates it with the goroutine.
  2. The panic() function executes the _defer function chain and handles the state of _panic accordingly.

What does it mean to handle _panic?

It means looping through the _defer function chain on the goroutine. If all the _defer functions are executed and the state of _panic hasn’t been recovered, there is no other option but to exit the process and print the stack trace.

If a friend on the _defer chain recovers the state of _panic, marking it as recovered, the process ends there. The normal code execution continues after that, following the deferreturn logic.

So, what is panic?

It’s just a special function call. That’s all it is.

How special is it? I will explore its deep principles in the next article. In the meantime, consider a few questions:

  • What exactly is panic? Is it a structure or a function?
  • Why does panic cause a Go process to exit?
  • Why does recover need to be placed within a defer to work?
  • Why does the process still not recover even when recover is placed within a defer?
  • Why is it possible to panic again after a panic? What are the consequences?

Summary

  1. Panic can occur in three ways: it can be actively triggered by developers, assisted by the compiler’s logic, or triggered by a software interrupt signal.
  2. Regardless of the posture, all methods ultimately rely on the panic() function. Panic is merely a language-level handling mechanism.
  3. By default, after a panic occurs, if not handled, the program prints the panic cause, the stack trace, and exits the process.
Licensed under CC BY-NC-SA 4.0
Last updated on Jun 27, 2024 15:45 CST
Built with Hugo
Theme Stack designed by Jimmy