Go 语言中 panic 和 recover 的实现原理

date
Jul 21, 2023
slug
How-panic-and-recover-are-implemented-in-the-Go-language
status
Published
tags
Go
summary
panic 为什么会更加影响程序性能?
type
Post
Created Time
Oct 28, 2023 01:45 PM
Updated Time
Oct 28, 2023 01:45 PM
AI summary
本文介绍了 Go 语言中 panic 和 recover 的实现原理。当程序出现 panic 时,Go 运行时会进行栈展开、堆内存分配、函数调用、锁竞争等操作,这些操作可能会对程序的性能产生影响。因此,在使用 panic 和 recover 时,需要合理地处理,以确保程序的正确性和可读性。
Status
Uber 的 Go 语言编码规范中明确说明:”不要使用 panic!在生产环境中运行的代码必须避免出现 panic。panic 是级联失败的主要根源 。如果发生错误,该函数必须返回错误,并允许调用方决定如何处理它。“但我也不想在代码里重复的 return err 呀!广泛听取各种意见之后,我发现使用 panic 进行错误处理,有以下优缺点。
优点:
  • panic 可以使代码更加简洁,避免过多的错误检查和处理代码,从而提高代码可读性和可维护性。
  • panic 可以在出现严重问题时及时中止程序运行,避免继续执行可能会导致更严重后果的代码。
  • panic 可以将错误信息传递给上层调用函数,从而更好地定位和解决问题。
缺点:
  • 直接使用 panic 可能会导致代码难以调试和测试,因为它会导致程序直接退出,并且不会在 panic 发生后继续执行下面的代码。
  • panic 可能会影响程序的性能,因为在 panic 发生时,Go 语言会执行一些额外的工作来终止程序运行并输出错误信息。
  • 直接使用 panic 可能会导致代码的可靠性降低,因为在 panic 发生时,程序可能会处于不确定的状态,这可能会导致数据丢失或其他问题。
可以看出,优点和缺点中不乏一些矛盾的论断,但有一点是明确的,panic 可能会影响程序的性能。panic 是如何影响程序性能的呢?

现象

defer 语句失效

运行上面的这段代码时会发现 main 函数中的 defer 语句并没有执行,只执行了 Goroutine 中的 defer 函数。

无效的 recover

运行上面这段代码会发现,在主程序中调用的 recover 函数并没有执行。 

嵌套崩溃

运行上面这段代码会发现,程序多次调用 panic 也不会影响 defer 函数的正常执行。

panic 的实现原理

数据结构

src/runtime/runtime2.go 文件中:
其中:
  1. argp unsafe.Pointer:指向被推迟函数参数的指针,用于在调用 recover 函数时,恢复 panic 时传递给 defer 函数。
  1. arg interface{} :调用 panic 时传入的参数。
  1. link *_panic:指向下一个 defer panic 的指针,用于形成 panic 链表。
  1. pc uintptr :表示导致 panic 的函数的程序计数器(program counter),也就是触发 panic 的函数在代码中的位置。
  1. sp unsafe.Pointer :表示一个指向当前 goroutine 栈顶的指针。
  1. recovered bool:表示当前 runtime._panic 是否被 recover 恢复。
  1. aborted bool:表示当前的 panic 是否被强行终止。
  1. goexit bool:表示是否调用了 goexit 函数。goexit 是一个内部函数,用于在当前 goroutine 结束时清理 goroutine 栈并退出函数。当 goexit 被调用时,当前 goroutine 的栈上的所有函数都会立即返回,并释放当前 goroutine 的资源。

panic 的执行过程

编译器会将关键字 panic 转换成 runtime.gopanic
这个函数会执行以下步骤:
  1. 调用 getg 函数获取当前 Goroutine 的 G 结构体,并检查当前 Goroutine 是否在系统栈或 malloc 过程中,或者是否持有锁或处于禁止抢占状态。如果是,则不允许执行 panic 操作,直接抛出异常。
  1. 创建一个 _panic 结构体 p,并将 panic 值 e 存储到 p.arg 中。
  1. 将 p 添加到当前 Goroutine 的 _panic 链表头部。
  1. 增加一个 runningPanicDefers 计数器,用于记录当前正在执行的 panic 相关的 defer 函数数量。
  1. 遍历当前 Goroutine 的 defer 链表,对于每个 defer 函数,执行以下操作:
    1. a. 检查当前 defer 函数是否已经开始执行,如果是,则标记 _panic 结构体中的 aborted 属性为 true,表示该 defer 函数已经被中断。
      b. 将 _panic 结构体 p 赋值给当前 defer 函数的 _panic 属性。
      c. 如果当前 defer 函数是 open-coded defer,即使用 ADD/SUB 指令手动实现的 defer,调用 runOpenDeferFrame 函数执行该 defer 函数。否则,调用 reflectcall 函数执行该 defer 函数。
      d. 如果 reflectcall 函数没有 panic,则表示该 defer 函数执行成功,删除该 defer 函数。否则,保留该 defer 函数。
  1. 如果当前 Goroutine 的 _panic 链表非空,则调用 preprintpanics 函数打印 _panic 链表中的信息。
  1. 调用 fatalpanic 函数处理 panic 信息,该函数会打印 panic 信息并终止程序。
