Golang 标准库 tips -- defer

defer 关键字用来标记最后执行的 Go 语句,一般用在资源释放、关闭连接等操作,会在函数返回前调用,defer 的执行顺序是先进后出,当同时定义了多个 defer 代码块时,golang 按照先定义后执行的顺序依次调用 defer。

defer 与参数解析

defer 在被声明的时候,defer 中执行的参数就已经被实现解析确定了而不是在 return 之后,如下代码定义了 i 之后,接着通过 defer 打印 i,后面的操作对 i 进行自增,程序会在结束的时候打印 0 而不是 1。

func main() {
    var i int
    defer fmt.Println(i)
    i++
}

// 打印结果:0

不过这里需要注意闭包的情况,我们对上面的代码稍微做一些改动,将 fmt.Println(i) 放在匿名函数 func() 中执行,打印的结果为 1,这是因为 defer 执行的匿名函数中保持了对外部变量的引用,在执行了 i++ 之后,defer 在执行的时候获取的是外部变量 i,所以输出为 1。

func main() {
    var i int
    defer func() {
        fmt.Println(i)
    }()
    i++
}

// 打印结果:1

防止以上闭包问题我们可以在执行的时候将外部变量 i 传入defer 匿名函数中,这种情况会对 i 进行值拷贝,defer 执行的时候拷贝的变量 i,外部变量 i++ 不会影响到匿名函数中的变量 i。

func main() {
    var i int
    defer func(i int) {
        fmt.Println(i)
    }(i)
    i++
}

// 打印结果:0

正是因为这个原因,我们在调用 defer 来关闭连接或者释放资源的时候,正确的做法应该是将 defer 执行操作放在 err 判断之后,如果 err 不为 nil,则执行 return 操作,比如下面的例子发送 http 请求,通过 defer 来关闭连接,但是将 defer 写在了 err 之前,这种做法会直接导致程序 panic,因为在调用 get 请求出错的时候 resp 对象是 nil,因为此时 defer 中的参数已经实时解析成了 nil, 通过 nil 对象调用 Close 方法自然会导致 panic。

// 错误的做法1
func main() {
    resp, err := http.Get("")
    defer resp.Body.Close() 
    if err != nil {
        fmt.Println(err.Error())
        return
    }
}

// 错误的做法2,忽略 err,也会导致 panic
func main() {
    resp, _ := http.Get("")
    defer resp.Body.Close() // 错误的做法2
}

正确的做法是将 defer 操作放在 err 判断之后,如果报错了就及时退出,以保障 defer 在执行 resp.Body.Close 的时候不会是 nil。

func main() {
    resp, err := http.Get("")
    if err != nil {
        fmt.Println(err.Error())
        return
    }
    defer resp.Body.Close() // 正确的做法
}
defer 与匿名/命名返回值

如果我们在 defer 中对函数命名返回值进行修改,那么就需要非常注意了,先来看几个简单的例子的输出结果:

func main() {
    fmt.Println(f1())
    fmt.Println(f2())
    fmt.Println(f3())
    fmt.Println(f4())
    fmt.Println(f5())
}

func f1() (res int) {
    res = 5
    defer func() {
        res = res + 5
    }()
    return
}

func f2() int {
    res := 5
    defer func() {
        res = res + 5
    }()
    return res
}

func f3() (res int) {
    t := 5
    defer func() {
        t = t + 5
    }()
    return t
}

func f4() (res int) {
    res = 5
    defer func(res int) {
        res = res + 5
    }(res)
    return
}

func f5() (res int) {
    res = 5
    defer func() {
        res = res + 5
    }()
    return 10
}

// 打印结果:
10
5
5
5
15

要理解以上的输出结果,关键在于 return xxx 这条语句,这条语句并不是一个原子的操作,经过编译之后会可以分解成以下 3 条指令:
第一步:函数返回值 = xxx,如果函数的返回值为匿名格式,那么将用一个临时变量保存该返回值
第二步:调用 defer 函数
第三步:执行 RET 指令将返回值返回
所以我们可以对以上 5 种情况做一个拆解的结果如下:

// 有名返回值
func f1() (res int) {
    // 第一步:判断函数是有名返回值,所以使用有名返回值变量来报错赋值的数据
    res = 5

    // 第一步:执行 defer 函数
    func() {
        res = res + 5
    }()

    // 第三步:将有名返回值变量返回
    return res // 这个 return 不会分解成 3 步,只是为了说明返回的是 res 变量
}

// 匿名返回值
func f2() int {
    res := 5
    
    // 第一步:判断函数是匿名返回值,所以先用一个临时变量保存该返回值
    tmp := res
    
    // 第二步:执行 defer 函数
    func() {
        res = res + 5
    }()
    
    // 第三步:将临时变量返回值返回
    return tmp // 这个 return 不会分解成 3 步,只是为了说明返回的是 tmp 变量
}

