Skip to content

panic ​

阅读提示

  • 适用版本:Go 1.21+(示例在 Go 1.22 下验证;panic(nil) 行为在 Go 1.21 有变化)· 前置知识:defer · 阅读时长:约 10 分钟

panic是 go 的内置函数,当遇到不可恢复的错误时,程序往往就会抛出panic,比如常见的空指针访问

go
func main() {
  var a *int
  *a = 1
}

运行上面这段代码,程序就会抛出如下的panic,然后程序就会停止。

panic: runtime error: invalid memory address or nil pointer dereference

在一些情况下,我们也会手动调用panic函数来让程序退出,从而避免更严重的后果。平时也会用另一个内置函数recover来捕获panic,并配合defer使用。

go
func main() {
  defer func() {
    if err := recover(); err != nil {
      fmt.Println(err)
    }
  }()
  var a *int
  *a = 1
}

为什么recover函数一定要在defer里面使用,recover做了些什么工作,这些问题都会在下面的内容得到解答。

结构 ​

panic在运行时也有对应的结构进行表示,那就是runtime._panic,其结构并不复杂,如下。

go
type _panic struct {
  argp      unsafe.Pointer // pointer to arguments of deferred call run during panic; cannot move - known to liblink
  arg       any            // argument to panic
  link      *_panic        // link to earlier panic
  pc        uintptr        // where to return to in runtime if this panic is bypassed
  sp        unsafe.Pointer // where to return to in runtime if this panic is bypassed
  recovered bool           // whether this panic is over
  aborted   bool           // the panic was aborted
  goexit    bool
}

它的结构与defer非常类似,

  • link指向下一个_panic结构,
  • pc和sp指向调用函数的执行现场便于日后恢复,
  • arg就是panic函数的参数,
  • argp指向defer的参数,panic发生后便会触发defer的执行
  • aborted表示其是否被强制停止

panic跟defer一样,也是以链表的形式存在于协程中

go
type g struct {
  _panic    *_panic // innermost panic - offset known to liblink
  _defer    *_defer // innermost defer
}

恐慌 ​

无论是我们主动调用panic函数,抑或是程序发生的panic,最终都会走入runtime.gopanic函数中

go
func gopanic(e any)

在开始时,首先会检测参数是否为nil,如果是nil的话就会 new 一个runtime.PanicNilError类型的错误

go
if e == nil {
    if debug.panicnil.Load() != 1 {
        e = new(PanicNilError)
    } else {
        panicnil.IncNonDefault()
    }
}

然后将当前的panic加入协程的链表头部

go
var p _panic
p.arg = e
p.link = gp._panic
gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

随后进入for循环开始逐个处理当前协程的defer链表

go
for {
    d := gp._defer
    if d == nil {
      break
    }

    if d.started {
      if d._panic != nil {
        d._panic.aborted = true
      }
      d._panic = nil
    }
    d.started = true
        d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
    ...
}

如果当前的defer已经被其它的panic触发了,即_defer.started == true,那么较早的panic将不会执行。然后再执行defer对应的函数

go
p.argp = unsafe.Pointer(getargp())
d.fn()
p.argp = nil
d._panic = nil

d.fn = nil
gp._defer = d.link
freedefer(d)

执行完后回收当前的defer结构,继续执行下一个defer,当执行完全部的defer结构后且期间没有被恢复,就会进入runtime.fatalpanic函数,该函数是unrecoverable即不可恢复的

go
func fatalpanic(msgs *_panic) {
  pc := getcallerpc()
  sp := getcallersp()
  gp := getg()
  var docrash bool
  systemstack(func() {
    if startpanic_m() && msgs != nil {
      runningPanicDefers.Add(-1)
      printpanics(msgs)
    }

    docrash = dopanic_m(gp, pc, sp)
  })

  if docrash {
    crash()
  }

  systemstack(func() {
    exit(2)
  })

  *(*int)(nil) = 0 // not reached
}

