• 【转】golang-defer坑的本质


    本文节选自https://tiancaiamao.gitbooks.io/go-internals/content/zh/03.4.html

    作者的分析非常透彻,从问题本质分析,就不会对defer产生的副作用产生迷茫。

    defer坑的本质是:本质原因是return xxx语句并不是一条原子指令,defer被插入到了赋值 与 ret之间,因此可能有机会改变最终的返回值。

     

    defer使用时的坑

    先来看看几个例子。

    例1:

    func f() (result int) {
        defer func() {
            result++
        }()
        return 0
    }

     

    例2:

    func f() (r int) {
         t := 5
         defer func() {
           t = t + 5
         }()
         return t
    }

    例3:

    func f() (r int) {
        defer func(r int) {
              r = r + 5
        }(r)
        return 1
    }
    
    

    请读者先不要运行代码,在心里跑一遍结果,然后去验证。

    例1的正确答案不是0,例2的正确答案不是10,如果例3的正确答案不是6......

    defer是在return之前执行的。这个在 官方文档中是明确说明了的。要使用defer时不踩坑,最重要的一点就是要明白,return xxx这一条语句并不是一条原子指令!

    函数返回的过程是这样的:先给返回值赋值,然后调用defer表达式,最后才是返回到调用函数中

    defer表达式可能会在设置函数返回值之后,在返回到调用函数之前,修改返回值,使最终的函数返回值与你想象的不一致。

    其实使用defer时,用一个简单的转换规则改写一下,就不会迷糊了。改写规则是将return语句拆成两句写,return xxx会被改写成:

    返回值 = xxx
    调用defer函数
    空的return
    

    先看例1,它可以改写成这样:

    func f() (result int) {
         result = 0          //return语句不是一条原子调用,return xxx其实是赋值+ret指令
         func() {            //defer被插入到return之前执行,也就是赋返回值和ret指令之间
             result++
         }()
         return
    }

    所以这个返回值是1。

    再看例2,它可以改写成这样:

    func f() (r int) {
         t := 5
         r = t           //赋值指令
         func() {        //defer被插入到赋值与返回之间执行,这个例子中返回值r没被修改过
             t = t + 5
         }
         return          //空的return指令
    }

    所以这个的结果是5。

    最后看例3,它改写后变成:

    func f() (r int) {
         r = 1                //给返回值赋值
         func(r int) {        //这里改的r是传值传进去的r,不会改变要返回的那个r值
              r = r + 5
         }(r)
         return               //空的return
    }

    所以这个例子的结果是1。

    defer确实是在return之前调用的。但表现形式上却可能不像。本质原因是return xxx语句并不是一条原子指令,defer被插入到了赋值 与 ret之间,因此可能有机会改变最终的返回值。

    defer的实现

    defer关键字的实现跟go关键字很类似,不同的是它调用的是runtime.deferproc而不是runtime.newproc。

    在defer出现的地方,插入了指令call runtime.deferproc,然后在函数返回之前的地方,插入指令call runtime.deferreturn

    普通的函数返回时,汇编代码类似:

    add xx SP
    return
    

    如果其中包含了defer语句,则汇编代码是:

    call runtime.deferreturn,
    add xx SP
    return
    

    goroutine的控制结构中,有一张表记录defer,调用runtime.deferproc时会将需要defer的表达式记录在表中,而在调用runtime.deferreturn的时候,则会依次从defer表中出栈并执行

     

     

    ------------------------------------------------

    我在补充一下,go 的函数 带参数名的返回值和不带参数名的函数返回值对defer是有影响的。

    如果defer 操作的是带参数的函数返回值的参数名,则会直接影响到函数的返回值

    如果defer操作的是函数内部的一个局部变量,这不会影响到函数的返回值

    原因很简单,return会做两个事情,

    1、拷贝返回值到函数的返回值内存区,有如下几个场景:

    • a.如果是函数的返回值带参数名(假设参数名为a),实际在函数内部对a的操作是直接操作这个返回值的内存区域

    如果函数返回直接调用return,不带任何返回值, 则不会有拷贝过程。此时defer如果没有显示的操作a,则不会影响到函数的返回值。

    如果函数返回直接调用return X,则会有个a=x的拷贝过程,此时defer如果没有显示的操作a,也不会影响到函数的返回值(即使操作了x也不会影响结果)。

    • b如果是函数的返回值不带参名,函数的返回值需要一个显示的return x语句,此时会有一个拷贝过程,就是将x的值拷贝到返回值的内存区域,此时defer操作x 不会有什么副作用,此时存放返回值的内存区域是个匿名区域,从源程序的角度看defer直接操作变量是不可能访问到这个区域。可见不带形参的返回是避免defer副作用的最有效手段。

    2、执行RET指令,执行跳转

     

    talk is cheep ,show me the code , 上一段代码:

    [csharp] view plain copy
     
    1. package main  
    2.   
    3. import (  
    4.     "fmt"  
    5. )  
    6.   
    7.   
    8. //f1 f2 f3 函数返回值带有参数名  
    9. func f1() (r int) {  
    10.     defer func() {  
    11.         r++  
    12.     }()  
    13.     return 0  
    14. }  
    15.   
    16. func f2() (r int) {  
    17.     defer func(i int) {  
    18.         i++  
    19.     }(r)  
    20.     return 0  
    21. }  
    22.   
    23. func f3() (r int) {  
    24.     t := 0  
    25.     defer func() {  
    26.         t++  
    27.     }()  
    28.     return t  
    29. }  
    30.   
    31.   
    32. //f4 f5 函数的返回值都不带参数名,只有类型  
    33. func f4() int {  
    34.     r := 0  
    35.     defer func() {  
    36.         r++  
    37.     }()  
    38.     return r  
    39. }  
    40.   
    41. func f5() int {  
    42.     r := 0  
    43.     defer func(i int) {  
    44.         i++  
    45.     }(r)  
    46.     return 0  
    47. }  
    48.   
    49. func main() {  
    50.     var v int = -1  
    51.     v = f1()  
    52.       
    53.     fmt.Println("v=", v)  
    54.     v = f2()  
    55.     fmt.Println("v=", v)  
    56.     v = f3()  
    57.     fmt.Println("v=", v)  
    58.   
    59.       
    60.     v = f4()  
    61.     fmt.Println("v=", v)  
    62.     v = f5()  
    63.     fmt.Println("v=", v)  
    64. }  

    结果:
    [csharp] view plain copy
     
    1. v= 1  
    2. v= 0  
    3. v= 0  
    4. v= 0  
    5. v= 0  

    结论:为了避免defer可能引发的歧义,在定义函数时,最好使用不带参数名的方式

     

    引申一下,我们来看下go函数的栈帧结构,首先学习下几个寄存器

    1. FP 栈底寄存器,指向一个函数栈的顶部;
    2. PC   程序计数器,指向下一条执行指令;
    3. SB 指向静态数据的基指针,全局符号;
    4. SP 栈顶寄存器;
     

     

    1.go的函数调用现场清理由主调函数负责维护;
    2.函数的返回地址也在主调函数的栈上开辟;
    3.栈上的数据是通过FB+- 偏移量来操作;
    4.return语句分两部分执行:
      把返回值拷贝到返回值的栈上空间内
      调用RET指令执行函数调用后的下一条指令
    但是return的语句不是原子的,defer语句的执行被插入在这两条指令执行之间,这是导致defer导致歧义的最根本原因。为避免困惑,
    defer 不应该操作函数返回值存放的区域,即defer语句里面不应该有对命名返回值参数的操作。
     
     
     
  • 相关阅读:
    如何解决无法成功git commit 和git push
    orleans 项目调试注意
    silo 主机 配置
    asp.net core 项目引用包版本问题
    C# async 方法怎么被正确的消费 (新篇)
    C# 虚方法 复习
    C# dynamic 适用场景进一步说明
    [MySQL]
    C# Subject 观察者模式
    C# 协变与逆变
  • 原文地址:https://www.cnblogs.com/ralap7/p/9194734.html
Copyright © 2020-2023  润新知