21

Go 语言踩坑记——panic 与 recover

 4 years ago
source link: https://studygolang.com/articles/28026
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Go 语言自发布以来,一直以高性能、高并发著称。因为标准库提供了 http 包,即使刚学不久的程序员,也能轻松写出 http 服务程序。 不过,任何事情都有两面性。一门语言,有它值得骄傲的有点,也必定隐藏了不少坑。新手若不知道这些坑,很容易就会掉进坑里。《 Go 语言踩坑记》系列博文将以 Go 语言中的 panicrecover 开头,给大家介绍笔者踩过的各种坑,以及填坑方法。

初识 panic 和 recover

  • panic panic 这个词,在英语中具有 恐慌、恐慌的 等意思。从字面意思理解的话,在 Go 语言中,代表极其严重的问题,程序员最害怕出现的问题。一旦出现,就意味着程序的结束并退出。Go 语言中 panic 关键字主要用于主动抛出异常,类似 java 等语言中的 throw 关键字。
  • recover recover 这个词,在英语中具有 恢复、复原 等意思。从字面意思理解的话,在 Go 语言中,代表将程序状态从严重的错误中恢复到正常状态。Go语言中 recover 关键字主要用于捕获异常,让程序回到正常状态,类似 java 等语言中的 try ... catch

笔者有过 6 年 linux 系统 C 语言开发经历。C 语言中没有异常捕获的概念,没有 try ... catch ,也没有 panicrecover 。不过,万变不离其宗,异常与 if error then return 方式的差别,主要体现在函数调用栈的深度上。如下图:

2euQfmM.png!web

正常逻辑下的函数调用栈,是逐个回溯的,而异常捕获可以理解为:程序调用栈的长距离跳转。这点在 C 语言里,是通过 setjumplongjump 这两个函数来实现的。例如以下代码:

#include <setjmp.h>
#include <stdio.h>

static jmp_buf env;

double divide(double to, double by)
{
    if(by == 0)
    {
        longjmp(env, 1);
    }
    return to / by;
}

void test_divide()
{
    divide(2, 0);
    printf("done\n");
}

int main()
{
    if (setjmp(env) == 0)
    {
        test_divide();
    }
    else
    {
        printf("Cannot / 0\n");
        return -1;
    }
    return 0;
}

复制代码

由于发生了长距离跳转,直接从 divide 函数内跳转到 main 函数内,中断了正常的执行流,以上代码编译后将输出 Cannot / 0 而不会输出 done 。是不是很神奇?

try catchrecoversetjump 等机制会将程序当前状态(主要是 cpu 的栈指针寄存器 sp 和程序计数器 pc , Go 的 recover 是依赖 defer 来维护 sp 和 pc )保存到一个与 throwpaniclongjump 共享的内存里。当有异常的时候,从该内存中提取之前保存的sp和pc寄存器值,直接将函数栈调回到sp指向的位置,并执行ip寄存器指向的下一条指令,将程序从异常状态中恢复到正常状态。

深入 panic 和 recover

源码

panicrecover 的源码在 Go 源码的 src/runtime/panic.go 里,名为 gopanicgorecover 的函数。

// gopanic 的代码,在 src/runtime/panic.go 第 454 行

