你好,我是徐逸。

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

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

接口变量判空

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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函数,最终会输出什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
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” 的奇特现象。

1
2
3
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。

1
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的分支逻辑。

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

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

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

1
2
3
4
5
6
func handle(req string) error {
if len(req) == 0 {
return ErrInvalidParam
}
return nil
}

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

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

循环变量使用

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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。

1
2
3
c
c
c

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

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

1
2
3
4
5
6
7
8
9
10
11
12
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。代码如下所示。

1
2
3
4
5
6
for _, v := range values {
go func(u string) {
fmt.Println(u)
done <- true
}(v)
}

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

1
2
3
4
5
6
7
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 数据中的数值类型时,如果不小心,我们可能会碰到一些令人困惑的情况。

1
2
3
4
5
6
7
8
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方法就已经返回,导致程序错误。你可以结合后面的代码示例来看看。

1
2
3
4
5
6
7
8
9
10
11
12
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 方法,示例代码如下。

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

channel阻塞

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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,具体实现可参考后面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 请求的协程无法正常关闭,最终造成协程泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 方法 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 通道接收到数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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函数,将连接放回连接池以便后续复用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 方法,以此规避协程泄漏风险。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 语言程序的过程中,还碰到过哪些奇奇怪怪的代码陷阱呢?

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