func f3() (res int) {
    t := 5
    // 第一步:判断函数是有名返回值,所以使用有名返回值变量来保持赋值的数据
    res = t

    // 第二步:执行 defer 方法
    func() {
        t = t + 5
    }()
    
    // 第三步:将有名返回值变量返回
    return res // 这个 return 不会分解成 3 步,只是为了说明返回的是 res 变量
}

func f4() (res int) {
   // 第一步:判断函数是有名返回值,所以使用有名返回值变量来报错赋值的数据
   res = 5
   
   // 第二步:执行 defer 方法
   func(res int) {
        res = res + 5 // res 和外层的 res 不是一个变量
    }(res)
    
    // 第三步:将有名返回值变量返回
    return res // 这个 return 不会分解成 3 步,只是为了说明返回的是 res 变量
}

func f5() (res int) {
    res = 5
    
    // 第一步:判断函数是有名返回值,所以使用有名返回值变量来报错赋值的数据
    res = 10
    
    // 第二步:执行 defer 函数
    func() {
        res = res + 5
    }()
    
    // 第三步:将有名返回值变量返回
    return res // 这个 return 不会分解成 3 步,只是为了说明返回的是 res 变量
}

通过上面的案例分析,我们可以看到 defer 是可以修改有名返回值函数的返回值,我们在处理这种问题的第一步需要先判断函数是有名返回函数还是匿名返回函数,这将决定函数最后 return 的时候使用的是哪个变量,如果是有名返回函数,则不需要创建一个新的变量来保存返回值,如果是匿名返回函数,及时在 return 的时候指定了返回某一个变量,也会在返回之前先创建一个临时的变量用来保存返回值。
如果我们对上面这个规则非常了解的话,那么下面的例子就能很快的分析成结果出来了:

func main() {
    fmt.Println("返回值的n为:", demo01()) // 输出11
    fmt.Println("返回值的n为:", demo02()) // 输出6
    fmt.Println("返回值的n为:", demo03()) // 输出5
    fmt.Println("返回值的n为:", demo04()) // 输出1
}

func demo01() int {
    n := 10
    defer func() {
        n++
        fmt.Println("defer1的值为:", n) // 输出12
    }()
    defer func() {
        n++
        fmt.Println("defer2的值为:", n) // 输出13
    }()
    n++
    return n
}

func demo02() (n int) {
    n = 10
    defer func() {
        n++
        fmt.Println("defer中的n为:", n) // 输出6
    }()
    return 5
}

func demo03() (i int) {
    n := 10
    defer func() {
        n++
        fmt.Println("defer中的n为:", n) // 输出11
    }()
    return 5
}

func demo04() (i int) {
    defer func(n int) {
        n++
        fmt.Println("defer中的n为:", n) // 输出1
    }(i)
    i++
    return i
}
defer 与 recover

Go 为了追求语法简洁,不支持传统的 try-catch-finally 这种语法来处理异常,但是当我们遇到 panic 比如空指针赋值等异常情况,可以通过 defer 与 recover 来捕获异常防止程序退出。
当程序发生 panic 之后,会一直往调用栈的外层抛,直至遇到recovery 。如果没有被recovery接住,主程序会被暴力终止,这里需要注意的有两点:

  1. recovery 只在本 goroutine 有效;
  2. 程序恢复运行的地方就是 panic 被 recovery 的地方,捕获 recover 的方法被执行之后就不会再执行方法后续的代码了而是直接退出 recover 所在的函数。
    第一点其实是非常重要的也是我们比较容易忽略的地方,我们在写代码过程中经常会使用 groutine 来创建一个协程异步运行,但有时很容易忽略这个 groutine 运行过程中是否会发生异常,如果我们没有使用 recover 来捕获这个异常,就会造成整个进程的退出,这里以 gin 启动一个 httpserver 为例来说明:
func main() {
    r := gin.Default()
    r.GET("/get", func(c *gin.Context) {
        go func() {
            var m map[string]int
            m["1"] = 1
        }()
        c.JSON(http.StatusOK, gin.H{
            "succes": 0,
        })
    })
    r.Run()
}

上面这个例子很简单,但是我们只要调用了 /get 请求一定会导致整个进程的退出,因为我们在 /get 请求里面发生了一个 panic 但是却没有使用 recover 来捕获,可能有的同学会问 gin.Default 函数已经封装了 recover Middleware 了,为什么没有把 panic recover 住,这就是一开始说的,recover 只会在当前的 groutine 生命周期内捕获 panic。
如果我们修改一下代码:

func main() {
    r := gin.Default()
    r.GET("/get", func(c *gin.Context) {
        var m map[string]int
        m["1"] = 1
        c.JSON(http.StatusOK, gin.H{
            "succes": 0,
        })
    })
    r.Run()
}

这种情况请求 /get 路由程序会报 panic,但是整个进程是不会退出的,因为这个 panic 和 gin 的 Recovery() Middlerware 是在同一个 groutine 中,最终会被捕获到。
所以这给我们的提示是一定要对自己创建的 groutine 负责任,一定要处理好 panic 的情况,所以比较推荐的方法是可以参考 SafeGo 的方法来代替直接使用 go 关键字创建 groutine。

