1. Go 的 panic 不是 Java 的“checked exception”

1) checked exception实质是“错误”,而 panic 是“异常”

而 panic 又是什么呢?Go 官方博客上的一篇文章《Defer, Panic, and Recover》是这么介绍引发panic的 panic 函数的:

panic 是一个 Go 内置函数,它用来停止当前常规控制流并启动panicking过程。当函数 F 调用 panic 函数时,函数 F 的执行停止,函数 F 中已进行了求值的 defer 函数都将得到正常执行,然后函数 F 将控制权返还给其调用者。对于函数 F 的调用者而言,函数 F 之后的行为就如同调用者调用的函数是 panic 一样,该panicking过程将继续在栈上进行下去,直到当前 goroutine 中的所有函数都返回为止,此时程序将崩溃退出。panic可以通过直接调用 panic 函数来引发,它们也可能是由运行时错误引起,例如越界数组访问。

和 Java 中checked exception的“有意而为之”相反,在 Go 中,panic 则是“不得已而为之”,即所有引发 panic 的情形,无论是显式的(我们主动调用 panic 函数引发的),还是隐式的(Go 运行时检测到违法情况而引发的)都是我们不期望看到的。对这些引发的 panic,我们很少有“预案”应对,更多的是让程序快速崩溃掉。因此一旦发生 panic,就意味着我们的代码很大可能出现了 bug。因此,Go 中的 panic 更接近于 Java 的RuntimeException+Error,而不是checked exception。

2) API 调用者没有义务必须去处理 panic

前面提到过 Java 的checked exception是必须要被上层代码处理的,要么捕获处理,要么重新抛给更上层。但是在 Go 中,我们通常会导入大量第三方包,这些第三方包 API 中是否会引发panic是不知道的(目前也没有现成的工具去发现),因此上层代码,即 API 调用者根本不会去逐一了解 API 是否会引发panic,也没有义务去处理引发的 panic。一旦你像checked exception那样使用 panic 作为正常错误处理的手段在你编写的 API 中将引发的panic当作错误,那么你就会给你的 API 使用者带去大麻烦!

3) 未被 recover 的 panic 意味着“游戏结束”(Game Over)

如果 API 抛出checked exception,那么 Java 编译器将严格要求上层代码对这个checked exception进行处理。但一旦你在 Go API 中引发panic,就像上面提到的,API 的调用者并没有义务处理该 panic,因此该 panic 会就会沿着调用函数栈向下“蔓延”,直到所有函数都返回,调用该 API 的 goroutine 将携带着 panic 信息退出。但事情并没有就此打住,一旦 panic 没有被 recover,它导致的可不是一个 goroutine 的退出,而是整个 Go 程序的“游戏结束” - 崩溃退出!
**

2. panic的典型应用

1) 充当断言角色,提示潜在 bug

Go 语言标准库没有提供 assert(虽然可以自己实现一个),我们可以使用 panic 来部分模拟断言的潜在 bug 提示的功能。下面是标准库encoding/json包中关于 panic 消息的一段注释:

  1. // $GOROOT/src/encoding/json/decode.go
  2. ... ...
  3. //当一些本不该发生的事情导致我们结束处理是,phasePanicMsg将被用作panic消息
  4. //它可以指示JSON解码器中的bug,或者
  5. //在解码器执行时还有其他代码正在修改数据切片。
  6. const phasePanicMsg = "JSON decoder out of sync - data changing underfoot?"
  7. func (d *decodeState) init(data []byte) *decodeState {
  8. d.data = data
  9. d.off = 0
  10. d.savedError = nil
  11. d.errorContext.Struct = nil
  12. // Reuse the allocated space for the FieldStack slice.
  13. d.errorContext.FieldStack = d.errorContext.FieldStack[:0]
  14. return d
  15. }

