Golang 高效实践之defer、panic、recover实践

 前言

我们知道Golang处理异常是用error返回的方式,然后调用方根据error的值走不同的处理逻辑。但是,如果程序触发其他的严重异常,比如说数组越界,程序就要直接崩溃。Golang有没有一种异常捕获和恢复机制呢?这个就是本文要讲的panic和recover。其中recover要配合defer使用才能发挥出效果。

Defer

Defer语句将一个函数放入一个列表(用栈表示其实更准确)中,该列表的函数在环绕defer的函数返回时会被执行。defer通常用于简化函数的各种各样清理动作,例如关闭文件,解锁等等的释放资源的动作。例如下面的这个函数打开两个文件,从一个文件拷贝内容到另外的一个文件:

func CopyFile(dstName, srcName string) (written int64, err error) {
    src, err := os.Open(srcName)
    if err != nil {
        return
    }

    dst, err := os.Create(dstName)
    if err != nil {
        return
    }

    written, err = io.Copy(dst, src)
    dst.Close()
    src.Close()
    return
}

这段代码可以工作,但是有一个bug。如果调用os.Create失败,函数将会直接返回,并没有关闭srcName文件。修复的方法很简单,可以把src.Close的调用放在第二个return语句前面。但是当我们程序的分支比较多的时候,也就是说当该函数还有几个其他的return语句时,就需要在每个分支return前都要加上close动作。这样使得资源的清理非常繁琐而且容易遗漏。所以Golang引入了defer语句:

func CopyFile(dstName, srcName string) (written int64, err error) {
    src, err := os.Open(srcName)
    if err != nil {
        return
    }
    defer src.Close()

    dst, err := os.Create(dstName)
    if err != nil {
        return
    }
    defer dst.Close()

    return io.Copy(dst, src)
}

在每个资源申请成功的后面都加上defer自动清理,不管该函数都多少个return,资源都会被正确的释放,例如上述例子的文件一定会被关闭。

关闭defer语句,有三条简单的规则:

1.defer的函数在压栈的时候也会保存参数的值,并非在执行时取值。

func a() {
    i := 0
    defer fmt.Println(i)
    i++
    return
}

例如该示例中,变量i会在defer时就被保存起来,所以defer函数执行时i的值是0.即便后面i的值变为了1,也不会影响之前的拷贝。

2.defer函数调用的顺序是后进先出。

func b() {
    for i := 0; i < 4; i++ {
        defer fmt.Print(i)
    }
}

函数输出3210

3.defer函数可以读取和重新赋值函数的命名返回参数。

func c() (i int) {
    defer func() { i++ }()
    return 1
}

这个例子中,defer函数中在函数返回时对命名返回值i进行了加1操作,因此函数返回值是2.可能你会有疑问,规则1不是说会在defer时保存i的值吗?保存的i是0,那加1操作之后也是1啊。这里就是闭包的魅力,i的值会被立马保存,但是保存的是i的引用,也可以理解为指针。当实际执行加1操作时,i的值其实被return置为了1,defer执行了加1操作i的值也就变成了2.

Panic

Panic是内建的停止控制流的函数。相当于其他编程语言的抛异常操作。当函数F调用了panic,F的执行会被停止,在F中panic前面定义的defer操作都会被执行,然后F函数返回。对于调用者来说,调用F的行为就像调用panic(如果F函数内部没有把panic recover掉)。如果都没有捕获该panic,相当于一层层panic,程序将会crash。panic可以直接调用,也可以是程序运行时错误导致,例如数组越界。

Recover

Recover是一个从panic恢复的内建函数。Recover只有在defer的函数里面才能发挥真正的作用。如果是正常的情况(没有发生panic),调用recover将会返回nil并且没有任何影响。如果当前的goroutine panic了,recover的调用将会捕获到panic的值,并且恢复正常执行。

例如下面这个例子:

package main

import "fmt"

func main() {
    f()
    fmt.Println("Returned normally from f.")
}

func f() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered in f", r)
        }
    }()
    fmt.Println("Calling g.")
    g(0)
    fmt.Println("Returned normally from g.")
}

func g(i int) {
    if i > 3 {
        fmt.Println("Panicking!")
        panic(fmt.Sprintf("%v", i))
    }
    defer fmt.Println("Defer in g", i)
    fmt.Println("Printing in g", i)
    g(i + 1)
}

