跳转至

19 代码陷阱:最易导致程序出错的四类代码坑

你好,我是徐逸。

在多年 Golang 编程实践里,我发现不少 Go 研发人员,因未透彻理解部分 Go 语言特性,导致在一些编程场景中不慎陷入代码陷阱。这些陷阱不仅影响程序的正确性与稳定性,还可能让我们耗费大量时间调试修复。

因此,在今天的课程里,我将带你深入剖析 Go 编程中常见的四类代码坑。提前掌握这些代码坑,能帮助你更好地理解和规避这些问题,提升代码的可用性。

接口变量判空

在 Go 编程里,变量判空处理十分常见。对于接口类型变量的判空,我们需要格外留意,否则稍有不慎,就有可能出现 “nil!= nil” 这种奇怪现象。

以下面代码为例,我们先定义一个自定义错误类型 CustomError,然后在 handle 函数中,进行参数校验,若参数为空,返回参数错误;否则返回 nil。

type CustomError struct {
    code    int
    message string
}

func (e CustomError) Error() string {
    return fmt.Sprintf("code %d,msg %s", e.code, e.message)
}

var ErrInvalidParam = &CustomError{code: 10000, message: "invalid param"}

func handle(req string) error {
    var p *CustomError = nil
    if len(req) == 0 {
        p = ErrInvalidParam
    }
    return p
}

现在,你不妨思考一下,如果我们按下面的代码,分别传入空字符串与非空字符串这两种参数去调用上面的handle函数,最终会输出什么呢?

func main() {
    req := ""
    err := handle(req)
    if err != nil {
        fmt.Printf("err1 req %s\n", req)
    }
    req = "aa" // 函数传入的参数不为空
    err = handle(req)
    if err != nil {
        fmt.Printf("err2 req %s\n", req)
    }
}

输出结果非常诡异,无论参数为空还是非空,handle 函数都返回了非 nil 错误,出现 “nil error 不等于 nil” 的奇特现象。

killianxu@KILLIANXU-MB0 error % go run main.go 
err1 req 
err2 req aa

这种看似不合常理的现象究竟是怎么产生的呢?

实际上,这种现象和 Go底层 interface 类型变量的存储机制紧密相关。在 Go 的运行机制里,interface 类型变量在底层会存储两个关键元素,一个是类型 T,另一个是值 V

举个例子,当我们执行类似下面这样的代码,将一个 int 型变量赋值给 interface 类型变量 a 时,变量 a 在底层会存储这样一对元素:T = int 以及 V = 3。

var a interface{} = 3

在 Go 语言中进行 nil 判断时,只有当类型 T 和值 V 同时为 nil 的情况下,才会判定interface类型变量为 nil

回到前面提到的 “nil error 不等于 nil” 问题。handle 函数的返回值属于 error 接口类型。当传入的参数不为空时,handle 函数返回的 error 变量,它内部的类型 T 为 *CustomError,值 V 为 nil 。由于类型T非空,因此会进入下面err!=nil的分支逻辑。

if err != nil {
    fmt.Printf("err2 req %s\n", req)
}

那么,我们该如何解决这个问题呢?

就像下面的代码一样,如果我们要给调用者返回一个 nil 错误,应该显式返回 nil,而非定义特定类型。

func handle(req string) error {
    if len(req) == 0 {
        return ErrInvalidParam
    }
    return nil
}

现在我们来重新运行一下代码,果然,当参数非空时,“nil error 不等于 nil” 的奇怪现象不再出现了。

killianxu@KILLIANXU-MB0 error % go run main.go
err1 req 

循环变量使用

了解完接口判空问题后,我们接着探讨另一个容易引发错误的陷阱——循环变量的使用。

以下面这段循环创建协程,并在匿名函数内部输出循环迭代变量 v 的代码为例。你不妨思考一下,在 Go 1.22 版本之前如果我们运行这段代码,将会输出什么结果呢?