下面是 json 包中的函数/方法使用phasePanicMsg的代码:

  1. // $GOROOT/src/encoding/json/decode.go
  2. func (d *decodeState) valueQuoted() interface{} {
  3. switch d.opcode {
  4. default:
  5. panic(phasePanicMsg)
  6. case scanBeginArray, scanBeginObject:
  7. d.skip()
  8. d.scanNext()
  9. case scanBeginLiteral:
  10. v := d.literalInterface()
  11. switch v.(type) {
  12. case nil, string:
  13. return v
  14. }
  15. }
  16. return unquotedValue{}
  17. }

同样,在 json 包的 encode.go 中也有 panic 做潜在 bug 提示的例子:

  1. // $GOROOT/src/encoding/json/encode.go
  2. func (w *reflectWithString) resolve() error {
  3. if w.v.Kind() == reflect.String {
  4. w.s = w.v.String()
  5. return nil
  6. }
  7. if tm, ok := w.v.Interface().(encoding.TextMarshaler); ok {
  8. if w.v.Kind() == reflect.Ptr && w.v.IsNil() {
  9. return nil
  10. }
  11. buf, err := tm.MarshalText()
  12. w.s = string(buf)
  13. return err
  14. }
  15. switch w.v.Kind() {
  16. case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
  17. w.s = strconv.FormatInt(w.v.Int(), 10)
  18. return nil
  19. case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
  20. w.s = strconv.FormatUint(w.v.Uint(), 10)
  21. return nil
  22. }
  23. panic("unexpected map key type") // ------------ 这里
  24. }

2) 用于简化错误处理控制结构

panic 的语义机制决定了它可以在函数栈间游走,直到被某函数栈上的 defer 函数中的 recover 捕获。因此在一定程度上可以用于简化错误处理的控制结构。在上一篇“优化反复出现的if err != nil”中,我们在介绍check/handle风格化这个方法时就利用了 panic 的这个特性,这里再回顾一下:

  1. // go-if-error-check-optimize-2.go
  2. func check(err error) {
  3. if err != nil {
  4. panic(err) // ------------------- 不管啥错误都 panic
  5. }
  6. }
  7. func CopyFile(src, dst string) (err error) {
  8. var r, w *os.File
  9. // error handler
  10. defer func() {
  11. if r != nil {
  12. r.Close()
  13. }
  14. if w != nil {
  15. w.Close()
  16. }
  17. if e := recover(); e != nil {
  18. if w != nil {
  19. os.Remove(dst)
  20. }
  21. err = fmt.Errorf("copy %s %s: %v", src, dst, err)
  22. }
  23. }()
  24. r, err = os.Open(src)
  25. check(err)
  26. w, err = os.Create(dst)
  27. check(err)
  28. _, err = io.Copy(w, r)
  29. check(err)
  30. return nil
  31. }

在 Go 标准库中,我们也看到了这种利用 panic 辅助简化错误处理控制结构,减少if err != nil重复出现的例子。我们来看一下 fmt 包中的这个例子:

  1. // $GOROOT/src/fmt/scan.go
  2. // scanError represents an error generated by the scanning software.
  3. // It's used as a unique signature to identify such errors when recovering.
  4. type scanError struct {
  5. err error
  6. }
  7. func (s *ss) error(err error) {
  8. panic(scanError{err})
  9. }
  10. func (s *ss) Token(skipSpace bool, f func(rune) bool) (tok []byte, err error) {
  11. defer func() {
  12. if e := recover(); e != nil {
  13. if se, ok := e.(scanError); ok {
  14. err = se.err
  15. } else {
  16. panic(e)
  17. }
  18. }
  19. }()
  20. if f == nil {
  21. f = notSpace
  22. }
  23. s.buf = s.buf[:0]
  24. tok = s.token(skipSpace, f)
  25. return
  26. }
  27. func (s *ss) token(skipSpace bool, f func(rune) bool) []byte {
  28. if skipSpace {
  29. s.SkipSpace()
  30. }
  31. // read until white space or newline
  32. for {
  33. r := s.getRune()
  34. if r == eof {
  35. break
  36. }
  37. if !f(r) {
  38. s.UnreadRune()
  39. break
  40. }
  41. s.buf.writeRune(r)
  42. }
  43. return s.buf
  44. }
  45. func (s *ss) getRune() (r rune) {
  46. r, _, err := s.ReadRune()
  47. if err != nil {
  48. if err == io.EOF {
  49. return eof
  50. }
  51. s.error(err)
  52. }
  53. return
  54. }