// 预定义函数 panic 的实现
func gopanic(e interface{}) {
	gp := getg()
	if gp.m.curg != gp {
		print("panic: ")
		printany(e)
		print("\n")
		throw("panic on system stack")
	}

	if gp.m.mallocing != 0 {
		print("panic: ")
		printany(e)
		print("\n")
		throw("panic during malloc")
	}
	if gp.m.preemptoff != "" {
		print("panic: ")
		printany(e)
		print("\n")
		print("preempt off reason: ")
		print(gp.m.preemptoff)
		print("\n")
		throw("panic during preemptoff")
	}
	if gp.m.locks != 0 {
		print("panic: ")
		printany(e)
		print("\n")
		throw("panic holding locks")
	}

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

	atomic.Xadd(&runningPanicDefers, 1)

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

        // 如果触发 defer 的 panic 是在前一个 panic 或者 Goexit 的 defer 中触发的,那么将前一个 defer 从列表中去除。前一个 panic 或者 Goexit 将不再继续执行。
		if d.started {
			if d._panic != nil {
				d._panic.aborted = true
			}
			d._panic = nil
			d.fn = nil
			gp._defer = d.link
			freedefer(d)
			continue
		}

        // 将 defer 标记为 started,但是保留在列表上,这样,如果在 reflectcall 开始执行 d.fn 之前发生了堆栈增长或垃圾回收,则 traceback 可以找到并更新 defer 的参数帧。
		d.started = true

        // 将正在执行 defer 的 panic 保存下来。如果在该 panic 的 defer 函数中触发了新的 panic ,则新 panic 在列表中将会找到 d 并将 d._panic 标记为 aborted 。
		d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

		p.argp = unsafe.Pointer(getargp(0))
		reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))
		p.argp = nil

		// reflectcall 不会 panic,移除 d 。
		if gp._defer != d {
			throw("bad defer entry in panic")
		}
		d._panic = nil
		d.fn = nil
		gp._defer = d.link

		// 这里用 GC() 来触发堆栈收缩以测试堆栈拷贝。由于是测试代码,所以注释掉了。参考 stack_test.go:TestStackPanic
		//GC()

		pc := d.pc
		sp := unsafe.Pointer(d.sp) // 必须是指针,以便在堆栈复制期间进行调整
        // defer 处理函数的内存是动态分配的,在执行完后需要释放内存。所以,如果 defer 一直得不到执行(比如在死循环中一直创建 defer),将会导致内存泄露
		freedefer(d)
		if p.recovered {
			atomic.Xadd(&runningPanicDefers, -1)

			gp._panic = p.link
            // 已退出的 panic 已经被标记,但还遗留在 g.panic 列表里,从列表里移除他们。
			for gp._panic != nil && gp._panic.aborted {
				gp._panic = gp._panic.link
			}
			if gp._panic == nil { // must be done with signal
				gp.sig = 0
			}
			// 将正在恢复的栈帧传给 recovery。
			gp.sigcode0 = uintptr(sp)
			gp.sigcode1 = pc
			mcall(recovery)
			throw("recovery failed") // mcall 不应该返回
		}
	}

	// 如果所有的 defer 都遍历完毕,意味着没有 recover(前面提到,mcall 执行 recovery 是不返回的),继续执行 panic 后续流程,如:输出调用栈信息和错误信息
	// 由于在冻结世界之后调用任意用户代码是不安全的,因此我们调用preprintpanics来调用所有必要的Error和String方法以在startpanic之前准备 panic 输出的字符串。
	preprintpanics(gp._panic)

	fatalpanic(gp._panic) // 不应该返回
	*(*int)(nil) = 0      // 因为 fatalpanic 不应该返回,正常情况下这里不会执行。如果执行到了,这行代码将触发 panic
}
复制代码
// gorecover 的代码,在 src/runtime/panic.go 第 585 行

// 预定义函数 recover 的实现。
// 无法拆分堆栈,因为它需要可靠地找到其调用方的堆栈段。
//
// TODO(rsc): Once we commit to CopyStackAlways,
// this doesn't need to be nosplit.
//go:nosplit
func gorecover(argp uintptr) interface{} {
	// 在处理 panic 的时候,recover 函数的调用必须放在 defer 的顶层处理函数中。
	// p.argp 是最顶层的延迟函数调用的参数指针,与调用方传递的argp进行比较,如果一致,则该调用方是可以恢复的。
	gp := getg()
	p := gp._panic
	if p != nil && !p.recovered && argp == uintptr(p.argp) {
		p.recovered = true
		return p.arg
	}
	return nil
}
复制代码

