Go语言panic/recover的实现

幸运草 2020年4月20日23:09:42函数代码评论阅读模式

本文主要分析Go语言的panic/recover在AMD64 Linux平台下的实现,包括:

  1. 主动调用 panic() 函数所引发的panic的处理流程,比如go代码中直接调用panic()函数或编译器插入的对panic()的调用;
  2. 非法操作所导致的panic的处理流程,这主要跟信号处理流程有关。

阅读本文所必需的预备知识:

  1. defer/panic/recover 的基本用法;
  2. defer 的实现机制;
  3. mcall/gogo 函数的实现。

panic/recover例子

先来热一下身,大家可以先想想下面几个例子的输出会是什么,检测一下自己对panic/recover的理解。

例1:

func f() {
    defer catch("f")

    g()
}

func g()  {
    defer m()
  
    panic("g panic")
}

func m() {
    panic("m panic")
}

func catch(funcname string) {
    if r := recover(); r != nil {
        fmt.Println(funcname, "recover:", r)
      }
}

例2:

func f() {
    defer catch("f")

    g()
}

func g()  {
    defer m()
  
    panic("g panic")
}

func m() {
    defer catch("m")
}

func catch(funcname string) {
    if r := recover(); r != nil {
        fmt.Println(funcname, "recover:", r)
      }
}

例3:

func f() {
    defer catch("f")

    g()
}

func catch(funcname string) {
    if r := recover(); r != nil {
        fmt.Println(funcname, "recover:", r)
      }
}

func g()  {
    defer m()
  
    panic("g panic")
}

func m() {
    defer catch("m")
  
    panic("m panic")
}

panic/recover要点简介

为了更好的理解panic/recover的实现代码,我们首先需要了解几个与之有关的要点:

  1. 当panic发生之后,程序从正常的执行流程跳转到执行panic发生之前通过defer语句注册的defered函数,直到某个defered函数通过recover函数捕获了panic后再恢复正常的执行流程,如果没有recover则当所有的defered函数被执行完成之后结束程序;
  2. defer语句会被编译器翻译成对runtime包中deferproc()函数的调用,该函数会把defered函数打包成一个_defer结构体对象挂入goroutine对应的g结构体对象的_defer链表中,_defer对象除了保存有defered函数的地址以及该函数需要的参数外,还会分别把call deferproc指令的下一条指令的地址以及此时函数调用栈顶指针保存在_defer.pc和_defer.sp成员之中,用于recover时恢复程序的正常执行流程;
  3. 当某个defered函数通过recover()函数捕获到一个panic之后,程序将从该defered函数对应的_defer结构体对象的pc成员所保存的指令地址处开始执行;
  4. panic可以嵌套,当发生panic之后在执行defer函数时又发生了panic即为嵌套。每个还未被recover的panic都会对应着一个_panic结构体对象,它们会被依次挂入g结构体的_panic链表之中,最近一次发生的panic位于_panic链表表头,最早发生的panic位于链表尾。

下面对第2点和第3点做个简单的说明。假设有如下程序片段:

例4

package main

import "fmt"

func main() {
    f()
    fmt.Println("main")
}

func f() {
    defer catch("f")      // 1

    panic("f panic")

    fmt.Println("f continue")
}

func catch(funcname string) {
    if r := recover(); r != nil {
        fmt.Println(funcname, "recover:", r)
     }
}

f()函数运行时会发生panic,但该panic会被它通过defer注册的catch函数所捕获从而恢复程序的正常执行流程,上一篇文章我们提到过deferproc函数有个隐含的返回值与panic/recover有关,下面我们通过f()函数再来看一下相关的汇编指令片段:

   ......
   # 对应defer catch("f")
   0x0000000000487245 <+69>: callq   0x426c00 <runtime.deferproc>
   0x000000000048724a <+74>: test   %eax,%eax
   0x000000000048724c <+76>: jne    0x48726c <main.f+108>
   ......
   # 对应panic("f panic")
   0x0000000000487265 <+101>: callq   0x427880 <runtime.gopanic>
   ......
   0x000000000048726c <+108>: nop
   0x000000000048726d <+109>: callq   0x427490 <runtime.deferreturn>  
   ......