看起来,fatalpanic 函数是影响性能的关键所在。那么 fatalpanic 函数做了哪些事情呢?
可以看出,fatalpanic 函数执行了如下步骤:
  1. 调用 getg 函数获取当前 Goroutine 的 G 结构体,调用 getcallerpc 和 getcallersp 函数获取调用者的 PC 和 SP。
  1. 使用 systemstack 函数切换到系统栈上,避免栈增长引起的问题。
  1. 调用 startpanic_m 函数判断是否可以打印 panic 消息。
  1. 如果 startpanic_m 函数返回 true 并且传入的 _panic 链表非空,调用 printpanics 函数打印 panic 消息。
  1. 调用 dopanic_m 函数处理 panic,如果该函数返回 true,表示程序已经处于非常不稳定的状态,无法恢复,此时调用 crash 函数崩溃程序。
  1. 如果 dopanic_m 函数返回 false,表示程序还可以继续执行,调用 exit 函数退出程序。
  1. 如果程序退出失败,则将空指针赋值给整型指针,触发空指针异常。

recover 的实现原理

编译器会将关键字 recover 转换成 runtime.gorecover
这个函数会执行以下步骤:
  1. 调用 getg 函数获取当前 Goroutine 的 G 结构体,并从中获取 _panic 链表头部的 _panic 结构体 p。
  1. 如果 _panic 链表非空,并且 p 不是因为调用 goexit 函数而被终止,并且 p 的 recovered 属性为 false,同时参数 argp 等于 p.argp,则表示当前 Goroutine 中有可以恢复的 panic。
  1. 将 p 的 recovered 属性设置为 true,表示该 panic 已经被恢复。
  1. 返回 p.arg,即 panic 值。
  1. 如果没有找到可以恢复的 panic,则返回 nil。

总结

了解了 panic recover 的执行过程,回头再看上面的三个现象,就不难理解了。
  • 第一个现象,defer 语句失效,是因为在发生 panic 之后,程序会立即进入 panic 状态, Goroutine 的执行会被中断,从而导致整个程序终止,因此 defer 语句并不会被执行。
  • 第二个现象,无效的 recover,是因为 panic 是在 defer 语句之前被调用的,recover 函数并没有被执行,if 语句中的代码也没有被执行。
  • 第三个现象,对应 panic 的执行顺序,就很好理解,当程序运行到 panic("panic once") 时,它会立即停止执行当前函数的代码,转而执行 defer 语句。由于该函数中存在多个 defer 语句,因此这些语句会按照后进先出的顺序执行。
最后,再来看 panicrecover 函数有哪些消耗程序性能的地方:
  1. 栈展开:当程序出现 panic 时,Go 运行时会进行栈展开(stack unwinding)操作,回溯整个调用栈,以找到对应的 recover 函数,这个过程需要耗费一定的 CPU 时间和内存资源。
  1. 堆内存分配:当一个 panic 发生时,Go 会创建一个 panic 对象,并将它放入堆内存中。这个过程会耗费一定的内存资源,并且可能会导致垃圾回收器在后续的运行中更频繁地工作。
  1. 函数调用:当程序执行 panic 和 recover 函数时,会涉及到函数调用的开销。虽然函数调用本身的开销很小,但是如果 panic 和 recover 函数被频繁调用,这些开销可能会累积起来,影响程序的性能。
  1. 锁竞争:当多个 goroutine 同时发生 panic 时,它们会竞争一个全局的互斥锁来获取错误信息。这个过程可能会导致锁竞争的开销,从而降低程序的性能。
  1. 程序流程控制:panic 和 recover 的使用会对程序的流程控制产生影响。在使用过程中,需要合理地处理 panic 和 recover,以确保程序的正确性和可读性。如果处理不当,可能会影响程序的性能和可维护性。
总的来说,这些性能消耗通常是微不足道的,并且只有在极端情况下才会对程序的性能产生显著的影响。因此,在正常情况下,不需要过度担心这些性能问题,应该优先考虑代码的可读性和可维护性。

参考文章

 

© 孙东辉 2022 - 2025