经过多年的发展,Go语言在国内已经占据了很多开发者的心。
在Go语言中,goroutine、panic和recover是非常重要的关键字,几乎在每一个项目中,我们都会主动地使用它们。
虽然它们在程序中十分常见,但许多刚入门Go语言的开发人员在初次使用时会遇到一些小“坑”。本文选自《Go语言编程之旅:一起用Go做项目》一书,下面我们就来解决这些小“坑”。
思考问题
观察下面这段代码,思考一下其输出的结果是“Go编程之旅:一起用Go做项目”,还是会因为“煎鱼焦了”而直接中断运行:
1func main() {
2 go func() {
3 panic("煎鱼焦了")
4 }()
5
6 log.Println("Go编程之旅:一起用Go做项目")
7}
输出结果如下:
1panic: 煎鱼焦了
2
3goroutine 6 [running]:
4main.main.func1()
5 /Users/eddycjy/go/src/github.com/eddycjy/awesomeProject/main.go:7 +0x39
6created by main.main
7 /Users/eddycjy/go/src/github.com/eddycjy/awesomeProject/main.go:6 +0x35
程序因为“煎鱼焦了”而中断运行。
这时候经常会有人提出一个疑问,即panic语句是写在子协程里的,为何会影响外面的主协程呢?它们应该是相互隔离的,为何会互相影响呢?
如何解决
对于panic事件,我们应使用组合方法recover来进行处理,代码如下:
1func main() {
2 go func() {
3 if e := recover(); e != nil {
4 log.Printf("recover: %v", e)
5 }
6 panic("煎鱼焦了")
7 }()
8
9 log.Println("Go编程之旅:一起用Go做项目")
10}
但是仅仅使用recover,仍然会输出“煎鱼焦了”,并且程序中断。正确的写法如下:
1func main() {
2 go func() {
3 defer func() {
4 if e := recover(); e != nil {
5 log.Printf("recover: %v", e)
6 }
7 }()
8 panic("煎鱼焦了")
9 }()
10
11 log.Println("Go编程之旅:一起用Go做项目")
12}
实际上,recover要与defer联用,并且不跨协程,才能真正地拦截panic事件。其最终的输出结果如下:
1Go编程之旅:一起用Go做项目
2recover: 煎鱼焦了
为什么要先 defer 才能 recover
从前文中我们知道,除panic和recover外,还必须要有defer关键字,三者缺一不可。为什么必须有defer,recover才能起作用呢?
▊ 快速了解panic
panic是Go语言中的一个内置函数,可以停止程序的控制流,改变其流转,并且触发“恐慌”事件。recover也是一个内置函数,其功能与panic相反,recover可以让程序重新获取“恐慌”事件后的程序控制权,但是recover必须在defer中才会生效。
panic的一切都基于一个_panic基础单元,基本结构如下:
1type _panic struct {
2 argp unsafe.Pointer
3 arg interface{}
4 link *_panic
5 pc uintptr
6 sp unsafe.Pointer
7 recovered bool
8 aborted bool
9 goexit bool
10}
我们每执行一次panic语句,就会创建一个_panic基础单元。它包含了一些基础的字段,用于存储当前的panic调用情况,涉及的字段如下。
通过查看link字段,可以得知,panic的基本单元是一个链表的数据结构,如下图。
▊ 快速了解defer
defer是Go语言中的一个内置函数,defer方法所注册的对应事件会在函数或方法结束后执行,常用于关闭各类资源以及“兜底”操作。defer的基础单元是_defer结构体,基本结构如下:
1type _defer struct {
2 siz int32
3 started bool
4 sp uintptr
5 pc uintptr
6 fn *funcval
7 _panic *_panic
8 link *_defer
9 ...
10}
11
12type funcval struct {
13 fn uintptr
14 // variable-size, fn-specific data here
15}
通过查看_panic和link字段可以得知,defer同时挂载着panic信息,如下图。
▊ recover是如何和defer搭上关系的
通过前面的介绍我们知道,defer和panic 存在一定的关联关系,那么recover又是如何与它们产生关联关系的呢?为什么不用defer,recover就无法生效?
为了解答这些问题,我们需要回到一切的起源panic才能知晓。panic关键字的具体代码如下:
1func gopanic(e interface{}) {
2 gp := getg()
3 ...
4 var p _panic
5 p.arg = e
6 p.link = gp._panic
7 gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
8
9 for {
10 d := gp._defer
11 if d == nil {
12 break
13 }
14
15 // defer...
16 ...
17 d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
18
19 p.argp = unsafe.Pointer(getargp(0))
20 reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))
21 p.argp = nil
22
23 // recover...
24 if p.recovered {
25 ...
26 mcall(recovery)
27 throw("recovery failed") // mcall should not return
28 }
29 }
30
31 preprintpanics(gp._panic)
32 fatalpanic(gp._panic) // should not return
33 *(*int)(nil) = 0 // not reached
34}
通过分析上述代码,可以大致了解其处理过程:
“recover是如何和defer搭上关系的?”在调用panic方法后,实际上runtime.gopanic方法处理的是当前goroutine上所挂载的._panic链表(所以无法响应其他goroutine的异常事件),然后对其所属的defer链表和recover进行检测并处理,最后调用退出命令中止应用程序,如图。
从代码实现来看,panic会触发延迟调用(defer)。假设当前goroutine中不存在defer,则会直接跳出,也就无法进行recover了。也就是说,在panic时,Go只会在defer中对reocver进行检测。
从设计实现来看,这是相对合理的,因为没有必要在程序中写若干个recover,而很多错误是无法预料在哪里发生,又是如何发生的。
recover是万能的吗
即便有了recover,也不能捕获所有的错误。
假设某一天,程序正在线上环境(容器里)运行着,突然就“挂”了,即它反复地重启,且每次都是运行一段时间后才宕机,难道有泄露了?
这个程序非常的简短,就是一段简单的并发清洗、组装数据的程序,核心(伪)代码如下:
1func main() {
2 m := make(map[int]string)
3 for i := 0; i < 10; i++ {
4 go func() {
5 defer func() {
6 if e := recover(); e != nil {
7 log.Printf("recover: %v", e)
8 }
9 }()
10
11 m[i] = "Go编程之旅:一起用Go做项目"
12 }()
13 }
14
15 // do something...
16}
已经把recover加进goroutine里了,为何还会出现无法捕获的错误,导致程序“挂”了?
此时对应的控制台日志的关键信息如下:
1fatal error: concurrent map writes
2
3goroutine 21 [running]:
4runtime.throw(0x10d2c3b, 0x15)
5 /usr/local/Cellar/go/1.14/libexec/src/runtime/panic.go:1112 +0x72 fp=0xc000029f50 sp=0xc000029f20 pc=0x102e892
6runtime.mapassign_fast64(0x10b58e0, 0xc000090180, 0x8, 0x0)
7 /usr/local/Cellar/go/1.14/libexec/src/runtime/map_fast64.go:101 +0x323 fp=0xc000029f90 sp=0xc000029f50 pc=0x100f733
通过错误信息我们可以得知,这是一个十分常见的问题,就是并发写入 map 导致的致命错误。为什么recover没有捕获到呢?先来看看runtime.throw方法,代码如下:
1func throw(s string) {
2 systemstack(func() {
3 print("fatal error: ", s, "\n")
4 })
5 gp := getg()
6 if gp.m.throwing == 0 {
7 gp.m.throwing = 1
8 }
9 fatalthrow()
10 *(*int)(nil) = 0 // not reached
11}
关键的中断步骤在fatalthrow方法中,代码如下:
1func fatalthrow() {
2 ...
3 systemstack(func() {
4 ...
5 exit(2)
6 })
7
8 *(*int)(nil) = 0 // not reached
9}
可以看到,该方法是直接通过调用exit方法进行中断的。实际上在Go语言中,是存在着一些无法恢复的“恐慌”事件的,如fatalthrow方法、fatalpanic方法等。
由此可见,recover并非万能的,它只对用户态下的panic关键字有效。
小 结
本文针对panic的常见问题,基于goroutine、panic 和 recover 做了初步的分析。在解析 recover相关行为时,发现其与defer是存在关联关系的,也就是说,goroutine、panic、recover和defer这四者在本质上是互相联动的关系。
使用细节总结如下:
图 书 推 荐
随着学习Go语言人数的增加,Go语言相关的图书也越来越多,却一直没有一本相对完整的项目实践类的图书。
《Go语言编程之旅:一起用Go做项目》是市面上少有的面向项目实践的一本书,希望本书的出版能将这一块的知识成体系地分享给大家。
《Go语言编程之旅:一起用Go做项目》
陈剑煜 徐新华 著
本书针对Go语言中较为常用的命令行应用、HTTP应用、RPC应用、WebSocket 应用、进程内缓存进行了详细的介绍,并开发了一系列小的适合程序员日常使用的工具。
同时对项目开发、细节分析、运行时分析等核心内容进行了较为深入的剖析,提供了相对完整的项目实践经验。
(扫码了解本书详情)
如果喜欢本文
欢迎 在看丨留言丨分享至朋友圈 三连
热文推荐
▼点击阅读原文,了解本书详情~
本文分享自 博文视点Broadview 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!