Go 常见错误
错误类别
Bugs
简单并不意味着容易
简单和容易之间有一个微妙的区别。简单,适用于一项技术,意味着学习或理解起来不复杂。然而,简单意味着我们可以不费吹灰之力实现任何事情。Go 简单易学,但不一定容易掌握。
2019 年,一项专注于并发性错误的研究被发表。“理解 Go 中的真实世界的并发性错误。” 这项研究是对并发性错误的第一次系统分析。它专注于多个流行的 Go 存储库,如 Docker、gRPC 和 Kubernetes。这项研究最重要的收获之一是,尽管人们认为消息传递比共享内存更容易处理,更不容易出错,但大多数阻塞性错误都是由于通过通道不准确地使用消息传递范式造成的。
是否应该认为语言设计者对消息传递的看法是错误的?是否应该重新考虑我们在项目中如何处理并发问题?当然不是。这不是一个面对消息传递与共享内存并决定胜负的问题。然而,作为 Go 的开发者,应该彻底了解如何使用并发性,它对现代处理器的影响,什么时候应该选择一种方法,以及如何避免常见的陷阱。这个例子充分说明,尽管像通道和 goroutines 这样的概念很容易学习,但在实践中却不是一个简单的话题。
简单并不意味着容易–可以推广到 Go 的许多方面,而不仅仅是并发性。因此,要成为熟练的 Go 开发者,我们必须对语言的许多方面有透彻的了解,这需要时间、努力和错误。
无谓的复杂性
软件复杂性的一个重要原因是,作为开发者,我们努力去思考想象中的未来。与其现在就解决具体的问题,不如建立进化的软件来解决未来出现的任何用例,这很诱人。然而,在大多数情况下,这导致了更多的缺点而不是好处,因为它可能使代码库更加复杂,难以理解和推理。
回到 Go,可以想到很多用例,在这些用例中,开发者可能会被诱惑为未来的需求设计抽象,例如接口或泛型。本书所讨论的主题是,我们应该保持谨慎,不要用不必要的复杂性来伤害代码库。
可读性较弱
另一种错误是削弱了可读性。正如罗伯特-C-马丁(Robert C. Martin)在他的《干净的代码》(Clean Code)一书中写道。正如 Robert C. Martin 在他的《干净的代码:敏捷软件工艺手册》一书中所写的,花在阅读和写作上的时间比例远远超过 10 比 1。然而,今天的软件工程是带有时间维度的编程:确保我们在几个月、几年、甚至几十年后仍然能够使用和维护一个应用程序。
在用 Go 编程时可能会犯许多错误,从而损害可读性。这些错误可能包括嵌套代码、数据类型的表述,或者在某些情况下不使用命名的结果参数, 应该学习如何编写可读的代码.
不理想的或不规范的组织
如何组织项目,如何处理实用程序包或初始函数
API 使用不方便
犯了一些常见的错误,削弱了 API 对客户的便利性,这是另一种类型的错误。如果一个 API 对用户不友好,那么它的表现力就会降低,因此也就更难理解,更容易出错。
比如过度使用任何类型,使用错误的创建模式来处理选项,或者盲目地应用面向对象编程中的标准实践,这些都会影响我们的 API 的可用性。
优化不足的代码
优化不足的代码是开发人员所犯的另一种错误。它可能由于各种原因而发生,如不了解语言特性,甚至缺乏趣味性的知识。性能是这种错误最明显的影响之一,但不是唯一的影响。
可以考虑为其他目标优化代码,例如准确性, 确保浮点运算的准确性。还有由于并行化执行不力,不知道如何减少分配,或者数据对齐的影响等。
缺少生产力
在大多数情况下,当在一个新项目上工作时,什么是最好的语言?就是我们最有成效的那一种。适应一种语言的工作方式,并利用它来获得最好的效果,对于达到熟练程度至关重要。
用 Go 工作时提高工作效率。例如,学习如何编写高效的测试以确保代码能够正常运行,如何依靠标准库来提高工作效率,以及如何从 profiling 工具和 linters 中获得最佳效果。
代码和项目组织
1. 无意中的 variable shadowing
短声明变量、内部块
在 Go 中,在一个块中声明的变量名可以在一个内部块中重新声明, 称为 varible shadowing.
2. 不必要的 nested code
代码的可读性是基于多种标准的,如命名、一致性、格式化等等。可读的代码需要较少的认知努力来维护一个心理模型;因此,它更容易阅读和维护。
可读性的一个关键方面是嵌套层的数量。
|
|
由于嵌套的 if/else 语句,在第一个版本中很难分辨出预期的执行流程。相反,第二个版本需要顺着一列扫描来查看预期的执行流程,并顺着第二列来查看边缘情况的处理。
一般来说,一个函数需要的嵌套层次越多,它的阅读和理解就越复杂。可能大多时候并不需要 else
3. 滥用 init 函数
init 函数是一个用于初始化应用程序状态的函数。它不需要参数,也不返回结果(一个 func()函数)。当一个包被初始化时,该包中所有的常量和变量声明都被计算。然后,init 函数被执行。
有时会在 Go 应用程序中误用 init 函数。潜在的后果是错误管理不到位,或者代码流更难理解。重新什么是 init 函数,什么时候建议使用或不建议使用它。
不应该依赖包内 init 函数的排序。因为源文件可以被重新命名,可能会影响执行顺序。
首先,一个使用 init 函数可以被认为是不合适的例子:持有一个数据库连接池。在例子中的 init 函数中,用 sql.Open 打开一个数据库。把这个数据库作为一个全局变量,其他函数以后可以使用。
在这个例子中,打开数据库,检查是否能 ping 到它,然后把它分配给全局变量。应该如何看待这个实现呢?三个主要的缺点:
-
首先,init 函数中的错误管理是有限的。事实上,由于 init 函数不返回错误,发出错误信号的唯一方法是恐慌,导致应用程序被停止。可能在某应用中,如果打开数据库失败,以任何方式停止应用程序都是可以的。然而,不一定要由包本身来决定是否停止应用程序。也许调用者可能更喜欢实现重试或使用回退机制。在这种情况下,在 init 函数中打开数据库会阻止客户包实现其错误处理逻辑。
-
另一个重要的缺点是与测试有关。如果在这个文件中添加测试,init 函数将在运行测试用例之前被执行,这不一定是想要的(例如,如果在一个不需要创建这个连接的实用函数上添加单元测试)。因此,init 函数使编写单元测试变得复杂。
-
最后一个缺点是,这个例子要求将数据库连接池分配给一个全局变量。全局变量有一些严重的弊端
- 任何函数都可以改变包内的全局变量。
- 单元测试可能会更加复杂,因为依赖于全局变量的函数不会再被隔离。全局变量的函数将不再是孤立的。
在大多数情况下,我们应该倾向于封装一个变量,而不是让它保持全局。由于这些原因,前面的初始化可能应该被处理成一个普通的函数来处理。
|
|
有必要不惜一切代价避免启动函数吗?并非如此。在一些用例中,init 函数仍然是有帮助的。例如,Go 官方博客(http:// mng.bz/PW6w)使用 init 函数来设置静态 HTTP 配置。
|
|
在这个例子中,init 函数不会失败(http.HandleFunc 会恐慌,但只有在处理程序为 nil 的情况下,这里不是这样的)。同时,不需要创建任何全局变量,该函数也不会影响可能的单元测试。因此,这个代码片断提供了一个很好的例子,说明 init 函数在哪里可以发挥作用。
应该对 init 函数持谨慎态度。然而,在某些情况下,它们可能是有帮助的,比如定义静态配置.
4. 过度使用 getters 和 setters
在编程中,数据封装是指隐藏一个对象的值或状态。获取器和设置器是通过在未导出的对象字段上提供导出的方法来实现封装的手段。
在 Go 中,没有像在某些语言中看到的那样自动支持 getters 和 setters。使用 getters 和 seters 来访问结构字段也被认为不是强制性的,也不是习惯性的。例如,标准库实现了一些结构,其中一些字段可以直接访问,如 time.Timer 结构。
虽然不推荐这样做,我们甚至可以直接修改 C(但我们不会再收到事件)。然而,这个例子说明了标准 Go 库并没有强制使用 getters 和/或 setters,即使不应该修改一个字段。
总之,如果结构上的 getters 和 setters 不能带来任何价值,就不应该用它们来淹没我们的代码。应该实事求是,努力在效率和遵循其他编程范式中有时被认为是无可争议的习惯之间找到合适的平衡点。
请记住,Go 是一种为许多特性而设计的独特语言,包括简单性。然而,如果我们发现需要使用 getters 和 setters,或者如前所述,在保证向前兼容的同时预见到未来的需要,使用它们并没有错。
10. 类型嵌入的问题
一个错误用法的例子: 实现了一个持有一些内存数据的结构 InMem,我们想用一个 mutex 来保护它免受并发访问。
因为 sync.Mutex 是一个嵌入类型,Lock 和 Unlock 方法将被提升。因此,这两个方法对于使用 InMem 的外部客户端来说是可见的。
在大多数情况下,mutex 是我们希望封装在一个结构中的东西,并使外部客户端看不到。因此,在这种情况下,我们不应该让它成为一个嵌入式字段。
11. 不使用 functional options pattern
- Config struct
- Builder pattern
- Functional options pattern
它提供了一种方便的、API 友好的方式来处理选项。尽管构建器模式可以是一个有效的选择,但它有一些小的缺点,这些缺点往往使函数式选项模式成为 Go 中处理这个问题的习惯方式。我们还要注意到,这种模式在不同的 Go 库中都有使用,比如 gRPC。
流程控制
30. 忽略元素在 for 循环中被复制
|
|
31. 忽略 for 循环中的计算
range后边的表达式只会在开始时计算一次 —— 被复制到临时变量
|
|
|
|
|
|
32. 忽视指针元素在 for 循环的影响
|
|
33. 在 Map 迭代的时候做出错误假设
- 顺序
- 迭代过程中更新
在 Go 中,在迭代过程中更新 Map(插入或删除一个元素)是允许的;它不会导致编译错误或运行时错误。然而,在迭代过程中在 Map 中添加一个条目时,应该考虑另一个方面,以避免非确定性的结果。
如果在迭代过程中向 Map 中添加 key-value,它可能在迭代过程中产生,也可能被跳过。对于每一个创建的 key-value,以及从一次迭代到下一次迭代,选择可能有所不同。
34. 错误理解 break 语句
for、select 时候, for 内层的 select 中 break 并不会退出外层 for 循环
35. 在循环中使用 defer
|
|
readFiles 返回时 defer 才执行, 如果不返回文件描述符将永远保持开放,导致泄漏.
函数和方法
42. 不确定该用哪种类型接收者
后续将彻底讨论值与指针的关系。所以,这次仅在性能方面做了一些表面文章。另外,在许多情况下,使用值或指针接收器不应该由性能决定,而应该由其他条件决定。
接收者必须是指针的情况:
- 方法需要修改接收者, 对于 slice 同样适用
- 方法接收者包含不能被拷贝的字段, 例如 sync 中的类型
接收者应该为指针:
- 接收者是个很大的对象, 指针更加高效. 无法直接说明具体大小, 可以借助 benchmarking 测试
接收者必须是值类型:
- 需要保证接收者是不变的
- 接收者是 map、function、或者 channel 否则会编译错误
接收者应该是值类型:
- 接收者是可以不必变化的 slice
- 接收者是个小的数组或结构体
- 接收者是基础类型, 例 int、float64、string…
另外, 一般情况下应避免混合接收器类型,但在 100%的情况下也不禁止。
43. 从不使用命名返回值
命名的结果参数是 Go 中一个不常使用的, 但有时使用命名的结果参数来使我们的 API 更加方便。
关于裸返回(没有参数的返回),我们认为在短的函数中可以接受;否则,它们会损害可读性,因为读者必须记住整个函数的输出。还应该在一个函数的范围内保持一致,要么只使用裸返回,要么只使用带参数的返回。
那么,关于命名的结果参数的规则是什么?在大多数情况下,在接口定义的上下文中使用命名的结果参数可以提高阅读能力,而不会导致任何副作用。但在方法实现的上下文中,没有严格的规则。在某些情况下,命名的结果参数也可以增加可读性:例如,如果两个参数有相同的类型。在其他情况下,它们也可以用于方便。因此,当有明显的好处时,我们应该谨慎地使用命名的结果参数。
如果需要返回同一类型的多个结果,也可以考虑用有意义的字段名创建一个特别的结构。然而,这并不总是可能的:例如,在满足一个我们无法更新的现有接口时。
44. 命名返回值的副作用
|
|
return 最先给返回值赋值;接着 defer 开始执行一些收尾工作;最后 RET 指令携带返回值退出函数
不管选择空 return 的返回还是带有参数的返回, 应尽量避免混用。记住,使用命名的结果参数并不一定意味着使用裸返回。有时我们可以使用命名的结果参数来使签名更清晰。
58. 不理解竞争的问题
数据竞争(Data races) 和 竞态条件 race conditions
|
|
数据竞争!! 因为 i++不是个原子操作. 可以使用原子操作、互斥锁、channel. 通过这三种方法,无论两个 goroutine 的执行顺序如何,i 的值最终都会被设置为 2。但是根据我们要执行的操作,没有数据竞争是否一定意味着确定性的结果?
|
|
不存在。两个 goroutine 都访问同一个变量,但不是在同一时间,因为 mutex 保护了它。但这个例子是确定性的吗?不,它不是。根据执行的顺序,i 最终会等于 1 或 2。这个例子并没有导致数据竞赛。但它有一个竞赛条件。当行为取决于事件的顺序或时间,而事件的顺序或时间不能被控制时,就会出现竞赛条件。这里,事件的时间就是 goroutines 的执行顺序。
总之,当我们在并发应用程序中工作时,必须理解数据竞赛与竞赛条件是不同的。当多个 goroutine 同时访问同一个内存位置,并且其中至少有一个在写时,就会发生数据竞赛。数据竞赛意味着意外的行为。然而,无数据竞赛的应用程序并不一定意味着确定性的结果。
理解这两个概念对于熟练设计并发应用程序至关重要。
Go 内存模型
Go 内存模型(https://golang.org/ref/mem)是一种规范,它定义了在一个goroutine中从一个变量的读取可以保证在不同goroutine中对同一变量的写入之后发生的条件。
在单一的 goroutine 中,不存在不同步的访问机会。事实上,我们的程序所表达的顺序保证了先发生的顺序。
然而,在多个 goroutine 内,我们应该记住其中的一些保证。我们将使用符号 A < B 来表示事件 A 发生在事件 B 之前。让我们来看看这些保证(有些是从 Go 内存模型中复制的):
- 创建一个 goroutine 是在 goroutine 的执行开始之前发生的。
- 一个 goroutine 的退出并不保证发生在任何事件之前:
- 在一个通道上的发送发生在该通道的相应接收完成之前。
- 关闭一个通道发生在这个闭合的接收之前。(只是没有发送消息)
- 关于通道的最后一个保证乍看之下可能是反直觉的:从一个没有缓冲的通道的接收发生在该通道的发送完成之前。
59. 不了解一个工作负载类型的并发性影响
根据工作负载是受 CPU 约束还是受 I/O 约束,可能需要以不同的方式解决这个问题。
在编程中,一个工作负载的执行时间受限于以下因素之一:
- CPU 的速度-例如,运行一个合并排序算法。该工作负载被称为 CPU-bound。
- I/O 的速度–例如,进行一个 REST 调用或数据库查询。该工作负载被称为 I/O-bound。
- 可用的内存量-工作负载被称为内存约束。
|
|
一种选择是使用所谓的工作者集合模式。这样做包括创建固定规模的工作者(goroutines),从一个共同的通道轮询任务(见图 8.11)。

现在的黄金问题是:池的大小应该是什么值?答案取决于工作负载的类型。
如果工作负载是 I/O 绑定的,答案主要取决于外部系统。如果我们想最大限度地提高吞吐量,系统可以应付多少个并发访问.
如果工作负载是 CPU 绑定的,一个最好的做法是依靠 GOMAXPROCS。GOMAXPROCS 是一个变量,用于设置分配给运行 goroutines 的 OS 线程数。默认情况下,这个值被设置为逻辑 CPU 的数量。
最后但同样重要的是,让我们记住,在大多数情况下,我们应该通过基准来验证我们的假设。并发并不简单,而且很容易做出草率的假设,结果是无效的。
60. 不理解 Go Context
Context values
提供的键和值是任何类型。的确,对于值,我们想传递任何类型。但是为什么键也应该是一个空的接口,而不是一个字符串,例如?这可能会导致冲突:来自不同包的两个函数可以使用相同的字符串值作为键。因此,后者会覆盖前者的值。因此,在处理上下文键时,最好的做法是创建一个未导出的自定义类型:
并发练习
61. 不恰当的传播 Context
在 Go 中处理并发问题时,上下文是无处不在的,在许多情况下,可能建议传播它们。然而,上下文的传播有时会导致微妙的错误,使子函数无法正确执行。
|
|
附加在 HTTP 请求上的上下文可以在不同的条件下取消
- 当客户端的连接关闭时
- 在 HTTP/2 请求的情况下,当请求被取消时
- 当响应被写回给客户端时
在前两种情况下,我们可能会正确处理事情。例如,如果我们从 doSomeTask 得到一个响应,但客户端已经关闭了连接,那么在已经取消了上下文的情况下调用发布可能是可以的,这样消息就不会被发布。但是最后一种情况呢?
当响应被写入客户端时,与请求相关的上下文将被取消。因此,我们正面临着一个竞赛条件:
- 如果响应是在 Kafka 发布之后写入的,我们既会返回一个响应,也会成功发布一个消息。
- 但是,如果响应是在 Kafka 发布之前或期间写入的,那么消息就不应该被发布。
62. 启动一个 goroutine 而不知道何时停止它
goroutine 很容易启动,也很便宜–如此简单和便宜,以至于我们不一定有计划何时停止一个新的 goroutine,这可能导致泄密。不知道何时停止一个 goroutine 是一个设计问题,也是 Go 中常见的并发性错误。
在内存方面,一个 goroutine 开始时的最小堆栈大小为 2KB,它可以根据需要增长和缩小(64 位的最大堆栈大小为 1GB,32 位为 250MB)。从内存上看,一个 goroutine 也可以持有分配到堆中的变量引用。同时,一个 goroutine 可以保存资源,如 HTTP 或数据库连接、打开的文件和网络套接字,这些资源最终应该被优雅地关闭。如果一个 goroutine 被泄露了,这些类型的资源也会被泄露。
|
|
一个具体的例子
|
|
|
|
当上下文被关闭时,观察者结构应该关闭其资源。然而,不能保证 watch 有时间这样做吗?绝对不能,这是一个设计缺陷。
|
|
调用这个 close 方法,使用 defer 来保证资源在应用程序退出前被关闭。
最后但并非最不重要的是,如果一个 goroutine 创建了资源,并且它的生命周期与应用程序的生命周期相联系,那么在退出应用程序之前等待这个 goroutine 完成可能会更安全。这样,我们可以确保资源可以被释放。
63.不小心使用 goroutines 和循环变量
|
|
闭包延迟绑定