函数g接受参数i,如果i大于3时触发panic,否则对i进行加1操作。函数f的defer函数里面调用了recover并且打印recover的值(非nil的话)。

程序将会输出:

Calling g.
Printing in g 0
Printing in g 1
Printing in g 2
Printing in g 3
Panicking!
Defer in g 3
Defer in g 2
Defer in g 1
Defer in g 0
Recovered in f 4
Returned normally from f.

Panic和recover可以接受任何类型的值,因为定义为interface{}:

func panic(v interface{})

func recover() interface{}

所以工作模式相当于:

panic(value)->recover()->value

传递给panic的value最终由recover捕获。

 

另外defer可以配合锁的使用来确保锁的释放,例如:

mu.Lock()

Defer mu.Unlock()

需要注意的是这样会延长锁的释放时间(需要等到函数return)。

 

容易踩坑的一些例子

通过上面的说明,我们已经对defer,panic和recover有了比较清晰的认识,下面通过一些实战中容易踩坑的例子来加深下印象。

在循环里面使用defer

不要在循环里面使用defer,除非你真的确定defer的工作流程,例如:

只有当函数返回时defer的函数才会被执行,如果在for循环里面defer定义的函数会不断的压栈,可能会爆栈而导致程序异常。

解决方法1:将defer移动到循环之外

解决方法2:构造一层新的函数包裹defer

defer方法

没有指针的情况:

type Car struct {
  model string
}
func (c Car) PrintModel() {
  fmt.Println(c.model)
}
func main() {
  c := Car{model: "DeLorean DMC-12"}
  defer c.PrintModel()
  c.model = "Chevrolet Impala"
}

程序输出DeLorean DMC-12。根据我们前面讲的内容,defer的时候会把函数和参考拷贝一份保存起来,所以c.model的值后面改变也不会影响defer的运行。

有指针的情况:

Car PrintModel()方法定义改为:

func (c *Car) PrintModel() {
  fmt.Println(c.model)
}

程序将会输出Chevrolet Impala。这些defer虽然将函数和参数保存了起来,但是由于参数的值本身是针对,随意后面的改动会影响到defer函数的行为。

同理的例子还有:

for i := 0; i < 3; i++ {
  defer func() {
   fmt.Println(i)
  }()
}

程序将会输出:

3
3
3

因为闭包引用匿名函数外面的变量相当于是指针引用,得到的是变量的地址,实际到defer真正执行时,指针指向的内容已经发生的变化:

解决的方法:

for i := 0; i < 3; i++ {
  defer func(i int) {
   fmt.Println(i)
  }(i)
}

或者:

for i := 0; i < 3; i++ {
  defer fmt.Println(i)
}

程序输出:

2
1
0

这里就不会用到闭包的上下文引用特性,是正经的函数参数拷贝传递,所以不会有问题。

defer中修改函数error返回值

package main

import (
    "errors"
    "fmt"
)

func main() {
    {
        err := release()
        fmt.Println(err)
    }

    {
        err := correctRelease()
        fmt.Println(err)
    }
}

func release() error {
    defer func() error {
        return errors.New("error")
    }()

    return nil
}

func correctRelease() (err error) {
    defer func() {
        err = errors.New("error")
    }()
    return nil
}

release函数中error的值并不会被defer的return返回,因为匿名返回值在defer执行前就已经声明好并复制为nil。correctRelease函数能够修改返回值是因为闭包的特性,defer中的err是实际的返回值err地址引用,指向的是同一个变量。defer修改程序返回值error一般用在和recover搭配中,上述的情况属于滥用defer的一种情况,其实error函数值可以直接在程序的return中修改,不用defer。

总结

文章介绍了defer、panic和recover的原理和用法,并且在最后给出了一些在实际应用的实践建议,不要滥用defer,注意defer搭配闭包时的一些特性。

参考

https://blog.golang.org/defer-panic-and-recover

https://blog.learngoprogramming.com/gotchas-of-defer-in-go-1-8d070894cb01

https://blog.learngoprogramming.com/5-gotchas-of-defer-in-go-golang-part-ii-cc550f6ad9aa

https://blog.learngoprogramming.com/golang-defer-simplified-77d3b2b817ff

https://blog.learngoprogramming.com/5-gotchas-of-defer-in-go-golang-part-iii-36a1ab3d6ef1

 

 

 

 

 

 

 

posted @ 2019-07-22 16:59  我是码客  阅读(33472)  评论(0编辑  收藏  举报