3) 使用 recover 捕获 panic,防止 goroutine 意外退出

前面提到了 panic 的“危害”:无论在哪个 goroutine 中发生未被 recover 的 panic,整个程序都将崩溃退出。在有些场景下我们必须抑制这种“危害”,保证程序的健壮性。在这方面,标准库中的 http server 就是一个典型的代表:

  1. // $GOROOT/src/net/http/server.go
  2. // Serve a new connection.
  3. func (c *conn) serve(ctx context.Context) {
  4. c.remoteAddr = c.rwc.RemoteAddr().String()
  5. ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())
  6. defer func() {
  7. if err := recover(); err != nil && err != ErrAbortHandler {
  8. const size = 64 << 10
  9. buf := make([]byte, size)
  10. buf = buf[:runtime.Stack(buf, false)]
  11. c.server.logf("http: panic serving %v: %v\n%s", c.remoteAddr, err, buf)
  12. }
  13. if !c.hijacked() {
  14. c.close()
  15. c.setState(c.rwc, StateClosed)
  16. }
  17. }()
  18. ... ...
  19. }

3. 理解 panic 的输出信息

下面是某程序 panic 时真实输出的 panic 信息摘录:

  1. panic: runtime error: invalid memory address or nil pointer dereference
  2. [signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x8ca449]
  3. goroutine 266900 [running]:
  4. pkg.tonybai.com/smspush/vendor/github.com/bigwhite/gocmpp.(*Client).Connect(0xc42040c7f0, 0xc4203d29c0, 0x11, 0xc420423256, 0x6, 0xc420423260, 0x8, 0x37e11d600, 0x0, 0x0)
  5. /root/.go/src/pkg.tonybai.com/smspush/vendor/github.com/bigwhite/gocmpp/client.go:79 +0x239
  6. pkg.tonybai.com/smspush/pkg/pushd/pusher.cmpp2Login(0xc4203d29c0, 0x11, 0xc420423256, 0x6, 0xc420423260, 0x8, 0x37e11d600, 0xc4203d29c0, 0x11, 0x73)
  7. /root/.go/src/pkg.tonybai.com/smspush/pkg/pushd/pusher/cmpp2_handler.go:25 +0x9a
  8. pkg.tonybai.com/smspush/pkg/pushd/pusher.newCMPP2Loop(0xc42071f800, 0x4, 0xaaecd8)
  9. /root/.go/src/pkg.tonybai.com/smspush/pkg/pushd/pusher/cmpp2_handler.go:65 +0x226
  10. pkg.tonybai.com/smspush/pkg/pushd/pusher.(*tchanSession).Run(0xc42071f800, 0xaba7c3, 0x17)
  11. /root/.go/src/pkg.tonybai.com/smspush/pkg/pushd/pusher/session.go:52 +0x98
  12. pkg.tonybai.com/smspush/pkg/pushd/pusher.(*gateway).addSession.func1(0xc4200881a0, 0xc42071f800, 0xc42040c700)
  13. /root/.go/src/pkg.tonybai.com/smspush/pkg/pushd/pusher/gateway.go:61 +0x11e
  14. created by pkg.tonybai.com/smspush/pkg/pushd/pusher.(*gateway).addSession
  15. /root/.go/src/pkg.tonybai.com/smspush/pkg/pushd/pusher/gateway.go:58 +0x350