从函数代码中我们可以看到 panic 内部主要流程是这样:

  • 获取当前调用者所在的 g ,也就是 goroutine
  • 遍历并执行 g 中的 defer 函数
  • 如果 defer 函数中有调用 recover ,并发现已经发生了 panic ,则将 panic 标记为 recovered
  • 在遍历 defer 的过程中,如果发现已经被标记为 recovered ,则提取出该 defer 的 sp 与 pc,保存在 g 的两个状态码字段中。
  • 调用 runtime.mcall 切到 m->g0 并跳转到 recovery 函数,将前面获取的 g 作为参数传给 recovery 函数。 runtime.mcall 的代码在 go 源码的 src/runtime/asm_xxx.s 中, xxx 是平台类型,如 amd64 。代码如下:
    // src/runtime/asm_amd64.s 第 274 行
    
    // func mcall(fn func(*g))
    // Switch to m->g0's stack, call fn(g).
    // Fn must never return. It should gogo(&g->sched)
    // to keep running g.
    TEXT runtime·mcall(SB), NOSPLIT, $0-8
        MOVQ	fn+0(FP), DI
    
        get_tls(CX)
        MOVQ	g(CX), AX	// save state in g->sched
        MOVQ	0(SP), BX	// caller's PC
        MOVQ	BX, (g_sched+gobuf_pc)(AX)
        LEAQ	fn+0(FP), BX	// caller's SP
        MOVQ	BX, (g_sched+gobuf_sp)(AX)
        MOVQ	AX, (g_sched+gobuf_g)(AX)
        MOVQ	BP, (g_sched+gobuf_bp)(AX)
    
        // switch to m->g0 & its stack, call fn
        MOVQ	g(CX), BX
        MOVQ	g_m(BX), BX
        MOVQ	m_g0(BX), SI
        CMPQ	SI, AX	// if g == m->g0 call badmcall
        JNE	3(PC)
        MOVQ	$runtime·badmcall(SB), AX
        JMP	AX
        MOVQ	SI, g(CX)	// g = m->g0
        MOVQ	(g_sched+gobuf_sp)(SI), SP	// sp = m->g0->sched.sp
        PUSHQ	AX
        MOVQ	DI, DX
        MOVQ	0(DI), DI
        CALL	DI
        POPQ	AX
        MOVQ	$runtime·badmcall2(SB), AX
        JMP	AX
        RET
    复制代码

这里之所以要切到 m->g0 ,主要是因为 Go 的 runtime 环境是有自己的堆栈和 goroutine ,而 recovery 是在 runtime 环境下执行的,所以要先调度到 m->g0 来执行 recovery 函数。

  • recovery 函数中,利用 g 中的两个状态码回溯栈指针 sp 并恢复程序计数器 pc 到调度器中,并调用 gogo 重新调度 g ,将 g 恢复到调用 recover 函数的位置, goroutine 继续执行。 代码如下:
    // gorecover 的代码,在 src/runtime/panic.go 第 637 行
    
    // 在 panic 后,在延迟函数中调用 recover 的时候,将回溯堆栈,并且继续执行,就像延迟函数的调用者正常返回一样。
    func recovery(gp *g) {
        // Info about defer passed in G struct.
        sp := gp.sigcode0
        pc := gp.sigcode1
    
        // 延迟函数的参数必须已经保存在堆栈中了(这里通过判断 sp 是否处于栈内存地址的范围内来保障参数的正确处理)
        if sp != 0 && (sp < gp.stack.lo || gp.stack.hi < sp) {
            print("recover: ", hex(sp), " not in [", hex(gp.stack.lo), ", ", hex(gp.stack.hi), "]\n")
            throw("bad recovery")
        }
    
    	// 让延迟函数的 deferproc 再次返回,这次返回 1 。调用函数将跳转到标准返回结尾。
        gp.sched.sp = sp
        gp.sched.pc = pc
        gp.sched.lr = 0
        gp.sched.ret = 1
        gogo(&gp.sched)
    }
    复制代码
    // src/runtime/asm_amd64.s 第 274 行
    
    // func gogo(buf *gobuf)
    // restore state from Gobuf; longjmp
    TEXT runtime·gogo(SB), NOSPLIT, $16-8
        MOVQ	buf+0(FP), BX		// gobuf
        MOVQ	gobuf_g(BX), DX
        MOVQ	0(DX), CX		// make sure g != nil
        get_tls(CX)
        MOVQ	DX, g(CX)
        MOVQ	gobuf_sp(BX), SP	// 从 gobuf 中恢复 SP ,以便后面做跳转
        MOVQ	gobuf_ret(BX), AX
        MOVQ	gobuf_ctxt(BX), DX
        MOVQ	gobuf_bp(BX), BP
        MOVQ	$0, gobuf_sp(BX)	// 这里开始清理 gobuf ,以便垃圾回收。
        MOVQ	$0, gobuf_ret(BX)
        MOVQ	$0, gobuf_ctxt(BX)
        MOVQ	$0, gobuf_bp(BX)
        MOVQ	gobuf_pc(BX), BX    // 从 gobuf 中恢复 pc ,以便跳转
        JMP	BX
    复制代码

