前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >初学Go语言时常见的小坑:goroutine、panic和recover

初学Go语言时常见的小坑:goroutine、panic和recover

作者头像
博文视点Broadview
发布2023-05-19 20:15:22
3220
发布2023-05-19 20:15:22
举报

经过多年的发展,Go语言在国内已经占据了很多开发者的心。

在Go语言中,goroutine、panic和recover是非常重要的关键字,几乎在每一个项目中,我们都会主动地使用它们。

虽然它们在程序中十分常见,但许多刚入门Go语言的开发人员在初次使用时会遇到一些小“坑”。本文选自《Go语言编程之旅:一起用Go做项目》一书,下面我们就来解决这些小“坑”。

思考问题

观察下面这段代码,思考一下其输出的结果是“Go编程之旅:一起用Go做项目”,还是会因为“煎鱼焦了”而直接中断运行:

代码语言:javascript
复制
1func main() {
2    go func() {
3        panic("煎鱼焦了")
4    }()
5
6    log.Println("Go编程之旅:一起用Go做项目")
7}

输出结果如下:

代码语言:javascript
复制
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来进行处理,代码如下:

代码语言:javascript
复制
 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,仍然会输出“煎鱼焦了”,并且程序中断。正确的写法如下:

代码语言:javascript
复制
 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事件。其最终的输出结果如下:

代码语言:javascript
复制
1Go编程之旅:一起用Go做项目
2recover: 煎鱼焦了

为什么要先 defer 才能 recover

从前文中我们知道,除panic和recover外,还必须要有defer关键字,三者缺一不可。为什么必须有defer,recover才能起作用呢?

▊ 快速了解panic

panic是Go语言中的一个内置函数,可以停止程序的控制流,改变其流转,并且触发“恐慌”事件。recover也是一个内置函数,其功能与panic相反,recover可以让程序重新获取“恐慌”事件后的程序控制权,但是recover必须在defer中才会生效。

panic的一切都基于一个_panic基础单元,基本结构如下:

代码语言:javascript
复制
 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调用情况,涉及的字段如下。

  • argp:指向defer延迟调用的参数的指针。
  • arg:panic的原因,即调用panic时传入的参数。
  • link:指向上一个调用的_panic。
  • pc:程序计数器,有时也称为指令指针(IP),线程利用它来跟踪下一个要执行的指令。在大多数处理器中,PC指向的是下一条指令,而不是当前指令。
  • sp:函数栈指针寄存器,一般指向当前函数栈的栈顶。
  • recovered:panic是否已经被处理,即是否被recover。
  • aborted:panic是否被中止。
  • goexit:是否调用runtime.Goexit方法中止主goroutine及所属的goroutine。

通过查看link字段,可以得知,panic的基本单元是一个链表的数据结构,如下图。

▊ 快速了解defer

defer是Go语言中的一个内置函数,defer方法所注册的对应事件会在函数或方法结束后执行,常用于关闭各类资源以及“兜底”操作。defer的基础单元是_defer结构体,基本结构如下:

代码语言:javascript
复制
 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}
  • siz:所有传入参数的总大小。
  • started:该defer是否已经执行过。
  • sp:函数栈指针寄存器。
  • pc:程序计数器。
  • fn:指向传入的函数地址和参数。
  • _panic:指向_panic链表。
  • link:指向_defer链表。

通过查看_panic和link字段可以得知,defer同时挂载着panic信息,如下图。

▊ recover是如何和defer搭上关系的

通过前面的介绍我们知道,defer和panic 存在一定的关联关系,那么recover又是如何与它们产生关联关系的呢?为什么不用defer,recover就无法生效?

为了解答这些问题,我们需要回到一切的起源panic才能知晓。panic关键字的具体代码如下:

代码语言:javascript
复制
 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}

通过分析上述代码,可以大致了解其处理过程:

  • 获取指向当前goroutine的指针。
  • 初始化一个panic的基本单位_panic,用作后续的操作。
  • 获取当前goroutine上挂载的_defer。
  • 若当前存在defer调用,则调用reflectcall方法执行先前defer中延迟执行的代码。若在执行过程中需要运行recover,则调用gorecover方法。
  • 调用preprintpanics方法打印所涉及的panic消息。
  • 调用fatalpanic中止应用程序,实际上是通过执行exit(2)来退出的。

“recover是如何和defer搭上关系的?”在调用panic方法后,实际上runtime.gopanic方法处理的是当前goroutine上所挂载的._panic链表(所以无法响应其他goroutine的异常事件),然后对其所属的defer链表和recover进行检测并处理,最后调用退出命令中止应用程序,如图。

从代码实现来看,panic会触发延迟调用(defer)。假设当前goroutine中不存在defer,则会直接跳出,也就无法进行recover了。也就是说,在panic时,Go只会在defer中对reocver进行检测。

从设计实现来看,这是相对合理的,因为没有必要在程序中写若干个recover,而很多错误是无法预料在哪里发生,又是如何发生的。

recover是万能的吗 

即便有了recover,也不能捕获所有的错误。

假设某一天,程序正在线上环境(容器里)运行着,突然就“挂”了,即它反复地重启,且每次都是运行一段时间后才宕机,难道有泄露了?

这个程序非常的简短,就是一段简单的并发清洗、组装数据的程序,核心(伪)代码如下:

代码语言:javascript
复制
 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里了,为何还会出现无法捕获的错误,导致程序“挂”了?

此时对应的控制台日志的关键信息如下:

代码语言:javascript
复制
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方法,代码如下:

代码语言:javascript
复制
 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方法中,代码如下:

代码语言:javascript
复制
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这四者在本质上是互相联动的关系。

使用细节总结如下:

  • panic只能触发当前goroutine 的 defer 调用。在defer调用中只要存在recover ,就能处理其抛出的“恐慌”事件。需要注意的是,其他goroutine中的defer对其不起作用,即不支持跨协程调用。
  • 想要捕获或处理panic造成的“恐慌”事件,recover必须与defer配套使用,否则无效。
  • 在Go语言中,是存在一些无法恢复的致命错误方法的,如fatalthrow方法和fatalpanic方法等,它们一般在并发写入map等处理时抛出,需要谨慎。

  图 书 推 荐  

随着学习Go语言人数的增加,Go语言相关的图书也越来越多,却一直没有一本相对完整的项目实践类的图书。

《Go语言编程之旅:一起用Go做项目》是市面上少有的面向项目实践的一本书,希望本书的出版能将这一块的知识成体系地分享给大家。

《Go语言编程之旅:一起用Go做项目》

陈剑煜 徐新华 著

本书针对Go语言中较为常用的命令行应用、HTTP应用、RPC应用、WebSocket 应用、进程内缓存进行了详细的介绍,并开发了一系列小的适合程序员日常使用的工具。

同时对项目开发、细节分析、运行时分析等核心内容进行了较为深入的剖析,提供了相对完整的项目实践经验。

(扫码了解本书详情)

如果喜欢本文

欢迎 在看留言分享至朋友圈 三连

热文推荐 


点击阅读原文,了解本书详情~

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-07-23,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 博文视点Broadview 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档