该代码片段前3条指令对应着f()函数中注释1处的一行go代码,最后两条指令通过调用deferreturn函数去执行defered函数,如果f函数不发生panic,其执行流程我们在上一篇文章中已经详细介绍过,但此处的f函数会发生panic,其流程稍有不同:

  1. 当f函数执行到callq  0x426c00 <runtime.deferproc>指令时,deferproc函数将会把catch函数的地址及其参数"f"、下一条指令的地址0x000000000048724a以及当前的栈顶指针打包成一个_defer结构体对象挂入当前goroutine的_defer链表,然后通过eax寄存器隐形的返回一个0值;
  2. 因为从deferproc函数返回时,eax寄存器的值为0,所以0x000000000048724a <+74>: test   %eax,%eax这条指令不会导致下一条jne指令发生跳转,于是程序会继续执行到0x0000000000487265 <+101>处的callq   0x427880 <runtime.gopanic>指令;
  3. gopanic函数内部会去调用defered函数即catch("f"),因为catch()函数调用了recover,这将导致CPU跳转到f()函数的0x000000000048724a <+74>: test   %eax,%eax指令处继续执行,这看起来就像deferproc函数再一次返回了,但这次eax寄存器的值却会被设置成1,所以执行这条test指令将导致下一条jne指令跳转到0x000000000048726c <+108>: nop处继续执行,此时已经恢复了程序的正常执行流程;
  4. 调用deferreturn函数,因为例4程序只有一个defered函数,而且已经被gopanic调用了,所以这里的deferreturn函数并不会调用任何defered函数就直接返回到了f()函数,然后从f函数返回到main函数继续执行main函数的fmt.Println("main")语句。

主动调用panic()函数

一般来说,Go程序在两种情况下会发生panic:

  1. 主动调用panic()函数,这包括go代码中直接调用以及由编译器插入的调用,比如编译器会插入代码检查访问数组/slice是否越界,同时还会插入调用panic()的代码,运行时如果越界就会去调用panic()函数;
  2. 非法操作,比如向只读内存写入数据,访问非法内存等也会发生panic。这种情况在Linux平台(其它平台不熟悉)下是通过信号(signal)机制来实现对panic()函数的调用。

我们先来看主动调用panic函数时panic/recover的流程。

通过反汇编可以得知go代码中对panic()/recover()函数的调用会被编译器翻译成对runtime包中的gopanic()以及gorecover()函数的调用。

runtime/panic.go : 453

// The implementation of the predeclared function panic.
func gopanic(e interface{}) {
    gp := getg()
    ......

     //panic可以嵌套,比如发生了panic之后运行defered函数又发生了panic,如上面的例3。
     //最新的panic会被挂入goroutine对应的g结构体对象的_panic链表的表头
    var p _panic  //创建_panic结构体对象
    p.arg = e  //panic的参数
    p.link = gp._panic
    gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

    atomic.Xadd(&runningPanicDefers, 1)

    for {
        d := gp._defer  //取出_defer链表头的defered函数
        if d == nil {
            break  //没有defer函数将会跳出循环,然后打印栈信息然后结束程序
        }

        // If defer was started by earlier panic or Goexit (and, since we're back here, that triggered a new panic),
        // take defer off list. The earlier panic or Goexit will not continue running.
        if d.started {
             //到这里一定发生了panic嵌套,即在defered函数中又发生了panic,请参考本文开头的例1
             //d.started = true是panic嵌套的充分条件,但并不是必要条件,也就是说
             //即使d.started为false也是可能发生嵌套的,请结合defer的处理流程并参考本文开头的例3
             //最近发生的一次panic并没有被recover所以取消上一次发生的panic
            if d._panic != nil {
                d._panic.aborted = true
            }
            d._panic = nil
            d.fn = nil
            gp._defer = d.link
            freedefer(d)
            continue
        }

        // Mark defer as started, but keep on list, so that traceback
        // can find and update the defer's argument frame if stack growth
        // or a garbage collection happens before reflectcall starts executing d.fn.
        d.started = true  //用于判断是否发生了嵌套panic

        // Record the panic that is running the defer.
        // If there is a new panic during the deferred call, that panic
        // will find d in the list and will mark d._panic (this panic) aborted.
         //把panic和defer函数关联起来
        d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

         //在panic中记录当前panic的栈顶位置,用于recover判断
        p.argp = unsafe.Pointer(getargp(0))
         //通过reflectcall函数调用defered函数
         //如果defered函数再次发生panic而且并未被该defered函数recover,则reflectcall永远不会返回,参考例2。
         //如果defered函数并没有发生过panic或者发生了panic但该defered函数成功recover了新发生的panic,
         //则此函数会返回继续执行后面的代码。
        reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))
        p.argp = nil

        // reflectcall did not panic. Remove d.
        if gp._defer != d {
            throw("bad defer entry in panic")
        }
         //defer函数已经被执行,脱链
        d._panic = nil
        d.fn = nil
        gp._defer = d.link

        // trigger shrinkage to test stack copy. See stack_test.go:TestStackPanic
        //GC()

        pc := d.pc  //call deferproc的下一条指令的地址,下一条指令为 test rax, rax,在defer实现机制一文中有详细说明
         //call deferproc指令执行前的栈顶指针
        sp := unsafe.Pointer(d.sp) // must be pointer so it gets adjusted during stack copy
        freedefer(d)
        if p.recovered {
             //defered函数调用recover成功捕获了panic会设置p.recovered = true
            atomic.Xadd(&runningPanicDefers, -1)

            gp._panic = p.link
            // Aborted panics are marked but remain on the g.panic list.
            // Remove them from the list.
            for gp._panic != nil && gp._panic.aborted {
                gp._panic = gp._panic.link
            }
            if gp._panic == nil { // must be done with signal
                gp.sig = 0
            }
            // Pass information about recovering frame to recovery.
            gp.sigcode0 = uintptr(sp)
            gp.sigcode1 = pc
             //mcall函数永远不会返回,mcall函数的实现可以参考公众号内的其它文章,有详细分析
             //调用recovery函数跳转到pc位置继续执行
            mcall(recovery)  
            throw("recovery failed") // mcall should not return
        }
    }

    // ran out of deferred calls - old-school panic now
    // Because it is unsafe to call arbitrary user code after freezing
    // the world, we call preprintpanics to invoke all necessary Error
    // and String methods to prepare the panic strings before startpanic.
    preprintpanics(gp._panic)

     //打印函数调用链,然后挂死程序
    fatalpanic(gp._panic) // should not return
    *(*int)(nil) = 0      // not reached
}