以上便是 Go 底层处理异常的流程,精简为三步便是:

  • defer 函数中调用 recover
  • 触发 panic 并切到 runtime 环境获取在 defer 中调用了 recoverg 的 sp 和 pc
  • 恢复到 deferrecover 后面的处理逻辑

都有哪些坑

前面提到, panic 函数主要用于主动触发异常。我们在实现业务代码的时候,在程序启动阶段,如果资源初始化出错,可以主动调用 panic 立即结束程序。对于新手来说,这没什么问题,很容易做到。

但是,现实往往是残酷的—— Go 的 runtime 代码中很多地方都调用了 panic 函数,对于不了解 Go 底层实现的新人来说,这无疑是挖了一堆深坑。如果不熟悉这些坑,是不可能写出健壮的 Go 代码。

接下来,笔者给大家细数下都有哪些坑。

  • 数组( slice )下标越界

    这个比较好理解,对于静态类型语言,数组下标越界是致命错误。如下代码可以验证:

    package main
    
    import (
        "fmt"
    )
    
    func foo(){
        defer func(){
            if err := recover(); err != nil {
                fmt.Println(err)
            }
        }()
        var bar = []int{1}
        fmt.Println(bar[1])
    }
    
    func main(){
        foo()
        fmt.Println("exit")
    }
    复制代码

    输出:

    runtime error: index out of range
    exit
    复制代码

    因为代码中用了 recover ,程序得以恢复,输出 exit

    如果将 recover 那几行注释掉,将会输出如下日志:

    panic: runtime error: index out of range
    
    goroutine 1 [running]:
    main.foo()
        /home/letian/work/go/src/test/test.go:14 +0x3e
    main.main()
        /home/letian/work/go/src/test/test.go:18 +0x22
    exit status 2
    复制代码
  • 访问未初始化的指针或 nil 指针

    对于有 c/c++ 开发经验的人来说,这个很好理解。但对于没用过指针的新手来说,这是最常见的一类错误。 如下代码可以验证:

    package main
    
    import (
        "fmt"
    )
    
    func foo(){
        defer func(){
            if err := recover(); err != nil {
                fmt.Println(err)
            }
        }()
        var bar *int
        fmt.Println(*bar)
    }
    
    func main(){
        foo()
        fmt.Println("exit")
    }
    复制代码

    输出:

    runtime error: invalid memory address or nil pointer dereference
    exit
    复制代码

    如果将 recover 那几行代码注释掉,则会输出:

    panic: runtime error: invalid memory address or nil pointer dereference
    [signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x4869ff]
    
    goroutine 1 [running]:
    main.foo()
        /home/letian/work/go/src/test/test.go:14 +0x3f
    main.main()
        /home/letian/work/go/src/test/test.go:18 +0x22
    exit status 2
    复制代码
  • 试图往已经 close 的 chan 里发送数据

    这也是刚学用 chan 的新手容易犯的错误。如下代码可以验证:

    package main
    
    import (
        "fmt"
    )
    
    func foo(){
        defer func(){
            if err := recover(); err != nil {
                fmt.Println(err)
            }
        }()
        var bar = make(chan int, 1)
        close(bar)
        bar<-1
    }
    
    func main(){
        foo()
        fmt.Println("exit")
    }
    复制代码

    输出:

    send on closed channel
    exit
    复制代码

    如果注释掉 recover ,将输出:

    panic: send on closed channel
    
    goroutine 1 [running]:
    main.foo()
        /home/letian/work/go/src/test/test.go:15 +0x83
    main.main()
        /home/letian/work/go/src/test/test.go:19 +0x22
    exit status 2
    复制代码

    源码处理逻辑在 src/runtime/chan.gochansend 函数中,如下图:

    // src/runtime/chan.go 第 269 行
    
    // 如果 block 不为 nil ,则协议将不会休眠,但如果无法完成则返回。
    // 当关闭休眠中的通道时,可以使用 g.param == nil 唤醒睡眠。
    // 我们可以非常容易循环并重新运行该操作,并且将会看到它处于已关闭状态。
    func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
        if c == nil {
            if !block {
                return false
            }
            gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
            throw("unreachable")
        }
    
        if debugChan {
            print("chansend: chan=", c, "\n")
        }
    
        if raceenabled {
            racereadpc(c.raceaddr(), callerpc, funcPC(chansend))
        }
    
        // Fast path: check for failed non-blocking operation without acquiring the lock.
        //
        // After observing that the channel is not closed, we observe that the channel is
        // not ready for sending. Each of these observations is a single word-sized read
        // (first c.closed and second c.recvq.first or c.qcount depending on kind of channel).
        // Because a closed channel cannot transition from 'ready for sending' to
        // 'not ready for sending', even if the channel is closed between the two observations,
        // they imply a moment between the two when the channel was both not yet closed
        // and not ready for sending. We behave as if we observed the channel at that moment,
        // and report that the send cannot proceed.
        //
        // It is okay if the reads are reordered here: if we observe that the channel is not
        // ready for sending and then observe that it is not closed, that implies that the
        // channel wasn't closed during the first observation.
        if !block && c.closed == 0 && ((c.dataqsiz == 0 && c.recvq.first == nil) ||
            (c.dataqsiz > 0 && c.qcount == c.dataqsiz)) {
            return false
        }
    
        var t0 int64
        if blockprofilerate > 0 {
            t0 = cputicks()
        }
    
        lock(&c.lock)
    
        if c.closed != 0 {
            unlock(&c.lock)
            panic(plainError("send on closed channel"))
        }
    
        if sg := c.recvq.dequeue(); sg != nil {
            // Found a waiting receiver. We pass the value we want to send
            // directly to the receiver, bypassing the channel buffer (if any).
            send(c, sg, ep, func() { unlock(&c.lock) }, 3)
            return true
        }
    
        if c.qcount < c.dataqsiz {
            // Space is available in the channel buffer. Enqueue the element to send.
            qp := chanbuf(c, c.sendx)
            if raceenabled {
                raceacquire(qp)
                racerelease(qp)
            }
            typedmemmove(c.elemtype, qp, ep)
            c.sendx++
            if c.sendx == c.dataqsiz {
                c.sendx = 0
            }
            c.qcount++
            unlock(&c.lock)
            return true
        }
    
        if !block {
            unlock(&c.lock)
            return false
        }
    
        // Block on the channel. Some receiver will complete our operation for us.
        gp := getg()
        mysg := acquireSudog()
        mysg.releasetime = 0
        if t0 != 0 {
            mysg.releasetime = -1
        }
        // No stack splits between assigning elem and enqueuing mysg
        // on gp.waiting where copystack can find it.
        mysg.elem = ep
        mysg.waitlink = nil
        mysg.g = gp
        mysg.isSelect = false
        mysg.c = c
        gp.waiting = mysg
        gp.param = nil
        c.sendq.enqueue(mysg)
        goparkunlock(&c.lock, waitReasonChanSend, traceEvGoBlockSend, 3)
        // Ensure the value being sent is kept alive until the
        // receiver copies it out. The sudog has a pointer to the
        // stack object, but sudogs aren't considered as roots of the
        // stack tracer.
        KeepAlive(ep)
    
        // someone woke us up.
        if mysg != gp.waiting {
            throw("G waiting list is corrupted")
        }
        gp.waiting = nil
        if gp.param == nil {
            if c.closed == 0 {
                throw("chansend: spurious wakeup")
            }
            panic(plainError("send on closed channel"))
        }
        gp.param = nil
        if mysg.releasetime > 0 {
            blockevent(mysg.releasetime-t0, 2)
        }
        mysg.c = nil
        releaseSudog(mysg)
        return true
    }
    复制代码
  • 并发读写相同 map

