Go 语言踩坑记——panic 与 recover
[作者简介] 易乐天,小米信息技术部海外商城组
题记
Go 语言自发布以来,一直以高性能、高并发著称。因为标准库提供了 http 包,即使刚学不久的程序员,也能轻松写出 http 服务程序。
不过,任何事情都有两面性。一门语言,有它值得骄傲的有点,也必定隐藏了不少坑。新手若不知道这些坑,很容易就会掉进坑里。《 Go 语言踩坑记》系列博文将以 Go 语言中的 panic
与 recover
开头,给大家介绍笔者踩过的各种坑,以及填坑方法。
初识 panic 和 recover
panic
panic
这个词,在英语中具有恐慌、恐慌的
等意思。从字面意思理解的话,在 Go 语言中,代表极其严重的问题,程序员最害怕出现的问题。一旦出现,就意味着程序的结束并退出。Go 语言中panic
关键字主要用于主动抛出异常,类似java
等语言中的throw
关键字。recover
recover
这个词,在英语中具有恢复、复原
等意思。从字面意思理解的话,在 Go 语言中,代表将程序状态从严重的错误中恢复到正常状态。Go 语言中recover
关键字主要用于捕获异常,让程序回到正常状态,类似java
等语言中的try ... catch
。
笔者有过 6 年 linux 系统 C 语言开发经历。C 语言中没有异常捕获的概念,没有 try ... catch
,也没有 panic
和 recover
。不过,万变不离其宗,异常与 if error then return
方式的差别,主要体现在函数调用栈的深度上。如下图:
正常逻辑下的函数调用栈,是逐个回溯的,而异常捕获可以理解为:程序调用栈的长距离跳转。这点在 C 语言里,是通过 setjump
和 longjump
这两个函数来实现的。
try catch
、 recover
、setjump
等机制会将程序当前状态(主要是 cpu 的栈指针寄存器 sp 和程序计数器 pc , Go 的 recover
是依赖 defer
来维护 sp 和 pc )保存到一个与 throw
、panic
、longjump
共享的内存里。当有异常的时候,从该内存中提取之前保存的 sp 和 pc 寄存器值,直接将函数栈调回到 sp 指向的位置,并执行 ip 寄存器指向的下一条指令,将程序从异常状态中恢复到正常状态。
深入 panic 和 recover
源码
panic
和 recover
的源码在 Go 源码的 src/runtime/panic.go
里,名为 gopanic
和 gorecover
的函数。
1 | // gopanic 的代码,在 src/runtime/panic.go 第 454 行 |
1 | // gorecover 的代码,在 src/runtime/panic.go 第 585 行 |
从函数代码中我们可以看到 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
。代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36// 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 继续执行。
代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25// gorecover 的代码,在 src/runtime/panic.go 第 637 行
// 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
pc := gp.sigcode1
// 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
gogo(&gp.sched)
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20// 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 // restore SP
MOVQ gobuf_ret(BX), AX
MOVQ gobuf_ctxt(BX), DX
MOVQ gobuf_bp(BX), BP
MOVQ $0, gobuf_sp(BX) // clear to help garbage collector
MOVQ $0, gobuf_ret(BX)
MOVQ $0, gobuf_ctxt(BX)
MOVQ $0, gobuf_bp(BX)
MOVQ gobuf_pc(BX), BX
JMP BX
以上便是 Go 底层处理异常的流程,精简为三步便是:
defer
函数中调用recover
- 触发
panic
并切到runtime
环境获取在defer
中调用了recover
的g
的 sp 和 pc - 恢复到
defer
中recover
后面的处理逻辑
都有哪些坑
前面提到,panic
函数主要用于主动触发异常。我们在实现业务代码的时候,在程序启动阶段,如果资源初始化出错,可以主动调用 panic
立即结束程序。对于新手来说,这没什么问题,很容易做到。
但是,现实往往是残酷的—— Go 的 runtime
代码中很多地方都调用了 panic
函数,对于不了解 Go 底层实现的新人来说,这无疑是挖了一堆深坑。如果不熟悉这些坑,是不可能写出健壮的 Go 代码。
接下来,笔者给大家细数下都有哪些坑。
这个比较好理解,对于静态类型语言,数组下标越界是致命错误。如下代码可以验证:
1 | package main |
输出:
1 | runtime error: index out of range |
因为代码中用了 recover
,程序得以恢复,输出 exit
。
如果将 recover
那几行注释掉,将会输出如下日志:
1 | panic: runtime error: index out of range |
对于有 c/c++ 开发经验的人来说,这个很好理解。但对于没用过指针的新手来说,这是最常见的一类错误。
如下代码可以验证:
1 | package main |
输出:
1 | runtime error: invalid memory address or nil pointer dereference |
如果将 recover
那几行代码注释掉,则会输出:
1 | panic: runtime error: invalid memory address or nil pointer dereference |
这也是刚学用 chan
的新手容易犯的错误。如下代码可以验证:
1 | package main |
输出:
1 | send on closed channel |
如果注释掉 recover ,将输出:
1 | panic: send on closed channel |
源码处理逻辑在 src/runtime/chan.go
的 chansend
函数中,如下:
1 | // src/runtime/chan.go 第 269 行 |
对于刚学并发编程的同学来说,并发读写 map 也是很容易遇到的问题。如下代码可以验证:
1 | package main |
输出:
1 | fatal error: concurrent map read and map write |
细心的朋友不难发现,输出日志里没有出现我们在程序末尾打印的 exit
,而是直接将调用栈打印出来了。查看 src/runtime/map.go
中的代码不难发现这几行:
1 | if h.flags&hashWriting != 0 { |
与前面提到的几种情况不同,runtime
中调用 throw
函数抛出的异常是无法在业务代码中通过 recover
捕获的,这点最为致命。所以,对于并发读写 map 的地方,应该对 map 加锁。
在使用类型断言对 interface
进行类型转换的时候也容易一不小心踩坑,而且这个坑是即使用 interface
有一段时间的人也容易忽略的问题。如下代码可以验证:
1 | package main |
输出:
1 | interface conversion: interface {} is string, not []string |
源码在 src/runtime/iface.go
中,如下两个函数:
1 | // panicdottypeE is called when doing an e.(T) conversion and the conversion fails. |
更多的 panic
前面提到的只是基本语法中常遇到的几种 panic
场景,Go 标准库中有更多使用 panic
的地方,大家可以在源码中搜索 panic(
找到调用的地方,以免后续用标准库函数的时候踩坑。
限于篇幅,本文暂不介绍填坑技巧,后面再开其他篇幅逐个介绍。感谢阅读!
下回预告
Go 语言踩坑记之 channel 与 goroutine。
作者
易乐天,小米信息技术部海外商城组
招聘
信息部是小米公司整体系统规划建设的核心部门,支撑公司国内外的线上线下销售服务体系、供应链体系、ERP 体系、内网 OA 体系、数据决策体系等精细化管控的执行落地工作,服务小米内部所有的业务部门以及 40 家生态链公司。
同时部门承担大数据基础平台研发和微服务体系建设落,语言涉及 Java、Go,长年虚位以待对大数据处理、大型电商后端系统、微服务落地有深入理解和实践的各路英雄。
欢迎投递简历:jin.zhang(a)xiaomi.com(武汉)
扫描二维码,分享此文章