func main() {
    done := make(chan bool)


    values := []string{"a", "b", "c"}
    for _, v := range values {
        go func() {
            fmt.Println(v)
            done <- true
        }()
    }

    // wait for all goroutines to complete before exiting
    for _ = range values {
        <-done
    }
}

如果用 Go 1.22 之前的版本运行这段代码,结果会令人诧异。原本我们预期输出的是 a、b、c 这三个字符,实际得到的却全是字符 c。

c
c
c

究竟为什么会出现这种奇怪的现象呢?

我们不妨将上述循环代码,等价转换为下面代码的形式。实际上,循环迭代变量 v 的作用域覆盖整个循环,每次迭代时,新值都会被赋给位于同一内存地址的变量 v。当协程中的闭包函数开始执行时,如果循环已完成所有迭代,此时变量 v 留存的值便会是切片的最后一个元素 c。正因如此,所有协程最终输出的结果都是字符c。

h1 := 0
// slice长度
hn := len(values)
v := "" // for i,v := range 中的 v

for ; h1 < hn; h1++ {
    v = values[h1]
    go func() {
        fmt.Println(v)
        done <- true
    }()
}

为了让闭包能访问到循环迭代时的变量值,我们可以在每次循环迭代时,创建一个新的变量。下面我为你介绍两种常用方法。

第一种方法是将 v 作为参数传递进匿名函数,在匿名函数内部,只访问作为参数传入的变量,避免访问外部的循环迭代变量 v。代码如下所示。

for _, v := range values {
    go func(u string) {
        fmt.Println(u)
        done <- true
    }(v)
}

第二种方法是,在每次循环迭代时,我们显式创建一个作用域仅限于本次迭代的局部变量,然后在闭包中访问这个临时变量,代码如下。

for _, v := range values {
    v := v // 创建一个新的 'v',其作用域仅在本次迭代
    go func() {
        fmt.Println(v)
        done <- true
    }()
}

通过上面的两种方法,我们就能有效解决循环迭代变量的使用难题。

当然,由于循环迭代变量这个坑踩的人比较多,因此在Go 1.22 版本中,Go 官方对循环迭代变量的作用域做出了调整,改为每次迭代都重新生成一个新变量,从而规避因复用变量而引发的各类问题。

数值类型JSON反序列化

了解完循环变量的使用问题后,我们继续探讨另一个容易引发错误的陷阱——数值类型的JSON反序列化。

在日常开发中,出于通用性考虑,我们可能会从其它服务或存储获取 JSON 字符串数据。为了方便解析,就像下面的代码这样,我们有时候会选择 map[string]interface{} 类型来接收这些数据。

不过,在处理 JSON 数据中的数值类型时,如果不小心,我们可能会碰到一些令人困惑的情况。

func main() {
    str := `{"name": "killianxu", "age": 18}`
    var mp map[string]interface{}

    json.Unmarshal([]byte(str), &mp)
    age := mp["age"].(int) // 报错:panic: interface conversion: interface {} is float64, not int
    fmt.Println(age)
}

例如,在上述代码中,我们看到 JSON 字符串里的 age 字段,直观呈现的是 int 类型。然而,当我们使用 map 进行反序列化操作后,age 字段的值却悄然变成了 float64 类型。

因此,在解析JSON数据时,如果使用map[string]interface 类型来做反序列化,我们就需要用 float64 去解析

并发原语和库使用

介绍完串行程序的代码陷阱,接下来,我们把目光转向另一类陷阱——Go 语言并发编程中涉及的代码坑。

WaitGroup使用不当

首先,咱们来看看WaitGroup类型的使用。

在使用 WaitGroup 类型时,一个常见的误区是在协程内部调用 Add 方法。这种做法可能会导致在执行 Wait 方法前,计数器未能正确设置,进而导致协程还没执行完,Wait方法就已经返回,导致程序错误。你可以结合后面的代码示例来看看。

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        go func(v int) {
            wg.Add(1) // 错误:在这里调用Add
            // Do some work...
            fmt.Println(v)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

正确的做法是,在启动协程之前,在外部调用 Add 方法,示例代码如下。

wg.Add(1) // 正确:在协程外部调用Add方法
go func(v int) {
    defer wg.Done()
    // Do some work...
    fmt.Println(v)
}(i)

channel阻塞

介绍完WaitGroup,接着,我们来看看channel的使用。在使用channel 时,如果不小心,很容易引起协程阻塞,进而导致协程泄漏问题。

以下面这段典型的、借助阻塞型 channel 实现超时返回机制的函数代码为例。

func handle(timeout time.Duration) *Obj {
    ch := make(chan *Obj)
    go func() {
        result := fn() // 逻辑处理
        ch <- result   // block
    }()
    select {
    case result := <-ch:
        return result
    case <-time.After(timeout):
        return nil
    }
}

当第 4 行在协程内执行的函数耗时较长,使得handle函数超时返回时,会导致阻塞型通道变量 ch 没有了接收者。这样一来,第 5 行向通道写入数据的操作就会永远处于阻塞状态,最终引发协程泄漏问题。

因此,为有效规避这一问题,在构建超时返回机制时,我们应采用非阻塞型 channel,具体实现可参考后面的代码。

func handle(timeout time.Duration) *Obj {
    //ch := make(chan *Obj)
    ch := make(chan *Obj, 1) // 使用非阻塞型channel
    go func() {
        result := fn() // 逻辑处理
        ch <- result   // block
    }()
    select {
    case result := <-ch:
        return result
    case <-time.After(timeout):
        return nil
    }
}

除了在代码中直接使用 channel 可能出现问题外,有时底层库在内部间接使用 channel,如果我们操作不当,同样会引发协程泄漏。

比如下面这段 HTTP 调用的代码,我们在判断返回的状态码不对时,直接返回,而没有调用Body的Close方法。这将会导致发起 HTTP 请求的协程无法正常关闭,最终造成协程泄漏。

func call() (string, error) {
    resp, err := http.Get("http://example.com")
    if err != nil {
        return "", err
    }
    // 不读Body也必须Close,否则会协程泄漏
    // defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return "", fmt.Errorf("status_code %d", resp.StatusCode)
    }
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", err
    }
    return string(body), nil
}

这段代码究竟为什么会导致协程泄漏呢?不妨让我们透过核心源码一探究竟。

首先,我们来看下 dialConn 方法,这个方法的功能是在连接池无可用连接时,负责创建连接,并同时启动两个协程分别执行 readLoop 和 writeLoop 方法 。

func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {
    pconn = &persistConn{
        t:             t,
        cacheKey:      cm.key(),
        reqch:         make(chan requestAndChan, 1),
        writech:       make(chan writeRequest, 1),
        closech:       make(chan struct{}),
        writeErrCh:    make(chan error, 1),
        writeLoopDone: make(chan struct{}),
    }
    go pconn.readLoop()
    go pconn.writeLoop()
    return pconn, nil
}

然后,我们重点看下 readLoop 方法。在 readLoop 方法内部,存在一个循环结构,并且这个循环会进入 select 语句引发的阻塞状态。值得注意的是,select 语句退出阻塞的关键条件,是 waitForBodyRead 通道接收到数据。

func (pc *persistConn) readLoop() {
    alive := true
    for alive {
        // Before looping back to the top of this function and peeking on
        // the bufio.Reader, wait for the caller goroutine to finish
        // reading the response body. (or for cancellation or death)
        select {
        case bodyEOF := <-waitForBodyRead: // 阻塞退出条件
            // bodyEOF 为 false,会导致 alive 变量被设置为 false,从而退出整个 for 循环,结束当前协程
            alive = alive &&
                bodyEOF &&
                !pc.sawEOF &&
                pc.wroteRequest() &&
                tryPutIdleConn(rc.treq) // 放入连接池连接复用
            if bodyEOF {
                eofc <- struct{}{}
            }

        }
    }
}

因此,一旦 waitForBodyRead 通道始终未被写入数据,这个协程就会一直处于阻塞状态。在实际应用中,如果存在大量连接,这意味着大量协程可能会因同样原因被阻塞,从而导致协程泄漏。

接下来,我们把重点聚焦在一个关键问题上——waitForBodyRead 究竟在什么时候会被写入数据呢?

实际上,waitForBodyRead 会在以下两种情形下被写入数据的

第一种情况是,当我们读取 Body 出错(包括读取完毕遇到 io.EOF)时,会调用下面的 fn 函数,这个函数会往waitForBodyRead写入信号。如果写入waitForBodyRead的值为true,也就是Body已经读取完成,上面的readLoop方法还会调用 tryPutIdleConn函数,将连接放回连接池以便后续复用。

body := &bodyEOFSignal{
    body: resp.Body,
    earlyCloseFn: func() error {
        waitForBodyRead <- false
        <-eofc // will be closed by deferred call at the end of the function
        return nil

    },
    fn: func(err error) error {
        isEOF := err == io.EOF
        waitForBodyRead <- isEOF
        return err
    },
}

另一个时机是,当我们调用 Body 的 Close 方法时,这会触发上面的 earlyCloseFn 函数执行,这时 waitForBodyRead 会写入 false,使得 readLoop方法退出循环,连接虽然不能被复用,但也避免了协程阻塞。

通过上述源码分析不难发现,对于Body,我们需要执行 Close 操作,或者读取其内容,不然内部协程会因 waitForBodyRead 而陷入阻塞。

最后,回到开头那段代码,当状态码不为 200 时,我们既未读取 Body,也未调用Body的 Close 方法 ,这就使得 readLoop 循环一直阻塞,因此会引发协程泄漏。

为防止协程泄漏,我们可以将http请求的代码像后面这样修改。即便不读取 Body 内容,也必须调用 Close 方法,以此规避协程泄漏风险。

func call() (string, error) {
    resp, err := http.Get("http://example.com")
    if err != nil {
        return "", err
    }
    // 不读Body也必须Close,否则会协程泄漏
    defer resp.Body.Close() // 增加这行代码
    if resp.StatusCode != http.StatusOK {
        return "", fmt.Errorf("status_code %d", resp.StatusCode)
    }
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", err
    }
    return string(body), nil
}

小结

今天这节课,我给你剖析了几个常见场景,以及在这些场景下代码可能遭遇的陷阱。

现在,让我们一同回顾今天学习的几类代码坑。

  • 接口变量判空问题。接口变量在底层会储存类型 T 和值 V 这两个关键元素。当值为 nil 但类型不为 nil 时,就可能出现类似 “nil 不等于 nil” 这种看似矛盾的奇怪现象。
  • 循环变量的使用问题。在 Go 1.22 版本之前,循环迭代变量的作用域涵盖整个循环体。如果我们在循环内部直接使用这个变量,极有可能引发一些令人困惑的问题。
  • 数值类型的JSON反序列化问题。当我们采用 map[string]interface{} 类型对 JSON 字符串进行反序列化操作时,会发现 int 类型悄然变成了 float64 类型的诡异现象。
  • 最后还有并发原语和库的使用问题。如果 WaitGroup 和 channel 使用不当,程序不仅可能出现错误,还极有可能导致协程泄漏,对程序的稳定性造成严重影响。

希望你能认真体会这些代码陷阱。在日后遇到类似场景时,务必格外留意,从而避免陷入这些坑点,编写出更加健壮、稳定的代码。

思考题

除了这节课介绍的这些代码坑,在你编写 Go 语言程序的过程中,还碰到过哪些奇奇怪怪的代码陷阱呢?

欢迎你把你的答案分享在评论区,也欢迎你把这节课的内容分享给需要的朋友,我们下节课再见!

精选留言(2)
  • 树心 👍(0) 💬(1)

    一个月没来了 非常实用~

    2025-02-17

  • 👍(0) 💬(3)

    //ch := make(chan *Obj) ch := make(chan *Obj, 1) // 使用非阻 这两行没看懂,为啥换成非阻塞的,如果第四行的业务逻辑函数处理很耗时,底边的管道不是一样无法写入消息嘛,麻烦前辈指点一下,谢谢🙏

    2025-01-20