func Recovery(ctx context.Context) {
    e := recover()
    if e == nil {
        return
    }
    if ctx == nil {
        ctx = context.Background()
    }
    err := fmt.Errorf("%v", e)
    panicLoc := identifyPanicLoc()
    Logger(ctx).WithErrTag("common_panic").WithError(err).Errorf(
        "catch panic!!! panic location: %v \nstacktrace:\n%s", panicLoc, debug.Stack())
}

func SafeGo(fn func()) {
    go func() {
        defer Recovery(nil)
        fn()
    }()
}

func SafeGoWithCtx(ctx context.Context, fn func()) {
    go func() {
        defer Recovery(ctx)
        fn()
    }()
}

recover 除了要和 panic 满足在一个 groutine 这个条件之外,还需要满足执行 defer 的函数要和 panic 所在的函数存在显示的包含关系,也就是 defer 只能在当前 panic 所在的函数或者当前 panic 函数的父层函数中,并且明确是通过 defer 来捕获的,我们可以看下面的两个例子,这两种情况下都是捕获不到 panic 的信息。

func main() {
    Recover() // 错误用法1:不满足在 panic 的父层函数中显示调用 panic
    testRecover()
}

func testRecover() {
    Recover() // 错误用法2:不满足在当前 panic 的方法中显示的调用 defer
    panic("test")
}

func Recover() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println(err)
        }
    }()
}

我们需要做如下的修改,将 defer 关键字提前出来显示的放在 panic 的同级或者父级函数中来调用:

func main() {
    defer Recover() // 正确的用法1:父层函数中显示的调用 defer 可以捕获子函数中的 panic
    testRecover()
}

func testRecover() {
    // defer Recover() // 正确的用法2:当前函数中显示的调用 defer 可以捕获当前函数中的 panic
    panic("test")
}

func Recover() {
    if err := recover(); err != nil {
        fmt.Println(err)
    }
}

再通过两个例子来说明 panic 被 recover 之后整个代码的执行流。
案例一:在内层recovery

func f1() {
    fmt.Println("f1 start")
    fmt.Println("f1 end")
}

func f2() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println(err)
        }
    }()
    fmt.Println("f2 start")
    panic("f2 panic") // panic 之后的代码是不会执行了
    fmt.Println("f2 end") 
}

func f3() {
    fmt.Println("f3 start")
    fmt.Println("f3 end")
}


func main() {  // f2 函数被panic终止 并在return时recovery()了,f3会被执行
    f1()           
    f2()
    f3()
}

// 输出:
// f1 start
// f1 end
// f2 start
// f2 panic
// f3 start
// f3 end

案例二:在外层recovery

func f1() {
    fmt.Println("f1 start")
    fmt.Println("f1 end")
}

func f2() {

    fmt.Println("f2 start")
    panic("f2 panic")
    fmt.Println("f2 end")
}

func f3() {
    fmt.Println("f3 start")
    fmt.Println("f3 end")
}

func main() {  // panic抛到main,main会被终止,f3不会被执行
    defer func() {
        if err := recover(); err != nil {
            fmt.Println(err) 
        }
    }()

    f1()
    f2() // f2 发送了 panic 之后 f3 是不会被执行了
    f3()
}

// 输出:
// f1 start
// f1 end
// f2 start
// f2 panic
defer 的性能问题

defer 为我们使用上提供了便利的用法,但是 defer 函数本身也是有一定的开销,我们在使用 defer 时内部 runtime 函数的调用多了 runtime.deferproc 和 runtime.deferreturn:
在 deferproc 阶段(注册延迟调用),还得获取/传入目标函数地址、函数参数等等。
在 deferreturn 阶段,需要在函数调用结尾处插入该方法的调用,同时若有被 defer 的函数,还需要使用 runtime·jmpdefer 进行跳转以便于后续调用。
所以如果对于热点代码有性能要求,能避免使用 defer 的地方可以尽量避免。
在 go1.13 之前,defer 存在着较大定的性能消耗。但官方提出,经过优化后,go1.13 的效率较之前提高了 30%,以下是在 go1.14.12 版本下进行的压测,可以看出使用 defer 的性能比不使用 defer 性能还是差了不少。

func BenchmarkDeferFunc(b *testing.B) {
    for n := 0; n < b.N; n++ {
        DeferFunc("a", "b")
    }
}

func BenchmarkNotDeferFunc(b *testing.B) {
    for n := 0; n < b.N; n++ {
        NotDeferFunc("a", "b")
    }
}

func DeferFunc(key, value string) {
    defer func() {
        _ = key + value
    }()
}

func NotDeferFunc(key, value string) {
    _ = key + value
}

// 压测结果
go test -v -bench=. -benchtime=10s
goos: darwin
goarch: amd64
BenchmarkDeferFunc
BenchmarkDeferFunc-8              643887152                19.2 ns/op
BenchmarkNotDeferFunc
BenchmarkNotDeferFunc-8           837066096                13.7 ns/op
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值