对于刚学并发编程的同学来说,并发读写 map 也是很容易遇到的问题。如下代码可以验证:

package main

  import (
      "fmt"
  )

  func foo(){
      defer func(){
          if err := recover(); err != nil {
              fmt.Println(err)
          }
      }()
      var bar = make(map[int]int)
      go func(){
          defer func(){
              if err := recover(); err != nil {
                  fmt.Println(err)
              }
          }()
          for{
              _ = bar[1]
          }
      }()
      for{
          bar[1]=1
      }
  }

  func main(){
      foo()
      fmt.Println("exit")
  }
复制代码

输出:

fatal error: concurrent map read and map write

  goroutine 5 [running]:
  runtime.throw(0x4bd8b0, 0x21)
      /home/letian/.gvm/gos/go1.12/src/runtime/panic.go:617 +0x72 fp=0xc00004c780 sp=0xc00004c750 pc=0x427f22
  runtime.mapaccess1_fast64(0x49eaa0, 0xc000088180, 0x1, 0xc0000260d8)
      /home/letian/.gvm/gos/go1.12/src/runtime/map_fast64.go:21 +0x1a8 fp=0xc00004c7a8 sp=0xc00004c780 pc=0x40eb58
  main.foo.func2(0xc000088180)
      /home/letian/work/go/src/test/test.go:21 +0x5c fp=0xc00004c7d8 sp=0xc00004c7a8 pc=0x48708c
  runtime.goexit()
      /home/letian/.gvm/gos/go1.12/src/runtime/asm_amd64.s:1337 +0x1 fp=0xc00004c7e0 sp=0xc00004c7d8 pc=0x450e51
  created by main.foo
      /home/letian/work/go/src/test/test.go:14 +0x68

  goroutine 1 [runnable]:
  main.foo()
      /home/letian/work/go/src/test/test.go:25 +0x8b
  main.main()
      /home/letian/work/go/src/test/test.go:30 +0x22
  exit status 2
