你好,我是徐逸。
在多年 Golang 编程实践里,我发现不少 Go 研发人员,因未透彻理解部分 Go 语言特性,导致在一些编程场景中不慎陷入代码陷阱。这些陷阱不仅影响程序的正确性与稳定性,还可能让我们耗费大量时间调试修复。
因此,在今天的课程里,我将带你深入剖析 Go 编程中常见的四类代码坑。提前掌握这些代码坑,能帮助你更好地理解和规避这些问题,提升代码的可用性。
接口变量判空
在 Go 编程里,变量判空处理十分常见。对于接口类型变量的判空,我们需要格外留意,否则稍有不慎,就有可能出现 “nil!= nil” 这种奇怪现象。
以下面代码为例,我们先定义一个自定义错误类型 CustomError,然后在 handle 函数中,进行参数校验,若参数为空,返回参数错误;否则返回 nil。
1 | type CustomError struct { |
现在,你不妨思考一下,如果我们按下面的代码,分别传入空字符串与非空字符串这两种参数去调用上面的handle函数,最终会输出什么呢?
1 | func main() { |
输出结果非常诡异,无论参数为空还是非空,handle 函数都返回了非 nil 错误,出现 “nil error 不等于 nil” 的奇特现象。
1 | killianxu@KILLIANXU-MB0 error % go run main.go |
这种看似不合常理的现象究竟是怎么产生的呢?
实际上,这种现象和 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 | if err != nil { |
那么,我们该如何解决这个问题呢?
就像下面的代码一样,如果我们要给调用者返回一个 nil 错误,应该显式返回 nil,而非定义特定类型。
1 | func handle(req string) error { |
现在我们来重新运行一下代码,果然,当参数非空时,“nil error 不等于 nil” 的奇怪现象不再出现了。
1 | killianxu@KILLIANXU-MB0 error % go run main.go |
循环变量使用
了解完接口判空问题后,我们接着探讨另一个容易引发错误的陷阱——循环变量的使用。
以下面这段循环创建协程,并在匿名函数内部输出循环迭代变量 v 的代码为例。你不妨思考一下,在 Go 1.22 版本之前如果我们运行这段代码,将会输出什么结果呢?
1 | func main() { |
如果用 Go 1.22 之前的版本运行这段代码,结果会令人诧异。原本我们预期输出的是 a、b、c 这三个字符,实际得到的却全是字符 c。
1 | c |
究竟为什么会出现这种奇怪的现象呢?
我们不妨将上述循环代码,等价转换为下面代码的形式。实际上,循环迭代变量 v 的作用域覆盖整个循环,每次迭代时,新值都会被赋给位于同一内存地址的变量 v。当协程中的闭包函数开始执行时,如果循环已完成所有迭代,此时变量 v 留存的值便会是切片的最后一个元素 c。正因如此,所有协程最终输出的结果都是字符c。
1 | h1 := 0 |
为了让闭包能访问到循环迭代时的变量值,我们可以在每次循环迭代时,创建一个新的变量。下面我为你介绍两种常用方法。
第一种方法是将 v 作为参数传递进匿名函数,在匿名函数内部,只访问作为参数传入的变量,避免访问外部的循环迭代变量 v。代码如下所示。
1 | for _, v := range values { |
第二种方法是,在每次循环迭代时,我们显式创建一个作用域仅限于本次迭代的局部变量,然后在闭包中访问这个临时变量,代码如下。
1 | for _, v := range values { |
通过上面的两种方法,我们就能有效解决循环迭代变量的使用难题。
当然,由于循环迭代变量这个坑踩的人比较多,因此在Go 1.22 版本中,Go 官方对循环迭代变量的作用域做出了调整,改为每次迭代都重新生成一个新变量,从而规避因复用变量而引发的各类问题。
数值类型JSON反序列化
了解完循环变量的使用问题后,我们继续探讨另一个容易引发错误的陷阱——数值类型的JSON反序列化。
在日常开发中,出于通用性考虑,我们可能会从其它服务或存储获取 JSON 字符串数据。为了方便解析,就像下面的代码这样,我们有时候会选择 map[string]interface{} 类型来接收这些数据。
不过,在处理 JSON 数据中的数值类型时,如果不小心,我们可能会碰到一些令人困惑的情况。
1 | func main() { |
例如,在上述代码中,我们看到 JSON 字符串里的 age 字段,直观呈现的是 int 类型。然而,当我们使用 map 进行反序列化操作后,age 字段的值却悄然变成了 float64 类型。
因此,在解析JSON数据时,如果使用map[string]interface 类型来做反序列化,我们就需要用 float64 去解析。
并发原语和库使用
介绍完串行程序的代码陷阱,接下来,我们把目光转向另一类陷阱——Go 语言并发编程中涉及的代码坑。
WaitGroup使用不当
首先,咱们来看看WaitGroup类型的使用。
在使用 WaitGroup 类型时,一个常见的误区是在协程内部调用 Add 方法。这种做法可能会导致在执行 Wait 方法前,计数器未能正确设置,进而导致协程还没执行完,Wait方法就已经返回,导致程序错误。你可以结合后面的代码示例来看看。
1 | func main() { |
正确的做法是,在启动协程之前,在外部调用 Add 方法,示例代码如下。
1 | wg.Add(1) // 正确:在协程外部调用Add方法 |
channel阻塞
介绍完WaitGroup,接着,我们来看看channel的使用。在使用channel 时,如果不小心,很容易引起协程阻塞,进而导致协程泄漏问题。
以下面这段典型的、借助阻塞型 channel 实现超时返回机制的函数代码为例。
1 | func handle(timeout time.Duration) *Obj { |
当第 4 行在协程内执行的函数耗时较长,使得handle函数超时返回时,会导致阻塞型通道变量 ch 没有了接收者。这样一来,第 5 行向通道写入数据的操作就会永远处于阻塞状态,最终引发协程泄漏问题。
因此,为有效规避这一问题,在构建超时返回机制时,我们应采用非阻塞型 channel,具体实现可参考后面的代码。
1 | func handle(timeout time.Duration) *Obj { |
除了在代码中直接使用 channel 可能出现问题外,有时底层库在内部间接使用 channel,如果我们操作不当,同样会引发协程泄漏。
比如下面这段 HTTP 调用的代码,我们在判断返回的状态码不对时,直接返回,而没有调用Body的Close方法。这将会导致发起 HTTP 请求的协程无法正常关闭,最终造成协程泄漏。
1 | func call() (string, error) { |
这段代码究竟为什么会导致协程泄漏呢?不妨让我们透过核心源码一探究竟。
首先,我们来看下 dialConn 方法,这个方法的功能是在连接池无可用连接时,负责创建连接,并同时启动两个协程分别执行 readLoop 和 writeLoop 方法 。
1 | func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) { |
然后,我们重点看下 readLoop 方法。在 readLoop 方法内部,存在一个循环结构,并且这个循环会进入 select 语句引发的阻塞状态。值得注意的是,select 语句退出阻塞的关键条件,是 waitForBodyRead 通道接收到数据。
1 | func (pc *persistConn) readLoop() { |
因此,一旦 waitForBodyRead 通道始终未被写入数据,这个协程就会一直处于阻塞状态。在实际应用中,如果存在大量连接,这意味着大量协程可能会因同样原因被阻塞,从而导致协程泄漏。
接下来,我们把重点聚焦在一个关键问题上——waitForBodyRead 究竟在什么时候会被写入数据呢?
实际上,waitForBodyRead 会在以下两种情形下被写入数据的
第一种情况是,当我们读取 Body 出错(包括读取完毕遇到 io.EOF)时,会调用下面的 fn 函数,这个函数会往waitForBodyRead写入信号。如果写入waitForBodyRead的值为true,也就是Body已经读取完成,上面的readLoop方法还会调用 tryPutIdleConn函数,将连接放回连接池以便后续复用。
1 | body := &bodyEOFSignal{ |
另一个时机是,当我们调用 Body 的 Close 方法时,这会触发上面的 earlyCloseFn 函数执行,这时 waitForBodyRead 会写入 false,使得 readLoop方法退出循环,连接虽然不能被复用,但也避免了协程阻塞。
通过上述源码分析不难发现,对于Body,我们需要执行 Close 操作,或者读取其内容,不然内部协程会因 waitForBodyRead 而陷入阻塞。
最后,回到开头那段代码,当状态码不为 200 时,我们既未读取 Body,也未调用Body的 Close 方法 ,这就使得 readLoop 循环一直阻塞,因此会引发协程泄漏。
为防止协程泄漏,我们可以将http请求的代码像后面这样修改。即便不读取 Body 内容,也必须调用 Close 方法,以此规避协程泄漏风险。
1 | func call() (string, error) { |
小结
今天这节课,我给你剖析了几个常见场景,以及在这些场景下代码可能遭遇的陷阱。
现在,让我们一同回顾今天学习的几类代码坑。
- 接口变量判空问题。接口变量在底层会储存类型 T 和值 V 这两个关键元素。当值为 nil 但类型不为 nil 时,就可能出现类似 “nil 不等于 nil” 这种看似矛盾的奇怪现象。
- 循环变量的使用问题。在 Go 1.22 版本之前,循环迭代变量的作用域涵盖整个循环体。如果我们在循环内部直接使用这个变量,极有可能引发一些令人困惑的问题。
- 数值类型的JSON反序列化问题。当我们采用 map[string]interface{} 类型对 JSON 字符串进行反序列化操作时,会发现 int 类型悄然变成了 float64 类型的诡异现象。
- 最后还有并发原语和库的使用问题。如果 WaitGroup 和 channel 使用不当,程序不仅可能出现错误,还极有可能导致协程泄漏,对程序的稳定性造成严重影响。
希望你能认真体会这些代码陷阱。在日后遇到类似场景时,务必格外留意,从而避免陷入这些坑点,编写出更加健壮、稳定的代码。
思考题
除了这节课介绍的这些代码坑,在你编写 Go 语言程序的过程中,还碰到过哪些奇奇怪怪的代码陷阱呢?
欢迎你把你的答案分享在评论区,也欢迎你把这节课的内容分享给需要的朋友,我们下节课再见!