在这期间会让printpanics打印panic的信息,我们通常看到的调用栈信息就是由它输出的,最后由runtime.exit函数通过系统调用_ExitProcess退出程序。

恢复 ​

通过调用内置函数recover,编译期间会变为对runtime.gorecover函数的调用

go
func gorecover(argp uintptr) any {
  gp := getg()
  p := gp._panic
  if p != nil && !p.goexit && !p.recovered && argp == uintptr(p.argp) {
    p.recovered = true
    return p.arg
  }
  return nil
}

它的实现非常简单,只干了p.recovered = true这么一件事,而真正负责处理恢复逻辑的代码实际上在gopanic函数里

go
for {
    ...
      d.fn()
      ...
    if p.recovered {
      ...
    }
}

恢复的逻辑在defer执行后,到这里也就明白了为什么recover函数只能在defer中使用,如果在defer之外使用recover函数的话gp._panic就等于nil,自然p.recovered就不会被设置为true,那么在gopanic函数中也就不会走到恢复这部分逻辑里面来。

go
if p.recovered {
    gp._panic = p.link
    for gp._panic != nil && gp._panic.aborted {
      gp._panic = gp._panic.link
    }
    if gp._panic == nil {
      gp.sig = 0
    }

    gp.sigcode0 = uintptr(sp)
    gp.sigcode1 = pc
    mcall(recovery)
    throw("recovery failed")
}

恢复时会清理链表中那些已经被强制停止的panic,然后进入runtime.recovery函数中,由runtime.gogo回到用户函数的正常逻辑流程中

go
func recovery(gp *g) {
  // Info about defer passed in G struct.
  sp := gp.sigcode0
  pc := gp.sigcode1

  gp.sched.sp = sp
  gp.sched.pc = pc
  gp.sched.lr = 0
  gp.sched.ret = 1
  gogo(&gp.sched)
}

然后有一个重点需要注意的是这行代码

gp.sched.ret = 1

它将ret值设置成为了 1,从runtime.deferproc的函数注释中可以看代码下面这些内容

go
func deferproc(fn func()) {
    ...
  // deferproc returns 0 normally.
  // a deferred func that stops a panic
  // makes the deferproc return 1.
  // the code the compiler generates always
  // checks the return value and jumps to the
  // end of the function if deferproc returns != 0.
  return0()
}

编译器生成的中间代码会检查该值是否为 1,如果是的话就会直接执行runtime.deferreturn函数,通常该函数只有在函数返回之前才会执行,这也说明了为什么recover过后函数会直接返回。

完整示例 ​

下面的程序可以直接运行:复制到文件后执行 go run main.go,或粘贴到 Go Playground 运行。

go
package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("recovered: %v", r)
		}
	}()
	return a / b, nil
}

func main() {
	v, err := safeDivide(10, 2)
	fmt.Println(v, err)

	v, err = safeDivide(10, 0)
	fmt.Println(v, err)
}

预期输出:

text
5 <nil>
0 recovered: runtime error: integer divide by zero

常见错误与踩坑 ​

  • recover 只有在延迟函数中直接调用才有效。 在其他位置调用会返回 nil,panic 继续向上传播。
  • recover 之后函数会直接返回。 应在受控路径继续执行(如上例),状态已破坏时应再次 panic。
  • panic 会执行 defer,os.Exit 不会。 调用 os.Exit 时,已注册的清理逻辑会被直接跳过。
  • 不要用 panic 处理普通错误。 它适合编程错误与不可恢复状态;常规错误应返回 error。
  • panic(nil) 的行为已变化。 Go 1.21 起会表现为非 nil 的 *runtime.PanicNilError,旧版本 recover 到的是 nil。

更新记录 ​

  • 2026-10-14:新增"阅读提示"、完整可运行示例(含预期输出)、常见错误与踩坑清单,以及本更新记录。