复制代码

细心的朋友不难发现,输出日志里没有出现我们在程序末尾打印的 exit ,而是直接将调用栈打印出来了。查看 src/runtime/map.go 中的代码不难发现这几行:

if h.flags&hashWriting != 0 {
      throw("concurrent map read and map write")
  }
复制代码

与前面提到的几种情况不同, runtime 中调用 throw 函数抛出的异常是无法在业务代码中通过 recover 捕获的,这点最为致命。所以,对于并发读写 map 的地方,应该对 map 加锁。

  • 类型断言

    在使用类型断言对 interface 进行类型转换的时候也容易一不小心踩坑,而且这个坑是即使用 interface 有一段时间的人也容易忽略的问题。如下代码可以验证:
    package main
    
    import (
        "fmt"
    )
    
    func foo(){
        defer func(){
            if err := recover(); err != nil {
                fmt.Println(err)
            }
        }()
        var i interface{} = "abc"
        _ = i.([]string)
    }
    
    func main(){
        foo()
        fmt.Println("exit")
    }
    复制代码
    输出:
    interface conversion: interface {} is string, not []string
    exit
    复制代码
    源码在 src/runtime/iface.go 中,如下两个函数:
    // panicdottypeE is called when doing an e.(T) conversion and the conversion fails.
    // have = the dynamic type we have.
    // want = the static type we're trying to convert to.
    // iface = the static type we're converting from.
    func panicdottypeE(have, want, iface *_type) {
        panic(&TypeAssertionError{iface, have, want, ""})
    }
    
    // panicdottypeI is called when doing an i.(T) conversion and the conversion fails.
    // Same args as panicdottypeE, but "have" is the dynamic itab we have.
    func panicdottypeI(have *itab, want, iface *_type) {
        var t *_type
        if have != nil {
            t = have._type
        }
        panicdottypeE(t, want, iface)
    }
    复制代码

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK