【2-8 Golang】Go并发编程—panic defer recover

tomato01 · · 1597 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

&emsp;&emsp;在Go程序中defer特别常见,通常用来执行一些清理工作,需要注意defer先入后出特性(先声明的后执行);panic意味着一些出乎意料的错误发生,Go程序在panic异常退出的时候,会打印运行时栈方便排查问题;panic的错误可以被recover捕获,从而避免Go程序的退出,但是要注意recover只能在defer中,其他任何地方声明的recover是不能捕获panic的。 ## panic/defer/recover基本使用 &emsp;&emsp;Go程序的defer具备延后执行的能力,因此通常用来执行一些清理工作,例如文件的关闭,锁的释放等等。如下面事例所示: ``` package main import "sync" var lock = sync.Mutex{} func main() { //1.接受到请求 //2.处理请求 doRequest() //3.请求响应 } //请求不能并行执行,所以需要加锁 func doRequest() { lock.Lock() defer lock.Unlock() //临界区代码逻辑 //这么执行也行,但是如果临界区出现panic,锁将无法释放 //lock.Unlock() } ``` &emsp;&emsp;语句defer lock.Unlock()并没有立即执行锁的释放操作,而是声明了一个延后执行操作,当doRequest函数返回时,会执行当前函数声明的defer操作,也就是doRequest函数返回时,才真正的释放锁。为什么要这么写呢?一般不是加锁,临界区代码,释放锁吗?想想如果临界区代码出现panic呢?这时候还能执行锁的释放操作吗?而一旦锁没有成功释放,后续其他请求不就全部阻塞了? &emsp;&emsp;这一点一定要切记,针对一些资源的关闭,锁的释放等操作,一定在defer执行,否则就有可能出现死锁,资源泄露等等情况。 &emsp;&emsp;我们看到,doRequest执行完毕返回时,才真正执行defer声明,那如果一个函数内声明了多个defer呢?函数返回时defer的执行顺序是怎么样的呢?如下面的事例: ``` package main import "fmt" func main() { for i := 0; i < 5; i ++ { defer fmt.Println(i) } } ``` &emsp;&emsp;这段程序输出什么呢?其实这里涉及两个问题:1)defer执行顺序;2)defer传参问题。需要注意的是,在声明defer fmt.Println时,参数i作为fmt.Println函数的输入参数,值已经明确了,且封装进interface数据类型,所以最终执行fmt.Println函数时,输出的是5个不同的值。另外,defer是先声明后执行的,所以最终执行顺序应该反着来看,输出 4-3-2-1-0。 &emsp;&emsp;panic意味着一些出乎意料的错误发生,Go程序在panic异常退出的时候,会打印运行时栈方便排查问题,例如map如果没有初始化,执行操作会panic;空指针引用也会panic;数组越界也会panic等等。如下面程序所示: ``` package main func main() { var data map[string]int data["test"] = 1 } /* panic: assignment to entry in nil map goroutine 1 [running]: main.main() /test.go:6 +0x2e */ ``` &emsp;&emsp;当然,我们也可以通过panic函数手动抛出panic,注意Go程序在遇到panic时可是会异常退出的,一般为了避免程序退出,我们会使用recover捕获panic,只是需要记得recover只能在defer中。如下面程序所示: ``` package main import "fmt" func main() { defer func() { if rec := recover(); rec != nil { //捕获到panic,记录日志等 fmt.Println(rec) } }() panic("this is a panic") } //this is a panic ``` &emsp;&emsp;recover在Go程序作为HTTP服务时特别有用,总不能因为一个HTTP请求处理异常,导致整个服务退出吧?通常我们在使用recover捕获到panic时,会记录一些日志,包括运行时栈数据,以及HTTP请求,方便排查问题。 ## 实现原理 &emsp;&emsp;通过上面介绍,我们基本了解了panic/defer/recover的基本使用,不过思考下,为什么defer是先声明后执行的呢?Go语言如何保证在函数返回时,执行当前函数内声明的defer呢?recover为什么只能在defer中呢?假设A协程抛出panic,在B协程能使用recover捕获到吗? &emsp;&emsp;在深入研究panic/defer/recover实现原理之前,我们先介绍下其对应的底层实现方法: ``` //参考文件runtime/panic.go // Create a new deferred function fn, which has no arguments and results. // The compiler turns a defer statement into a call to this. func deferproc(fn func()) // deferprocStack queues a new deferred function with a defer record on the stack. func deferprocStack(d *_defer) // The implementation of the predeclared function panic. func gopanic(e any) // The implementation of the predeclared function recover. func gorecover(argp uintptr) any // deferreturn runs deferred functions for the caller's frame. // The compiler inserts a call to this at the end of any // function which calls defer. func deferreturn() ``` &emsp;&emsp;看到deferreturn函数的注释,基本也就明白了Go语言如何保证在函数返回时执行当前函数内声明的defer。在编译阶段,如果检测到当前函数声明了defer,则会在函数末尾添加deferreturn函数调用,该函数遍历当前函数声明的defer并执行: ``` func deferreturn() { gp := getg() for { d := gp._defer if d == nil { return } //defer链表存储在当前协程G,d.sp=sp说明defer就是在当前函数声明的 sp := getcallersp() if d.sp != sp { return } fn := d.fn d.fn = nil gp._defer = d.link freedefer(d) fn() } } ``` &emsp;&emsp;从deferreturn函数定义可以看到,defer链表是存储在当前协程G上的,所以在遍历过程中需要判断defer是否声明在当前函数,怎么判断呢?基于栈顶寄存器sp,在将defer加入到协程G链表时,记录了声明该defer时候的栈顶寄存器sp(也就是当前函数栈顶)。 &emsp;&emsp;貌似从函数deferreturn的实现看不出来为什么defer是先声明后执行的,不过基本能确定协程G上维护了一个defer链表,那么在新增defer节点时,头插法是不是对应的就是栈呢?我们简单看看deferproc函数的实现逻辑(deferprocStack类似): ``` func deferproc(fn func()) { gp := getg() d := newdefer() //头插法 d.link = gp._defer gp._defer = d d.fn = fn //设置函数栈顶寄存器sp以及指令寄存器pc d.sp = getcallersp() d.pc = getcallerpc() return0() } ``` &emsp;&emsp;还有一个问题,panic是怎么触发程序退出的呢?recover为什么只能在defer中呢?假设A协程抛出panic,在B协程能使用recover捕获到吗?我们先看看gopanic函数的实现逻辑: ``` func gopanic(e any) { //遍历当前协程defer链表 for { d := gp._defer if d == nil { break } //执行defer d.fn() pc := d.pc sp := unsafe.Pointer(d.sp) //recover捕获,恢复程序执行 if p.recovered { gp.sigcode0 = uintptr(sp) gp.sigcode1 = pc mcall(recovery) } } //打印栈桢,exit(2)退出 fatalpanic(gp._panic) // should not return } ``` &emsp;&emsp;在触发panic时,Go语言遍历当前协程defer链表,如果其中某个defer执行recover捕获了异常,则恢复程序执行,否则最后通过exit(2)退出。看到这里基本也能猜出来,gorecover函数肯定会设置p.recovered=true。另外,由于Go语言遍历的是当前协程defer链表,所以其他协程中defer+recover是无法捕获该panic的,而且如果recover不在defer中也是无法捕获的。 &emsp;&emsp;最后一个问题,recover捕获了panic,恢复程序执行后,下一条执行的指令是什么呢?其实可以直观的想想,当执行到某一个defer并且当前defer捕获了异常,一般情况什么时候执行defer呢?函数执行完毕返回之前!那假设某一个defer执行了,并且需要恢复程序正常执行流程,那怎么办?继续执行当前协程的defer显然不合适,这不是正常流程,只能按照当前defer所在函数执行结束返回的逻辑往下走了,也就是继续执行当前defer所在函数内声明的defer,如果没有,函数返回,返回到哪?当然是调用该函数的地方了! ``` package main import "fmt" func main() { test() fmt.Println("test end") } func test() { defer fmt.Println("defer 1") defer func() { fmt.Println("defer 2") if rec := recover(); rec != nil { fmt.Println(rec) } }() defer fmt.Println("defer 3") panic("this is a panic") } /* defer 3 defer 2 this is a panic defer 1 test end */ ``` &emsp;&emsp;仔细观察deferproc函数最后一行代码return0(),刚才省略了其注释: ``` // 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. ``` &emsp;&emsp;如果deferproc返回1,跳转到函数返回处执行(deferreturn)。这怎么实现的呢?怎么返回1呢?deferproc不是在声明defer的时候就执行了吗?程序又是怎么跳转到这里而且还能返回1呢?defer内捕获到panic后,通过mcall(recovery)恢复了程序的执行(gopanic函数实现),就是这一行代码,跳转到了deferproc函数下一行代码,并且设置了返回值1 ``` func recovery(gp *g) { //结合gopanic + deferproc函数,这里的sp以及pc(就是调用deferproc函数时的寄存器地址) sp := gp.sigcode0 pc := gp.sigcode1 gp.sched.sp = sp gp.sched.pc = pc //设置返回值为1 gp.sched.ret = 1 //跳转 gogo(&gp.sched) } ``` &emsp;&emsp;recovery函数设置寄存器sp以及pc,以及返回值ret=1,跳转到该上下文继续执行程序。这里的pc就是调用deferproc函数时的寄存器地址,也就是deferproc下一行指令,就是这一行指令判断了返回值如果为1,跳转到函数末尾执行deferreturn。当然这一行指令一般情况是看不到的,只能看汇编后的代码: ``` package main import "fmt" func main() { defer fmt.Println(1) fmt.Println("hello world") } //go tool compile -S -N -l test.go /* 0x00a3 00163 (test.go:6) CALL runtime.deferprocStack(SB) 0x00a8 00168 (test.go:6) TESTL AX, AX 0x00aa 00170 (test.go:6) JNE 288 0x0109 00265 (test.go:9) CALL runtime.deferreturn(SB) 0x010e 00270 (test.go:9) MOVQ 192(SP), BP 0x0116 00278 (test.go:9) ADDQ $200, SP 0x011d 00285 (test.go:9) RET 0x011e 00286 (test.go:9) NOP 0x0120 00288 (test.go:6) CALL runtime.deferreturn(SB) 0x0125 00293 (test.go:6) MOVQ 192(SP), BP 0x012d 00301 (test.go:6) ADDQ $200, SP 0x0134 00308 (test.go:6) RET */ ``` ## 总结 &emsp;&emsp;本篇文章主要介绍panic/defer/recover的基本使用以及实现原理,要切记针对一些资源的关闭,锁的释放等操作,一定在defer执行,否则就有可能出现死锁,资源泄露等等情况;另外,在程序可能出现panic的地方,记得添加defer+recover,不然你的程序在遇到panic时可是会退出的。

有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

1597 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传