// runtime/panic.go : 578

// The implementation of the predeclared function recover.
// Cannot split the stack because it needs to reliably
// find the stack segment of its caller.
//
// TODO(rsc): Once we commit to CopyStackAlways,
// this doesn't need to be nosplit.
//go:nosplit
func gorecover(argp uintptr) interface{} {
    // Must be in a function running as part of a deferred call during the panic.
    // Must be called from the topmost function of the call
    // (the function used in the defer statement).
    // p.argp is the argument pointer of that topmost deferred function call.
    // Compare against argp reported by caller.
    // If they match, the caller is the one who can recover.
    gp := getg()
    p := gp._panic
     //条件argp == uintptr(p.argp)在判断panic和recover是否匹配,内层recover不能捕获外层的panic
     //比如本文开头的例2中m函数中的defer catch("m")不能捕获g函数中的panic
    if p != nil && !p.recovered && argp == uintptr(p.argp) {
        p.recovered = true  //通过设置p.recovered = true告诉gopanic函数panic已经被recover了
        return p.arg
    }
    return nil
}

// runtime/panic.go : 634
// Unwind the stack after a deferred function calls recover
// after a panic. Then arrange to continue running as though
// the caller of the deferred function returned normally.
func recovery(gp *g) {
    // Info about defer passed in G struct.
    sp := gp.sigcode0   //call deferproc时的栈顶指针
    pc := gp.sigcode1   //call deferproc下一条指令的地址

    // d's arguments need to be in the stack.
    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")
    }

    // Make the deferproc for this d return again,
    // this time returning 1.  The calling function will
    // jump to the standard return epilogue.
    gp.sched.sp = sp
    gp.sched.pc = pc
    gp.sched.lr = 0
    gp.sched.ret = 1     //该值(1)会被gogo函数放入eax寄存器
    gogo(&gp.sched)   //跳转到pc所指的指令处继续执行,gogo函数的实现请参考公众号内的其它文章,有详细分析
}

从代码可以看出,如果不考虑嵌套,主动 panic/recover 的流程比较清晰:遍历当前 goroutine 所注册的 defered 函数并通过 reflectcall 调用遍历到的函数,如果某个 defered 函数调用了recover(对应到runtime的gorecover函数)则使用 mcall(recovery)  恢复程序的正常流程,否则执行完所有的 defered 函数之后打印出 panic 的栈信息然后退出程序。这里需要说明一下为什么需要通过 reflectcall 来调用 defered 函数而不是直接调用 defered 函数。原因在于直接调用 defered 函数就得在当前栈帧中为它准备参数,而不同的 defered 函数的参数大小可能会有很大差异,比如有的defered函数没有参数而有些defered函数可能又需要成千上万字节的参数,然而gopanic 函数的栈帧大小固定而且很小,所以很有可能没有足够的空间来存放 defered 函数的参数,而reflectcall函数可以处理这种情况,具体是怎么处理的这里就不介绍了,有兴趣的话大家可以去看一下reflectcall函数的代码。

对于panic的嵌套,也就是defered函数再次发生了panic,这会导致gopanic函数再次被调用,也就是说gopanic函数会存在递归调用,其调用链为 gopanic()->reflectcall()->defered函数->gopanic() ,这时有两种情况:

  1. defered函数通过defer再次注册了defered函数而且recover了最新的panic,则上面的调用链将原路从reflect call()返回到gopanic函数继续执行;
  2. defered函数没有recover它自己的panic,则reflectcall()不会返回。要么第二次gopanic执行完所有defered函数之后退出程序,要么新发生的panic代替了前一次panic然后由外层的defered函数recover。

对于上述两种情况,大家可以结合前面代码中的注释以及例2和例3加以理解。

非法操作引起的panic

最常见的非法操作主要是非法访问内存,我们来看一个例子:

package main

import (
    "fmt"
)

func f() {
    var p *int

    *p = 100  // crash

    fmt.Println("not reached")
}

func main() {
    f()
}

这个程序运行时会发生panic,原因是f()函数企图向p所指的内存写入100,但指针变量p却是nil。来看看f函数的汇编代码片段:

   0x0000000000487200 <+0>: mov    %fs:0xfffffffffffffff8,%rcx
   0x0000000000487209 <+9>: cmp    0x10(%rcx),%rsp
   0x000000000048720d <+13>: jbe    0x4872a1 <main.f+161>
   0x0000000000487213 <+19>: sub    $0x70,%rsp
   0x0000000000487217 <+23>: mov    %rbp,0x68(%rsp)
   0x000000000048721c <+28>: lea    0x68(%rsp),%rbp
   0x0000000000487221 <+33>: movq   $0x0,0x30(%rsp)
   0x000000000048722a <+42>: xor    %eax,%eax
   0x000000000048722c <+44>: test   %al,(%rax)
   0x000000000048722e <+46>: movq   $0x64,(%rax)    #  *p = 100
   0x0000000000487235 <+53>: xorps  %xmm0,%xmm0
   0x0000000000487238 <+56>: movups %xmm0,0x40(%rsp)
   0x000000000048723d <+61>: lea    0x40(%rsp),%rax
   ......

通过汇编代码我们可以确定编译器并未插入对gopanic函数的调用,但这个程序运行起来发生panic时与在go代码中直接调用gopanic函数时的表现是一样的,都会输出panic时栈的信息,所以这种非法操作最终应该也会调用到gopanic函数,但具体是怎么调用到它的呢?我们可以使用调试工具dlv给gopanic下一个断点,等断下来之后使用bt可以看到其函数调用链为:

f()->runtime.sigpanic()->runtime.panicmem()->runtime.gopanic()

可以看到f()调用了runtime.sigpanic()函数,但从上面的汇编代码可以得知f()其实并没有直接调用runtime.sigpanic()函数,是不是有些奇怪?

事实上,当CPU在执行

0x000000000048722e <+46>: movq   $0x64,(%rax)    #  *p = 100

这一条指令时,CPU会发生异常,异常发生后将依次执行如下流程:

  1. CPU在内存中保存发生异常的指令的地址(这里是0x000000000048722e)。为了方便描述,我们称这个地址为异常返回地址;
  2. 陷入内核执行由操作系统在系统启动时提供的异常处理程序,该异常处理程序会负责把CPU的所有相关寄存器的值保存在内存之中;
  3. 向引起异常的当前线程发送一个SIGSEGV信号;
  4. 从内核返回,在返回过程中发现有信号需要处理;
  5. 从内核返回到用户态执行信号处理程序(go程序启动时向内核注册的信号处理函数),该信号处理程序将会把第1步中由CPU保存的异常返回地址修改为runtime.sigpanic函数的地址;
  6. 信号处理程序执行完成后再次进入内核;
  7. 从内核返回开始执行runtime.sigpanic函数;

上述整个流程与Linux系统的信号处理有关,了解即可,如果有兴趣可以参考相关的内核资料,这里我们只需要关注第5步和第7步。从该流程可以看出,当go程序发生异常之后之所以能够最终执行到gopanic函数,关键在于上述流程的第5步修改了异常之后的执行流程,而第5步中的信号处理程序是由go语言的runtime提供的,所以下面我们直接从信号处理程序开始大致看一下其流程。

SIGSEGV信号处理流程

对于SIGSEGV信号,信号处理程序的函数调用链为

内核返回-> runtime.sigtramp() ->runtime.sigtrampgo()->runtime.sighandler()->sigctxt.preparePanic()修改异常返回地址

这个调用链中的函数由大家自己去挖掘细节,这里只说两点:

  1. runtime.sigtramp是go程序启动时向内核注册的信号处理函数,所以当线程收到SIGSEGV信号后内核会负责让CPU进入这个函数运行;
  2. 内核在返回用户态执行信号处理程序runtime.sigtramp()函数之前,内核会把异常返回地址等数据保存在信号处理程序的函数调用栈之中,等信号处理程序执行完成之后再次进入内核时,内核会把它之前保存在信号处理程序函数栈上的异常返回地址等数据拷贝回内核,然后再返回到用户态继续执行异常返回地址处的指令。这个流程给信号处理程序提供了一个可以修改CPU执行流程的机会,我们来看看sigctxt.preparePanic()函数是怎么修改异常返回地址的:
// preparePanic sets up the stack to look like a call to sigpanic.
func (c *sigctxt) preparePanic(sig uint32, gp *g) {
    if GOOS == "darwin" {
        ......
    }

     //指针c所指的内存即执行信号处理程序之前由内核保存在栈上的数据
     //c.rip即为异常返回地址,也就是异常发生时CPU正在执行的指令的地址
    pc := uintptr(c.rip())
    sp := uintptr(c.rsp())

    if shouldPushSigpanic(gp, pc, *(*uintptr)(unsafe.Pointer(sp))) {
        // Make it look the like faulting PC called sigpanic.
        if sys.RegSize > sys.PtrSize {
            sp -= sys.PtrSize
            *(*uintptr)(unsafe.Pointer(sp)) = 0
        }
        sp -= sys.PtrSize
        *(*uintptr)(unsafe.Pointer(sp)) = pc
        c.set_rsp(uint64(sp))
    }
    c.set_rip(uint64(funcPC(sigpanic)))  //修改异常返回地址为sigpanic函数的地址
}

这个函数的最后一行把异常返回地址修改成了runtime.sigpanic函数的地址,等信号处理完成进入内核后再次返回用户态时CPU将会从runtime.sigpanic函数开始执行,最终执行到前面已经分析过的gopanic函数,这部分代码很清晰,大家有兴趣的话可以自己看看。

特别声明:以上文章内容仅代表作者本人观点,不代表变化吧观点或立场。如有关于作品内容、版权或其它问题请于作品发表后的30日内与变化吧联系。

  • 赞助本站
  • 微信扫一扫
  • weinxin
  • 加入Q群
  • QQ扫一扫
  • weinxin
幸运草
Go语言中的常量 函数代码

Go语言中的常量

1 概述 常量,一经定义不可更改的量。功能角度看,当出现不需要被更改的数据时,应该使用常量进行存储,例如圆周率。从语法的角度看,使用常量可以保证数据,在整个运行期间内,不会被更改。例如当前处理器的架构...
Go语言的接口 函数代码

Go语言的接口

Go语言-接口 在Go语言中,一个接口类型总是代表着某一种类型(即所有实现它的类型)的行为。一个接口类型的声明通常会包含关键字type、类型名称、关键字interface以及由花括号包裹的若干方法声明...
Go语言支持的正则语法 函数代码

Go语言支持的正则语法

1 字符 语法 说明 . 任意字符,在单行模式(s标志)下,也可以匹配换行 字符类 否定字符类 d Perl 字符类 D 否定 Perl 字符类 ASCII 字符类 否定 ASCII 字符类 pN U...
Go语言的包管理 函数代码

Go语言的包管理

1 概述 Go 语言的源码复用建立在包(package)基础之上。包通过 package, import, GOPATH 操作完成。 2 main包 Go 语言的入口 main() 函数所在的包(pa...

发表评论