对于 panic 导致的程序崩溃,我们首先检查位于栈顶的栈跟踪信息,并定位到直接引发 panic 的那一行代码:

  1. /root/.go/src/pkg.tonybai.com/smspush/vendor/github.com/bigwhite/gocmpp/client.go:79 +0x239

下面是 client.go 这个源文件第 79 行周围的代码片段:

image.png

如果没能做到,接下来,我们将继续调查一下 panic 输出的函数调用栈中参数是否正确。要想知道函数调用栈中参数传递是否有问题,我们就要知晓 panic 后输出的栈帧信息都是什么!比如下面 panic 信息中参数中的各种数值都代表什么!

  1. gocmpp.(*Client).Connect(0xc42040c7f0, 0xc4203d29c0, 0x11, 0xc420423256, 0x6, 0xc420423260, 0x8, 0x37e11d600, 0x0, 0x0)
  2. pusher.cmpp2Login(0xc4203d29c0, 0x11, 0xc420423256, 0x6, 0xc420423260, 0x8, 0x37e11d600, 0xc4203d29c0, 0x11, 0x73)
  3. pusher.newCMPP2Loop(0xc42071f800, 0x4, 0xaaecd8)

关于 panic 后输出的栈跟踪信息(以下称 stack trace)的识别,总体可遵循以下几个要点:

  1. stack trace 中每个函数/方法后面的“参数数值”个数与函数/方法原型的参数个数不是一一对应的;
  2. stack trace 中每个函数/方法后面的“参数数值”是按照函数/方法原型参数列表中从左到右的参数类型的内存布局逐一展开的; 每个数值占用一个 word(64 位平台下面为 8 字节);
  3. 如果是方法(method),则第一个参数是 receiver 自身。如果 receiver 是指针类型,则第一个参数数值就是一个指针地址;如果是非指针的实例,则 stack trace 会按照其内存布局输出;
  4. 函数/方法返回值放在 stack trace 的“参数数值”列表的后面;如果有多个返回值,则同样按从左到右顺序,按照返回值类型的内存布局输出;
  5. 指针类型参数:占用 stack trace 的“参数数值”列表的 1 个位置;数值表示指针值,也是指针指向的对象的地址;
  6. string 类型参数:由于 string 在内存中由两个字(word)表示,第一个字是数据指针,第二个字是 string 的长度,因此在 stack trace 的“参数数值”列表中将占用两个位置;
  7. slice 类型参数:由于 slice 类型在内存中由三个字表示,第一个 word 是数据指针,第二个 word 是 len,第三个字是 cap,因此在 stack trace 的“参数数值”列表中将占用三个位置;
  8. 内建整型(int,rune,byte):由于按 word 逐个输出,对于类型长度不足一个 Word 的参数,会做合并处理;比如:一个函数有 5 个 int16 类型的参数,那么在 stack trace 的信息中,这 5 个参数将占用 stack trace 的“参数数值”列表中的两个位置;第一个位置是前 4 个参数的“合体”,第二个位置则是最后那个 int16 类型的参数值;
  9. struct 类型参数: 会按照 struct 中字段的内存布局顺序在 stack trace 中展开;
  10. interface 类型参数:由于 interface 类型在内存中由两部分组成,一部分是接口类型的参数指针,一部分是接口值的参数指针,因此 interface 类型参数将用 stack trace 的“参数数值”列表中的两个位置;
  11. stack trace 输出的信息是在函数调用过程中的“快照”信息,因此一些输出数值看似不合理,但是由于其并不是最终值,所以问题不一定发生在这些参数身上,比如:返回值参数。

合上面要点、函数/方法原型以及 stack trace 的输出,我们来“定位”一下上述 stack trace 输出的各个“参数”的含义:

cmpp2Login 和 Connect 的函数/方法原型以及调用关系如下:

  1. func cmpp2Login(dstAddr, user, password string, connectTimeout time.Duration) (*cmpp.Client, error)
  2. func (cli *Client) Connect(servAddr, user, password string, timeout time.Duration) error
  3. func cmpp2Login(dstAddr, user, password string, connectTimeout time.Duration) (*cmpp.Client, error) {
  4. c := cmpp.NewClient(cmpp.V21)
  5. return c, c.Connect(dstAddr, user, password, connectTimeout)
  6. }

将上述原型与 stack trace 的参数对照后,我们得出下面对应关系:

  1. pusher.cmpp2Login(
  2. 0xc4203d29c0, // dstAddr string的数据指针
  3. 0x11, // dstAddr string的length
  4. 0xc420423256, // user string的数据指针
  5. 0x6, // user string的length
  6. 0xc420423260, // password string的数据指针
  7. 0x8, // password string的length
  8. 0x37e11d600, // connectTimeout (64位整型)
  9. 0xc4203d29c0, // 返回值:Client的指针
  10. 0x11, // 返回值:error接口的类型指针
  11. 0x73) // 返回值:error接口的数据指针
  12. gocmpp.(*Client).Connect(
  13. 0xc42040c7f0, // cli的指针
  14. 0xc4203d29c0, // servAddr string的数据指针
  15. 0x11, // servAddr string的length
  16. 0xc420423256, // user string的数据指针
  17. 0x6, // user string的length
  18. 0xc420423260, // password string的数据指针
  19. 0x8, // password string的length
  20. 0x37e11d600, // timeout
  21. 0x0, // 返回值:error接口的类型指针
  22. 0x0) // 返回值:error接口的数据指针

在这里,cmpp2Login 的 dstAddr、user、password、connectTimeout 这些输入参数值都非常正常;看起来不正常的两个返回值在栈帧中的值其实意义不大,因为 connect 没有返回,所以这些值处于“非最终态”;而 Connect 执行到第 79 行 panic,其返回值 error 的两个值也是处于“中间状态”。因此,在这个例子中虽然我们读懂了 panic 输出的 stack trace 信息,但这些信息并没有给与我们过多的解题提示。这是因为导致这个真实案例 panic 的真正元凶是上面图片中第 78 行。这行中我们使用了类型断言(type assertion),但却没有对类型断言返回的ok值进行有效性判断就使用了类型断言返回的rsp变量。由于类型断言失败,rsp 为 nil,这就是导致 panic 的真实原因。我们看到这次 panic 的位置对参数和返回值没有造成“污染”,这也是我们觉得 stack trace 信息都是正常的原因

在Go 1.11及以后版本中,Go 编译器做了更深入的优化,很多“简单”的函数或方法会被自动 inline(内联)了。函数一旦内联化了,在 stack trace 中我们就无法看到栈帧信息了,栈帧信息都变成了省略号

  1. $go run go-panic-stacktrace.go
  2. panic: panic in baz
  3. goroutine 1 [running]:
  4. main.(*Y).baz(...)
  5. /Users/tonybai/.../go-panic-stacktrace.go:32
  6. main.main()
  7. /Users/tonybai/.../go-panic-stacktrace.go:51 +0x39
  8. exit status 2

我们可以使用-gcflags=”-l”来告诉编译器不要执行内联优化(inline),这样便可以看到 stacktrace 信息:

  1. $ go run -gcflags="-l" go-panic-stacktrace.go
  2. panic: panic in baz
  3. goroutine 1 [running]:
  4. main.(*Y).baz(0xc00006cf30, 0xc00006cf28, 0x5, 0x10ccd43, 0x5, 0xc00006cf60, 0xe000d000c000b, 0xc00010000f, 0xc00006cf48, 0x103d29a)
  5. /Users/tonybai/.../go-panic-stacktrace.go:32 +0x39
  6. main.main()
  7. /Users/tonybai/.../go-panic-stacktrace.go:51 +0xff
  8. exit status 2