100-个-Go-错误以及如何避免-全-

100 个 Go 错误以及如何避免(全)

原文:100 Go Mistakes

协议:CC BY-NC-SA 4.0

一、GO:学起来简单,但很难掌握

本章涵盖

  • 是什么让 Go 成为一门高效、可扩展和多产的语言
  • 探究为什么GO简单易学却难精通
  • 展示开发人员常见的错误类型

犯错是每个人生活的一部分。正如阿尔伯特·爱因斯坦曾经说过的,

一个从未犯过错误的人也从未尝试过新事物。

最终重要的不是我们犯了多少错误,而是我们从错误中学习的能力。这个断言也适用于编程。我们在一门语言中获得的资历并不是一个神奇的过程;它包括犯许多错误,并从中吸取教训。这本书的目的就是围绕这个想法。它将帮助你,读者,成为一个更熟练的 Go 开发者,通过观察和学习人们在语言的许多领域中犯的 100 个常见错误。

这一章快速回顾了为什么GO这么多年来成为主流。我们将讨论为什么尽管GO被认为简单易学,但掌握它的细微差别却很有挑战性。最后,我们将介绍本书涵盖的概念。

1.1 Go 大纲

如果你正在读这本书,很可能你已经爱上了 Go。因此,本节提供了一个简短的提示,是什么让 Go 成为如此强大的语言。

在过去的几十年里,软件工程有了长足的发展。大多数现代系统不再是由一个人编写的,而是由多个程序员组成的团队编写的——有时甚至是数百人,如果不是数千人的话。如今,代码必须具有可读性、表达性和可维护性,以保证系统的持久性。同时,在我们这个快速发展的世界中,最大限度地提高灵活性和缩短上市时间对于大多数组织来说至关重要。编程也应该遵循这一趋势,公司努力确保软件工程师在阅读、编写和维护代码时尽可能地高效。

为了应对这些挑战,谷歌在 2007 年创建了 Go 编程语言。从那时起,许多组织已经采用这种语言来支持各种用例:API、自动化、数据库、CLI(命令行界面)等等。今天许多人认为 Go 是云的语言。

就特性而言,Go 没有类型继承、没有异常、没有宏、没有部分函数、不支持惰性变量求值或不变性、没有运算符重载、没有模式匹配等等。为什么语言中缺少这些特性?官方的 Go FAQ (go.dev/doc/faq)给了我们一些启示:

为什么 Go 没有特征 X?您最喜欢的功能可能会丢失,因为它不合适,因为它影响编译速度或设计的清晰度,或者因为它会使基本的系统模型太难。

通过特性的数量来判断编程语言的质量可能不是一个准确的标准。至少,这不是GO的目标。相反,当组织大规模采用一种语言时,Go 利用了一些基本特征。其中包括以下内容:

  • 稳定性——虽然 Go 经常更新(包括改进和安全补丁),但它仍然是一种稳定的语言。有些人甚至认为这是这门语言最好的特性之一。

  • 表现性——我们可以通过我们如何自然和直观地编写和读取代码来定义编程语言中的表现性。数量减少的关键字和解决常见问题的有限方法使 Go 成为大型代码库的一种表达性语言。

  • 编译——作为开发人员,还有什么比等待构建来测试我们的应用更让人恼火的呢?快速编译一直是语言设计者有意识的目标。这反过来又提高了生产率。

  • 安全 ——Go 是一种强大的静态类型语言。因此,它有严格的编译时规则,确保代码在大多数情况下是类型安全的。

Go 是从底层开始构建的,具有可靠的特性,比如具有 goroutines 和通道的出色的并发原语。不太需要依赖外部库来构建高效的并发应用。观察并发性在这些日子里是多么重要,也证明了为什么 Go 对于现在和可预见的将来都是如此合适的语言。

一些人也认为 Go 是一种简单的语言。从某种意义上说,这并不一定是错的。例如,一个新手可以在不到一天的时间里学会这门语言的主要特征。那么,如果GO很简单,为什么要读一本以错误概念为中心的书呢?

1.2 简单并不意味着容易

简单和容易是有细微差别的。简单,应用于一项技术,意思是学习或理解起来不复杂。然而,容易意味着我们不需要太多努力就可以实现任何事情。GO学起来简单,但不一定容易掌握。

让我们以并发性为例。2019 年,一项专注于并发 bug 的研究发表了:“理解 Go 中真实世界的并发 bug。¹” 这项研究是首次对并发 bug 的系统分析。它关注多个流行的 Go 存储库,比如 Docker、gRPC 和 Kubernetes。这项研究中最重要的一点是,大多数阻塞错误都是由通过通道的消息传递范式的不正确使用引起的,尽管人们认为消息传递比共享内存更容易处理,更不容易出错。

对于这样的外卖,应该有什么合适的反应?我们应该认为语言设计者在消息传递方面是错误的吗?我们是否应该重新考虑如何处理项目中的并发性?当然不是。

这不是一个对抗信息传递和共享内存并决定谁是赢家的问题。然而,作为 Go 开发人员,我们需要彻底了解如何使用并发性,它对现代处理器的影响,何时支持一种方法,以及如何避免常见的陷阱。这个例子强调了虽然像通道和 goroutines 这样的概念很容易学习,但在实践中却不是一个容易的话题。

这个主题——简单并不意味着容易——可以推广到 Go 的许多方面,而不仅仅是并发性。因此,要成为精通GO的开发者,我们必须对这门语言的许多方面有透彻的理解,这需要时间、精力和错误。

这本书旨在通过深入研究 100 个 Go 错误来帮助我们加速迈向熟练的旅程。

1.3 100 个 Go 错误

我们为什么要读一本关于常见GO错误的书?为什么不用一本挖掘不同主题的普通书来加深我们的知识呢?

在 2011 年的一篇文章中,神经科学家证明了大脑生长的最佳时间是我们面临错误的时候。我们都经历过从一个错误中学习的过程,并且在几个月甚至几年后回忆起那个事件,当一些背景与它相关时?正如珍妮特·梅特卡夫(Janet Metcalfe)在另一篇文章中介绍的那样,这种情况的发生是因为错误具有促进效应。主要意思是我们不仅能记住错误,还能记住错误周围的上下文。这是从错误中学习如此高效的原因之一。

为了加强这种促进作用,本书尽可能多地用真实世界的例子来说明每个错误。这本书不仅仅是关于理论;它还帮助我们更好地避免错误,做出更明智、更有意识的决策,因为我们现在理解了它们背后的基本原理。

告诉我,我会忘记。教我,我会记住。让我参与进来,我会学到东西。

——未知

这本书提出了七大类错误。总的来说,这些错误可以归类为

  • BUG

  • 不必要的复杂

  • 可读性较弱

  • 次优或不完善的组织

  • 缺乏 API 便利性

  • 优化不足的代码

  • 缺乏生产力

接下来我们介绍每一个错误类别。

1.3.1 错误

第一种错误可能也是最明显的错误是软件错误。2020 年,Synopsys 进行的一项研究估计,仅在美国,软件错误的成本就超过 2 万亿美元⁴。

此外,错误还会导致悲剧性的影响。例如,我们可以提到加拿大原子能有限公司(AECL)生产的 Therac-25 放射治疗机。由于比赛条件,这台机器给病人的辐射剂量超过预期数百倍,导致三名病人死亡。因此,软件错误不仅仅是钱的问题。作为开发人员,我们应该记住我们的工作是多么有影响力。

这本书涵盖了大量可能导致各种软件错误的案例,包括数据竞争、泄漏、逻辑错误和其他缺陷。虽然准确的测试应该是尽早发现这类 bug 的一种方式,但我们有时可能会因为时间限制或复杂性等不同因素而错过案例。因此,作为一名 Go 开发者,确保我们避免常见的错误是至关重要的。

1.3.2 不必要的复杂性

下一类错误与不必要的复杂性有关。软件复杂性的一个重要部分来自于这样一个事实,即作为开发人员,我们努力思考想象中的未来。与其现在就解决具体的问题,不如构建进化的软件来解决未来出现的任何用例。然而,在大多数情况下,这样做弊大于利,因为这会使代码库变得更加复杂,难以理解和推理。

回到过去,我们可以想到许多用例,在这些用例中,开发人员可能倾向于为未来需求设计抽象,比如接口或泛型。这本书讨论了我们应该小心不要用不必要的复杂性伤害代码库的主题。

1.3.3 可读性较弱

另一种错误是削弱可读性。正如 Robert C. Martin 在他的书《Clean Code:A Handbook of Agile Software crafts》中所写的,花在阅读和写作上的时间比远远超过 10 比 1。我们大多数人开始在可读性不那么重要的单独项目上编程。然而,今天的软件工程是有时间维度的编程:确保我们在几个月、几年,甚至几十年后仍然可以使用和维护应用。

在用 Go 编程时,我们可能会犯很多会损害可读性的错误。这些错误可能包括嵌套代码、数据类型表示,或者在某些情况下没有使用命名结果参数。通过这本书,我们将学习如何编写可读的代码,并关心未来的读者(包括我们未来的自己)。

1.3.4 次优或不适应的组织

无论是在进行一个新项目时,还是因为我们获得了不准确的反应,另一种错误是次优地和单向地组织我们的代码和项目。这样的问题会使项目更难推理和维护。这本书涵盖了GO中的一些常见错误。例如,我们将了解如何构建一个项目,以及如何处理实用工具包或init函数。总之,查看这些错误应该有助于我们更有效、更习惯地组织我们的代码和项目。

1.3.5 缺乏 API 便利性

另一种类型的错误是犯一些削弱 API 对客户的便利性的常见错误。如果一个 API 不是用户友好的,它将缺乏表现力,因此更难理解,更容易出错。

我们可以考虑许多情况,比如过度使用any类型,使用错误的创建模式来处理选项,或者盲目应用影响我们 API 可用性的面向对象编程的标准实践。这本书涵盖了一些常见的错误,这些错误阻止我们向用户公开方便的 API。

1.3.6 优化不足的代码

优化不足的代码是开发人员犯的另一种错误。这可能是由于各种原因造成的,比如不理解语言特征,甚至缺乏基础知识。性能是这个错误最明显的影响之一,但不是唯一的。

我们可以考虑为其他目标优化代码,比如准确性。例如,这本书提供了一些确保浮点运算准确的常用技术。与此同时,我们将讨论大量可能对性能代码产生负面影响的情况,例如,由于并行化执行不佳,不知道如何减少分配,或者数据对齐的影响。我们将通过不同的棱镜解决优化问题。

1.3.7 缺乏生产力

在大多数情况下,当我们着手一个新项目时,我们能选择的最佳语言是什么?我们工作效率最高的一个。熟悉一门语言的工作方式并充分利用它是达到熟练的关键。

在本书中,我们将介绍许多案例和具体的例子,这些案例和例子将帮助我们在 Go 中工作时更有效率。例如,我们将着眼于编写高效的测试来确保我们的代码工作,依靠标准库来提高效率,并充分利用分析工具和 linters。现在,是时候深入研究这 100 个常见的GO错误了。

总结

  • Go 是一种现代编程语言,能够提高开发人员的工作效率,这对于当今大多数公司来说至关重要。

  • GO学起来简单,但不容易掌握。这就是为什么我们需要加深我们的知识来最有效地使用语言。

  • 通过错误和具体的例子来学习是精通一门语言的有效方法。这本书将通过探究 100 个常见错误来加快我们的熟练程度。


¹ T. Tu,X. Liu 等,“理解 Go 中真实世界的并发 bug”,发表于 2019 年 4 月 13 日-17 日的 ASPLOS 2019。

J. S. Moser,H. S. Schroder 等人,“注意你的错误:将成长心态与适应性后错误调整联系起来的神经机制的证据”,《心理科学》,第 22 卷,第 12 期,第 1484-1489 页,2011 年 12 月。

³ J. Metcalfe,“从错误中学习”,《心理学年度评论》,第 68 卷,第 465–489 页,2017 年 1 月。

⁴ Synopsys,“美国软件质量差的代价:2020 年报告。”2020. news.synopsys.com/2021-01-06-Synopsys-Sponsored-CISQ-Research-Estimates-Cost-of-Poor-Software-Quality-in-the-US-2-08-Trillion-in-2020

R. C. Martin,《干净的代码:敏捷软件工艺手册》。普伦蒂斯霍尔,2008 年。

二、代码和项目组织

本章涵盖

  • 习惯性地组织我们的代码
  • 有效处理抽象:接口和泛型
  • 关于如何构建项目的最佳实践

以一种干净、惯用和可维护的方式组织 Go 代码库并不是一件容易的事情。理解所有与代码和项目组织相关的最佳实践需要经验,甚至是错误。要避免哪些陷阱(例如,变量隐藏和嵌套代码滥用)?我们如何构造包?我们何时何地使用接口或泛型、init函数和实用工具包?在这一章中,我们检查常见的组织错误。

2.1 #1:意外的变量隐藏

变量的作用域指的是变量可以被引用的地方:换句话说,就是应用中名字绑定有效的部分。在 Go 中,块中声明的变量名可以在内部块中重新声明。这个原理叫做变量隐藏,容易出现常见错误。

以下示例显示了由于隐藏变量而产生的意外副作用。它以两种不同的方式创建 HTTP 客户端,这取决于一个tracing布尔值:

var client *http.Client                          // ❶
if tracing {
    client, err := createClientWithTracing()     // ❷
    if err != nil {
        return err
    }
    log.Println(client)
} else {
    client, err := createDefaultClient()         // ❸
    if err != nil {
        return err
    }
    log.Println(client)
}
// Use client

❶ 声明了一个client变量

❷ 创建一个启用了跟踪的 HTTP 客户端。(client变量在此块中被隐藏。)

❸ 创建一个默认的 HTTP 客户端。(client变量在这个块中也被隐藏。)

在这个例子中,我们首先声明一个client变量。然后,我们在两个内部块中使用短变量声明操作符(:=)将函数调用的结果分配给内部client变量——而不是外部变量。因此,外部变量总是nil

注意这段代码会编译,因为内部的client变量会在日志调用中使用。如果没有,我们就会出现client declared and not used等编译错误。

我们如何确保给原始的client变量赋值呢?有两种不同的选择。

第一个选项以这种方式在内部块中使用临时变量:

var client *http.Client
if tracing {
    c, err := createClientWithTracing()    // ❶
    if err != nil {
        return err
    }
    client = c                             // ❷
} else {
    // Same logic
}

❶ 创建了一个临时变量c

❷ 将这个临时变量分配给client

这里,我们将结果赋给一个临时变量c,它的范围只在if块内。然后,我们将它赋回给client变量。同时,我们对else部分做同样的工作。

第二个选项使用内部程序块中的赋值运算符(=)将函数结果直接赋给client变量。然而,这需要创建一个error变量,因为赋值操作符只有在已经声明了变量名的情况下才起作用。例如:

var client *http.Client
var err error                                  // ❶
if tracing {
    client, err = createClientWithTracing()    // ❷
    if err != nil {
        return err
    }
} else {
    // Same logic
}

❶ 声明了一个err变量

❷ 使用赋值操作符给*http赋值。客户端直接返回到client变量

不用先赋给一个临时变量,我们可以直接把结果赋给client

两种选择都完全有效。这两个选项之间的主要区别是,我们在第二个选项中只执行一个赋值,这可能被认为更容易阅读。同样,使用第二个选项,我们可以在if / else语句之外共同化和实现错误处理,如下例所示:

if tracing {
    client, err = createClientWithTracing()
} else {
    client, err = createDefaultClient()
}
if err != nil {
    // Common error handling
}

当在内部块中重新声明变量名时,会出现变量隐藏,但是我们看到这种做法容易出错。强加一个禁止隐藏变量的规则取决于个人喜好。例如,有时重用现有的变量名(如err)来处理错误会很方便。然而,总的来说,我们应该保持谨慎,因为我们现在知道我们可能会面临这样的场景:代码可以编译,但是接收值的变量不是预期的变量。在本章的后面,我们还将看到如何检测隐藏变量,这可能有助于我们发现可能的错误。

下一节展示了避免滥用嵌套代码的重要性。

2.2 #2:不必要的嵌套代码

应用于软件的心智模型是系统行为的内部表示。在编程时,我们需要维护心智模型(例如,关于整体代码交互和功能实现)。基于多种标准,如命名、一致性、格式等,代码被限定为可读的。可读代码需要较少的认知努力来维护心智模型;因此,它更容易阅读和维护。

可读性的一个重要方面是嵌套层次的数量。让我们做一个练习。假设我们正在进行一个新项目,需要理解下面的join函数是做什么的:

func join(s1, s2 string, max int) (string, error) {
    if s1 == "" {
        return "", errors.New("s1 is empty")
    } else {
        if s2 == "" {
            return "", errors.New("s2 is empty")
        } else {
            concat, err := concatenate(s1, s2)     // ❶
            if err != nil {
                return "", err
            } else {
                if len(concat) > max {
                    return concat[:max], nil
                } else {
                    return concat, nil
                }
            }
        }
    }
}

func concatenate(s1 string, s2 string) (string, error) {
    // ...
}

❶ 调用concatenate函数来执行某些特定的连接,但可能会返回错误

这个join函数连接两个字符串,如果长度大于max,则返回一个子字符串。同时,它处理对s1s2的检查,以及对concatenate的调用是否返回错误。

从实现的角度来看,这个函数是正确的。然而,建立一个包含所有不同情况的心智模型可能不是一件简单的任务。为什么?因为嵌套层次的数量。

现在,让我们使用相同的函数,但以不同的方式再次尝试这个练习:

func join(s1, s2 string, max int) (string, error) {
    if s1 == "" {
        return "", errors.New("s1 is empty")
    }
    if s2 == "" {
        return "", errors.New("s2 is empty")
    }
    concat, err := concatenate(s1, s2)
    if err != nil {
        return "", err
    }
    if len(concat) > max {
        return concat[:max], nil
    }
    return concat, nil
}

func concatenate(s1 string, s2 string) (string, error) {
    // ...
}

你可能已经注意到,尽管做着和以前一样的工作,但构建这个新版本的心智模型需要的认知负荷更少。这里我们只维护两个嵌套层次。正如 Mat Ryer 在 Go Time 播客(medium.com/@matryer/line-of-sight-in-code-186dd7cdea88)中提到的:

向左对齐幸福路径;您应该很快能够向下扫描一列,以查看预期的执行流。

由于嵌套的if / else语句,在第一个版本中很难区分预期的执行流。相反,第二个版本需要向下扫描一列来查看预期的执行流,向下扫描第二列来查看边缘情况是如何处理的,如图 2.1 所示。

图 2.1 为了理解预期的执行流程,我们只需浏览一下快乐路径列。

一般来说,函数需要的嵌套层次越多,阅读和理解起来就越复杂。让我们看看这条规则的一些不同应用,以优化我们的代码可读性:

  • 当一个if块返回时,我们应该在所有情况下省略else块。例如,我们不应该写

    if foo() {
        // ...
        return true
    } else {
        // ...
    }
    

    相反,我们像这样省略了else块:

    if foo() {
        // ...
        return true
    }
    // ...
    

    在这个新版本中,先前在else块中的代码被移到顶层,使其更容易阅读。

  • 我们也可以沿着这个逻辑走一条不快乐的路:

    if s != "" {
        // ...
    } else {
        return errors.New("empty string")
    }
    

    这里,空的s代表非快乐路径。因此,我们应该像这样翻转条件:

    if s == "" {                           // ❶
        return errors.New("empty string")
    }
    // ...
    

    ❶翻转了if条件

    这个新版本更容易阅读,因为它将快乐路径保留在左边,并减少了块数。

编写可读的代码对每个开发人员来说都是一个重要的挑战。努力减少嵌套块的数量,将快乐路径放在左边,尽可能早地返回,这些都是提高代码可读性的具体方法。

在下一节中,我们将讨论 Go 项目中一个常见的误用:init函数。

2.3 #3:误用init函数

有时我们会在 Go 应用中误用init函数。潜在的后果是糟糕的错误管理或更难理解的代码流。让我们重温一下什么是init函数。然后,我们将会看到它的用法是否被推荐。

2.3.1 概念

init函数是用于初始化应用状态的函数。它不接受任何参数,也不返回任何结果(一个func()函数)。当一个包被初始化时,包中所有的常量和变量声明都会被求值。然后,执行init函数。下面是一个初始化main包的例子:

package main

import "fmt"

var a = func() int {
    fmt.Println("var")        // ❶
    return 0
}()

func init() {
    fmt.Println("init")       // ❷
}

func main() {
    fmt.Println("main")       // ❸
}

❶ 首先被执行

❷ 其次被执行

❸ 最后被执行

运行此示例将打印以下输出:

var
init
main

初始化软件包时会执行init函数。在下面的例子中,我们定义了两个包,mainredis,其中main依赖于redis。首先,主要的。从main包开始:

package main

import (
    "fmt"

    "redis"
)

func init() {
    // ...
}

func main() {
    err := redis.Store("foo", "bar")    // ❶
    // ...
}

❶ 依赖于redis实现

然后从redis包中redis.go:

package redis

// imports

func init() {
    // ...
}

func Store(key, value string) error {
    // ...
}

因为main依赖于redis,所以首先执行redis包的init函数,然后是main包的init,然后是的main函数本身。图 2.2 显示了这个顺序。

我们可以为每个包定义多个init函数。当我们这样做时,包内init函数的执行顺序是基于源文件的字母顺序。例如,如果一个包包含一个a.go文件和一个b.go文件,并且这两个文件都有一个init函数,则首先执行a.go init函数。

图 2.2 首先执行redis包的init函数,然后是maininit函数,最后是的main函数。

我们不应该依赖包中init函数的排序。事实上,这可能很危险,因为源文件可能会被重命名,从而潜在地影响执行顺序。

我们也可以在同一个源文件中定义多个init函数。例如,这段代码完全有效:

package main

import "fmt"

func init() {               // ❶
    fmt.Println("init 1")
}

func init() {               // ❷
    fmt.Println("init 2")
}

func main() {
}

❶ 第一个init函数

❷ 第二个init函数

执行的第一个init函数是源代码顺序中的第一个。以下是输出结果:

init 1
init 2

我们也可以使用init函数来产生副作用。在下一个例子中,我们定义了一个main包,它对foo没有很强的依赖性(例如,没有直接使用公共函数)。然而,这个例子需要初始化foo包。我们可以这样使用_操作符:

package main

import (
    "fmt"

    _ "foo"    // ❶
)

func main() {
    // ...
}

❶ 导入foo有副作用

在这种情况下,foo包在main之前初始化。因此,执行fooinit函数。

init函数的另一个特点是它不能被直接调用,如下例所示:

package main

func init() {}

func main() {
    init()       // ❶
}

❶ 无效引用

这段代码会产生以下编译错误:

$ go build .
./main.go:6:2: undefined: init

既然我们已经了解了init函数是如何工作的,那么让我们看看什么时候应该使用或者不使用它们。下一节将对此进行阐述。

2.3.2 何时使用init函数

首先,让我们看一个使用init函数被认为不合适的例子:持有数据库连接池。在示例中的init函数中,我们使用sql.Open打开一个数据库。我们使这个数据库成为一个全局变量,其他函数以后可以使用:

var db *sql.DB

func init() {
    dataSourceName :=
        os.Getenv("MYSQL_DATA_SOURCE_NAME")       // ❶
    d, err := sql.Open("mysql", dataSourceName)
    if err != nil {
        log.Panic(err)
    }
    err = d.Ping()
    if err != nil {
        log.Panic(err)
    }
    db = d                                        // ❷
}

❶ 环境变量

❷ 将数据库连接分配给全局db变量

在本例中,我们打开数据库,检查是否可以 ping 它,然后将它赋给全局变量。我们应该如何看待这个实现?让我们描述三个主要的缺点。

首先,init函数中的错误管理是有限的。事实上,由于init函数不返回错误,发出错误信号的唯一方式就是恐慌,导致应用停止。在我们的例子中,如果打开数据库失败,无论如何停止应用也是可以的。然而,不应该由包本身来决定是否停止应用。也许调用者可能更喜欢实现重试或使用回退机制。在这种情况下,在init函数中打开数据库会阻止客户端包实现它们的错误处理逻辑。

另一个重要的缺点与测试有关。如果我们向这个文件添加测试,init函数将在运行测试用例之前执行,这不一定是我们想要的(例如,如果我们在一个不需要创建这个连接的实用函数上添加单元测试)。因此,本例中的init函数使编写单元测试变得复杂。

最后一个缺点是,该示例要求将数据库连接池分配给一个全局变量。全局变量有一些严重的缺点;例如:

  • 任何函数都可以改变包内的全局变量。

  • 单元测试可能会更复杂,因为依赖于全局变量的函数不再是孤立的。

在大多数情况下,我们应该倾向于封装一个变量,而不是保持它的全局。

出于这些原因,之前的初始化可能应该作为普通旧函数的一部分来处理,如下所示:

func createClient(dsn string) (*sql.DB, error) {    // ❶
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        return nil, err                             // ❷
    }
    if err = db.Ping(); err != nil {
        return nil, err
    }
    return db, nil
}

❶ 接受数据源名称并返回一个*sql.DB和一个错误

❷ 返回一个错误

使用这个函数,我们解决了前面讨论的主要缺点。方法如下:

  • 错误处理的责任留给了调用者。

  • 可以创建一个集成测试来检查该函数是否有效。

  • 连接池封装在函数中。

有必要不惜一切代价避免init函数吗?不完全是。在一些用例中,init函数仍然是有用的。例如,官方的 Go 博客(mng.bz/PW6w)使用init函数来设置静态 HTTP 配置:

func init() {
    redirect := func(w http.ResponseWriter, r *http.Request) {
        http.Redirect(w, r, "/", http.StatusFound)
    }
    http.HandleFunc("/blog", redirect)
    http.HandleFunc("/blog/", redirect)

    static := http.FileServer(http.Dir("static"))
    http.Handle("/favicon.ico", static)
    http.Handle("/fonts.css", static)
    http.Handle("/fonts/", static)

    http.Handle("/lib/godoc/", http.StripPrefix("/lib/godoc/",
        http.HandlerFunc(staticHandler)))
}

在这个例子中,init函数不会失败(http.HandleFunc可能会恐慌,但只有在处理器是nil的情况下才会恐慌,但这里的情况不是这样)。同时,不需要创建任何全局变量,该函数不会影响可能的单元测试。因此,这个代码片段提供了一个很好的例子,说明了init函数的用处。总之,我们看到init函数会导致一些问题:

  • 他们可以限制错误管理。

  • 它们会使如何实现测试变得复杂(例如,必须建立一个外部依赖,这对于单元测试的范围来说可能是不必要的)。

  • 如果初始化需要我们设置一个状态,那必须通过全局变量来完成。

我们应该谨慎使用init函数。然而,在某些情况下,它们会很有帮助,比如定义静态配置,正如我们在本节中看到的。否则,在大多数情况下,我们应该通过特殊函数来处理初始化。

2.4 #4:过度使用获取器和设置器

在编程中,数据封装是指隐藏一个对象的值或状态。获取器和设置器是通过在未导出的对象字段上提供导出的方法来启用封装的方法。

在 Go 中,没有像我们在一些语言中看到的那样自动支持获取器和设置器。使用获取器和设置器来访问结构字段也被认为既不强制也不习惯。例如,标准库实现了这样的结构,其中一些字段可以直接访问,例如作为time.Timer结构:

timer := time.NewTimer(time.Second)
<-timer.C                             // ❶

❶ C 是一个<–chan Time字段

尽管不推荐,我们甚至可以直接修改C(但是我们不会再接收事件了)。然而,这个例子说明了标准的 Go 库并不强制使用获取器和/或设置器,即使我们不应该修改一个字段。

另一方面,使用获取器和设置器有一些优点,包括:

  • 它们封装了与获取或设置字段相关的行为,允许以后添加新功能(例如,验证字段、返回计算值或用互斥体包装对字段的访问)。

  • 它们隐藏了内部表现,让我们在展示时更加灵活。

  • 它们为运行时属性的改变提供了一个调试拦截点,使得调试更加容易。

如果我们陷入这些情况或者预见到一个可能的用例,同时保证向前兼容,使用获取器和设置器可以带来一些价值。例如,如果我们将它们用于一个名为balance的字段,我们应该遵循这些命名约定:

  • 获取器方法应该命名为Balance(不是GetBalance)。

  • 设置器方法应该命名为SetBalance

这里有一个例子:

currentBalance := customer.Balance()     // ❶
if currentBalance < 0 {
    customer.SetBalance(0)               // ❷
}

❶ 获取器

❷ 设置器

总之,如果结构上的获取器和设置器没有带来任何价值,我们就不应该用它们来淹没我们的代码。我们应该务实,努力在效率和遵循习惯用法之间找到正确的平衡,这些习惯用法在其他编程范例中有时被认为是无可争议的。

请记住,Go 是一种独特的语言,它具有许多特性,包括简单性。然而,如果我们发现需要获取器和设置器,或者,如前所述,在保证向前兼容性的同时预见到未来的需要,使用它们没有任何问题。

接下来,我们将讨论过度使用接口的问题。

2.5 #5:接口污染

在设计和构建我们的代码时,接口是 Go 语言的基石之一。然而,像许多工具或概念一样,滥用它们通常不是一个好主意。接口污染就是用不必要的抽象来淹没我们的代码,使代码更难理解。这是来自不同习惯的另一种语言的开发人员经常犯的错误。在深入探讨这个话题之前,我们先来回顾一下 Go 的接口。然后,我们将看到什么时候使用接口是合适的,什么时候它可能被认为是污染。

2.5.1 概念

接口提供了一种指定对象行为的方式。我们使用接口来创建多个对象可以实现的公共抽象。使 Go 接口如此不同的是它们被隐式地满足了。没有像implements这样明确的关键字来标记一个对象X实现了接口Y

为了理解是什么让接口如此强大,我们将从标准库中挖掘两个流行的接口:io.Readerio.Writerio包为 I/O 原语提供了抽象。在这些抽象中,io.Reader与从数据源读取数据有关,io.Writer与向目标写入数据有关,如图 2.3 所示。

图 2.3 io.Reader从数据源读取并填充一个字节切片,而io.Writer从一个字节切片写入目标。

io.Reader包含一个单个Read方法:

type Reader interface {
    Read(p []byte) (n int, err error)
}

接口的定制实现应该接受一个字节切片,用它的数据填充它,并返回读取的字节数或一个错误。

另一方面,io.Writer定义了单个方法,Write:

type Writer interface {
    Write(p []byte) (n int, err error)
}

io.Writer的定制实现应该将来自一个片的数据写入一个目标,并返回写入的字节数或一个错误。因此,这两个接口都提供了基本的抽象:

  • io.Reader从数据源读取数据。

  • io.Writer将数据写入目标。

语言中有这两个接口的基本原理是什么?创建这些抽象的目的是什么?

假设我们需要实现一个将一个文件的内容复制到另一个文件的函数。我们可以创建一个特定的函数,将两个*os.File作为输入。或者,我们可以选择使用io.Readerio.Writer抽象来创建一个更加通用的函数:

func copySourceToDest(source io.Reader, dest io.Writer) error {
    // ...
}

这个函数将与*os.File参数一起工作(因为*os.File实现了io.Readerio.Writer)以及实现这些接口的任何其他类型。例如,我们可以创建自己的写入数据库的io.Writer,而代码保持不变。它增加了函数的通用性;因此,它的可重用性。

此外,为这个函数编写单元测试更加容易,因为我们可以使用stringsbytes包,而提供了有用的实现,而不是处理文件:

func TestCopySourceToDest(t *testing.T) {
    const input = "foo"
    source := strings.NewReader(input)            // ❶
    dest := bytes.NewBuffer(make([]byte, 0))      // ❷

    err := copySourceToDest(source, dest)         // ❸
    if err != nil {
        t.FailNow()
    }

    got := dest.String()
    if got != input {
        t.Errorf("expected: %s, got: %s", input, got)
    }
}

❶ 创建了一个io.Reader

❷ 创建了一个io.Writer

❸ 从*stringsio.Readerio.Writer调用copySourceToDest

在本例中,source是一个*strings.Reader,而dest是一个*bytes.Buffer。这里,我们在不创建任何文件的情况下测试copySourceToDest的行为。

在设计接口时,粒度(接口包含多少方法)也是需要记住的。Go (www.youtube.com/watch?v=PAAkCSZUG1c&t=318s)中一个众所周知的谚语与一个接口应该有多大有关:

接口越大,抽象越弱。

——罗布·派克

事实上,向接口添加方法会降低接口的可重用性。io.Readerio.Writer是强大的抽象,因为它们不能再简单了。此外,我们还可以结合细粒度的接口来创建更高级别的抽象。io.ReadWriter就是这种情况,它结合了读者和作者的行为:

type ReadWriter interface {
    Reader
    Writer
}

注正如爱因斯坦所说,“一切都应该尽可能简单,但不能再简单了。”应用于接口,这意味着找到接口的完美粒度不一定是一个简单的过程。

现在让我们讨论推荐接口的常见情况。

2.5.2 何时使用接口

我们应该什么时候在 Go 中创建接口?让我们看三个具体的用例,在这些用例中,接口通常被认为是带来价值的。请注意,我们的目标并不是详尽无遗的,因为我们添加的案例越多,它们就越依赖于上下文。然而,这三个案例应该给我们一个大致的概念:

  • 普通行为

  • 解耦

  • 限制行为

普通行为

我们将讨论的第一个选项是当多个类型实现一个公共行为时使用接口。在这种情况下,我们可以分析出接口内部的行为。如果我们看看标准库,我们可以找到许多这样的用例的例子。例如,可以通过三种方法对集合进行排序:

  • 检索集合中元素的数量

  • 报告一个元素是否必须在另一个元素之前排序

  • 交换两个元素

因此,以下接口被添加到sort包中:

type Interface interface {
    Len() int               // ❶
    Less(i, j int) bool     // ❷
    Swap(i, j int)          // ❸
}

元素的❶数

❷ 检查了两个要素

❸ 互换了两个元素

这个接口具有很强的可重用性,因为它包含了对任何基于索引的集合进行排序的通用行为。

纵观sort包,我们可以找到几十个实现。例如,如果在某个时候我们计算了一个整数集合,并且我们想对它进行排序,我们有必要对实现类型感兴趣吗?排序算法是归并排序还是快速排序重要吗?很多时候,我们并不在意。因此,排序行为可以被抽象出来,我们可以依赖于sort.Interface

找到正确的抽象来分解行为也可以带来很多好处。例如,sort包提供了同样依赖于sort.Interface的实用函数,比如检查集合是否已经排序。举个例子,

func IsSorted(data Interface) bool {
    n := data.Len()
    for i := n - 1; i > 0; i-- {
        if data.Less(i, i-1) {
            return false
        }
    }
    return true
}

因为sort.Interface是正确的抽象层次,所以它非常有价值。

现在让我们看看使用接口的另一个主要用例。

退耦

另一个重要的用例是关于从实现中分离我们的代码。如果我们依赖一个抽象而不是一个具体的实现,实现本身可以被另一个代替,甚至不需要改变我们的代码。这就是利斯科夫替代原理(Robert C. Martin 的 SOLID 设计原理中的 L)。

解耦的一个好处与单元测试有关。让我们假设我们想要实现一个CreateNewCustomer方法来创建一个新客户并存储它。我们决定直接依赖于具体的实现(比如说一个mysql.Store结构):

type CustomerService struct {
    store mysql.Store          // ❶
}

func (cs CustomerService) CreateNewCustomer(id string) error {
    customer := Customer{id: id}
    return cs.store.StoreCustomer(customer)
}

❶ 取决于具体的实现

现在,如果我们想测试这个方法呢?因为customerService依赖于实际的实现来存储一个Customer,我们不得不通过集成测试来测试它,这需要构建一个 MySQL 实例(除非我们使用另一种技术,比如go-sqlmock,但这不是本节的范围)。尽管集成测试很有帮助,但这并不总是我们想要做的。为了给我们更多的灵活性,我们应该将CustomerService从实际的实现中分离出来,这可以通过这样的接口来实现:

type customerStorer interface {      // ❶
    StoreCustomer(Customer) error
}

type CustomerService struct {
    storer customerStorer            // ❷
}

func (cs CustomerService) CreateNewCustomer(id string) error {
    customer := Customer{id: id}
    return cs.storer.StoreCustomer(customer)
}

❶ 创建了存储抽象

❷ 将客户服务从实际实现中分离出来

因为存储一个客户现在是通过一个接口完成的,这给了我们更多的灵活性来测试这个方法。例如,我们可以

  • 通过集成测试使用具体实现

  • 通过单元测试使用模拟(或任何类型的双测试)

  • 或者两者都有

现在让我们讨论另一个用例:限制一个行为。

限制行为

我们将讨论的最后一个用例乍一看可能非常违反直觉。它是关于将一个类型限制到一个特定的行为。假设我们实现了一个定制的配置包来处理动态配置。我们通过一个IntConfig结构为int配置创建一个特定的容器,该结构还公开了两个方法:GetSet。下面是代码的样子:

type IntConfig struct {
    // ...
}

func (c *IntConfig) Get() int {
    // Retrieve configuration
}

func (c *IntConfig) Set(value int) {
    // Update configuration
}

现在,假设我们收到一个IntConfig,它保存了一些特定的配置,比如一个阈值。然而,在我们的代码中,我们只对检索配置值感兴趣,并且我们希望防止更新它。如果我们不想改变我们的配置包,我们怎么能强制这个配置在语义上是只读的呢?通过创建一个抽象,将行为限制为仅检索配置值:

type intConfigGetter interface {
    Get() int
}

然后,在我们的代码中,我们可以依靠intConfigGetter而不是具体的实现:

type Foo struct {
    threshold intConfigGetter
}

func NewFoo(threshold intConfigGetter) Foo {    // ❶
    return Foo{threshold: threshold}
}

func (f Foo) Bar()  {
    threshold := f.threshold.Get()              // ❷
    // ...
}

intConfigGetter

❷ 读取配置

在这个例子中,配置获取器被注入到NewFoo工厂方法中。它不会影响这个函数的客户端,因为它仍然可以在实现intConfigGetter时传递一个IntConfig结构。然后,我们只能读取Bar方法中的配置,不能修改。因此,出于各种原因,我们也可以使用接口将类型限制为特定的行为,例如语义强制。

在本节中,我们看到了三个潜在的用例,其中接口通常被认为是有价值的:分解出一个公共行为,创建一些解耦,以及将一个类型限制到某个特定的行为。同样,这个列表并不详尽,但是它应该让我们对接口在 Go 中的作用有一个大致的了解。

现在,让我们结束这一节,讨论接口污染的问题。

2.5.3 接口污染

在 Go 项目中过度使用接口是很常见的。也许开发人员的背景是 C#或 Java,他们发现在具体类型之前创建接口是很自然的。然而,这并不是GO的工作方式。

正如我们所讨论的,接口是用来创建抽象的。当编程遇到抽象时,主要的警告是记住抽象应该被发现,而不是被创建。这是什么意思?这意味着如果没有直接的理由,我们就不应该开始在代码中创建抽象。我们不应该设计接口,而应该等待具体的需求。换句话说,我们应该在需要的时候创建接口,而不是在预见到可能需要的时候。

如果我们过度使用接口,主要问题是什么?答案是它们使代码流更加复杂。增加一个无用的间接层不会带来任何价值;它创建了一个毫无价值的抽象,使得代码更难阅读、理解和推理。如果我们没有添加接口的充分理由,并且不清楚接口如何使代码更好,我们应该质疑这个接口的用途。为什么不直接调用实现?

注意当我们通过一个接口调用一个方法时,我们也可能经历性能开销。它需要在哈希表的数据结构中查找,以找到接口指向的具体类型。但是在很多情况下这不是问题,因为开销很小。

总之,在我们的代码中创建抽象时,我们应该谨慎——抽象应该被发现,而不是被创建。对于我们这些软件开发人员来说,基于我们认为以后可能需要的东西,通过试图猜测什么是完美的抽象层次来过度工程化我们的代码是很常见的。应该避免这个过程,因为在大多数情况下,它用不必要的抽象污染了我们的代码,使其阅读起来更加复杂。

不要设计接口,去发现它们。

——抢派克

让我们不要试图抽象地解决问题,而是解决现在必须解决的问题。最后,但同样重要的是,如果不清楚一个接口如何使代码变得更好,我们可能应该考虑删除它以使我们的代码更简单。

下一节继续这个主题,并讨论一个常见的接口错误:在生成器端创建接口。

2.6 #6:生产者方面的接口

我们在上一节中看到了接口被认为是有价值的。但是 Go 开发者经常会误解一个问题:一个接口应该活在哪里?

在深入探讨这个主题之前,让我们确保我们在本节中使用的术语是清楚的:

  • 生产者端——与具体实现定义在同一个包中的接口(见图 2.4)。

    图 2.4 接口是在具体实现的旁边定义的。

  • 消费者端——在使用它的外部包中定义的接口(参见图 2.5)。

    图 2.5 接口是在使用的地方定义的。

常见的是,开发人员在具体实现的同时,在生产者端创建接口。这种设计可能是具有 C#或 Java 背景的开发人员的习惯。但在GO中,大多数情况下这并不是我们应该做的。

让我们讨论下面的例子。这里,我们创建一个特定的包来存储和检索客户数据。同时,仍然在同一个包中,我们决定所有的调用都必须通过以下接口:

package store

type CustomerStorage interface {
    StoreCustomer(customer Customer) error
    GetCustomer(id string) (Customer, error)
    UpdateCustomer(customer Customer) error
    GetAllCustomers() ([]Customer, error)
    GetCustomersWithoutContract() ([]Customer, error)
    GetCustomersWithNegativeBalance() ([]Customer, error)
}

我们可能认为我们有一些很好的理由在生产者端创建和公开这个接口。也许这是将客户端代码从实际实现中分离出来的好方法。或者,也许我们可以预见它将帮助客户创建测试替身。不管是什么原因,这都不是GO的最佳实践。

如前所述,接口在 Go 中是隐式满足的,与具有显式实现的语言相比,Go 往往是游戏规则的改变者。在大多数情况下,要遵循的方法类似于我们在上一节中描述的:抽象应该被发现,而不是被创建。这意味着不能由生产者来为所有客户强制一个给定的抽象。相反,由客户决定是否需要某种形式的抽象,然后确定满足其需求的最佳抽象级别。

在前面的例子中,也许一个客户端对解耦它的代码不感兴趣。也许另一个客户想要解耦它的代码,但是只对GetAllCustomers方法感兴趣。在这种情况下,这个客户机可以用一个方法创建一个接口,从外部包中引用Customer结构:

package client

type customersGetter interface {
    GetAllCustomers() ([]store.Customer, error)
}

从一个包组织,图 2.6 显示了结果。有几点需要注意:

  • 因为customersGetter接口只在client包中使用,所以可以不导出。

  • 视觉上,在图中,看起来像是循环依赖。然而,从storeclient没有依赖性,因为接口是隐式满足的。这就是为什么这种方法在具有显式实现的语言中并不总是可行的。

图 2.6client包通过创建自己的接口定义了它需要的抽象。

主要的一点是client包现在可以为它的需求定义最精确的抽象(这里,只有一个方法)。它涉及到接口分离原则的概念(SOLID 中的 I),该原则声明不应该强迫任何客户端依赖它不使用的方法。因此,在这种情况下,最好的方法是在生产者端公开具体的实现,让客户决定如何使用它以及是否需要抽象。

为了完整起见,让我们提一下这种方法——生产者端的接口——有时在标准库中使用。例如,encoding包定义了由其他子包如encoding/jsonencoding/binary实现的接口。encoding包装在这点上有错吗?肯定不是。在这种情况下,encoding包中定义的抽象在标准库中使用,语言设计者知道预先创建这些抽象是有价值的。我们回到上一节的讨论:如果你认为抽象在想象的未来可能是有帮助的,或者至少,如果你不能证明这个抽象是有效的,就不要创建它。

在大多数情况下,接口应该位于消费者端。然而,在特定的环境中(例如,当我们知道——而不是预见——一个抽象将对消费者有帮助时),我们可能希望它在生产者一方。如果我们这样做了,我们应该努力使它尽可能的小,增加它的可重用性,使它更容易组合。

让我们在函数签名的上下文中继续讨论接口。

2.7 #7:返回接口

在设计函数签名时,我们可能需要返回一个接口或者一个具体的实现。让我们来理解为什么返回一个接口在很多情况下被认为是 Go 中的一个坏习惯。

我们刚刚介绍了为什么接口通常存在于消费者端。图 2.7 显示了如果一个函数返回一个接口而不是一个结构,依赖关系会发生什么。我们会看到它会导致一些问题。

我们将考虑两种方案:

  • client,其中包含一个Store接口

  • store,包含Store的一个实现

图 2.7 从store包到client包有一个依赖关系。

store包中,我们定义了一个实现Store接口的InMemoryStore结构。同时,我们创建一个NewInMemoryStore函数来返回一个Store接口。在这个设计中,从实现包到客户机包有一个依赖关系,这听起来可能有点奇怪。

比如client包已经不能调用NewInMemoryStore函数了;否则,就会出现循环依赖。一个可能的解决方案是从另一个包中调用这个函数,并将一个Store实现注入到client。然而,被迫这样做意味着设计应该受到质疑。

此外,如果另一个客户机使用了InMemoryStore结构会怎么样?在这种情况下,也许我们想将Store接口移动到另一个包中,或者回到实现包中——但是我们讨论了为什么在大多数情况下,这不是最佳实践。这看起来像代码的味道。

因此,一般来说,返回一个接口会限制灵活性,因为我们强迫所有的客户端使用一种特定类型的抽象。大多数情况下,我们可以从 Postel 定律(datatracker.ietf.org/doc/html/rfc761)中得到启发:

做自己的事要保守,接受别人的东西要开明。

——传输控制协议

如果我们把这个习语用到GO上,那就意味着

  • 返回结构而不是接口

  • 如果可能的话接受接口

当然,也有一些例外。作为软件工程师,我们熟悉这样一个事实:规则从来不是 100%正确的。最相关的是类型,一个由许多函数返回的接口。我们还可以用包io检查标准库中的另一个异常:

func LimitReader(r Reader, n int64) Reader {
    return &LimitedReader{r, n}
}

这里,函数返回一个导出的结构,io.LimitedReader。然而,函数签名是一个接口io.Reader。打破我们到目前为止讨论的规则的基本原理是什么?io.Reader是一个预先的抽象概念。它不是由客户定义的,但它是强制的,因为语言设计者事先知道这种抽象级别会有帮助(例如,在可重用性和可组合性方面)。

总而言之,大多数情况下,我们不应该返回接口,而应该返回具体的实现。否则,由于包的依赖性,它会使我们的设计更加复杂,并且会限制灵活性,因为所有的客户端都必须依赖相同的抽象。同样,结论类似于前面的章节:如果我们知道(不是预见)一个抽象对客户有帮助,我们可以考虑返回一个接口。否则,我们不应该强迫抽象;他们应该被客户发现。如果客户端出于某种原因需要抽象一个实现,它仍然可以在客户端这样做。

在下一节中,我们将讨论一个与使用any相关的常见错误。

2.8 #8:any什么都不代表

在 Go 中,指定零方法的接口类型被称为空接口,interface{}。到了 Go 1.18,预声明的类型any变成了空接口的别名;因此,所有的interface{}事件都可以用any代替。在很多情况下,any可以认为是一种过度概括;而且就像罗布派克说的,不传达任何东西(www.youtube.com/watch?v=PAAkCSZUG1c&t=7m36s)。让我们先提醒自己核心概念,然后我们可以讨论潜在的问题。

一个any类型可以保存任何值类型:

func main() {
    var i any

    i = 42           // ❶
    i = "foo"        // ❷
    i = struct {     // ❸
        s string
    }{
        s: "bar",
    }
    i = f            // ❹

    _ = i            // ❺
}

func f() {}

国际// ❶

❷ 字符串

❸ 结构

❹ 函数

❺ 赋值给空白标识符,以便该示例编译

在给和any类型赋值时,我们丢失了所有的类型信息,这需要一个类型断言来从i变量中获取任何有用的信息,就像前面的例子一样。让我们看另一个例子,这里使用any是不准确的。在下面,我们实现了一个Store结构和两个方法GetSet的框架。我们使用这些方法来存储不同的结构类型,CustomerContract:

package store

type Customer struct{
    // Some fields
}
type Contract struct{
    // Some fields
}

type Store struct{}

func (s *Store) Get(id string) (any, error) {     // ❶
    // ...
}

func (s *Store) Set(id string, v any) error {     // ❷
    // ...
}

❶ 返回any

❷ 接受any

虽然Store在编译方面没有任何问题,但是我们应该花一分钟来考虑一下方法签名。因为我们接受并返回any参数,所以这些方法缺乏表现力。如果未来的开发人员需要使用Store结构,他们可能需要钻研文档或阅读代码来理解如何使用这些方法。因此,接受或返回一个any类型并不能传达有意义的信息。此外,因为在编译时没有安全措施,所以没有什么可以阻止调用者用任何数据类型调用这些方法,比如一个int:

s := store.Store{}
s.Set("foo", 42)

通过使用any,我们失去了 Go 作为静态类型语言的一些好处。相反,我们应该避免any类型,尽可能使我们的签名显式化。对于我们的例子,这可能意味着为每个类型复制GetSet方法:

func (s *Store) GetContract(id string) (Contract, error) {
    // ...
}

func (s *Store) SetContract(id string, contract Contract) error {
    // ...
}

func (s *Store) GetCustomer(id string) (Customer, error) {
    // ...
}

func (s *Store) SetCustomer(id string, customer Customer) error {
    // ...
}

在这个版本中,这些方法很有表现力,减少了不理解的风险。拥有更多的方法不一定是问题,因为客户也可以使用一个接口创建他们自己的抽象。例如,如果一个客户只对Contract方法感兴趣,它可以写这样的东西:

type ContractStorer interface {
    GetContract(id string) (store.Contract, error)
    SetContract(id string, contract store.Contract) error
}

有哪些any有帮助的情况?让我们看看标准库,看看函数或方法接受any参数的两个例子。第一个例子是在即encoding/json包中。因为我们可以封送任何类型,Marshal函数接受any参数:

func Marshal(v any) ([]byte, error) {
    // ...
}

另一个例子是在的database/sql包中。如果查询是参数化的(例如,SELECT * FROM FOO WHERE id = ?),参数可以是任何种类。因此,它也使用any参数:

func (c *Conn) QueryContext(ctx context.Context, query string,
    args ...any) (*Rows, error) {
    // ...
}

总之,如果确实需要接受或返回任何可能的类型(例如,当涉及到封送或格式化时),any会很有帮助。一般来说,我们应该不惜一切代价避免过度概括我们编写的代码。也许少量的重复代码偶尔会更好,如果它改善了其他方面,比如代码的表达能力。

接下来,我们将讨论另一种类型的抽象:泛型。

2.9 #9:对何时使用泛型感到困惑

Go 1.18 在语言中加入了泛型。简而言之,这允许用可以在以后指定并在需要时实例化的类型来编写代码。然而,什么时候使用泛型,什么时候不使用泛型可能会令人困惑。在这一节中,我们将描述 Go 中泛型的概念,然后看看常见的用法和误用。

2.9.1 概念

考虑以下从map[string]int类型中提取所有键的函数:

func getKeys(m map[string]int) []string {
    var keys []string
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

如果我们想对另一种映射类型(如map[int]string)使用类似的函数,该怎么办?在泛型出现之前,Go 开发者有几个选择:使用代码生成、反射或复制代码。例如,我们可以编写两个函数,每个函数对应一种映射类型,或者甚至尝试扩展getKeys来接受不同的映射类型:

func getKeys(m any) ([]any, error) {                      // ❶
    switch t := m.(type) {
    default:
        return nil, fmt.Errorf("unknown type: %T", t)     // ❷
    case map[string]int:
        var keys []any
        for k := range t {
            keys = append(keys, k)
        }
        return keys, nil
    case map[int]string:
        // Copy the extraction logic
    }
}

❶ 接受并返回任何参数

❷ 如果类型还没有实现,处理运行时错误

通过这个例子,我们开始注意到一些问题。首先,它增加了样板代码。事实上,当我们想要添加一个案例时,它需要复制的range循环。同时,函数现在接受了和any类型,这意味着我们失去了 Go 作为类型化语言的一些好处。事实上,检查一个类型是否被支持是在运行时而不是编译时完成的。因此,如果提供的类型未知,我们也需要返回一个错误。最后,因为键类型可以是intstring,我们必须返回一部分any类型来提取键类型。这种方法增加了调用方的工作量,因为客户端可能还需要执行键的类型检查或额外的转换。多亏了泛型,我们现在可以使用类型参数重构这段代码。

类型参数是我们可以在函数和类型中使用的泛型类型。例如,以下函数接受类型参数:

func foo[T any](t T) {     // ❶
    // ...
}

T是一个类型参数。

调用foo时,我们传递一个any类型的类型实参。提供类型参数是调用实例化,工作在编译时完成。这使得类型安全成为核心语言特性的一部分,并避免了运行时开销。

让我们回到getKeys函数,使用类型参数编写一个通用版本,它可以接受任何类型的映射:

func getKeys[K comparable, V any](m map[K]V) []K {   // ❶
    var keys []K                                     // ❷
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

❶ 键是可比较的,而值是任意类型的。

❷ 创建了键的切片

为了处理映射,我们定义了两种类型参数。首先,值可以是any类型:V any。然而,在 Go 中,映射键不能是和any类型。例如,我们不能使用切片:

var m map[[]byte]int

这段代码导致编译错误:invalid map key type []byte。因此,我们不接受任何键类型,而是必须限制类型参数,以便键类型满足特定的要求。这里的要求是键的类型必须具有可比性(我们可以用==或者!=)。因此,我们将K定义为comparable而不是any

限制类型参数以匹配特定的需求被称为约束。约束是一种接口类型,可以包含

  • 一套行为(方法)

  • 任意类型

让我们来看看后者的一个具体例子。假设我们不想为map键类型接受任何comparable类型。例如,我们希望将限制为的intstring类型。我们可以这样定义自定义约束:

type customConstraint interface {
    ~int | ~string                   // ❶
}

func getKeys[K customConstraint,     // ❷
         V any](m map[K]V) []K {
    // Same implementation
}

❶ 定义了一个自定义类型,将类型限制为intstring

❷ 将类型参数k更改为customConstraint类型

首先,我们定义一个customConstraint接口,使用联合操作符|将和类型限制为intstring(稍后我们将讨论~的用法)。K现在是customConstraint而不是之前的comparable

getKeys的签名要求我们可以用任何值类型的映射来调用它,但是键类型必须是intstring——例如,在调用者端:

m = map[string]int{
    "one":   1,
    "two":   2,
    "three": 3,
}
keys := getKeys(m)

注意 Go 可以推断出getKeys是用string类型参数调用的。前面的调用相当于:

keys := getKeys[string](m)

~int vs. int

使用~int的约束和使用int的约束有什么区别?使用int将其限制为该类型,而~int则限制所有底层类型为int的类型。为了说明,让我们设想一个约束,我们希望将一个类型限制为实现String()``string方法的任何int类型:

type customConstraint interface {
    ~int
    String() string
}

使用此约束将类型参数限制为自定义类型。举个例子,

type customInt int

func (i customInt) String() string {
    return strconv.Itoa(int(i))
}

因为customInt是一个int并实现了String() string方法,所以customInt类型满足定义的约束。然而,如果我们改变约束来包含一个int而不是~int,使用customInt会导致编译错误,因为类型int没有实现String() string

到目前为止,我们已经讨论了对函数使用泛型的例子。然而,我们也可以使用数据结构的泛型。例如,我们可以创建一个包含任何类型值的链表。为此,我们将编写一个Add方法来追加一个节点:

type Node[T any] struct {                // ❶
    Val  T
    next *Node[T]
}

func (n *Node[T]) Add(next *Node[T]) {   // ❷
    n.next = next
}

❶ 使用类型参数

❷ 实例化一个类型接收器

在示例中,我们使用类型参数来定义T,并在Node中使用这两个字段。关于该方法,接收器被实例化。事实上,因为Node是泛型的,所以它也必须遵循定义的类型参数。

关于类型参数需要注意的最后一点是,它们不能与方法参数一起使用,只能与函数参数或方法接收器一起使用。例如,下面的方法不会编译:

type Foo struct {}

func (Foo) bar[T any](t T) {}
./main.go:29:15: methods cannot have type parameters

如果我们想在方法中使用泛型,那么接收者需要成为类型参数。

现在,让我们检查一下我们应该和不应该使用泛型的具体情况。

2.9.2 常见用途和误用

泛型什么时候有用?让我们讨论一些建议使用泛型的常见用法:

  • 数据结构——例如,如果我们实现了二叉树、链表或堆,我们可以使用泛型来提取元素类型。

  • 处理任何类型的切片、贴图和通道的函数——例如,合并两个通道的函数可以处理任何类型的通道。因此,我们可以使用类型参数来提取通道类型:

    func merge[T any](ch1, ch2 <-chan T) <-chan T {
        // ...
    }
    
  • 分解出行为而不是类型——sort包,例如,包含一个接口和三个方法:

    type Interface interface {
        Len() int
        Less(i, j int) bool
        Swap(i, j int)
    }
    

    该接口由sort.Intssort .Float64s等不同的函数使用。使用类型参数,我们可以分解出排序行为(例如,通过定义一个包含切片和比较函数的结构):

    type SliceFn[T any] struct {    // ❶
        S       []T
        Compare func(T, T) bool     // ❷
    }
    
    func (s SliceFn[T]) Len() int           { return len(s.S) }
    func (s SliceFn[T]) Less(i, j int) bool { return s.Compare(s.S[i], s.S[j]) }
    func (s SliceFn[T]) Swap(i, j int)      { s.S[i], s.S[j] = s.S[j], s.S[i] }
    

    ❶使用类型参数

    ❷比较了两个元素

    然后,因为SliceFn结构实现了sort.Interface,我们可以使用的sort.Sort(sort.Interface)函数对提供的切片进行排序:

    s := SliceFn[int]{
        S: []int{3, 2, 1},
        Compare: func(a, b int) bool {
            return a < b
        },
    }
    sort.Sort(s)
    fmt.Println(s.S)
    [1 2 3]
    

    在这个例子中,分解出一个行为允许我们避免为每个类型创建一个函数。

反过来说,什么时候建议我们不要使用泛型?

  • 当调用类型参数的方法时——考虑一个接收io.Writer并调用的Write方法的函数,例如:

    func foo[T io.Writer](w T) {
        b := getBytes()
        _, _ = w.Write(b)
    }
    

    在这种情况下,使用泛型不会给我们的代码带来任何价值。我们应该把w直接变成io.Writer

  • 当它让我们的代码变得更复杂的时候——泛型从来都不是强制性的,作为 Go 开发者,我们已经没有它们十多年了。如果我们正在编写通用的函数或结构,并且我们发现它并没有使我们的代码更清晰,我们可能应该重新考虑我们对于这个特殊用例的决定。

虽然泛型在特定的情况下会有帮助,但是我们应该小心什么时候使用它们,什么时候不使用它们。一般来说,如果我们想回答什么时候不使用泛型,我们可以找到与什么时候不使用接口的相似之处。事实上,泛型引入了一种抽象形式,我们必须记住,不必要的抽象引入了复杂性。

同样,让我们不要用不必要的抽象污染我们的代码,现在让我们专注于解决具体的问题。这意味着我们不应该过早地使用类型参数。让我们等到要写样板代码的时候再考虑使用泛型。

在下一节中,我们将讨论使用类型嵌入时可能出现的问题。

2.10 #10:不知道类型嵌入可能存在的问题

当创建一个结构时,Go 提供了嵌入类型的选项。但是如果我们不理解类型嵌入的所有含义,这有时会导致意想不到的行为。在这一节中,我们将探讨如何嵌入类型,它们会带来什么,以及可能出现的问题。

在 Go 中,如果一个结构字段没有名字就被声明,那么它就被称为嵌入的。举个例子,

type Foo struct {
    Bar              // ❶
}

type Bar struct {
    Baz int
}

❶ 嵌入字段

Foo结构中,Bar类型是在没有关联名称的情况下声明的;因此,它是一个嵌入式字段。

我们使用嵌入来提升嵌入类型的字段和方法。因为Bar包含一个Baz字段,这个字段被提升为Foo(见图 2.8)。因此,BazFoo开始变为可用:

foo := Foo{}
foo.Baz = 42

请注意,Baz可从两个不同的路径获得:要么从使用Foo.Baz的提升路径获得,要么通过BarFoo.Bar.Baz从名义路径获得。两者都涉及同一个字段。

图 2.8 baz被提升,因此可直接从S进入。

接口和嵌入

嵌入也用在接口中,与其他接口组成一个接口。在下面的例子中,io.ReadWriter由一个io.Reader和一个io.Writer组成:

type ReadWriter interface {
    Reader
    Writer
}

但是本节的范围只与结构中的嵌入字段相关。

现在我们已经提醒自己什么是嵌入类型,让我们看一个错误用法的例子。在下面的代码中,我们实现了一个保存一些内存数据的结构,我们希望使用互斥锁来保护它免受并发访问:

type InMem struct {
    sync.Mutex         // ❶
    m map[string]int
}

func New() *InMem {
    return &InMem{m: make(map[string]int)}
}

❶ 嵌入字段

我们决定不导出映射,这样客户端就不能直接与它交互,只能通过导出的方法。同时,互斥字段被嵌入。因此,我们可以这样实现一个Get方法:

func (i *InMem) Get(key string) (int, bool) {
    i.Lock()                     // ❶
    v, contains := i.m[key]
    i.Unlock()                   // ❷
    return v, contains
}

❶ 直接访问Lock方法

Unlock方法也是如此。

因为互斥体是嵌入的,所以我们可以从i接收器直接访问LockUnlock方法。

我们提到过这样的例子是类型嵌入的错误用法。这是什么原因呢?由于sync.Mutex是一个嵌入式类型,所以LockUnlock方法将被提升。因此,这两种方法对于使用InMem的外部客户端都是可见的:

m := inmem.New()
m.Lock() // ??

这种提升可能是不可取的。在大多数情况下,互斥体是我们希望封装在一个结构中并对外部客户端不可见的东西。因此,在这种情况下,我们不应该将其作为嵌入字段:

type InMem struct {
    mu sync.Mutex      // ❶
    m map[string]int
}

❶ 指定sync.Mutex不是嵌入的

因为互斥体没有嵌入也没有导出,所以它不能从外部客户端访问。现在让我们看另一个例子,但是这次嵌入被认为是一种正确的方法。

我们想要编写一个定制的日志记录器,它包含一个io.WriteCloser并公开两个方法WriteClose。如果io.WriteCloser没有嵌入,我们需要这样写:

type Logger struct {
    writeCloser io.WriteCloser
}

func (l Logger) Write(p []byte) (int, error) {
    return l.writeCloser.Write(p)     // ❶
}

func (l Logger) Close() error {
    return l.writeCloser.Close()      // ❶
}
func main() {
    l := Logger{writeCloser: os.Stdout}
    _, _ = l.Write([]byte("foo"))
    _ = l.Close()
}

❶ 将调用转发给writeCloser

Logger必须为提供一个Write和一个Close方法,该方法只能将调用转发给io.WriteCloser。但是,如果该字段现在变成嵌入的,我们可以删除这些转发方法:

type Logger struct {
    io.WriteCloser       // ❶
}

func main() {
    l := Logger{WriteCloser: os.Stdout}
    _, _ = l.Write([]byte("foo"))
    _ = l.Close()
}

❶ 指定io.WriteCloser是嵌入的

对于具有两个导出的WriteClose方法的客户端来说是一样的。但是该示例阻止实现这些附加方法来简单地转移调用。同样,随着WriteClose被提升,意味着Logger满足的io.WriteCloser接口。

嵌入与 OOP 子类化

区分嵌入和 OOP 子类有时会令人困惑。主要的区别与方法接收者的身份有关。我们来看下图。左手边代表嵌入在Y中的类型X,而右手边的Y延伸出X

对于嵌入,嵌入类型仍然是方法的接收者。相反,有了子类化,子类就变成了方法的接收者。

通过嵌入,Foo的接收者仍然是X。然而,通过子类化,Foo的接收者变成了子类,Y。嵌入是构图,不是继承。

关于类型嵌入我们应该得出什么结论?首先,让我们注意到这很少是必要的,这意味着无论什么用例,我们都可以不用类型嵌入来解决它。类型嵌入主要是为了方便:在大多数情况下,是为了促进行为。

如果我们决定使用类型嵌入,我们需要记住两个主要约束:

  • 它不应该仅仅作为某种语法糖来简化对字段的访问(比如用Foo.Baz()代替Foo.Bar.Baz())。如果这是唯一的理由,让我们不要嵌入内部类型,而是使用字段。

  • 它不应该促进我们想要对外部隐藏的数据(字段)或行为(方法):例如,如果它允许客户端访问一个锁定行为,该行为应该对该结构保持私有。

注意,有些人可能会认为,在导出结构的上下文中,使用类型嵌入会导致额外的维护工作。事实上,在导出的结构中嵌入一个类型意味着当这个类型发展时要保持谨慎。例如,如果我们向内部类型添加一个新方法,我们应该确保它不会破坏后面的约束。因此,为了避免这种额外的工作,团队还可以防止在公共结构中嵌入类型。

通过记住这些约束,有意识地使用类型嵌入有助于避免带有额外转发方法的样板代码。然而,让我们确保我们不仅仅是为了化妆品而这样做,也不宣传那些应该隐藏的元素。

在下一节中,我们将讨论处理可选配置的常见模式。

2.11 #11:不使用函数式选项模式

设计 API 时,可能会出现一个问题:我们如何处理可选配置?有效地解决这个问题可以提高我们的 API 的便利性。这一节将通过一个具体的例子来介绍处理可选配置的不同方法。

对于这个例子,假设我们必须设计一个库,它公开一个函数来创建一个 HTTP 服务器。这个函数接受不同的输入:一个地址和一个端口。下面显示了该函数的框架:

func NewServer(addr string, port int) (*http.Server, error) {
    // ...
}

我们库的客户端已经开始使用这个函数了,大家都很高兴。但是在某个时候,我们的客户开始抱怨这个函数有些受限,并且缺少其他参数(例如,写超时和连接上下文)。然而,我们注意到添加新的函数参数破坏了兼容性,迫使客户端修改它们调用NewServer的方式。同时,我们希望以这种方式丰富与端口管理相关的逻辑(图 2.9):

  • 如果未设置端口,则使用默认端口。

  • 如果端口为负,则返回错误。

  • 如果端口等于 0,则使用随机端口。

  • 否则,使用客户端提供的端口。

图 2.9 与端口选项相关的逻辑

我们如何以一种 API 友好的方式实现这个功能?让我们看看不同的选项。

2.11.1 配置结构

因为 Go 不支持函数签名中的可选参数,第一种可能的方法是使用配置结构来传达什么是强制的,什么是可选的。例如,强制参数可以作为函数参数存在,而可选参数可以在Config结构中处理:

type Config struct {
    Port        int
}

func NewServer(addr string, cfg Config) {
}

此解决方案解决了兼容性问题。事实上,如果我们添加新的选项,它不会在客户端中断。然而,这种方法不能解决我们与端口管理相关的需求。事实上,我们应该记住,如果没有提供结构字段,它将被初始化为零值:

  • 整数为 0

  • 浮点型为 0.0

  • 字符串为""

  • 对于切片、映射、通道、指针、接口和函数,为nil

因此,在下面的示例中,两个结构是相等的:

c1 := httplib.Config{
    Port: 0,              // ❶
}
c2 := httplib.Config{
                          // ❷
}

❶ 将端口初始化为 0

❷ 端口丢失,所以它被初始化为 0。

在我们的例子中,我们需要找到一种方法来区分故意设置为 0 的端口和丢失的端口。也许一种选择是以这种方式将配置结构的所有参数作为指针来处理:

type Config struct {
    Port        *int
}

使用整数指针,在语义上,我们可以突出显示值0和缺失值(零指针)之间的差异。

这种选择是可行的,但也有一些缺点。首先,客户端提供一个整数指针并不方便。客户端必须创建一个变量,然后以这种方式传递指针:

port := 0
config := httplib.Config{
    Port: &port,             // ❶
}

❶ 提供一个整数指针

它本身并不引人注目,但是整体的 API 使用起来有点不方便。同样,我们添加的选项越多,代码就变得越复杂。

第二个缺点是,使用默认配置的库的客户端需要以这种方式传递一个空结构:

httplib.NewServer("localhost", httplib.Config{})

这段代码看起来不怎么样。读者必须理解这个神奇的结构是什么意思。

另一种选择是使用经典的构建器模式,这将在下一节中介绍。

2.11.2 构建器模式

builder 模式最初是四人组设计模式的一部分,它为各种对象创建问题提供了灵活的解决方案。Config的构造与结构本身是分离的。它需要一个额外的结构ConfigBuilder,该结构接收配置和构建Config的方法。

让我们看一个具体的例子,看看它如何帮助我们设计一个友好的 API 来满足我们的所有需求,包括端口管理:

type Config struct {                                 // ❶
    Port int
}

type ConfigBuilder struct {                          // ❷
    port *int
}

func (b *ConfigBuilder) Port(
    port int) *ConfigBuilder {                       // ❸
    b.port = &port
    return b
}

func (b *ConfigBuilder) Build() (Config, error) {    // ❹
    cfg := Config{}

    if b.port == nil {                               // ❺
        cfg.Port = defaultHTTPPort
    } else {
        if *b.port == 0 {
            cfg.Port = randomPort()
        } else if *b.port < 0 {
            return Config{}, errors.New("port should be positive")
        } else {
            cfg.Port = *b.port
        }
    }

    return cfg, nil
}

func NewServer(addr string, config Config) (*http.Server, error) {
    // ...
}

❶ 配置结构

❷ 配置生成器结构,包含可选端口

❸ 公共端口的设置方法

创建配置结构的❹构建方法

❺ 与港口管理相关的主要逻辑

ConfigBuilder结构保存客户端配置。它公开了一个设置端口的Port方法。通常,这样的配置方法会返回构建器本身,以便我们可以使用方法链接(例如,builder.Foo("foo").Bar("bar"))。它还公开了一个Build方法,该方法保存初始化端口值的逻辑(指针是否为nil等等)。)并在创建后返回一个Config结构。

请注意,构建器模式没有单一的可能实现。例如,有些人可能喜欢定义最终端口值的逻辑在Port方法中而不是在Build中的方法。本节的范围是呈现构建器模式的概述,而不是查看所有不同的可能变体。

然后,一个客户会以下面的方式使用我们的基于构建器的 API(我们假设我们已经把代码放在了一个httplib包中):

builder := httplib.ConfigBuilder{}                   // ❶
builder.Port(8080)                                   // ❷
cfg, err := builder.Build()                          // ❸
if err != nil {
    return err
}

server, err := httplib.NewServer("localhost", cfg)   // ❹
if err != nil {
    return err
}

❶ 创建一个生成器配置

❷ 设置端口

❸ 构建配置结构

❹ 传递配置结构

首先,客户端创建一个ConfigBuilder并使用它来设置一个可选字段,比如端口。然后,它调用Build方法并检查错误。如果正常,配置被传递到NewServer

这种方法使得端口管理更加方便。不需要传递整数指针,因为Port方法接受整数。但是,如果客户端想要使用默认配置,我们仍然需要传递一个可以为空的配置结构:

server, err := httplib.NewServer("localhost", nil)

在某些情况下,另一个缺点与错误管理有关。在抛出异常的编程语言中,如果输入无效,像Port这样的构建器方法可以引发异常。如果我们想保持链接调用的能力,函数就不能返回错误。因此,我们不得不延迟在Build方法中的验证。如果一个客户端可以传递多个选项,但是我们想要精确地处理端口无效的情况,这使得错误处理变得更加复杂。

现在让我们看看另一种方法,叫做函数选项模式,它依赖于变量参数。

2.11.3 函数式选项模式

我们将讨论的最后一种方法是函数式选项模式(图 2.10)。虽然有不同的实现,但有细微的变化,主要思想如下:

  • 未导出的结构保存配置:options

  • 每个选项都是返回相同类型的函数:type Option func(options *options) error。例如,WithPort接受一个代表端口的int参数,并返回一个代表如何更新options结构的Option类型。

图 2.10WithPort选项更新最终的options结构。

下面是options结构、Option类型和WithPort选项的 Go 实现:

type options struct {                          // ❶
    port *int
}

type Option func(options *options) error       // ❷

func WithPort(port int) Option {               // ❸
    return func(options *options) error {
        if port < 0 {
            return errors.New("port should be positive")
        }
        options.port = &port
        return nil
    }
}

❶ 配置结构

❷ 表示更新配置结构的函数类型

❸ 更新端口的配置函数

这里,WithPort返回一个闭包。一个闭包是一个匿名函数,从它的正文外部引用变量;在这种情况下,port变量。闭包遵循Option类型并实现端口验证逻辑。每个配置字段都需要创建一个公共函数(按照惯例,以前缀With开始),包含类似的逻辑:如果需要,验证输入并更新配置结构。

让我们看看提供者端的最后一部分:NewServer实现。我们将把选项作为变量参数传递。因此,我们必须迭代这些选项来改变options配置结构:

func NewServer(addr string, opts ...Option) (     // ❶
    *http.Server, error) {
    var options options                           // ❷
    for _, opt := range opts {                    // ❸
        err := opt(&options)                      // ❹
        if err != nil {
            return nil, err
        }
    }

    // At this stage, the options struct is built and contains the config
    // Therefore, we can implement our logic related to port configuration
    var port int
    if options.port == nil {
        port = defaultHTTPPort
    } else {
        if *options.port == 0 {
            port = randomPort()
        } else {
            port = *options.port
        }
    }

    // ...
}

❶ 接受可变选项参数

❷ 创建了一个空的选项结构

❸ 迭代所有的输入选项

❹ 调用每个选项,这导致修改公共选项结构

我们首先创建一个空的options结构。然后,我们迭代每个Option参数并执行它们来改变options结构(记住Option类型是一个函数)。一旦构建了options结构,我们就可以实现关于端口管理的最终逻辑。

因为NewServer接受可变的Option参数,客户端现在可以通过在强制地址参数后传递多个选项来调用这个 API。举个例子,

server, err := httplib.NewServer("localhost",
        httplib.WithPort(8080),
        httplib.WithTimeout(time.Second))

但是,如果客户机需要默认配置,它不必提供参数(例如,一个空结构,正如我们在前面的方法中看到的)。客户端的调用现在可能看起来像这样:

server, err := httplib.NewServer("localhost")

这种模式就是函数式选项模式。它提供了一种方便且 API 友好的方式来处理选项。尽管构建者模式可能是一个有效的选项,但是它有一些小的缺点,这使得函数可选项模式成为 Go 中处理这个问题的惯用方法。我们还要注意,这种模式在 gRPC 等不同的 Go 库中使用。

下一节将讨论另一个常见的错误:组织不当。

2.12 #12:项目组织不当

组织一个GO项目并不是一件容易的事情。因为 Go 语言在设计包和模块方面提供了很大的自由度,所以最佳实践并没有像它们应该的那样普遍存在。本节首先讨论构建项目的一种常见方法,然后讨论一些最佳实践,展示改进我们如何组织项目的方法。

2.12.1 项目结构

Go 语言维护者对于在 Go 中构建项目没有很强的约定。然而,这些年来出现了一种布局:项目布局(github.com/golang-standards/project-layout)。

如果我们的项目足够小(只有几个文件),或者如果我们的组织已经创建了它的标准,它可能不值得使用或者迁移到project-layout。否则,可能值得考虑。让我们看一下这个布局,看看主要目录是什么:

  • /cmd——主源文件。foo应用的main.go应该位于/cmd/foo/main.go中。

  • /internal——我们不希望其他人为他们的应用或库导入的私有代码。

  • /pkg——我们要公开给别人的公共代码。

  • /test——附加外部测试和测试数据。中的单元测试与源文件放在同一个包中。但是,公共 API 测试或集成测试应该位于/test中。

  • /configs——配置文件。

  • /docs——设计和用户文档。

  • /examples——我们的应用和/或公共库的示例。

  • /api——API 合同文件(Swagger、协议缓冲区等)。

  • /web——特定于 Web 应用的资产(静态文件等)。

  • /build——打包和持续集成(CI)文件。

  • /script——用于分析、安装等的脚本。

  • /vendor——应用依赖关系(例如,Go 模块依赖关系)。

不像其他语言那样有/src目录。理由是/src太通用了;因此,这种布局倾向于使用/cmd/internal/pkg这样的目录。

注 2021 年,GO核心维护者之一 Russ Cox 批评了这种布局。尽管不是官方标准,但一个项目主要隶属于 GitHub golang 标准组织。无论如何,我们必须记住,关于项目结构,没有强制性的约定。这种布局可能对你有帮助,也可能没有,但这里重要的是,优柔寡断是唯一错误的决定。因此,在布局上达成一致,以保持组织中的一致性,这样开发人员就不会浪费时间从一个存储库切换到另一个存储库。

现在,让我们讨论如何组织 Go 存储库的主要逻辑。

2,12,2 包组织

在 Go 中,没有子包的概念。然而,我们可以决定在子目录中组织包。如果我们看一下标准库,net目录是这样组织的:

/net
    /http
        client.go
        ...
    /smtp
        auth.go
        ...
    addrselect.go
    ...

net既作为一个包,又作为包含其他包的目录。但是net/http并不从net继承,也没有对net包的特定访问权限。net/http内的元素只能看到导出的net元素。子目录的主要好处是保持包在一个地方,在那里它们有很高的内聚性。

关于整体组织,有不同的学派。例如,我们应该按上下文还是按层来组织我们的应用?这取决于我们的喜好。我们可能倾向于按上下文(如客户上下文、合同上下文等)对代码进行分组。),或者我们可能倾向于遵循六边形架构原则并按技术层分组。如果我们做出的决策符合我们的用例,只要我们保持一致,它就不会是一个错误的决策。

关于包,有许多我们应该遵循的最佳实践。首先,我们应该避免过早打包,因为这可能会导致项目过于复杂。有时,最好使用简单的组织,当我们理解了项目包含的内容时,让我们的项目发展,而不是强迫我们自己预先构建完美的结构。

粒度是另一个需要考虑的基本问题。我们应该避免几十个只包含一两个文件的 nano 包。如果我们这样做了,那是因为我们可能错过了这些包之间的一些逻辑联系,使得读者更难理解我们的项目。反过来,我们也应该避免淡化包装名称意义的巨大包装。

包命名也应该仔细考虑。众所周知(作为开发者),命名很难。为了帮助客户理解一个 Go 项目,我们应该根据它们提供的东西来命名我们的包,而不是它们包含的内容。还有,命名要有意义。因此,包名应该简短,有表现力,按照惯例,应该是一个小写的单词。

关于导出什么,规则非常简单。我们应该尽可能地减少应该导出的内容,以减少包之间的耦合,并隐藏不必要的导出元素。如果我们不确定是否要导出一个元素,我们应该默认不导出它。稍后,如果我们发现我们需要导出它,我们可以调整我们的代码。让我们记住一些例外,比如导出字段,以便可以用encoding/json解组一个结构。

组织一个项目并不简单,但是遵循这些规则应该有助于使它更容易维护。然而,记住一致性对于简化可维护性也是至关重要的。因此,让我们确保代码库中的东西尽可能保持一致。

在下一节中,我们将讨论实用工具包。

2.13 #13:创建实用工具包

本节讨论一个常见的不好的实践:创建共享的包,比如utilscommonbase。我们将用这种方法来检查问题,并学习如何改进我们的组织。

让我们看一个受 Go 官方博客启发的例子。它是关于实现一个集合数据结构(一个值被忽略的映射)。在 Go 中惯用的方法是通过一个带有Kmap[K]struct{}类型来处理它,它可以是映射中允许的任何类型作为键,而值是一个struct{}类型。事实上,值类型为struct{}的映射表明我们对值本身不感兴趣。让我们在一个util包中公开两个方法:

package util

func NewStringSet(...string) map[string]struct{} {    // ❶
    // ...
}

func SortStringSet(map[string]struct{}) []string {    // ❷
    // ...
}

❶ 创建了一个字符串集合

❷ 返回一个排序的键列表

客户端将像这样使用这个包:

set := util.NewStringSet("c", "a", "b")
fmt.Println(util.SortStringSet(set))

这里的问题是util没有意义。我们可以称它为commonsharedbase,但是它仍然是一个没有意义的名字,不能提供任何关于这个包提供了什么的信息。

我们应该创建一个表达性的包名,比如stringset,而不是一个实用工具包。举个例子,

package stringset

func New(...string) map[string]struct{} { ... }
func Sort(map[string]struct{}) []string { ... }

在本例中,我们删除了NewStringSetSortStringSet的后缀,它们分别变成了NewSort。在客户端,现在看起来是这样的:

set := stringset.New("c", "a", "b")
fmt.Println(stringset.Sort(set))

注:在上一节中,我们讨论了纳米封装的概念。我们提到了在一个应用中创建几十个 nano 包会使代码路径变得更加复杂。然而,纳米包装的想法本身并不一定是坏的。如果一个小的代码组具有很高的内聚性,并且不属于其他地方,那么将它组织到一个特定的包中是完全可以接受的。没有严格的规则可以适用,通常,挑战在于找到正确的平衡。

我们甚至可以更进一步。我们可以创建一个特定的类型并将Sort作为方法公开,而不是公开实用函数,如下所示:

package stringset

type Set map[string]struct{}
func New(...string) Set { ... }
func (s Set) Sort() []string { ... }

这一变化使得客户端更加简单。只有一个对stringset包的引用:

set := stringset.New("c", "a", "b")
fmt.Println(set.Sort())

通过这个小小的重构,我们去掉了一个无意义的包名,公开了一个有表现力的 API。正如 Dave Cheney(Go 的项目成员)提到的,我们经常合理地找到处理公共设施的实用工具包。例如,如果我们决定有一个客户机和一个服务器包,那么我们应该把公共类型放在哪里呢?在这种情况下,也许一个解决方案是将客户机、服务器和公共代码组合成一个包。

命名包是应用设计的一个关键部分,我们也应该对此保持谨慎。根据经验,创建没有有意义的名字的共享包不是一个好主意;这包括实用工具包,如utilscommonbase。此外,请记住,以包提供的内容而不是包包含的内容来命名包是增加其表达性的有效方法。

在下一节中,我们将讨论包和包冲突。

2.14 #14:忽略包名冲突

当一个变量名与一个已存在的包名冲突时,包冲突就会发生,阻止包被重用。让我们看一个具体的例子,一个库公开了一个 Redis 客户机:

package redis

type Client struct { ... }

func NewClient() *Client { ... }

func (c *Client) Get(key string) (string, error) { ... }

现在,让我们跳到客户端。尽管包名为redis,但在 Go 中创建一个名为redis的变量是完全有效的:

redis := redis.NewClient()     // ❶
v, err := redis.Get("foo")     // ❷

❶ 从redis包中调用NewClient

❷ 使用redis变量

这里,redis变量名与redis包名冲突。即使这是允许的,也应该避免。事实上,在redis变量的整个范围内,redis包将不会被访问。

假设一个限定符在整个函数中同时引用了变量和包名。在这种情况下,对于代码读者来说,知道限定符指的是什么可能是不明确的。有什么选择可以避免这样的碰撞?第一种选择是使用不同的变量名。举个例子,

redisClient := redis.NewClient()
v, err := redisClient.Get("foo")

这可能是最直接的方法。然而,如果出于某种原因,我们希望保留名为redis的变量,我们可以使用包导入。使用包导入,我们可以使用别名来改变限定符来引用redis包。举个例子,

import redisapi "mylib/redis"    // ❶

// ...

redis := redisapi.NewClient()    // ❷
v, err := redis.Get("foo")

❶ 为redis包创建了一个别名

❷ 通过redisapi别名访问redis

这里,我们使用了redisapi导入别名来引用redis包,这样就可以保留我们的变量名redis

注一个选择也可以是使用点导入来访问一个包的所有公共元素,而不用包限定符。但是,这种方法会增加混乱,在大多数情况下应该避免。

还要注意,我们应该避免变量和内置函数之间的命名冲突。例如,我们可以这样做:

copy := copyFile(src, dst)     // ❶

❶ 复制变量与复制内置函数冲突。

在这种情况下,只要copy变量存在,内置函数copy就不会被访问。总之,我们应该防止变量名冲突,以避免歧义。如果我们面临冲突,我们应该找到另一个有意义的名称或使用导入别名。

在下一节中,我们将看到一个与代码文档相关的常见错误。

2.15 #15:缺少代码文档

文档是编码的一个重要方面。它简化了客户使用 API 的方式,但也有助于维护项目。在 Go 中,我们应该遵循一些规则来使我们的代码符合习惯。让我们检查一下这些规则。

首先,必须记录每个导出的元素。不管是结构、接口、函数,还是别的什么,如果导出来了,就必须有文档记录。惯例是添加注释,从导出元素的名称开始。举个例子,

// Customer is a customer representation.
type Customer struct{}

// ID returns the customer identifier.
func (c Customer) ID() string { return "" }

按照惯例,每个注释都应该是一个完整的句子,以标点符号结尾。还要记住,当我们记录一个函数(或者一个方法)时,我们应该强调函数打算做什么,而不是它是如何做的;这属于函数和注释的核心,而不是文档。此外,理想情况下,文档应该提供足够的信息,使用户不必查看我们的代码就能理解如何使用导出的元素。

不推荐使用的元素

可以这样使用// Deprecated:注释来废弃导出的元素:

// ComputePath returns the fastest path between two points.
// Deprecated: This function uses a deprecated way to compute
// the fastest path. Use ComputeFastestPath instead.
func ComputePath() {}

然后,如果开发人员使用了ComputePath函数,他们应该会得到一个警告。(大多数 ide 处理不赞成使用的注释。)

当涉及到记录变量或常数时,我们可能对传达两个方面感兴趣:它的目的和它的内容。前者应该作为代码文档存在,以便对外部客户有用。不过,后者不一定是公开的。举个例子,

// DefaultPermission is the default permission used by the store engine.
const DefaultPermission = 0o644 // Need read and write accesses.

此常数表示默认权限。代码文档传达了它的目的,而常量旁边的注释描述了它的实际内容(读写访问)。

为了帮助客户和维护者理解一个包的范围,我们也应该记录每个包。惯例是以// Package开始注释,后跟包名:

// Package math provides basic constants and mathematical functions.
//
// This package does not guarantee bit-identical results
// across architectures.
package math

包注释的第一行应该简洁。那是因为它会出现在包里(图 2.11 提供了一个例子)。然后,我们可以在下面几行中提供我们需要的所有信息。

图 2.11 生成的 Go 标准库示例

可以在任何 Go 文件中记录一个包;没有规则。一般来说,我们应该将包文档放在与包同名的相关文件中,或者放在特定的文件中,比如doc.go

关于包文档最后要提到的一点是,与声明不相邻的注释被省略了。例如,以下版权注释在生成的文档中不可见:

// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package math provides basic constants and mathematical functions.
//                                                                        // ❶
// This package does not guarantee bit-identical results
// across architectures.
package math

❶ 空行。之前的注释将不包括在文档中。

总之,我们应该记住,每个导出的元素都需要被记录。记录我们的代码不应该成为一种约束。我们应该抓住机会,确保它有助于客户和维护人员理解我们代码的目的。

最后,在本章的最后一节,我们将看到一个关于工具的常见错误:不使用linter。

2.16 #16:不使用linter

一个 linter 是一个自动分析代码和捕捉错误的工具。本节的范围不是给出现有linter的详尽列表;否则,它很快就会被弃用。但是我们应该理解并记住为什么linter对于大多数GO项目是必不可少的。

为了理解为什么linter很重要,让我们举一个具体的例子。在错误#1,“意外的变量阴影”,我们讨论了与变量阴影相关的潜在错误。使用vet(Go 工具集中的一个标准工具)和shadow,我们可以检测隐藏的变量:

package main

import "fmt"

func main() {
    i := 0
    if true {
        i := 1          // ❶
        fmt.Println(i)
    }
    fmt.Println(i)
}

❶ 阴影变量

因为vet包含在 Go 二进制文件中,所以让我们首先安装shadow,将其与 Go vet链接,然后在前面的例子中运行它:

$ go install \
  golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow     // ❶
$ go vet -vettool=$(which shadow)                             // ❷
./main.go:8:3:
  declaration of "i" shadows declaration at line 6            // ❸

❶ 安装阴影

❷ 使用vettol参数链接到 Go vet

❸ GO兽医检测影子变量。

正如我们所看到的,vet通知我们在这个例子中变量i被隐藏了。使用适当的 linters 可以帮助我们的代码更加健壮,并检测潜在的错误。

注意短评没有涵盖本书中的所有错误。所以,建议你只是继续读下去;).

同样,本节的目标不是列出所有可用的linter。然而,如果你不是 linters 的经常用户,这里有一个你可能想每天使用的列表:

除了 linters,我们还应该使用代码格式化程序来修复代码风格。这里有一些代码格式化程序供您尝试:

同时,我们还应该看看golangci-lintgithub.com/golangci/golangci-lint)。这是一个林挺工具,在许多有用的 linters 和排版工具之上提供了一个门面。此外,它允许并行运行 linters 以提高分析速度,这非常方便。

Linters 和排版工具是提高我们代码库的质量和一致性的一个强大的方法。让我们花点时间来理解我们应该使用哪一个,并确保我们自动执行它们(例如 CI 或 Git 预提交钩子)。

总结

  • 避免隐藏变量有助于防止出现错误,比如引用错误的变量或迷惑读者。

  • 避免嵌套层次并保持快乐路径在左侧对齐使得构建心理代码模型更容易。

  • 初始化变量时,记住init函数的错误处理有限,会使状态处理和测试更加复杂。在大多数情况下,初始化应该作为特定的函数来处理。

  • 在 Go 中强制使用获取器和设置器并不符合习惯。务实一点,在效率和盲从某些习惯用法之间找到合适的平衡点,应该是应该走的路。

  • 抽象应该被发现,而不是被创建。为了避免不必要的复杂性,在你需要的时候创建一个接口,而不是在你预见到需要的时候,或者如果你至少能证明抽象是有效的,就创建一个接口。

  • 在客户端保留接口可以避免不必要的抽象。

  • 为了防止在灵活性方面受到限制,在大多数情况下,函数不应该返回接口,而应该返回具体的实现。相反,函数应该尽可能接受接口。

  • 只在需要接受或返回任何可能的类型时才使用any,比如json. Marshal。否则,any不会提供有意义的信息,并且会导致编译时问题,因为它允许调用者调用任何数据类型的方法。

  • 依赖泛型和类型参数可以防止编写样板代码来提取元素或行为。但是,不要过早地使用类型参数,只有当您看到对它们的具体需求时才使用。否则,它们会引入不必要的抽象和复杂性。

  • 使用类型嵌入还有助于避免样板代码;但是,要确保这样做不会导致一些本应隐藏的字段出现可见性问题。

  • 为了以 API 友好的方式方便地处理选项,请使用函数选项模式。

  • 遵循诸如project-layout这样的布局是开始构建 Go 项目的好方法,尤其是如果你正在寻找现有的约定来标准化一个新项目。

  • 命名是应用设计的关键部分。创建出commonutilshared这样的包装,并不能给读者带来多少价值。将这样的包重构为有意义的、特定的包名。

  • 为了避免变量和包之间的命名冲突,导致混乱甚至错误,为每个变量使用唯一的名字。如果这不可行,可以使用导入别名来更改限定符,以区分包名和变量名,或者想一个更好的名称。

  • 为了帮助客户和维护者理解你的代码的目的,记录导出的元素。

  • 为了提高代码质量和一致性,使用 linters 和排版工具。

三、数据类型

本章涵盖

  • 与基本类型相关的常见错误
  • 切片和映射的基本概念,以防止可能的错误、泄漏或不准确
  • 比较数值

处理数据类型是软件工程师的一项经常性操作。本章深入探讨了与基本类型、切片和贴图相关的最常见错误。我们省略的唯一数据类型是字符串,因为后面的章节将专门讨论这种类型。

3.1 #17:用八进制字面值制造混乱

我们先来看一个对八进制字面值表示的常见误解,这种误解会导致混乱甚至 bug。您认为以下代码的输出应该是什么?

sum := 100 + 010
fmt.Println(sum)

乍一看,我们可能期望这段代码打印出100 + 10 = 110的结果。但是它打印的是 108。这怎么可能呢?

在 Go 中,以 0 开头的整数字面值被视为八进制整数(基数为 8),因此基数为 8 的 10 等于基数为 10 的 8。因此,上例中的总和等于100 + 8 = 108。这是需要记住的整数字面值的一个重要属性——例如,在读取现有代码时避免混淆。

八进制整数在不同的场景中都很有用。例如,假设我们想使用os.OpenFile打开一个文件。这个函数需要传递一个权限作为uint32。如果我们想匹配一个 Linux 权限,为了可读性,我们可以传递一个八进制数,而不是十进制数:

file, err := os.OpenFile("foo", os.O_RDONLY, 0644)

在这个例子中,0644代表一个特定的 Linux 权限(对所有人都是读的,对当前用户只写)。也可以在零后面添加一个o字符(小写字母):

file, err := os.OpenFile("foo", os.O_RDONLY, 0o644)

使用0o作为前缀,而不是仅仅使用0,意思是一样的。但是,它有助于使代码更加清晰。

注意我们也可以使用大写的O字符来代替小写的o。但是传递0O644可能会增加混乱,因为根据字符字体的不同,0可能看起来与O非常相似。

我们还应该注意其他整数字面值表示:

  • 二进制——使用0b0B前缀(例如,0b100在 10 进制中等于 4)

  • 十六进制——使用0x0X前缀(例如,0xF在十进制中等于 15)

  • 虚数——使用一个i后缀(例如3i)

最后,我们还可以使用下划线字符(_)作为分隔符,以提高可读性。比如我们可以这样写 10 亿:1_000_000_000。我们也可以在其他表示中使用下划线字符(例如,0b00_00_01)。

总之,Go 处理二进制、十六进制、虚数和八进制数。八进制数以 0 开始。然而,为了提高可读性并避免未来代码读者的潜在错误,使用前缀0o使八进制数显式化。

下一节深入探讨整数,我们将讨论如何在 Go 中处理溢出。

3.2 #18:忽略整数溢出

不了解 Go 中如何处理整数溢出会导致严重的错误。本节将深入探讨这个主题。但首先,让我们提醒自己一些与整数相关的概念。

3.2.1 概念

Go 一共提供了 10 种整数类型。有四种有符号整数类型和四种无符号整数类型,如下表所示。

有符号整数 无符号整数
int8 (8 位) uint8 (8 位)
int16 (16 位) uint16 (16 位)
int32 (32 位) uint32 (32 位)
int64 (64 位) uint64 (64 位)

另外两个整数类型是最常用的:和int。这两种类型的大小取决于系统:在 32 位系统上是 32 位,在 64 位系统上是 64 位。

现在让我们讨论溢出。假设我们想要初始化一个int32到它的最大值,然后递增它。这段代码的行为应该是什么?

var counter int32 = math.MaxInt32
counter++
fmt.Printf("counter=%d\n", counter)

这段代码可以编译,并且在运行时不会恐慌。然而,counter++语句会产生一个整数溢出:

counter=-2147483648

当算术运算创建的值超出了可以用给定字节数表示的范围时,就会发生整数溢出。使用 32 位来表示一个int32。下面是最大int32值(math.MaxInt32)的二进制表示:

01111111111111111111111111111111
 |------31 bits set to 1-------|

因为一个int32是一个有符号的整数,左边的位代表整数的符号:0 代表正数,1 代表负数。如果我们增加这个整数,就没有空间来表示新值了。因此,这会导致整数溢出。从二进制角度来看,新值如下:

10000000000000000000000000000000
 |------31 bits set to 0-------|

正如我们所看到的,位号现在等于 1,意味着负。该值是用 32 位表示的有符号整数的最小可能值。

注意最小可能的负值不是111111111111111111111111 11111111。事实上,大多数系统依靠二进制补码运算来表示二进制数(反转每一位并加 1)。这个操作的主要目标是使x+(–x)等于 0,而不管x

在 Go 中,可以在编译时检测到的整数溢出会产生编译错误。举个例子,

var counter int32 = math.MaxInt32 + 1
constant 2147483648 overflows int32

然而,在运行时,整数溢出或下溢是无声的;这不会导致应用恐慌。将这种行为牢记在心是很重要的,因为它会导致偷偷摸摸的错误(例如,导致负结果的整数递增或正整数相加)。

在深入研究如何用常见操作检测整数溢出之前,让我们考虑一下什么时候应该关注它。在大多数情况下,比如处理请求的计数器或者基本的加法/乘法,如果使用了正确的整数类型,我们不应该太担心。但是在某些情况下,比如使用较小整数类型的内存受限项目,处理较大的数字,或者进行转换,我们可能想要检查可能的溢出。

请注意,1996 年阿丽亚娜 5 号发射失败(www.bugsnag.com/blog/bug-day-ariane-5-disaster)是由于将 64 位浮点转换为 16 位有符号整数导致溢出。

3.2.2 递增时检测整数溢出

如果我们想在基于定义的大小(int8int16int32int64uint8uint16uint32uint64)的类型的递增操作期间检测整数溢出,我们可以对照math常量检查该值。例如,用一个int32:

func Inc32(counter int32) int32 {
    if counter == math.MaxInt32 {    // ❶
        panic("int32 overflow")
    }
    return counter + 1
}

❶ 与math.MaxInt32作比较。

该函数检查输入是否已经等于math.MaxInt32。我们知道增量是否会导致溢出,如果是这样的话。

intuint类型有哪些?在 Go 1.17 之前,我们必须手动构建这些常量。现在,math.MaxIntmath.MinIntmath.MaxUint是包math的一部分。如果我们必须在和int类型上测试溢出,我们可以使用math.MaxInt来完成:

func IncInt(counter int) int {
    if counter == math.MaxInt {
        panic("int overflow")
    }
    return counter + 1
}

对于uint来说,逻辑是相同的。我们可以使用math.MaxUint:

func IncUint(counter uint) uint {
    if counter == math.MaxUint {
        panic("uint overflow")
    }
    return counter + 1
}

在这一节中,我们学习了如何在增量运算后检查整数溢出。那么,加法呢?

3.2.3 加法期间检测整数溢出

如何检测加法运算中的整数溢出?答案是重用math.MaxInt:

func AddInt(a, b int) int {
    if a > math.MaxInt-b {       // ❶
        panic("int overflow")
    }

    return a + b
}

❶ 检查是否会发生整数溢出

在示例中,ab是两个操作数。如果a大于math.MaxInt - b,运算将导致整数溢出。现在,让我们看看乘法运算。

3.2.4 在乘法期间检测整数溢出

乘法处理起来有点复杂。我们必须根据最小整数math.MinInt进行检查:

func MultiplyInt(a, b int) int {
    if a == 0 || b == 0 {                       // ❶
        return 0
    }

    result := a * b
    if a == 1 || b == 1 {                       // ❷
        return result
    }
    if a == math.MinInt || b == math.MinInt {   // ❸
        panic("integer overflow")
    }
    if result/b != a {                          // ❹
        panic("integer overflow")
    }
    return result
}

❶ 如果其中一个操作数等于 0,它直接返回 0。

❷ 检查操作数之一是否等于 1

❸ 检查是否有一个操作数等于数学。米尼特

❹ 检查乘法运算是否会导致整数溢出

用乘法检查整数溢出需要多个步骤。首先,我们需要测试操作数之一是否等于01math.MinInt。然后我们将乘法结果除以b。如果结果不等于原始因子(a,则意味着发生了整数溢出。

总之,整数溢出(和下溢)是 Go 中的无声操作。如果我们想检查溢出以避免偷偷摸摸的错误,我们可以使用本节中描述的实用函数。还要记住 Go 提供了一个处理大数的包:math/big。如果一个int还不够,这可能是一个选择。

我们将在下一节继续讨论浮点的基本 Go 类型。

3.3 #19:不理解浮点

在GO中,有两种浮点类型(如果我们省略虚数的话):float32float64。发明浮点的概念是为了解决整数的主要问题:它们不能表示小数值。为了避免糟糕的意外,我们需要知道浮点运算是实数运算的近似。让我们来看看使用近似值的影响以及如何提高精确度。为此,我们来看一个乘法示例:

var n float32 = 1.0001
fmt.Println(n * n)

我们可能期望这段代码打印出1.0001 * 1.0001 = 1.00020001的结果,对吗?但是,在大多数 x86 处理器上运行它会打印出 1.0002。我们该如何解释?我们需要先了解浮点运算。

让我们以float64型为例。请注意,在math.SmallestNonzeroFloat64(最小值float64)和math.MaxFloat64(最大值float64)之间有无限多个实数值。相反,float64类型有有限的位数:64。因为让无限的值适合一个有限的空间是不可能的,我们必须使用近似值。因此,我们可能会失去精度。同样的逻辑也适用于和float32型。

Go 中的浮点遵循 IEEE-754 标准,一些位代表尾数,其他位代表指数。尾数是基值,而指数是应用于尾数的乘数。在单精度浮点类型(float32)中,8 位表示指数,23 位表示尾数。在双精度浮点类型(float64)中,指数和尾数的值分别是 11 位和 52 位。剩余的位用于符号。要将浮点转换为小数,我们使用以下计算方法:

sign * 2^exponent * mantissa

图 3.1 将 1.0001 表示为一个float32。指数使用 8 位超额/偏差符号:01111111 指数值表示2^0,而尾数等于 1.000100016593933。(注意,本节的范围不是解释转换是如何工作的。)因此,十进制值等于1 × 2^0 × 1.000100016593933。因此,我们在单精度浮点值中存储的不是 1.0001,而是 1.000100016593933。缺乏精度会影响存储值的准确性。

图 3.1float32中 1.0001 的表示

一旦我们理解了float32float64是近似值,这对我们作为开发者意味着什么呢?第一个含义与比较有关。使用==操作符来比较两个浮点数会导致不准确。相反,我们应该比较它们的差异,看它是否小于某个小错误值。例如,testify测试库(github.com/stretchr/testify)有一个InDelta函数来断言两个值在彼此给定的增量内。

还要记住,浮点计算的结果取决于实际的处理器。大多数处理器都有一个浮点单元(FPU)来处理这样的计算。不能保证在一台机器上执行的结果在另一台具有不同 FPU 的机器上是相同的。使用 delta 比较两个值是在不同机器上实现有效测试的一种解决方案。

浮点数的种类

Go 还有三种特殊的浮点数:

  • 正无穷大

  • 负无穷大

  • NaN(非数字),是未定义或不可表示的运算的结果

根据 IEEE-754,NaN 是唯一满足f != f的浮点数。下面是一个构建这些特殊类型的数字以及输出的示例:

var a float64
positiveInf := 1 / a
negativeInf := -1 / a
nan := a / a
fmt.Println(positiveInf, negativeInf, nan)
+Inf -Inf NaN

我们可以用math.IsInf检查一个浮点数是否无穷大,用math.IsNaN检查它是否为 NaN。

到目前为止,我们已经看到十进制到浮点的转换会导致精度的损失。这是转换造成的错误。还要注意,错误会在一系列浮点运算中累积。

让我们来看一个例子,其中有两个函数以不同的顺序执行相同的操作序列。在我们的例子中,f1通过将一个float64初始化为 10,000 开始,然后重复地将 1.0001 加到这个结果上(n次)。反之,f2执行相同的操作,但顺序相反(最后加 10,000):

func f1(n int) float64 {
    result := 10_000.
    for i := 0; i < n; i++ {
        result += 1.0001
    }
    return result
}

func f2(n int) float64 {
    result := 0.
    for i := 0; i < n; i++ {
        result += 1.0001
    }
    return result + 10_000.
}

现在,让我们在 x86 处理器上运行这些函数。然而这一次,我们将改变n

n 确切的结果 f1 f2
10 10010.001 10010.000999999993 10010.001
1k 11000.1 11000.099999999293 11000.099999999982
1m 1.0101e+06 1.0100999999761417e+06 1.010099999766762 e+06

注意n越大,不精确性越大。不过我们也可以看到f2的精度比f1好。请记住,浮点计算的顺序会影响结果的准确性。

当执行一连串的加法和减法时,我们应该将运算分组,以便在加或减幅度不接近的值之前加或减幅度相似的值。因为f2加了 10000,最后产生的结果比f1更准确。

乘法和除法呢?假设我们想要计算以下内容:

a × (b + c)

我们知道,这个计算等于

a × b + a × c

让我们用与bc不同数量级的a来运行这两个计算:

a := 100000.001
b := 1.0001
c := 1.0002

fmt.Println(a * (b + c))
fmt.Println(a*b + a*c)
200030.00200030004
200030.0020003

精确的结果是 200,030.002。因此,第一种计算的准确性最差。事实上,当执行涉及加、减、乘或除的浮点计算时,我们必须首先完成乘法和除法运算才能获得更好的精度。有时,这可能会影响执行时间(在前面的示例中,它需要三个操作,而不是两个)。在这种情况下,这是准确性和执行时间之间的选择。

Go 的float32float64是近似值。因此,我们必须牢记一些规则:

  • 比较两个浮点数时,检查它们的差值是否在可接受的范围内。

  • 在执行加法或减法时,将具有相似数量级的运算分组,以获得更高的精度。

  • 为了提高准确性,如果一系列运算需要加、减、乘或除,请先执行乘法和除法运算。

下一节开始我们对切片的研究。它讨论了两个至关重要的概念:切片的长度和容量。

3.4 #20:不了解切片长度和容量

Go 开发者混淆切片长度和容量或者没有彻底理解它们是很常见的。吸收这两个概念对于有效处理核心操作是必不可少的,比如切片初始化和用append添加元素、复制或切片。这种误解可能导致次优地使用切片,甚至导致内存泄漏(我们将在后面的章节中看到)。

在 Go 中,一个切片由一个数组支持。这意味着切片的数据连续存储在一个数组数据结构中。切片还处理在后备数组已满时添加元素或在后备数组几乎为空时收缩后备数组的逻辑。

在内部,一个片包含一个指向后备数组的指针,加上一个长度和一个容量。长度是切片包含的元素数量,而容量是支持数组中的元素数量。让我们来看几个例子,让事情更清楚。首先,让我们用给定的长度和容量初始化一个切片:

s := make([]int, 3, 6)     // ❶

❶ 长度为三,容量为六的切片

第一个参数代表长度,是必需的。但是,代表容量的第二个参数是可选的。图 3.2 显示了这段代码在内存中的结果。

图 3.2 一个三长度、六容量的切片

在本例中,make创建了一个包含六个元素(容量)的数组。但是因为长度被设置为3,Go 只初始化前三个元素。此外,因为切片是一个[]int类型的,前三个元素被初始化为一个int : 0的零值。灰色元素已分配但尚未使用。

如果我们打印这个切片,我们得到长度范围内的元素,[0 0 0]。如果我们将s[1]设置为1,切片的第二个元素会更新,而不会影响其长度或容量。图 3.3 说明了这一点。

图 3.3 更新切片的第二个元素:s[1] = 1

然而,访问长度范围之外的元素是被禁止的,即使它已经在内存中被分配了。例如,s[4] = 0会导致以下恐慌:

panic: runtime error: index out of range [4] with length 3

如何利用切片剩余的空间?通过使用的append内置函数:

s = append(s, 2)

这段代码向现有的s切片追加一个新元素。它使用第一个灰显的元素(已分配但尚未使用)来存储元素2,如图 3.4 所示。

图 3.4 将元素添加到s

切片的长度从 3 更新为 4,因为切片现在包含四个元素。现在,如果我们再添加三个元素,使得支持数组不够大,会发生什么呢?

s = append(s, 3, 4, 5)
fmt.Println(s)

如果我们运行这段代码,我们会看到切片能够处理我们的请求:

[0 1 0 2 3 4 5]

因为数组是固定大小的结构,所以它可以存储新元素,直到元素 4。当我们想要插入元素 5 时,数组已经满了:Go 内部通过将容量加倍,复制所有元素,然后插入元素 5 来创建另一个数组。图 3.5 显示了这个过程。

图 3.5 由于初始后备数组已满,Go 创建另一个数组并复制所有元素。

注意在 Go 中,一个切片的大小增加一倍,直到它包含 1,024 个元素,之后增长 25%。

切片现在引用新的支持数组。之前的后备数组会怎么样?如果不再被引用,如果被分配到堆上,它最终会被垃圾收集器(GC)释放。(我们在错误#95“不理解栈和堆”中讨论堆内存,我们在错误#99“不理解 GC 如何工作”中查看 GC 如何工作)

切片会发生什么?切片是在数组或切片上做的操作,提供半开范围;包括第一个索引,而排除第二个索引。以下示例显示了影响,图 3.6 显示了内存中的结果:

s1 := make([]int, 3, 6)    // ❶
s2 := s1[1:3]              // ❷

❶ 长度为三,容量为六的切片

❷ 从索引 1 到 3 的切片

图 3.6 切片s1s2引用了具有不同长度和容量的相同支持数组。

首先,s1被创建为三长度、六容量的切片。当通过切片s1创建s2时,两个切片引用同一个后备数组。然而,s2从不同的索引 1 开始。所以它的长度和容量(一个两长度,五容量切片)和s1不一样。如果我们更新s1[1]s2[0],变化是对同一个数组进行的,因此,在两个切片中都可见,如图 3.7 所示。

图 3.7 因为s1s2是由同一个数组支持的,更新一个公共元素会使变化在两个切片中都可见。

现在,如果我们向s2追加一个元素会发生什么?下面的代码也改变了s1吗?

s2 = append(s2, 2)

共享后备数组被修改,但只有s2的长度改变。图 3.8 显示了向s2追加一个元素的结果。

图 3.8 将元素添加到s2

s1仍然是三长度、六容量的切片。因此,如果我们打印s1s2,添加的元素仅对s2可见:

s1=[0 1 0], s2=[1 0 2]

理解这种行为很重要,这样我们在使用append时就不会做出错误的假设。

注意在这些例子中,支持数组是内部的,Go 开发者不能直接使用。唯一的例外是通过对现有数组切片来创建切片。

最后要注意的一点是:如果我们一直将元素追加到s2直到后备数组满了会怎么样?就内存而言,状态会是什么?让我们再添加三个元素,这样后备数组将没有足够的容量:

s2 = append(s2, 3)
s2 = append(s2, 4)
s2 = append(s2, 5)     // ❶

❶ 在这个阶段,后备数组已经满了。

这段代码导致创建另一个后备数组。图 3.9 显示了内存中的结果。

图 3.9 将元素追加到s2直到后备数组已满

s1s2现在引用了两个不同的数组。由于s1仍然是一个三长度、六容量的片,它仍然有一些可用的缓冲区,所以它继续引用初始数组。此外,新的支持数组是通过从s2的第一个索引复制初始数组制成的。这就是为什么新数组从元素1开始,而不是0

总而言之,切片长度是切片中可用元素的数量,而切片容量是后备数组中元素的数量。将一个元素添加到一个完整的片(length == capacity)会导致创建一个具有新容量的新后备数组,从以前的数组中复制所有元素,并将片指针更新到新数组。

在下一节中,我们将长度和容量的概念用于片初始化。

3.5 #21:低效的切片初始化

在使用make初始化一个片时,我们看到我们必须提供一个长度和一个可选容量。忘记为这两个参数传递合适的值是一个普遍的错误。让我们精确地看看什么时候这被认为是合适的。

假设我们想要实现一个convert函数,将一个Foo的片映射到一个Bar的片,两个片将具有相同数量的元素。这是第一个实现:

func convert(foos []Foo) []Bar {
    bars := make([]Bar, 0)                   // ❶

    for _, foo := range foos {
        bars = append(bars, fooToBar(foo))   // ❷
    }
    return bars
}

❶ 创建结果切片

❷ 将一个Foo转换成一个Bar,并将其添加到切片中

首先,我们使用make([]Bar, 0)初始化一个空的Bar元素片段。然后,我们使用append来添加Bar元素。起初,bars是空的,所以添加第一个元素会分配一个大小为 1 的后备数组。每当后备数组满了,Go 就通过加倍其容量来创建另一个数组(在上一节中讨论过)。

当我们添加第三个元素、第五个元素、第九个元素等等时,这种因为当前数组已满而创建另一个数组的逻辑会重复多次。假设输入切片有 1,000 个元素,该算法需要分配 10 个后备数组,并将总共 1,000 多个元素从一个数组复制到另一个数组。这导致了 GC 清理所有这些临时后备数组的额外工作。

就性能而言,没有什么好的理由不帮助 Go 运行时。对此有两种不同的选择。第一种选择是重用相同的代码,但分配给定容量的片:

func convert(foos []Foo) []Bar {
    n := len(foos)
    bars := make([]Bar, 0, n)                // ❶

    for _, foo := range foos {
        bars = append(bars, fooToBar(foo))   // ❷
    }
    return bars
}

❶ 用零长度和给定的容量初始化

❷ 追加一个新元素并更新bar

唯一的变化是创建容量等于n、长度为foosbars

在内部,Go 预分配了一个由n个元素组成的数组。因此,增加 n 个元素意味着重用相同的后备数组,从而大大减少分配的数量。第二种选择是分配给定长度的bars:

func convert(foos []Foo) []Bar {
    n := len(foos)
    bars := make([]Bar, n)         // ❶

    for i, foo := range foos {
        bars[i] = fooToBar(foo)    // ❷
    }
    return bars
}

❶ 用给定的长度初始化

❷ 设置切片的元素i

因为我们用长度初始化切片,所以已经分配了n个元素并将其初始化为零值Bar。因此,要设置元素,我们必须使用bars[i]而不是append

哪个选项最好?让我们用这三个解决方案和 100 万个元素的输入片段运行一个基准测试:

BenchmarkConvert_EmptySlice-4        22     49739882 ns/op     // ❶
BenchmarkConvert_GivenCapacity-4     86     13438544 ns/op     // ❷
BenchmarkConvert_GivenLength-4       91     12800411 ns/op     // ❸

❶ 第一个解决方案是空切片

❷ 第二个解决方案使用给定容量并追加

❸ 第三个解决方案使用给定长度,并设置元素i

正如我们所看到的,第一个解决方案对性能有重大影响。当我们不断分配数组和复制元素时,第一个基准测试比另外两个几乎慢了 400%。比较第二个和第三个解决方案,第三个方案大约快 4%,因为我们避免了重复调用内置的append函数,与直接赋值相比,它的开销很小。

如果设置一个容量并使用append比设置一个长度并分配给一个直接索引效率更低,为什么我们看到这种方法在 Go 项目中使用?我们来看 Pebble 中的一个具体例子,这是蟑螂实验室(github.com/cockroachdb/pebble)开发的一个开源键值存储。

一个名为collectAllUserKeys的函数需要遍历一片结构来格式化一个特定的字节切片。结果切片的长度将是输入切片的两倍:

func collectAllUserKeys(cmp Compare,
    tombstones []tombstoneWithLevel) [][]byte {
    keys := make([][]byte, 0, len(tombstones)*2)
    for _, t := range tombstones {
        keys = append(keys, t.Start.UserKey)
        keys = append(keys, t.End)
    }
    // ...
}

这里,有意识的选择是使用给定的容量和append。有什么道理?如果我们使用给定的长度而不是容量,代码将如下所示:

func collectAllUserKeys(cmp Compare,
    tombstones []tombstoneWithLevel) [][]byte {
    keys := make([][]byte, len(tombstones)*2)
    for i, t := range tombstones {
        keys[i*2] = t.Start.UserKey
        keys[i*2+1] = t.End
    }
    // ...
}

注意处理切片索引的代码看起来有多复杂。鉴于这个函数对性能不敏感,我们决定选择最容易读取的选项。

切片和条件

如果不能精确知道切片的未来长度会怎样?例如,如果输出切片的长度取决于某个条件,那该怎么办?

func convert(foos []Foo) []Bar {
    // bars initialization

    for _, foo := range foos {
        if something(foo) {         // ❶
            // Add a bar element
        }
    }
    return bars
}

❶ 只有在特定条件有效时才添加Foo元素。

在这个例子中,一个Foo元素被转换成一个Bar,并仅在特定条件下(if something(foo))被添加到切片中。我们应该将bars初始化为一个空片还是给定长度或容量?

这里没有严格的规定。这是一个传统的软件问题:CPU 和内存哪个更好交易?也许如果something(foo)在 99%的情况下为真,那么用一个长度或容量初始化bars是值得的。这取决于我们的用例。

将一种切片类型转换成另一种切片类型是 Go 开发人员经常进行的操作。正如我们所看到的,如果未来片的长度是已知的,就没有理由先分配一个空片。我们的选择是分配具有给定容量或给定长度的存储片。在这两种解决方案中,我们已经看到第二种方案要稍微快一些。但是在某些情况下,使用给定的容量和append会更容易实现和读取。

下一节将讨论nil和空切片之间的区别,以及为什么它对 Go 开发者很重要。

3.6 #22:对nil切片和空切片感到困惑

Go 开发者相当频繁地混合nil和空切片。根据具体的使用情况,我们可能希望使用其中的一个。同时,一些库对两者进行了区分。要精通切片,我们需要确保不混淆这些概念。在查看示例之前,让我们先讨论一些定义:

  • 如果切片长度等于0,则切片为空。

  • 如果切片等于nil,则该片为零。

现在,让我们看看初始化切片的不同方法。你能猜出下面代码的输出吗?每次,我们将打印切片是空还是零:

func main() {
    var s []string         // ❶
    log(1, s)

    s = []string(nil)      // ❷
    log(2, s)

    s = []string{}         // ❸
    log(3, s)

    s = make([]string, 0)  // ❹
    log(4, s)
}

func log(i int, s []string) {
    fmt.Printf("%d: empty=%t\tnil=%t\n", i, len(s) == 0, s == nil)
}

❶ 选项 1 (nil值)

❷ 选项 2

❸ 选项 3

❹ 选项 4

此示例打印以下内容:

1: empty=true   nil=true
2: empty=true   nil=true
3: empty=true   nil=false
4: empty=true   nil=false

所有切片都是空的,意味着长度等于0。因此,nil切片也是一个空切片。但是,只有前两个是nil切片。如果我们有多种初始化切片的方法,我们应该选择哪一种?有两件事需要注意:

  • nil切片和空切片的主要区别之一是分配。初始化一个nil切片不需要任何分配,而对于一个空的片来说就不是这样了。

  • 不管一个片是否为零,调用append内置函数都有效。举个例子,

var s1 []string
fmt.Println(append(s1, "foo")) // [foo]

因此,如果一个函数返回一个片,我们不应该像在其他语言中那样,出于防御原因返回一个非零集合。因为一个零片不需要任何分配,我们应该倾向于返回一个零片而不是一个空片。让我们看看这个函数,它返回一段字符串:

func f() []string {
    var s []string
    if foo() {
        s = append(s, "foo")
    }
    if bar() {
        s = append(s, "bar")
    }
    return s
}

如果foobar都为假,我们得到一个空切片。为了防止在没有特殊原因的情况下分配一个空片,我们应该选择选项 1 ( var s []string)。我们可以将选项 4 ( make([]string, 0))与零长度字符串一起使用,但是这样做与选项 1 相比并不会带来任何价值;它需要一个分配。

但是,在我们必须生成一个已知长度的切片的情况下,我们应该使用选项 4,s := make([]string, length),如本例所示:

func intsToStrings(ints []int) []string {
    s := make([]string, len(ints))
    for i, v := range ints {
        s[i] = strconv.Itoa(v)
    }
    return s
}

正如错误#21“低效的片初始化”中所讨论的,我们需要在这样的场景中设置长度(或容量),以避免额外的分配和拷贝。现在,示例中剩下了两个选项,这两个选项研究了初始化切片的不同方法:

  • 选项 2: s := []string(nil)

  • 选项 3: s := []string{}

选项 2 并不是使用最广泛的。但是它作为语法糖是有帮助的,因为我们可以在一行中传递一个nil切片——例如,使用append:

s := append([]int(nil), 42)

如果我们使用选项 1 ( var s []string),它将需要两行代码。这可能不是有史以来最重要的可读性优化,但仍然值得了解。

请注意,在错误#24“没有正确制作切片副本”中,我们将看到一个附加到nil切片的基本原理。

现在,我们来看选项 3: s := []string{}。建议使用此表单创建具有初始元素的切片:

s := []string{"foo", "bar", "baz"}

但是,如果我们不需要用初始元素创建切片,我们就不应该使用这个选项。它带来了与选项 1 ( var s []string)相同的好处,只是切片不是零;因此,它需要分配。因此,应避免没有初始要素的选项 3。

注意,有些 linters 可以在没有初始值的情况下捕捉选项 3,并建议将其更改为选项 1。然而,我们应该记住,这也将语义从非零片改变为零片。

我们还应该提到,一些库区分nil和空片。例如,encoding/json包装就是这种情况。下面的示例封送两个结构,一个包含nil切片,另一个包含非零的空切片:

var s1 []float32                 // ❶
    customer1 := customer{
    ID:         "foo",
    Operations: s1,
}
b, _ := json.Marshal(customer1)
fmt.Println(string(b))

s2 := make([]float32, 0)         // ❷
    customer2 := customer{
    ID:         "bar",
    Operations: s2,
}
b, _ = json.Marshal(customer2)
fmt.Println(string(b))

nil切片

❷ 非nil,空切片

运行此示例时,请注意这两个结构的封送处理结果是不同的:

{"ID":"foo","Operations":null}
{"ID":"bar","Operations":[]}

这里,一个nil切片作为一个null元素被封送,而一个非nil的空片作为一个空数组被封送。如果我们在区分null[]的严格 JSON 客户端的环境中工作,记住这种区别是很重要的。

encoding/json包并不是标准库中唯一做出这种区分的包。例如,如果我们比较一个nil和一个非零的空片,那么reflect.DeepEqual返回false,这是在单元测试的上下文中需要记住的。在任何情况下,当使用标准库或外部库时,我们应该确保当使用一个或另一个版本时,我们的代码不会导致意外的结果。

总结一下,在GO中,nil和空切片是有区别的。nil切片等于nil,而空切片的长度为零。nil切片是空的,但空切片不一定是nil。同时,nil切片不需要任何分配。在本节中,我们已经看到了如何通过使用

  • var s []string如果不确定最终长度,切片可以为空

  • []string(nil)作为语法糖创建一个nil和空切片

  • make([]string, length)如果未来长度已知

如果我们初始化没有元素的切片,那么应该避免最后一个选项[]string{}。最后,让我们检查一下我们使用的库是否区分了nil和空片以防止意外行为。

在下一节中,我们将继续这一讨论,并了解在调用函数后检查空片的最佳方式。

3.7 #23:未正确检查切片是否为空

我们在上一节看到了nil和空切片是有区别的。记住这些概念后,检查切片是否包含元素的惯用方法是什么?没有明确的答案会导致微妙的错误。

在这个例子中,我们调用一个返回一部分float32getOperations函数。只有当切片包含元素时,我们才希望调用一个handle函数。这是第一个(错误的)版本:

func handleOperations(id string) {
    operations := getOperations(id)
    if operations != nil {                  // ❶
        handle(operations)
    }
}

func getOperations(id string) []float32 {
    operations := make([]float32, 0)        // ❷

    if id == "" {
        return operations                   // ❸
    }

    // Add elements to operations

    return operations
}

❶ 检查operations切片是否为nil

❷ 初始化operations切片

❸ 如果提供的id为空,将返回operations

我们通过检查operations切片是否不是nil来确定切片是否有元素。但是这段代码有一个问题:getOperations从不返回一个nil切片;相反,它返回一个空切片。因此,operations != nil检查将始终为true

在这种情况下我们该怎么办?一种方法可能是修改getOperations以在id为空时返回一个nil切片:

func getOperations(id string) []float32 {
    operations := make([]float32, 0)

    if id == "" {
        return nil      // ❶
    }

    // Add elements to operations

    return operations
}

❶ 返回nil而不是operations

如果id为空,我们返回nil,而不是返回operations。这样,我们实现的关于测试片无效匹配的检查。然而,这种方法并不适用于所有情况——我们并不总是处于可以改变被调用者的环境中。例如,如果我们使用一个外部库,我们就不会创建一个拉取请求来将空变成nil切片。

那么我们如何检查一个片是空的还是零呢?解决方法是检查长度:

func handleOperations(id string) {
    operations := getOperations(id)
    if len(operations) != 0 {          // ❶
        handle(operations)
    }
}

❶ 检查切片长度

我们在上一节中提到,根据定义,空切片的长度为零。同时,nil切片总是空的。因此,通过检查切片的长度,我们涵盖了所有场景:

  • 如果切片为nillen(operations) != 0false

  • 如果切片不是nil而是空的,len(operations) != 0也是false

因此,检查长度是最好的选择,因为我们不能总是控制我们调用的函数所采用的方法。与此同时,正如 Go wiki 所言,在设计接口时,我们应该避免区分nil和空切片,这会导致微妙的编程错误。当返回切片时,如果我们返回一个nil或空的切片,应该不会产生语义或技术上的差异。对于调用者来说,这两个词的意思应该是一样的。这个原理同样适用于映射。要检查映射是否为空,要检查它的长度,而不是它是否是nil

在下一节中,我们将了解如何正确制作切片副本。

3.8 #24:没有正确制作切片副本

copy内置函数允许将元素从源片复制到目标片。虽然它是一个方便的内置函数,但 Go 开发者有时会误解它。让我们来看一个导致复制错误数量的元素的常见错误。

在下面的示例中,我们创建了一个切片,并将其元素复制到另一个切片中。这段代码的输出应该是什么?

src := []int{0, 1, 2}
var dst []int
copy(dst, src)
fmt.Println(dst)

如果我们运行这个例子,它打印的是[],而不是[0 1 2]。我们错过了什么?

为了有效地使用copy,必须了解复制到目标切片的元素数量对应于以下值中的最小值:

  • 源切片的长度

  • 目标切片的长度

在前面的例子中,src是一个三长度切片,但是dst是一个零长度切片,因为它被初始化为零值。因此,copy函数复制了最小数量的元素(在 3 和 0 之间):在这种情况下为 0。结果切片是空的。

如果我们要执行完整拷贝,目标切片的长度必须大于或等于源切片的长度。这里,我们根据源切片设置长度:

src := []int{0, 1, 2}
dst := make([]int, len(src))     // ❶
copy(dst, src)
fmt.Println(dst)

❶ 创建一个dst切片,但具有给定的长度

因为dst现在是一个长度等于 3 的初始化切片,所以它复制了三个元素。这一次,如果我们运行代码,它会打印出[0 1 2]

注意另一个常见的错误是在调用copy时颠倒参数的顺序。请记住,目的地是前一个参数,而来源是后一个参数。

我们还要提到,使用copy内置函数并不是复制切片元素的唯一方式。有不同的选择,最著名的可能是下面的,它使用了append:

src := []int{0, 1, 2}
dst := append([]int(nil), src...)

我们将源切片中的元素添加到一个nil切片中。因此,这段代码创建了一个三长度、三容量的切片副本。这种方法的优点是可以在一行中完成。然而,使用copy更符合习惯,因此更容易理解,尽管它需要额外的一行。

将元素从一个片复制到另一个片是相当频繁的操作。使用copy时,我们必须记住复制到目的地的元素数量对应于两个切片长度之间的最小值。还要记住,复制切片还有其他选择,所以如果我们在代码库中找到它们,我们也不应该感到惊讶。

我们继续讨论使用append时常见错误的切片。

3.9 #25:使用切片附加的意外副作用

本节讨论使用append时的一个常见错误,在某些情况下可能会产生意想不到的副作用。在下面的例子中,我们初始化一个s1切片,通过切片s1创建s2,通过向s2追加一个元素创建s3:

s1 := []int{1, 2, 3}
s2 := s1[1:2]
s3 := append(s2, 10)

我们初始化一个包含三个元素的s1切片,从切片s1中创建s2。然后我们在s3上调用append。这段代码结尾的这三个切片应该是什么状态?你能猜到吗?

在第二行之后,创建了s2之后,图 3.10 显示了内存中两个片的状态。s1是一个三长度、三容量的片,s2是一个一长度、两容量的片,两者都由我们已经提到的相同数组支持。使用append添加一个元素检查切片是否已满(长度==容量)。如果未满,append函数通过更新后备数组并返回长度增加 1 的切片来添加元素。

图 3.10 两个存储片都由相同的数组支持,但长度和容量不同。

在这个例子中,s2还没有满;它可以再接受一个元素。图 3.11 显示了这三个切片的最终状态。

图 3.11 所有切片都由同一个数组支持。

在后备数组中,我们更新了最后一个元素来存储10。因此,如果我们打印所有切片,我们会得到以下输出:

s1=[1 2 10], s2=[2], s3=[2 10]

虽然我们没有直接更新s1[2]s2[1],但是s1切片的内容已经被修改。我们应该记住这一点,以避免意想不到的后果。

让我们通过将切片操作的结果传递给一个函数来看看这个原则的影响。在下面的例子中,我们用三个元素初始化一个切片,并调用一个只有前两个元素的函数:

func main() {
    s := []int{1, 2, 3}

    f(s[:2])
    // Use s
}

func f(s []int) {
    // Update s
}

在这个实现中,如果f更新了前两个元素,那么这些变化对于main中的片是可见的。然而,如果f调用append,它会更新切片的第三个元素,尽管我们只传递了两个元素。举个例子,

func main() {
    s := []int{1, 2, 3}

    f(s[:2])
    fmt.Println(s) // [1 2 10]
}

func f(s []int) {
    _ = append(s, 10)
}

如果我们出于防御原因想要保护第三个元素,意思是确保f不会更新它,我们有两个选择。

第一种方法是传递切片的副本,然后构造结果切片:

func main() {
    s := []int{1, 2, 3}
    sCopy := make([]int, 2)
    copy(sCopy, s)                    // ❶

    f(sCopy)
    result := append(sCopy, s[2])     // ❷
    // Use result
}

func f(s []int) {
    // Update s
}

❶ 把s的前两个元素复制到sCopy

❷ 将s[2]附加到sCopy上以构建结果切片

因为我们传递了一个副本给f,所以即使这个函数调用了append,也不会导致前两个元素范围之外的副作用。这个选项的缺点是,它使代码阅读起来更复杂,并且增加了一个额外的副本,如果切片很大,这可能是一个问题。

第二个选项可用于将潜在副作用的范围仅限于前两个元素。这个选项涉及到所谓的全切片表达式 : s[low:high:max]。该语句创建一个类似于用s[low:high]创建的片,除了产生的片的容量等于max - low。这里有一个调用f时的例子:

func main() {
    s := []int{1, 2, 3}
    f(s[:2:2])            // ❶
    // Use s
}

func f(s []int) {
    // Update s
}

❶ 使用完整切片表达式传递子切片

这里,传递给f的切片不是s[:2]而是s[:2:2]。因此,切片的容量为 2–0 = 2,如图 3.12 所示。

图 3.12 s[0:2]创建了一个两长度、三容量的切片,而s[0:2:2]创建了一个两长度、两容量的切片。

当通过s[:2:2]时,我们可以将效果范围限制在前两个元素。这样做还可以避免我们必须执行切片拷贝。

使用切片时,我们必须记住,我们可能会面临导致意外副作用的情况。如果结果切片的长度小于其容量,append可以改变原始切片。如果我们想限制可能的副作用的范围,我们可以使用切片复制或完整切片表达式,这将阻止我们进行复制。

在下一节中,我们将继续讨论片,但是是在潜在内存泄漏的背景下。

3.10 #26:切片和内存泄漏

本节说明了在某些情况下,对现有切片或数组进行切片会导致内存泄漏。我们讨论两种情况:一种是容量泄漏,另一种与指针有关。

3.10.1 泄漏容量

对于第一种情况,泄漏容量,让我们设想实现一个定制的二进制协议。一条消息可以包含 100 万字节,前 5 个字节代表消息类型。在我们的代码中,我们使用这些消息,出于审计目的,我们希望在内存中存储最新的 1,000 种消息类型。这是我们功能的框架:

func consumeMessages() {
    for {
        msg := receiveMessage()                  // ❶
        // Do something with msg
        storeMessageType(getMessageType(msg))    // ❷
    }
}

func getMessageType(msg []byte) []byte {         // ❸
    return msg[:5]
}

❶ 收到赋值给msg的新[]byte切片

❷ 在内存中存储了最新的 1000 种消息类型

❸ 通过对消息进行切片来计算消息类型

getMessageType函数通过对输入切片进行切片来计算消息类型。我们测试了这个实现,一切正常。然而,当我们部署应用时,我们注意到应用消耗了大约 1 GB 的内存。这怎么可能呢?

使用msg[:5]msg上的切片操作创建了一个五长度切片。但是,其容量与初始切片保持不变。剩余的元素仍然分配在内存中,即使最终msg没有被引用。让我们看一个例子,它有一个 100 万字节的大消息,如图 3.13 所示。

图 3.13 一次新的循环迭代后,msg不再使用。但是它的后备数组还是会被msg[:5]使用。

切片操作后,切片的支持数组仍包含 100 万字节。因此,如果我们在内存中保存 1,000 条消息,而不是存储大约 5 KB,我们将保存大约 1 GB。

我们能做些什么来解决这个问题?我们可以制作切片副本来代替切片msg:

func getMessageType(msg []byte) []byte {
    msgType := make([]byte, 5)
    copy(msgType, msg)
    return msgType
}

因为我们执行复制,msgType是一个五长度、五容量的片段,不管接收到的消息有多大。因此,我们每种消息类型只存储 5 个字节。

全切片表达式和容量泄漏

用全切片表达式来解决这个问题怎么样?让我们看看这个例子:

func getMessageType(msg []byte) []byte {
    return msg[:5:5]
}

这里,getMessageType返回初始切片的缩小版本:一个五长度、五容量的切片。但是 GC 能够从字节 5 中回收不可访问的空间吗?Go 规范没有正式指定行为。然而,通过使用runtime.Memstats,我们可以记录关于内存分配器的统计数据,比如在堆上分配的字节数:

func printAlloc() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("%d KB\n", m.Alloc/1024)
}

如果我们在调用getMessageTyperuntime.GC()之后调用这个函数来强制运行垃圾收集,不可访问的空间不会被回收。整个后备数组仍然存在于内存中。因此,使用完整切片表达式不是一个有效的选项(除非 Go 的未来更新解决了这个问题)。

根据经验,记住对一个大的切片或数组进行切片可能会导致潜在的高内存消耗。剩余的空间不会被 GC 回收,我们可以保留一个大的后备数组,尽管只使用了很少的元素。使用切片拷贝是防止这种情况的解决方案。

3.10.2 切片和指针

我们已经看到切片会因为切片容量而导致泄漏。但是元素呢,它们仍然是支持数组的一部分,但是在长度范围之外。GC 收集它们吗?

让我们使用一个包含字节切片的Foo结构来研究这个问题:

type Foo struct {
    v []byte
}

我们希望在每个步骤之后检查内存分配,如下所示:

  1. 分配 1000 个Foo元素的切片。

  2. 迭代每个Foo元素,对于每个元素,为v片分配 1 MB。

  3. 调用keepFirstTwoElementsOnly,它使用切片只返回前两个元素,然后调用一个 GC。

我们想看看内存在调用keepFirstTwoElementsOnly和垃圾收集之后的表现。下面是 Go 中的场景(我们重用了前面提到的printAlloc函数):

func main() {
    foos := make([]Foo, 1_000)              // ❶
    printAlloc()

    for i := 0; i < len(foos); i++ {        // ❷
        foos[i] = Foo{
            v: make([]byte, 1024*1024),
        }
    }
    printAlloc()

    two := keepFirstTwoElementsOnly(foos)   // ❸
    runtime.GC()                            // ❹
    printAlloc()
    runtime.KeepAlive(two)                  // ❺
}

func keepFirstTwoElementsOnly(foos []Foo) []Foo {
    return foos[:2]
}

❶ 分配 1000 个元素的切片

❷ 为每个元素分配一个 1 MB 的切片

❸ 只保留了前两个元素

❹ 运行 GC 来强制清理堆

❺ 保留了对这两个变量的引用

在这个例子中,我们分配了foos片,为每个元素分配一个 1 MB 的片,然后调用keepFirstTwoElementsOnly和一个 GC。最后,我们使用runtime .KeepAlive在垃圾收集之后保留一个对two变量的引用,这样它就不会被收集。

我们可能期望 GC 收集剩余的 998 个Foo元素和为片分配的数据,因为这些元素不再能被访问。然而,事实并非如此。例如,代码可以输出以下内容:

83 KB
1024072 KB
1024072 KB     // ❶

切片手术后的// ❶

第一个输出分配了大约 83 KB 的数据。的确,我们分配了 1000 个Foo的零值。第二个结果为每个片分配 1 MB,这增加了内存。但是,请注意,在最后一步之后,GC 没有收集剩余的 998 个元素。原因是什么?

使用切片时,一定要记住这条规则:如果元素是指针或带有指针字段的结构,那么元素不会被 GC 回收。在我们的例子中,因为Foo包含一个切片(切片是后备数组顶部的指针),所以剩余的 998 个Foo元素和它们的切片没有被回收。因此,即使这 998 个元素不能被访问,只要被引用了由keepFirstTwoElementsOnly返回的变量,它们就留在内存中。

有哪些选项可以确保我们不会泄露剩余的Foo元素?同样,第一个选项是创建切片的副本:

func keepFirstTwoElementsOnly(foos []Foo) []Foo {
    res := make([]Foo, 2)
    copy(res, foos)
    return res
}

因为我们复制了切片的前两个元素,GC 知道这 998 个元素将不再被引用,现在可以被收集了。

如果我们想要保持 1000 个元素的底层容量,还有第二个选择,就是将剩余元素的切片显式标记为nil:

func keepFirstTwoElementsOnly(foos []Foo) []Foo {
    for i := 2; i < len(foos); i++ {
        foos[i].v = nil
    }
    return foos[:2]
}

这里,我们返回一个 2 长度、1000 容量的切片,但是我们将剩余元素的切片设置为nil。因此,GC 可以收集 998 个后备数组。

哪个选项是最好的?如果我们不想将容量保持在 1000 个元素,第一个选项可能是最好的。然而,决定也可以取决于元素的比例。图 3.14 提供了一个我们可以选择的选项的可视化例子,假设一个切片包含了我们想要保存i元素的n元素。

图 3.14 选项 1 迭代到i,而选项 2 从i开始迭代。

第一个选项创建一个i元素的副本。因此,它必须从元素 0 迭代到i。第二个选项将剩余的片设置为零,因此它必须从元素i迭代到n。如果性能很重要,并且i比 0 更接近于n,我们可以考虑第二个选项。这需要迭代更少的元素(至少,可能值得对这两个选项进行基准测试)。

在本节中,我们看到了两个潜在的内存泄漏问题。第一个是对现有存储片或数组进行切片以保留容量。如果我们处理大的切片并重新切片以只保留一小部分,那么大量的内存将仍然被分配但未被使用。第二个问题是,当我们使用带有指针的切片操作或带有指针字段的结构时,我们需要知道 GC 不会回收这些元素。在这种情况下,有两种选择,要么执行复制,要么显式地将剩余的元素或它们的字段标记为nil

现在,让我们在初始化的上下文中讨论映射。

3.11 #27:低效的映射初始化

本节讨论了一个类似于切片初始化的问题,但是使用了贴图。但是首先,我们需要知道关于如何在 Go 中实现映射的基础知识,以理解为什么调整映射初始化是重要的。

3.11.1 概念

一个映射提供了一个无序的键值对集合,其中所有的键都是不同的。在 Go 中,映射是基于哈希表数据结构的。在内部,哈希表是一个桶的数组,每个桶是一个指向键值对数组的指针,如图 3.15 所示。

图 3.15 中的哈希表后面是一个由四个元素组成的数组。如果我们检查数组索引,我们会注意到一个由单个键值对(元素)组成的桶:"two" / 2。每个桶有八个元素的固定大小。

图 3.15 关注桶 0 的散列表示例

每个操作(读取、更新、插入、删除)都是通过将一个键与一个数组索引相关联来完成的。这个步骤依赖于散列函数。这个函数是稳定的,因为我们希望它返回相同的桶,给定相同的键,保持一致。在前面的例子中,hash("two")返回 0;因此,该元素存储在数组索引 0 引用的桶中。

如果我们插入另一个元素,并且散列键返回相同的索引,Go 将另一个元素添加到相同的桶中。图 3.16 显示了这个结果。

图 3.16 hash("six")返回 0;因此,元素存储在同一个桶中。

在插入一个已经满了的桶(桶溢出)的情况下,Go 创建另一个包含八个元素的桶,并将前一个桶链接到它。图 3.17 给出了这个结果。

图 3.17 在桶溢出的情况下,Go 分配一个新的桶,并将前一个桶链接到它。

关于读取、更新和删除,Go 必须计算相应的数组索引。然后 Go 依次遍历所有的键,直到找到提供的键。因此,这三个操作的最坏情况时间复杂度是O(p),其中p是桶中元素的总数(默认为一个桶,溢出时为多个桶)。

现在让我们讨论一下为什么有效地初始化映射很重要。

3.12.2 初始化

为了理解与低效的映射初始化相关的问题,让我们创建一个包含三个元素的map[string]int类型:

m := map[string]int{
    "1": 1,
    "2": 2,
    "3": 3,
}

在内部,这个映射由一个包含单个条目的数组支持:因此,只有一个桶。如果我们增加 100 万个元素会发生什么?在这种情况下,单个条目是不够的,因为在最坏的情况下,找到一个键意味着要遍历数千个桶。这就是为什么映射应该能够自动增长以应对元素的数量。

当一个映射增长时,它的桶的数量会翻倍。映射成长的条件是什么?

  • 桶中物品的平均数量(称为装载系数)大于一个恒定值。这个常数等于 6.5(但是在未来的版本中可能会改变,因为它是内部的)。

  • 过多的桶溢出(包含八个以上的元素)。

当一个映射增长时,所有的键被再次分配给所有的桶。这就是为什么在最坏的情况下,插入一个键可以是一个O(n)操作,其中n是图中元素的总数。

我们看到,当使用切片时,如果我们预先知道要添加到切片中的元素数量,我们可以用给定的大小或容量初始化它。这避免了必须不断重复代价高昂的切片增长操作。这个想法对于映射来说是类似的。事实上,我们可以使用make内置函数来在创建映射时提供初始大小。例如,如果我们想要初始化一个包含一百万个元素的映射,可以这样做:

m := make(map[string]int, 1_000_000)

有了映射,我们可以只给内置函数make一个初始大小,而不是容量,就像切片一样:因此,只有一个参数。

通过指定大小,我们提供了一个关于预期进入映射的元素数量的提示。在内部,使用适当数量的存储桶来创建映射,以存储一百万个元素。这节省了大量的计算时间,因为映射不必动态创建存储桶和处理重新平衡存储桶。

此外,指定一个尺寸n并不意味着用最大数量的n元素来制作映射。如果需要,我们仍然可以添加多于 n 个元素。相反,这意味着要求 Go 运行时为至少n个元素分配一个映射空间,如果我们事先已经知道元素的大小,这是很有帮助的。

为了理解为什么指定大小很重要,让我们运行两个基准测试。第一个示例在没有设置初始大小的情况下在一个映射中插入一百万个元素,而我们用一个大小来初始化第二个映射:

BenchmarkMapWithoutSize-4     6    227413490 ns/op
BenchmarkMapWithSize-4       13     91174193 ns/op

第二个版本,初始大小,大约快 60%。通过提供一个大小,我们可以防止映射增长以适应插入的元素。

因此,就像切片一样,如果我们预先知道映射将包含的元素数量,我们应该通过提供初始大小来创建它。这样做避免了潜在的映射增长,这在计算上是相当繁重的,因为它需要重新分配足够的空间和重新平衡所有的元素。

让我们继续关于映射的讨论,看看一个导致内存泄漏的常见错误。

3.12 #28:映射和内存泄漏

在 Go 中使用映射时,我们需要了解映射如何增长和收缩的一些重要特征。让我们深入研究这个问题,以防止可能导致内存泄漏的问题。

首先,为了查看此问题的具体示例,让我们设计一个场景,其中我们将使用以下映射:

m := make(map[int][128]byte)

m的每个值都是一个 128 字节的数组。我们将执行以下操作:

  1. 分配一个空映射。

  2. 添加 100 万个元素。

  3. 移除所有元素,并运行 GC。

在每一步之后,我们想要打印堆的大小(这次使用 MB)。这向我们展示了这个例子在内存方面的表现:

n := 1_000_000
m := make(map[int][128]byte)
printAlloc()

for i := 0; i < n; i++ {      // ❶
    m[i] = randBytes()
}
printAlloc()

for i := 0; i < n; i++ {      // ❷
    delete(m, i)
}

runtime.GC()                  // ❸
printAlloc()
runtime.KeepAlive(m)          // ❹

❶ 添加 100 万个元素

❷ 删除一百万个元素

❸ 触发了手动 GC

❹ 保留了一个对m的引用,这样映射就不会被收集

我们分配一个空的映射,添加一百万个元素,删除一百万个元素,然后运行一个 GC。我们还确保使用runtime .KeepAlive保存对映射的引用,这样映射就不会被收集。让我们运行这个例子:

0 MB       // ❶
461 MB     // ❷
293 MB     // ❸

❶ 在m被分配后

❷ 我们添加 100 万个元素后

❸ 在我们移除一百万个元素后

我们能观察到什么?起初,堆的大小是最小的。然后,在映射上添加了一百万个元素后,它会显著增长。但是,如果我们期望在移除所有元素后堆的大小会减小,那么这不是映射在 Go 中的工作方式。最后,即使 GC 已经收集了所有的元素,堆的大小仍然是 293 MB。所以内存缩小了,但并不像我们预期的那样。有什么道理?

我们在上一节中讨论了一个映射由八个元素的桶组成。在幕后,Go映射是一个指向runtime.hmap结构的指针。该结构包含多个字段,包括一个B字段,给出了映射中的桶数:

type hmap struct {
    B uint8 // log_2 of # of buckets
            // (can hold up to loadFactor * 2^B items)
    // ...
}

添加 100 万个元素后,B的值等于 18,这意味着2^18 = 262144个桶。当我们去掉 100 万个元素,B的值是多少?还是 18。因此,映射仍然包含相同数量的桶。

原因是映射中的存储桶数量不能减少。因此,从映射中删除元素不会影响现有存储桶的数量;它只是将桶中的槽归零。一张映射只能成长,只能有更多的桶;它从不缩水。

在前面的例子中,我们从 461 MB 增加到 293 MB,因为收集了元素,但是运行 GC 并不影响映射本身。甚至额外桶(由于溢出而创建的桶)的数量也保持不变。

让我们后退一步,讨论一下映射不能缩小的事实何时会成为问题。想象使用map[int][128]byte构建一个缓存。这个映射包含每个客户 ID,一个 128 字节的序列。现在,假设我们想要保留最后 1000 名客户。映射大小将保持不变,所以我们不应该担心映射不能缩小的事实。

然而,假设我们想要存储一个小时的数据。与此同时,我们公司决定在黑色星期五进行一次大促销:一小时后,我们可能会有数百万客户连接到我们的系统。但是在黑色星期五之后的几天,我们的映射将包含与高峰时间相同数量的桶。这解释了为什么我们会经历高内存消耗,而在这种情况下不会显著减少。

如果我们不想手动重启服务来清理映射消耗的内存量,有什么解决方案?一种解决方案可以是定期重新创建当前映射的副本。比如每个小时,我们可以建立一个新的映射,复制所有的元素,释放上一个。这种方法的主要缺点是,在复制之后直到下一次垃圾收集之前,我们可能会在短时间内消耗两倍于当前的内存。

另一个解决方案是改变映射类型来存储数组指针:map[int]*[128]byte。它没有解决我们将会有相当数量的桶的事实;然而,每个桶条目将为该值保留指针的大小,而不是 128 字节(在 64 位系统上是 8 字节,在 32 位系统上是 4 字节)。

回到最初的场景,让我们按照每个步骤比较每个映射类型的内存消耗。下表显示了这种比较。

步骤 map[int][128]byte map[int]*[128]byte
分配一个空映射。 0 MB 0 MB
添加 100 万个元素。 461 MB 182 MB
移除所有元素并运行 GC。 293 MB 38 MB

正如我们所看到的,删除所有元素后,使用map[int]*[128]byte类型所需的内存量明显减少。此外,在这种情况下,由于一些减少内存消耗的优化,高峰时间所需的内存量不太重要。

注意如果一个键或者一个值超过 128 个字节,Go 不会把它直接存储在映射桶中。相反,Go 存储一个指针来引用键或值。

正如我们已经看到的,向一个映射添加n个元素,然后删除所有元素意味着在内存中保持相同数量的存储桶。所以,我们必须记住,因为 Go 映射的大小只会增加,所以它的内存消耗也会增加。没有自动化的策略来缩小它。如果这导致高内存消耗,我们可以尝试不同的选项,如强制 Go 重新创建映射或使用指针检查是否可以优化。

在本章的最后一节,我们来讨论在 Go 中比较数值。

3.13 #29:不正确地比较值

比较数值是软件开发中常见的操作。我们经常实现比较:编写一个函数来比较两个对象,测试来比较一个值和一个期望值,等等。我们的第一反应可能是在任何地方都使用==操作符。但是正如我们将在本节中看到的,情况不应该总是这样。那么什么时候使用==比较合适,有哪些替代方案呢?

要回答这些问题,我们先来看一个具体的例子。我们创建一个基本的customer结构并使用==来比较两个实例。在您看来,这段代码的输出应该是什么?

type customer struct {
    id string
}

func main() {
    cust1 := customer{id: "x"}
    cust2 := customer{id: "x"}
    fmt.Println(cust1 == cust2)
}

比较这两个customer结构是 Go 中的有效操作,它会打印true。现在,如果我们稍微修改一下customer结构,添加一个切片字段,会发生什么呢?

type customer struct {
    id         string
    operations []float64      // ❶
}

func main() {
    cust1 := customer{id: "x", operations: []float64{1.}}
    cust2 := customer{id: "x", operations: []float64{1.}}
    fmt.Println(cust1 == cust2)
}

❶ 新字段

我们可能希望这段代码也能打印出true。然而,它甚至不能编译:

invalid operation:
    cust1 == cust2 (struct containing []float64 cannot be compared)

该问题与==!=操作器的工作方式有关。这些运算符不适用于切片或贴图。因此,因为customer结构包含一个片,所以它不能编译。

了解如何使用==!=进行有效的比较是非常重要的。我们可以在可比的操作数上使用这些操作符:

  • 布尔型——比较两个布尔型是否相等。

  • 数值 (整数、浮点和复数类型)——比较两个数值是否相等。

  • 字符串——比较两个字符串是否相等。

  • 通道——比较两个通道是否由同一个对make的调用创建,或者是否都是nil

  • 接口——比较两个接口是否具有相同的动态类型和相等的动态值,或者是否都是nil

  • 指针——比较两个指针是否指向内存中的同一个值或者是否都是nil

  • 结构和数组——比较它们是否由相似的类型组成。

注意我们也可以使用>=<>操作符,对数字类型使用这些操作符来比较值,对字符串使用这些操作符来比较它们的词汇顺序。

在最后一个例子中,我们的代码编译失败,因为结构是在不可比较的类型(片)上构成的。

我们还需要知道将==!=any类型一起使用可能出现的问题。例如,允许比较分配给any类型的两个整数:

var a any = 3
var b any = 3
fmt.Println(a == b)

该代码打印:

true

但是如果我们初始化两个customer类型(最新版本包含一个切片字段)并将值赋给any类型会怎么样呢?这里有一个例子:

var cust1 any = customer{id: "x", operations: []float64{1.}}
var cust2 any = customer{id: "x", operations: []float64{1.}}
fmt.Println(cust1 == cust2)

这段代码可以编译。但是由于两种类型不能比较,因为customer结构包含一个切片字段,这导致了运行时的错误:

panic: runtime error: comparing uncomparable type main.customer

考虑到这些行为,如果我们必须比较两个切片、两个映射或者两个包含不可比较类型的结构,有什么选择呢?如果我们坚持使用标准库,一个选择是对reflect包使用运行时反射。

反射是元编程的一种形式,它指的是应用自省和修改其结构和行为的能力。比如GO,我们可以用reflect.DeepEqual。该函数通过递归遍历两个值来报告两个元素是否完全相等。它接受的元素是基本类型加上数组、结构、切片、映射、指针、接口和函数。

注意reflect.DeepEqual根据我们提供的类型有特定的行为。使用之前,请仔细阅读文档。

让我们重新运行第一个例子,添加reflect.DeepEqual:

cust1 := customer{id: "x", operations: []float64{1.}}
cust2 := customer{id: "x", operations: []float64{1.}}
fmt.Println(reflect.DeepEqual(cust1, cust2))

尽管customer结构包含不可比较的类型(slice ),但它会像预期的那样运行,打印true

但是,在使用reflect.DeepEqual的时候,有两点需要记住。首先,它区分了空集合和nil集合,正如错误#22 中所讨论的,“混淆nil和空切片。”这是个问题吗?不一定;这取决于我们的用例。例如,如果我们想要比较两个解组操作(比如从 JSON 到 Go 结构)的结果,我们可能想要提高这个差异。但是为了有效地使用reflect.DeepEqual,记住这种行为是值得的。

另一个问题是在大多数语言中相当标准的东西。因为这个函数使用反射,即在运行时自省值以发现它们是如何形成的,所以它有一个性能损失。用不同大小的结构在本地做几个基准测试,平均来说,reflect.DeepEqual==慢 100 倍左右。这可能是支持在测试环境中而不是在运行时使用它的原因。

如果性能是一个关键因素,另一个选择可能是实现我们自己的比较方法。下面是一个比较两个customer结构并返回布尔值的例子:

func (a customer) equal(b customer) bool {
    if a.id != b.id {                             // ❶
        return false
    }
    if len(a.operations) != len(b.operations) {   // ❷
        return false
    }
    for i := 0; i < len(a.operations); i++ {      // ❸
        if a.operations[i] != b.operations[i] {
            return false
        }
    }
    return true
}

❶ 比较id字段

❷ 检查两个切片的长度

❸ 比较了两个切片的每个元素

在这段代码中,我们用对customer结构的不同字段的自定义检查来构建我们的比较方法。在由 100 个元素组成的切片上运行本地基准测试表明,我们的定制equal方法比reflect.DeepEqual快大约 96 倍。

一般来说,我们应该记住==操作符是非常有限的。例如,它不适用于切片和贴图。在大多数情况下,使用reflect.DeepEqual是一种解决方案,但是主要的问题是性能损失。在单元测试的上下文中,一些其他的选项是可能的,比如使用带有go-cmpgithub.com/google/go-cmp)或者testifygithub.com/stretchr/testify)的外部库。然而,如果性能在运行时至关重要,实现我们的定制方法可能是最好的解决方案。

一个额外的注意:我们必须记住标准库有一些现有的比较方法。例如,我们可以使用优化的bytes.Compare函数来比较两个字节切片。在实现一个定制方法之前,我们需要确保我们不会重复发明轮子。

总结

  • 阅读现有代码时,请记住以 0 开头的整数是八进制数。此外,为了提高可读性,通过在八进制整数前面加上前缀0o,使它们显式。

  • 因为在 Go 中整数溢出和下溢是静默处理的,所以你可以实现自己的函数来捕捉它们。

  • 在给定的增量内进行浮点比较可以确保你的代码是可移植的。

  • 执行加法或减法时,将具有相似数量级的运算分组,以提高精确度。还有,先做乘除,再做加减。

  • 理解切片长度和容量之间的区别应该是 Go 开发人员核心知识的一部分。切片长度是切片中可用元素的数量,而切片容量是后备数组中元素的数量。

  • 创建切片时,如果长度已知,用给定的长度或容量初始化切片。这减少了分配的数量并提高了性能。同样的逻辑也适用于映射,您需要初始化它们的大小。

  • 如果两个不同的函数使用由同一数组支持的片,使用复制或完整片表达式是防止append产生冲突的一种方式。但是,如果您想要收缩一个大的切片,只有切片复制可以防止内存泄漏。

  • 使用copy内置函数将一个切片复制到另一个切片,记住复制元素的数量对应于两个切片长度之间的最小值。

  • 使用指针切片或带有指针字段的结构,可以通过将切片操作排除的元素标记为nil来避免内存泄漏。

  • 为了防止常见的混淆,例如在使用encoding/jsonreflect包时,您需要理解nil切片和空切片之间的区别。两者都是零长度、零容量的片,但是只有零片不需要分配。

  • 要检查切片是否不包含任何元素,请检查其长度。无论切片是nil还是空的,该检查都有效。映射也是如此。

  • 为了设计明确的 API,你不应该区分nil和空切片。

  • 一张映射在内存中可以一直增长,但永远不会缩小。因此,如果它导致一些内存问题,您可以尝试不同的选项,例如强制 Go 重新创建映射或使用指针。

  • 要比较 Go 中的类型,如果两个类型是可比较的,可以使用==!=操作符:布尔值、数字、字符串、指针、通道和结构完全由可比较的类型组成。否则,您可以使用reflect.DeepEqual并付出反射的代价,或者使用定制的实现和库。

四、控制结构

本章涵盖

  • 一个range循环如何分配元素值并求值所提供的表达式
  • 处理range循环和指针
  • 防止常见的映射迭代和破环错误
  • 在循环内部使用defer

Go 中的控制结构类似于 C 或 Java 中的控制结构,但在很多方面有很大的不同。比如GO中没有dowhile循环,只有一个广义的for。本章深入探讨与控制结构相关的最常见错误,重点关注循环range,这是一个常见的误解来源。

4.1 #30:忽略元素在范围循环中被复制的事实

range循环是迭代各种数据结构的便捷方式。我们不必处理索引和终止状态。Go 开发人员可能会忘记或者没有意识到range循环是如何赋值的,从而导致常见的错误。首先,让我们提醒自己如何使用一个range循环;然后我们来看看值是如何赋值的。

4.1.1 概念

一个range循环允许迭代不同的数据结构:

  • 字符串

  • 数组

  • 指向数组的指针

  • 切片

  • 映射

  • 接收通道

与经典的for循环相比,range循环是迭代这些数据结构中所有元素的一种便捷方式,这要归功于它简洁的语法。它也更不容易出错,因为我们不必手动处理条件表达式和迭代变量,这可以避免诸如一个接一个的错误之类的错误。下面是一个对字符串片段进行迭代的示例:

s := []string{"a", "b", "c"}
for i, v := range s {
    fmt.Printf("index=%d, value=%s\n", i, v)
}

这段代码循环遍历切片的每个元素。在每次迭代中,当我们迭代一个片时,range产生一对值:一个索引和一个元素值,分别分配给iv。一般来说,range为每个数据结构生成两个值,除了接收通道,它为接收通道生成一个元素(值)。

在某些情况下,我们可能只对元素值感兴趣,而对索引不感兴趣。因为不使用局部变量会导致编译错误,所以我们可以使用空白标识符来替换索引变量,如下所示:

s := []string{"a", "b", "c"}
for _, v := range s {
    fmt.Printf("value=%s\n", v)
}

多亏了空白标识符,我们通过忽略索引并只将元素值赋给v来迭代每个元素。

如果我们对值不感兴趣,我们可以省略第二个元素:

for i := range s {}

既然我们已经用一个range循环刷新了我们的思维,让我们看看在一次迭代中返回什么样的值。

4.1.2 值的复制

理解在每次迭代中如何处理值对于有效使用range循环至关重要。让我们用一个具体的例子来看看它是如何工作的。

我们创建一个包含单个balance字段的account结构:

type account struct {
    balance float32
}

接下来,我们创建一片account结构,并使用一个range循环遍历每个元素。在每次迭代中,我们递增每个accountbalance:

accounts := []account{
    {balance: 100.},
    {balance: 200.},
    {balance: 300.},
}
for _, a := range accounts {
    a.balance += 1000
}

根据这段代码,您认为以下两个选项中的哪一个显示了切片的内容?

  • [{100} {200} {300}]

  • [{1100} {1200} {1300}]

答案是[{100} {200} {300}]。在本例中,range循环不影响切片的内容。我们来看看为什么。

在 Go 中,我们分配的所有内容都是副本:

  • 如果我们赋值一个函数的结果,返回一个结构,它执行该结构的一个拷贝。

  • 如果我们赋值一个函数的结果,返回一个指针,它执行内存地址的复制(在 64 位架构上一个地址是 64 位长)。

牢记这一点以避免常见错误是至关重要的,包括那些与range循环相关的错误。事实上,当一个range循环遍历一个数据结构时,它会将每个元素复制到值变量(第二项)。

回到我们的例子,迭代每个account元素导致一个结构体副本被赋给值变量a。因此,用a.balance += 1000增加余额只会改变值变量(a),而不会改变切片中的元素。

那么,如果我们想要更新切片元素呢?有两个主要选项。第一种选择是使用片索引访问元素。这可以通过使用索引而不是值变量的经典for循环或range循环来实现:

for i := range accounts {                // ❶
    accounts[i].balance += 1000
}

for i := 0; i < len(accounts); i++ {     // ❷
    accounts[i].balance += 1000
}

❶ 使用索引变量来访问切片的元素

❷ 使用传统的for循环

两次迭代具有相同的效果:更新accounts切片中的元素。

我们应该支持哪一个?这要看上下文。如果我们想检查每个元素,第一个循环读写起来会更短。但是如果我们需要控制想要更新哪个元素(比如两个中的一个),我们应该使用第二个循环。

更新切片元素:第三个选项

另一种选择是继续使用range循环并访问值,但是将切片类型修改为一个account指针切片:

accounts := []*account{       // ❶
    {balance: 100.},
    {balance: 200.},
    {balance: 300.},
}
for _, a := range accounts {
    a.balance += 1000         // ❷
}

❶ 将切片类型更新为[]*account

❷ 直接更新切片元素

在这种情况下,正如我们提到的,a变量是存储在切片中的account指针的副本。但是由于两个指针引用同一个结构,a.balance += 1000语句更新切片元素。

然而,这种选择有两个主要缺点。首先,它需要更新切片类型,这并不总是可能的。第二,如果性能很重要,我们应该注意到,由于缺乏可预测性,迭代指针片对 CPU 来说可能效率较低(我们将在错误#91“不理解 CPU 缓存”中讨论这一点)。

一般来说,我们应该记住range循环中的值元素是一个副本。因此,如果值是我们需要改变的结构,我们将只更新副本,而不是元素本身,除非我们修改的值或字段是指针。更好的选择是使用一个range循环或者一个经典的for循环通过索引访问元素。

在下一节中,我们继续使用range循环,看看如何计算提供的表达式。

4.2 #31:忽略参数在范围循环中的求值方式

range循环语法需要一个表达式。比如在for i, v := range expexp就是表达式。正如我们所见,它可以是一个字符串、一个数组、一个指向数组的指针、一个切片、一个映射或一个通道。现在,我们来讨论下面这个问题:这个表达式是如何评价的?使用range循环时,这是避免常见错误的要点。

让我们看看下面的例子,它将一个元素附加到我们迭代的切片上。你相信循环会终止吗?

s := []int{0, 1, 2}
for range s {
    s = append(s, 10)
}

为了理解这个问题,我们应该知道当使用一个range循环时,所提供的表达式只计算一次,在循环开始之前。在这个上下文中,“求值”意味着提供的表达式被复制到一个临时变量,然后range迭代这个变量。在本例中,当对s表达式求值时,结果是一个切片副本,如图 4.1 所示。

图 4.1 s被复制到range使用的临时变量中。

range循环使用这个临时变量。原始切片s也在每次迭代期间更新。因此,在三次迭代之后,状态如图 4.2 所示。

图 4.2 临时变量仍然是一个三长度的切片;因此,迭代完成。

每一步都会追加一个新元素。然而,在三个步骤之后,我们已经检查了所有的元素。实际上,range使用的临时切片仍然是三长度切片。因此,循环在三次迭代后完成。

这种行为与传统的for循环有所不同:

s := []int{0, 1, 2}
for i := 0; i < len(s); i++ {
    s = append(s, 10)
}

在这个例子中,循环永远不会结束。在每次迭代中,len(s)表达式被求值,因为我们不断添加元素,所以我们永远不会到达终止状态。为了准确地使用 Go 循环,记住这一点是很重要的。

回到range操作符,我们应该知道我们描述的行为(表达式只计算一次)也适用于所有提供的数据类型。作为一个例子,让我们用另外两种类型来看看这种行为的含义:通道和数组。

4.2.1 通道

让我们看一个基于使用range循环迭代一个通道的具体例子。我们创建了两个 goroutines,都将元素发送到两个不同的通道。然后,在父 goroutine 中,我们使用一个range循环在一个通道上实现一个消费者,该循环试图在迭代期间切换到另一个通道:

ch1 := make(chan int, 3)     // ❶
go func() {
    ch1 <- 0
    ch1 <- 1
    ch1 <- 2
    close(ch1)
}()

ch2 := make(chan int, 3)     // ❷
go func() {
    ch2 <- 10
    ch2 <- 11
    ch2 <- 12
    close(ch2)
}()

ch := ch1                    // ❸
for v := range ch {          // ❹
    fmt.Println(v)
    ch = ch2                 // ❺
}

❶ 创建包含元素 0、1 和 2 的第一个通道

❷ 创建了包含元素 10、11 和 12 的第二个通道

❸ 将第一个通道分配给ch

❹ 通过遍历ch创建了一个通道消费者

❺ 将第二通道分配给ch

在这个例子中,同样的逻辑适用于如何求值range表达式。提供给range的表达式是一个指向ch1ch通道。因此,rangech求值,执行对临时变量的复制,并迭代这个通道中的元素。尽管有ch = ch2语句,但是range一直在ch1上迭代,而不是ch2:

0
1
2

然而,ch = ch2声明并不是没有效果。因为我们将ch赋给了第二个变量,如果我们在这段代码后调用close(ch),它将关闭第二个通道,而不是第一个。

现在让我们来看看range操作符在使用数组时只对每个表达式求值一次的影响。

4.2.2 数组

对数组使用range循环有什么影响?因为range表达式是在循环开始之前计算的,所以分配给临时循环变量的是数组的副本。让我们通过下面的例子来看看这个原则的实际应用,这个例子在迭代过程中更新了一个特定的数组索引:

a := [3]int{0, 1, 2}      // ❶
for i, v := range a {     // ❷
    a[2] = 10             // ❸
    if i == 2 {           // ❹
        fmt.Println(v)
    }
}

❶ 创建了一个由三个元素组成的数组

❷ 迭代数组

❸ 更新了最后一个元素

❹ 打印最后一个元素的内容

这段代码将最后一个索引更新为 10。但是,如果我们运行这段代码,它不会打印10;相反,它打印出2,如图 4.3 所示。

图 4.3 range迭代数组副本(左),同时循环修改a(右)。

正如我们提到的,range操作符创建了数组的副本。同时,循环不更新副本;它更新原始数组:a。所以最后一次迭代时v的值是2,而不是10

如果我们想打印最后一个元素的实际值,我们可以用两种方法:

  • 通过从索引中访问元素:

    a := [3]int{0, 1, 2}
    for i := range a {
        a[2] = 10
        if i == 2 {
            fmt.Println(a[2])     // ❶ 
        }
    }
    

    ❶ 访问[2]而不是范围值变量

    因为我们访问的是原始数组,这段代码打印的是2而不是10

  • 使用数组指针:

    a := [3]int{0, 1, 2}
    for i, v := range &a {     // ❶ 
        a[2] = 10
        if i == 2 {
            fmt.Println(v)
        }
    }
    

    ❶ 的范围超过 1000 英镑,而不是 1000 英镑

    我们将数组指针的副本分配给range使用的临时变量。但是因为两个指针引用同一个数组,所以访问v也会返回10

两个选项都有效。然而,第二个选项不会导致复制整个数组,这可能是在数组非常大的情况下需要记住的事情。

总之,range循环只对提供的表达式求值一次,在循环开始之前,通过复制(不考虑类型)。我们应该记住这种行为,以避免常见的错误,例如,可能导致我们访问错误的元素。

在下一节中,我们将看到如何使用带有指针的range循环来避免常见错误。

4.3 #32:忽略在范围循环中使用指针元素的影响

本节着眼于使用带有指针元素的range循环时的一个具体错误。如果我们不够谨慎,可能会导致我们引用错误的元素。让我们检查一下这个问题以及如何修复它。

在开始之前,让我们澄清一下使用指针元素切片或映射的基本原理。主要有三种情况:

  • 就语义而言,使用指针语义存储数据意味着共享元素。例如,以下方法包含将元素插入缓存的逻辑:

    type Store struct {
        m map[string]*Foo
    }
    
    func (s Store) Put(id string, foo *Foo) {
        s.m[id] = foo
        // ...
    }
    

    这里,使用指针语义意味着Foo元素由Put的调用者和Store结构共享。

  • 有时我们已经在操作指针了。因此,在集合中直接存储指针而不是值会很方便。

  • 如果我们存储大型结构,并且这些结构经常发生改变,我们可以使用指针来避免每次改变的复制和插入:

    func updateMapValue(mapValue map[string]LargeStruct, id string) {
        value := mapValue[id]              // ❶
        value.foo = "bar"
        mapValue[id] = value               // ❷
    }
    
    func updateMapPointer(mapPointer map[string]*LargeStruct, id string) {
        mapPointer[id].foo = "bar"         // ❸
    }
    

    ❶拷贝

    ❷插页

    ❸直接改变了映射元素

    因为updateMapPointer接受指针映射,所以foo字段的改变可以在一个步骤中完成。

现在是时候讨论一下range循环中指针元素的常见错误了。我们将考虑以下两种结构:

  • 一个代表客户的Customer结构

  • 一个Store,它保存了一个Customer指针的映射

type Customer struct {
    ID      string
    Balance float64
}

type Store struct {
    m map[string]*Customer
}

下面的方法迭代一片Customer元素,并将它们存储在m映射中:

func (s *Store) storeCustomers(customers []Customer) {
    for _, customer := range customers {
        s.m[customer.ID] = &customer         // ❶
    }
}

❶ 将customer指针存储在映射中

在这个例子中,我们使用操作符range对输入切片进行迭代,并将Customer指针存储在映射中。但是这种方法能达到我们预期的效果吗?

让我们用三个不同的Customer结构来调用它,试一试:

s.storeCustomers([]Customer{
    {ID: "1", Balance: 10},
    {ID: "2", Balance: -10},
    {ID: "3", Balance: 0},
})

如果我们打印映射,下面是这段代码的结果:

key=1, value=&main.Customer{ID:"3", Balance:0}
key=2, value=&main.Customer{ID:"3", Balance:0}
key=3, value=&main.Customer{ID:"3", Balance:0}

正如我们所看到的,不是存储三个不同的Customer结构,而是存储在映射中的所有元素都引用同一个Customer结构:3。我们做错了什么?

使用range循环迭代customers片,不管元素的数量,创建一个具有固定地址的单个customer变量。我们可以通过在每次迭代中打印指针地址来验证这一点:

func (s *Store) storeCustomers(customers []Customer) {
    for _, customer := range customers {
        fmt.Printf("%p\n", &customer)      // ❶
        s.m[customer.ID] = &customer
    }
}
0xc000096020
0xc000096020
0xc000096020

❶ 打印customer地址

为什么这很重要?让我们检查一下每个迭代:

  • 在第一次迭代中,customer引用第一个元素:Customer 1。我们存储了一个指向customer结构的指针。

  • 在第二次迭代中,customer现在引用了另一个元素:Customer 2。我们还存储了一个指向customer结构的指针。

  • 最后,在最后一次迭代中,customer引用最后一个元素:Customer 3。同样,相同的指针存储在映射中。

在迭代结束时,我们已经在映射中存储了同一个指针三次(见图 4.4)。这个指针的最后一个赋值是对切片的最后一个元素的引用:Customer 3。这就是为什么所有映射元素都引用同一个Customer

图 4.4customer变量有一个常量地址,所以我们在映射中存储了相同的指针。

那么,我们如何解决这个问题呢?有两种主要的解决方案。第一个类似于我们在错误 1 中看到的,“非预期的变量隐藏”它需要创建一个局部变量:

func (s *Store) storeCustomers(customers []Customer) {
    for _, customer := range customers {
        current := customer                 // ❶
        s.m[current.ID] = &current          // ❷
    }
}

❶ 创建一个本地current变量

❷ 将这个指针存储在映射中

在这个例子中,我们不存储引用customer的指针;相反,我们存储一个引用current的指针。current是在每次迭代中引用唯一Customer的变量。因此,在循环之后,我们在映射中存储了引用不同Customer结构的不同指针。另一种解决方案是使用片索引存储引用每个元素的指针:

func (s *Store) storeCustomers(customers []Customer) {
    for i := range customers {
        customer := &customers[i]        // ❶
        s.m[customer.ID] = customer      // ❷
    }
}

❶ 给customer分配一个i元素的指针

❷ 存储customer指针

在这个解决方案中,customer现在是一个指针。因为它是在每次迭代中初始化的,所以它有一个唯一的地址。因此,我们在映射中存储不同的指针。

当使用一个range循环迭代一个数据结构时,我们必须记住所有的值都被分配给一个具有唯一地址的唯一变量。因此,如果我们在每次迭代中存储一个引用这个变量的指针,我们将会在这样一种情况下结束:我们存储了引用同一个元素的同一个指针:最新的元素。我们可以通过在循环范围内强制创建一个局部变量或者创建一个指针通过它的索引引用一个切片元素来解决这个问题。两种解决方案都可以。还要注意,我们将切片数据结构作为输入,但是问题与映射类似。

在下一节中,我们将看到与映射迭代相关的常见错误。

4.4 #33:在映射迭代过程中做出错误的假设

对映射进行迭代是误解和错误的常见来源,主要是因为开发人员做出了错误的假设。在本节中,
我们讨论两种不同的情况:

  • 排序

  • 迭代期间的映射更新

我们将看到两个基于错误假设的常见错误。

4.4.1 排序

关于排序,我们需要了解映射数据结构的一些基本行为:

  • 它不保持数据按键排序(映射不是基于二叉树)。

  • 它不保留数据添加的顺序。例如,如果我们在对 B 之前插入对 A,我们不应该根据这个插入顺序做出任何假设。

此外,当迭代一个映射时,我们根本不应该做任何排序假设。让我们来看看这句话的含义。

我们将考虑图 4.5 所示的映射,由四个桶组成(元素代表键)。后备数组的每个索引引用一个给定的桶。

图 4.5 有四个桶的映射

现在,让我们使用一个range循环来迭代这个映射,并打印所有的键:

for k := range m {
    fmt.Print(k)
}

我们提到过数据不是按键排序的。因此,我们不能期望这段代码打印出acdeyz。与此同时,我们说过映射不保留插入顺序。因此,我们也不能期望代码打印出ayzcde

但是我们至少可以期望代码按照键当前存储在映射中的顺序打印键吧?不,这个也不行。在 Go 中,映射上的迭代顺序不是指定的。也不能保证从一次迭代到下一次迭代的顺序是相同的。我们应该记住这些映射行为,这样我们就不会把代码建立在错误的假设上。

我们可以通过运行前面的循环两次来确认所有这些语句:

zdyaec
czyade

正如我们所看到的,每次迭代的顺序都是不同的。

注意尽管迭代顺序没有保证,但迭代分布并不均匀。这就是为什么官方的 Go 规范声明迭代是未指定的,而不是随机的。

那么为什么 Go 有如此惊人的方法来迭代映射呢?这是语言设计者有意识的选择。他们想添加一些随机性,以确保开发人员在使用映射时不会依赖任何排序假设(见 mng.bz/M2JW )。

因此,作为 Go 开发者,我们不应该在迭代一个映射时对排序做任何假设。然而,让我们注意使用来自标准库或外部库的包会导致不同的行为。例如,当encoding/json包将一个映射整理到 JSON 中时,它按照键的字母顺序对数据进行重新排序,而不考虑插入顺序。但这并不是 Go映射本身的属性。如果需要排序,我们应该依赖其他数据结构,比如二进制堆(GoDS 库在 github.com/emirpasic/gods 包含有用的数据结构实现)。

现在让我们看看第二个错误,它与迭代映射时更新映射有关。

4.4.2 迭代期间的映射插入

在 Go 中,允许在迭代过程中更新映射(插入或删除元素);它不会导致编译错误或运行时错误。然而,在迭代过程中向映射中添加条目时,我们应该考虑另一个方面,以避免不确定的结果。

让我们来看看下面这个迭代一个map[int]bool的例子。如果偶对值为真,我们添加另一个元素。你能猜到这段代码的输出是什么吗?

m := map[int]bool{
    0: true,
    1: false,
    2: true,
}

for k, v := range m {
    if v {
        m[10+k] = true
    }
}

fmt.Println(m)

这段代码的结果是不可预测的。如果我们多次运行此代码,下面是一些结果示例:

map[0:true 1:false 2:true 10:true 12:true 20:true 22:true 30:true]
map[0:true 1:false 2:true 10:true 12:true 20:true 22:true 30:true 32:true]
map[0:true 1:false 2:true 10:true 12:true 20:true]

为了理解其中的原因,我们必须阅读 Go 规范对迭代过程中的新映射条目的描述:

如果映射条目是在迭代过程中创建的,它可能是在迭代过程中生成的,也可能被跳过。对于创建的每个条目,以及从一次迭代到下一次迭代,选择可能会有所不同。

因此,当一个元素在迭代过程中被添加到一个映射中时,它可能会在后续的迭代过程中产生,也可能不会产生。作为 Go 开发者,我们没有任何方法来强制执行这种行为。它也可能因迭代而异,这就是为什么我们三次得到不同的结果。

记住这种行为以确保我们的代码不会产生不可预测的输出是很重要的。如果我们想在迭代时更新映射,并确保添加的条目不是迭代的一部分,一种解决方案是处理映射的副本,如下所示:

m := map[int]bool{
    0: true,
    1: false,
    2: true,
}
m2 := copyMap(m)            // ❶

for k, v := range m {
    m2[k] = v
    if v {
        m2[10+k] = true     // ❷
    }
}

fmt.Println(m2)

❶ 创建了初始映射的副本

❷ 更新m2而不是m

在本例中,我们将正在读取的映射与正在更新的映射分离开来。事实上,我们一直在迭代m,但是更新是在m2完成的。这个新版本创建了可预测和可重复的输出:

map[0:true 1:false 2:true 10:true 12:true]

总而言之,当我们使用映射时,我们不应该依赖以下内容:

  • 数据按键排序

  • 插入顺序的保留

  • 确定性迭代顺序

  • 在添加元素的同一次迭代中产生的元素

记住这些行为应该有助于我们避免基于错误假设的常见错误。

在下一节中,我们将看到一个在中断循环时经常犯的错误。

4.5 #34:忽略break语句的工作方式

一个break语句是常用来终止一个循环的执行。当循环与switchselect一起使用时,开发人员经常会犯破坏错误语句的错误。

让我们看看下面的例子。我们在和for循环中实现了一个switch。如果循环索引的值为2,我们想要中断循环:

for i := 0; i < 5; i++ {
    fmt.Printf("%d ", i)

    switch i {
    default:
    case 2:
        break      // ❶
    }
}

❶ 如果i等于 2,就break

这段代码乍一看可能没错;然而,它并没有做我们所期望的。break语句没有终止循环:相反,它终止了语句switch。因此,这段代码不是从 0 迭代到 2,而是从 0 迭代到 4: 0 1 2 3 4

要记住的一个基本规则是,break语句终止最里面的forswitchselect语句的执行。在前面的例子中,它终止了switch语句。

那么我们如何编写代码来打破循环而不是switch语句的?最惯用的方法是使用标签:

loop:                           // ❶
    for i := 0; i < 5; i++ {
        fmt.Printf("%d ", i)

        switch i {
        default:
        case 2:
            break loop          // ❷
        }
    }

❶ 定义了一个loop标签

❷ 终止的是附在loop标签上的循环,而不是switch

这里,我们将标签与for循环联系起来。然后,因为我们向break语句提供了loop标签,所以它中断了循环,而不是切换。因此,这个新版本将打印0 1 2,正如我们所料。

带标签的break是不是跟goto一样?

一些开发人员可能会质疑带有标签的break是否是惯用的,并将它视为一个花哨的goto语句。然而,事实并非如此,标准库中使用了这样的代码。例如,我们在从缓冲区读取行时,在net/http包中看到这个:

readlines:
    for {
        line, err := rw.Body.ReadString('\n')
        switch {
        case err == io.EOF:
            break readlines
        case err != nil:
            t.Fatalf("unexpected error reading from CGI: %v", err)
        }
        // ...
    }

这个例子使用了一个带有readlines的表达性标签来强调循环的目标。因此,我们应该考虑使用标签来中断语句,这是 Go 中惯用的方法。

循环内的select也可能会中断错误的语句。在下面的代码中,我们想在两种情况下使用select,如果上下文取消,则中断循环:

for {
    select {
    case <-ch:
        // Do something
    case <-ctx.Done():
        break             // ❶
    }
}

如果上下文取消,❶会中断

这里最里面的forswitchselect语句是的select语句,而不是for循环。因此,循环重复。同样,为了打破循环本身,我们可以使用一个标签:

loop:                          // ❶
    for {
        select {
        case <-ch:
            // Do something
        case <-ctx.Done():
            break loop         // ❷
        }
    }

❶ 定义了一个loop标签

❷ 终止附加到loop标签的循环,而不是select

现在,正如预期的那样,break语句中断了循环,而不是select

注意,我们也可以使用带标签的continue来进入带标签循环的下一次迭代。

在循环中使用语句switchselect时,我们应该保持谨慎。当使用break时,我们应该始终确保我们知道它将影响哪个语句。正如我们所见,使用标签是强制中断特定语句的惯用解决方案。

在本章的最后一节,我们继续讨论循环,但这次是结合关键字defer来讨论。

4.6 #35:在循环中使用defer

defer语句延迟一个调用的执行,直到周围的函数返回。它主要用于减少样板代码。例如,如果一个资源最终必须关闭,我们可以使用defer来避免在每个return之前重复关闭调用。然而,一个常见的错误是没有意识到在循环中使用defer的后果。让我们来研究一下这个问题。

我们将实现一个打开一组文件的函数,其中的文件路径是通过一个通道接收的。因此,我们必须遍历这个通道,打开文件,并处理闭包。这是我们的第一个版本:

func readFiles(ch <-chan string) error {
    for path := range ch {                    // ❶
        file, err := os.Open(path)            // ❷
        if err != nil {
            return err
        }

        defer file.Close()                    // ❸

        // Do something with file
    }
    return nil
}

❶ 迭代通道

❷ 打开文件

❸ 延迟调用file.Close

注意我们将讨论如何处理错误#54“不处理延迟错误”中的延迟错误

这种实现有一个很大的问题。我们必须回忆一下,当包围函数返回时,defer调度一个函数调用。在这种情况下,延迟调用不是在每次循环迭代中执行,而是在readFiles函数返回时执行。如果readFiles没有返回,文件描述符将永远保持打开,导致泄漏。

有什么办法可以解决这个问题?一种可能是去掉defer,手动处理文件关闭。但是如果我们那样做,我们将不得不放弃 Go 工具集的一个方便的特性,仅仅因为我们在一个循环中。那么,如果我们想继续使用defer,有哪些选择呢?我们必须围绕defer创建另一个在每次迭代中调用的周围函数。

例如,我们可以实现一个readFile函数来保存接收到的每个新文件路径的逻辑:

func readFiles(ch <-chan string) error {
    for path := range ch {
        if err := readFile(path); err != nil {    // ❶
            return err
        }
    }
    return nil
}

func readFile(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return err
    }

    defer file.Close()                            // ❷

    // Do something with file
    return nil
}

❶ 调用包含主逻辑的readFile函数

❷ 延迟调用file.Close

在这个实现中,当readFile返回时,调用defer函数,这意味着在每次迭代结束时。因此,在父readFiles函数返回之前,我们不会打开文件描述符。

另一种方法是让readFile函数成为一个闭包:

func readFiles(ch <-chan string) error {
    for path := range ch {
        err := func() error {
            // ...
            defer file.Close()
            // ...
        }()                  // ❶
        if err != nil {
            return err
        }
    }
    return nil
}

❶ 运行提供的闭包

但本质上,这仍然是相同的解决方案:在每次迭代中添加另一个周围函数来执行defer调用。普通的旧函数的优点是可能更清晰一点,我们也可以为它编写一个特定的单元测试。

当使用defer时,我们必须记住,当周围的函数返回时,它调度一个函数调用。因此,在一个循环中调用defer将会堆叠所有的调用:它们不会在每次迭代中执行,例如,如果循环没有终止,这可能会导致内存泄漏。解决这个问题最方便的方法是在每次迭代中引入另一个要调用的函数。但是如果性能是至关重要的,一个缺点是函数调用增加了开销。如果我们有这样的情况,并且我们想要防止这种开销,我们应该去掉defer并且在循环之前手动处理延迟调用。

总结

  • 循环range中的值元素是一个副本。因此,例如,要改变一个结构,可以通过它的索引或者通过一个经典的for循环来访问它(除非你想要修改的元素或者字段是一个指针)。

  • 了解传递给range操作符的表达式在循环开始前只计算一次,可以帮助您避免常见的错误,如通道或片迭代中的低效赋值。

  • 使用局部变量或使用索引访问元素,可以防止在循环内复制指针时出错。

  • 为了在使用映射时确保可预测的输出,请记住映射数据结构

    • 不按键排序数据
    • 不保留插入顺序
    • 没有确定的迭代顺序
    • 不保证在一次迭代中添加的元素会在这次迭代中产生
  • 使用带标签的breakcontinue强制中断特定语句。这对循环中的switchselect语句很有帮助。

  • 提取函数内部的循环逻辑会导致在每次迭代结束时执行一个defer语句。

五、字符串

本章涵盖

  • 理解GO中符文的基本概念
  • 通过字符串迭代和修剪防止常见错误
  • 避免因字符串连接或无用转换而导致的低效代码
  • 用子字符串避免内存泄漏

在 Go 中,字符串是一种不可变的数据结构,包含以下内容:

  • 指向不可变字节序列的指针

  • 该序列中的总字节数

我们将在本章中看到 Go 有一个非常独特的处理字符串的方法。Go 引入了一个概念叫做符文;这个概念对于理解是必不可少的,可能会让新手感到困惑。一旦我们知道了字符串是如何被管理的,我们就可以避免在字符串上迭代时的常见错误。我们还将看看 Go 开发者在使用或生成字符串时所犯的常见错误。此外,我们会看到有时我们可以直接使用[]byte工作,避免额外的分配。最后,我们将讨论如何避免一个常见的错误,这个错误会造成子字符串的泄漏。本章的主要目的是通过介绍常见的字符串错误来帮助你理解字符串在 Go 中是如何工作的。

5.1 #36:不理解符文的概念

如果不讨论GO中的符文概念,我们就不能开始这一章。正如您将在接下来的部分中看到的,这个概念是彻底理解如何处理字符串和避免常见错误的关键。但是在深入研究 Go runes 之前,我们需要确保我们在一些基本的编程概念上是一致的。

我们应该理解字符集和编码之间的区别:

  • 字符集,顾名思义,就是一组字符。例如,Unicode 字符集包含 2^21 字符。

  • 编码是字符列表的二进制翻译。例如,UTF-8 是一种能够以可变字节数(从 1 到 4 个字节)对所有 Unicode 字符进行编码的编码标准。

我们提到字符是为了简化字符集的定义。但是在Unicode中,我们使用码位的概念来指代由单个值表示的项。例如,在汉字符由U+6C49代码点标识。使用UTF-8,汉使用三个字节编码:0xE60xB10x89。为什么这很重要?因为在 GO 中,一个符文就是一个Unicode码位。

同时,我们提到 UTF-8 将字符编码成 1 到 4 个字节,因此,最多 32 位。这就是为什么在GO中,符文是int32的别名:

type rune = int32

关于 UTF-8,另一件要强调的事情是:有些人认为GO字符串总是 UTF-8,但这不是真的。让我们考虑下面的例子:

s := "hello"

我们将一个字符串字面值(一个字符串常量)赋给s。在GO中,源代码是用 UTF 8 编码的。因此,所有的字符串都使用 UTF-8 编码成一个字节序列。然而,字符串是任意字节的序列;它不一定基于 UTF-8。因此,当我们操作一个不是从字符串初始化的变量时(例如,从文件系统中读取),我们不能假定它使用 UTF-8 编码。

注意 golang.org/x ,一个为标准库提供扩展的库,包含使用 UTF-16 和 UTF-32 的包。

让我们回到hello的例子。我们有一个由五个字符组成的字符串:helli

这些简单的字符每个都用一个字节进行编码。这就是为什么得到s的长度会返回5:

s := "hello"
fmt.Println(len(s)) // 5

但是一个字符并不总是被编码成一个字节。回到字符,我们提到了UTF-8,这个字符被编码成三个字节。我们可以用下面的例子来验证这一点:

s := "汉"
fmt.Println(len(s)) // 3

这个例子打印的不是1,而是3。事实上,对字符串应用的len内置函数不会返回字符数;它返回字节数。

相反,我们可以从字节列表中创建一个字符串。我们提到过汉字符使用三个字节编码,即0xE6、0xB1和0x89:

s := string([]byte{0xE6, 0xB1, 0x89})
fmt.Printf("%s\n", s)

这里,我们构建一个由这三个字节组成的字符串。当我们打印字符串时,代码打印的不是三个字符,而是一个字符:

总而言之:

  • 字符集是一组字符,而编码描述了如何将字符集转换成二进制。

  • 在 Go 中,一个字符串引用一个不可变的任意字节切片。

  • Go 源代码使用 UTF-8 编码。因此,所有字符串都是 UTF 8 字符串。但是因为一个字符串可以包含任意字节,如果它是从其他地方(不是源代码)获得的,就不能保证它是基于 UTF-8 编码的。

  • 一个符文对应一个 Unicode 码点的概念,意思是用单个值表示的物品。

  • 使用 UTF-8,一个 Unicode 码位可以编码成 1 到 4 个字节。

  • 在 Go 中对一个字符串使用len返回字节数,而不是符文数。

记住这些概念是必要的,因为在GO中,符文无处不在。让我们来看看这一知识的具体应用,它涉及一个与字符串迭代相关的常见错误。

5.2 #37:不准确的字符串迭代

对字符串进行迭代是开发者的常用操作。也许我们希望对字符串中的每个符文执行一次操作,或者实现一个自定义函数来搜索特定的子字符串。在这两种情况下,我们都必须迭代一个字符串的不同符文。但是很容易混淆迭代是如何工作的。

我们来看一个具体的例子。这里,我们要打印字符串中不同的符文及其对应的位置:

s := "hêllo"            // ❶
for i := range s {
    fmt.Printf("position %d: %c\n", i, s[i])
}
fmt.Printf("len=%d\n", len(s))

❶ 字符串字面值包含一个特殊的符文:ê

我们使用range操作符对进行迭代,然后我们希望使用字符串中的索引打印每个符文。以下是输出结果:

position 0: h
position 1: Ã
position 3: l
position 4: l
position 5: o
len=6

这段代码没有做我们想要的事情。让我们强调三点:

  • 第二个符文是输出中的Ã而不是ê

  • 我们从位置 1 跳到位置 3:位置 2 是什么?

  • len返回 6 的计数,而s只包含 5 个符文。

让我们从最后一个观察开始。我们已经提到过len返回一个字符串中的字节数,而不是符文数。因为我们给s分配了一个字符串字面值,s是一个 UTF-8 字符串。同时,特殊字符ê不是用一个字节编码的;它需要 2 个字节。因此,调用len(s)会返回6

计算一串符文的数量

如果我们想得到一个字符串中符文的个数,而不是字节数呢?我们如何做到这一点取决于编码。

在前面的例子中,因为我们给s分配了一个字符串,所以我们可以使用unicode/utf8包中的:

fmt.Println(utf8.RuneCountInString(s)) // 5

让我们回到迭代中来理解剩余的惊喜:

for i := range s {
    fmt.Printf("position %d: %c\n", i, s[i])
}

我们必须认识到,在这个例子中,我们没有迭代每个符文;相反,我们迭代一个符文的每个起始索引,如图 5.1 所示。

图 5.1 打印s[i]打印索引i处每个字节的 UTF-8 表示。

打印s[i]不打印第i个符文;它打印索引i处字节的 UTF-8 表示。因此,我们印了hÃllo而不是hêllo。那么,如果我们想打印所有不同的符文,我们如何修复代码呢?有两个主要选项。

我们必须使用range操作符的值元素:

s := "hêllo"
for i, r := range s {
    fmt.Printf("position %d: %c\n", i, r)
}

我们使用了r变量,而不是使用s[i]来打印符文。在上使用range循环,字符串返回两个变量,符文的起始索引和符文本身:

position 0: h
position 1: ê
position 3: l
position 4: l
position 5: o

另一种方法是将字符串转换成一片符文,并对其进行迭代:

s := "hêllo"
runes := []rune(s)
for i, r := range runes {
    fmt.Printf("position %d: %c\n", i, r)
}
position 0: h
position 1: ê
position 2: l
position 3: l
position 4: o

在这里,我们使用[]rune(s)s转换成一片符文。然后我们迭代这个切片,使用range操作符的值元素打印所有的符文。唯一的区别与位置有关:代码直接打印符文的索引,而不是打印符文字节序列的起始索引。

请注意,与前一个解决方案相比,这个解决方案引入了运行时开销。事实上,将一个字符串转换成一片符文需要分配一个额外的片并将字节转换成符文:一个O(n)的时间复杂度,n 是字符串中的字节数。因此,如果我们想迭代所有的符文,我们应该使用第一种解决方案。

但是,如果我们想用第一个选项访问一个字符串的第i个符文,我们没有访问符文索引的权限;相反,我们知道一个符文在字节序列中的起始索引。因此,在大多数情况下,我们应该倾向于第二种选择:

s := "hêllo"
r := []rune(s)[4]
fmt.Printf("%c\n", r) // o

这段代码打印第四个符文,首先将字符串转换成一个符文片。

访问特定符文的可能优化

如果一个字符串由单字节的符文组成,那么一个优化是可能的:例如,如果字符串包含字母AZaz。我们可以通过使用s[i]直接访问字节来访问第i个符文,而不用将整个字符串转换成一片符文:

s := "hello"
fmt.Printf("%c\n", rune(s[4])) // o

总之,如果我们想要迭代一个字符串的符文,我们可以直接在字符串上使用range循环。但是我们必须记住,索引并不对应于符文索引,而是对应于符文字节序列的起始索引。因为一个符文可以由多个字节组成,所以如果我们要访问符文本身,应该使用range的值变量,而不是字符串中的索引。同时,如果我们对得到一个字符串的第i个符文感兴趣,我们应该在大多数情况下将该字符串转换成一片符文。

在下一节中,我们来看看在和strings包中使用trim函数时常见的混淆来源。

5.3 #38:误用trim函数

GO开发者在使用strings包时的一个常见错误是将的TrimRightTrimSuffix混在一起。这两个函数的目的相似,很容易混淆。让我们来看看。

在下面的例子中,我们使用TrimRight。这段代码的输出应该是什么?

fmt.Println(strings.TrimRight("123oxo", "xo"))

答案是123。这是你所期望的吗?如果没有,你可能期待的是TrimSuffix的结果。让我们回顾一下这两个函数。

移除给定集合中包含的所有尾随符文。在我们的例子中,我们作为一个集合xo传递,它包含两个符文:xo。图 5.2 显示了逻辑。

图 5.2 TrimRight向后迭代,直到找到一个不属于集合的符文。

在每个符文上向后迭代。如果某个符文是所提供符文的一部分,该函数会将其移除。如果没有,函数停止迭代并返回剩余的字符串。这就是我们的例子返回123的原因。

另一方面,TrimSuffix返回一个没有提供尾随后缀的字符串:

fmt.Println(strings.TrimSuffix("123oxo", "xo"))

因为123oxoxo结尾,所以这段代码打印123o。此外,删除尾部后缀不是一个重复的操作,所以TrimSuffix("123xoxo", "xo")返回123xo

对于带有TrimLeftTrimPrefix的字符串的左侧,原理是相同的:

fmt.Println(strings.TrimLeft("oxo123", "ox")) // 123
fmt.Println(strings.TrimPrefix("oxo123", "ox")) /// o123

strings.TrimLeft移除一组符文中的所有前导符文,并因此打印123TrimPrefix删除提供的前导前缀,打印o123

与这个主题相关的最后一个注意事项:Trim在一个字符串上同时应用TrimLeftTrimRight。因此,它删除了集合中包含的所有前导和尾随符文:

fmt.Println(strings.Trim("oxo123oxo", "ox")) // 123

总之,我们必须确保理解TrimRight / TrimLeftTrimSuffix / TrimPrefix之间的区别:

  • TrimRight / TrimLeft移除一组中的尾随/前导符文。

  • TrimSuffix / TrimPrefix删除给定的后缀/前缀。

在下一节中,我们将深入研究字符串连接。

5.4 #39:优化不足的字符串连接

当谈到连接字符串时,Go 中有两种主要的方法,其中一种在某些情况下效率很低。让我们检查这个主题,以了解我们应该支持哪个选项以及何时支持。

让我们编写一个concat函数,使用+=操作符连接一个片的所有字符串元素:

func concat(values []string) string {
    s := ""
    for _, value := range values {
        s += value
    }
    return s
}

在每次迭代中,+=操作符将svalue字符串连接起来。乍一看,这个函数可能不会出错。但是在这个实现中,我们忘记了字符串的一个核心特征:它的不变性。所以每次迭代不更新s;它会在内存中重新分配一个新字符串,这会显著影响该函数的性能。

幸运的是,有一个解决这个问题的方法,使用strings包和Builder结构:

func concat(values []string) string {
    sb := strings.Builder{}               // ❶
    for _, value := range values {
        _, _ = sb.WriteString(value)      // ❷
    }
    return sb.String()                    // ❸
}

❶ 创建了strings.Builder

❷ 追加了一个字符串

❸ 返回结果字符串

首先,我们使用的零值创建了一个strings.Builder结构。在每一次迭代中,我们通过调用WriteString方法来构造结果字符串,这个方法让将value的内容附加到它的内部缓冲区中,从而最小化内存复制。

注意WriteString返回一个错误作为第二个输出,但是我们故意忽略它。事实上,这个方法永远不会返回非零错误。那么这个方法返回一个错误作为其签名的一部分的目的是什么呢?strings.Builder实现的io.StringWriter接口,其中包含一个单独的方法:WriteString(s string) (n int, err error)。因此,为了符合这个接口,WriteString必须返回一个错误。

注意,我们将讨论错误#53“不处理错误”中惯用的忽略错误

使用strings.Builder,我们还可以追加

  • 字节切片使用Write

  • 单字节使用WriteByte

  • 单个符文使用WriteRune

在内部,strings.Builder保存一个字节切片。对WriteString的每次调用都会导致对该片上的append的调用。有两个影响。首先,这个结构不应该同时使用,因为对append的调用会导致竞争条件。第二个影响是我们在错误#21“低效的片初始化”中看到的:如果片的未来长度是已知的,我们应该预分配它。为此,strings.Builder公开了一个方法Grow(n int)来保证另外的n字节的空间。

让我们通过用总字节数调用Grow来编写另一个版本的concat方法:

func concat(values []string) string {
    total := 0
    for i := 0; i < len(values); i++ {     // ❶
        total += len(values[i])
    }

    sb := strings.Builder{}
    sb.Grow(total)                         // ❷
    for _, value := range values {
        _, _ = sb.WriteString(value)
    }
    return sb.String()
}

❶ 遍历每个字符串来计算总字节数

❷ 使用这个总数调用sb.Grow

在迭代之前,我们计算最终字符串包含的总字节数,并将结果赋给total。注意,我们对符文的数量不感兴趣,而是对字节的数量感兴趣,所以我们使用了len函数。然后我们调用Grow来保证在遍历字符串之前有total字节的空间。

让我们运行一个基准来比较三个版本(v1 使用+=;v2 使用strings.Builder{}无预分配;和 v3 使用带有预分配的strings.Builder{})。输入片段包含 1,000 个字符串,每个字符串包含 1,000 个字节:

BenchmarkConcatV1-4             16      72291485 ns/op
BenchmarkConcatV2-4           1188        878962 ns/op
BenchmarkConcatV3-4           5922        190340 ns/op

正如我们所见,最新版本是迄今为止效率最高的:比 v1 快 99%,比 v2 快 78%。我们可能会问自己,在输入片上迭代两次如何能使代码更快?答案在于错误#21,“低效的片初始化”:如果一个片没有被分配给给定的长度或容量,该片将在每次变满时继续增长,导致额外的分配和拷贝。因此,在这种情况下,迭代两次是最有效的选择。

strings.Builder是连接字符串列表的推荐解决方案。通常,这种解决方案应该在循环中使用。事实上,如果我们只需要连接几个字符串(比如一个名字和一个姓氏),不推荐使用strings.Builder,因为这样做会使代码的可读性比使用+=操作符或fmt.Sprintf差一些。

一般来说,我们可以记住,从性能角度来看,从我们必须连接超过五个字符串的那一刻起,strings.Builder解决方案就比快。尽管这个确切的数字取决于许多因素,如连接字符串的大小和机器,但这可以作为帮助我们决定何时选择一个解决方案的经验法则。同样,我们不应该忘记,如果未来字符串的字节数是预先知道的,我们应该使用Grow方法来预分配内部字节切片。

接下来,我们将讨论bytes包和为什么它可以防止无用的字符串转换。

5.5 #40:无用的字符串转换

当选择使用字符串还是使用[]byte时,为了方便起见,大多数程序员倾向于使用字符串。但是大多数 I/O 实际上都是用[]byte完成的。比如io.Readerio.Writerio.ReadAll用的是[]byte,不是字符串。因此,处理字符串意味着额外的转换,尽管bytes包包含许多与strings包相同的操作。

让我们看一个我们不该做什么的例子。我们将实现一个getBytes函数,它将一个io.Reader作为输入,从中读取,并调用一个sanitize函数。清理将通过修剪所有前导和尾随空格来完成。这里是getBytes的骨架:

func getBytes(reader io.Reader) ([]byte, error) {
    b, err := io.ReadAll(reader)                    // ❶
    if err != nil {
        return nil, err
    }
    // Call sanitize
}

b是一个[]byte

我们调用ReadAll并将字节切片分配给b。怎样才能实现sanitize函数?一种选择可能是使用strings包创建一个sanitize(string) string函数:

func sanitize(s string) string {
    return strings.TrimSpace(s)
}

现在,回到getBytes:当我们操作一个[]byte时,在调用sanitize之前,我们必须首先将它转换成一个字符串。然后我们必须将结果转换回一个[]byte,因为getBytes返回一个字节切片:

return []byte(sanitize(string(b))), nil

这个实现有什么问题?我们要付出额外的代价,先把一个[]byte转换成一个字符串,再把一个字符串转换成一个[]byte。就内存而言,每一次转换都需要额外的分配。事实上,即使一个字符串由一个[]byte支持,将一个[]byte转换成一个字符串也需要一个字节切片的副本。这意味着一个新的内存分配和所有字节的副本。

字符串不变性

我们可以使用下面的代码来测试从[]byte创建一个字符串导致一个副本的事实:

b := []byte{'a', 'b', 'c'}
s := string(b)
b[1] = 'x'
fmt.Println(s)

运行这段代码会打印出abc,而不是axc。的确,在GO中,一个字符串是不可变的。

那么,应该如何实现sanitize函数呢?我们应该操作一个字节切片,而不是接受和返回一个字符串:

func sanitize(b []byte) []byte {
    return bytes.TrimSpace(b)
}

bytes包还有一个函数来修剪所有的前导和尾随空白。然后,调用sanitize函数不需要任何额外的转换:

return sanitize(b), nil

正如我们提到的,大多数 I/O 是通过[]byte完成的,而不是字符串。当我们想知道我们应该使用字符串还是[]byte时,让我们回忆一下使用[]byte并不一定不方便。实际上,包中所有导出的函数在包中也有替代:SplitCountContainsIndex等等。因此,无论我们是否正在进行 I/O,我们都应该首先检查我们是否可以使用字节而不是字符串来实现整个工作流,并避免额外转换的代价。

本章的最后一节讨论了子串操作有时会导致内存泄漏的情况。

5.6 #41:子字符串和内存泄漏

在错误 26“切片和内存泄漏”中,我们看到了切片或数组如何导致内存泄漏。这个原则也适用于字符串和子字符串操作。首先,我们将看到如何在 Go 中处理子字符串以防止内存泄漏。

要提取字符串的子集,我们可以使用以下语法:

s1 := "Hello, World!"
s2 := s1[:5] // Hello

s2被构造为s1的子串。这个例子从前五个字节创建一个字符串,而不是前五个符文。因此,我们不应该在用多字节编码的符文中使用这种语法。相反,我们应该首先将输入字符串转换成[]rune类型:

s1 := "Hêllo, World!"
s2 := string([]rune(s1)[:5]) // Hêllo

既然我们已经对子串操作有了新的认识,让我们来看一个具体的问题来说明可能的内存泄漏。

我们将接收字符串形式的日志消息。每个日志将首先用一个通用的唯一标识符(UUID;36 个字符)后跟消息本身。我们希望将这些 UUID 存储在内存中:例如,保存最新的n个 UUID 的缓存。我们还应该注意到,这些日志消息可能非常大(高达数KB)。下面是我们的实现:

func (s store) handleLog(log string) error {
    if len(log) < 36 {
        return errors.New("log is not correctly formatted")
    }
    uuid := log[:36]
    s.store(uuid)
    // Do something
}

为了提取 UUID,我们使用带有log[:36]的子串操作,因为我们知道 UUID 编码为 36 字节。然后我们将这个uuid变量传递给store方法,后者会将它存储在内存中。这个解决方案有问题吗?是的,它是。

在进行子串操作时,Go 规范并没有指定结果字符串和子串操作中涉及的字符串是否应该共享相同的数据。然而,标准的 Go 编译器确实让它们共享相同的后备数组,这可能是内存和性能方面的最佳解决方案,因为它防止了新的分配和复制。

我们提到过,日志消息可能会非常多。log[:36]将创建一个引用相同后备数组的新字符串。因此,我们存储在内存中的每个uuid字符串将不仅包含 36 个字节,还包含初始log字符串中的字节数:潜在地,数KB。

我们如何解决这个问题?通过制作子字符串的深度副本,使uuid的内部字节切片引用一个只有 36 个字节的新后备数组:

func (s store) handleLog(log string) error {
    if len(log) < 36 {
        return errors.New("log is not correctly formatted")
    }
    uuid := string([]byte(log[:36]))     // ❶
    s.store(uuid)
    // Do something
}

❶ 执行一个[]byte,然后是一个字符串转换

复制是通过首先将子串转换成[]byte,然后再转换成字符串来执行的。通过这样做,我们防止了内存泄漏的发生。uuid字符串由一个仅包含 36 个字节的数组支持。

注意,一些 ide 或 linters 可能警告说string([]byte(s))转换是不必要的。例如,Go JetBrains IDE GoLand 会对冗余的类型转换发出警告。从我们把一个字符串转换成一个字符串的意义上来说这是真的,但是这个操作有实际的效果。如前所述,它防止新字符串被与uuid相同的数组支持。我们需要意识到 ide 或 linters 发出的警告有时可能是不准确的。

注意因为字符串主要是一个指针,所以调用函数来传递字符串不会导致字节的深度复制。复制的字符串仍将引用相同的支持数组。

从 Go 1.18 开始,标准库还包括一个带有strings.Clone的解决方案,它返回一个字符串的新副本:

uuid := strings.Clone(log[:36])

调用strings.Clone会将log[:36]的副本放入新的分配中,从而防止内存泄漏。

在 Go 中使用子串操作时,我们需要记住两件事。第一,提供的区间是基于字节数,而不是符文数。其次,子字符串操作可能导致内存泄漏,因为结果子字符串将与初始字符串共享相同的支持数组。防止这种情况发生的解决方案是手动执行字符串复制或使用 Go 1.18 中的strings.Clone

总结

  • 理解符文对应于 Unicode 码位的概念,并且它可以由多个字节组成,这应该是 Go 开发者的核心知识的一部分,以便准确地处理字符串。

  • range操作符在字符串上迭代,在符文上迭代的索引对应于符文字节序列的起始索引。要访问特定的符文索引(如第三个符文),将字符串转换为[]rune

  • strings.TrimRight / strings.TrimLeft删除给定集合中包含的所有尾随/前导符文,而strings.TrimSuffix / strings.TrimPrefix返回一个没有提供后缀/前缀的字符串。

  • 应该使用strings.Builder来连接字符串列表,以防止在每次迭代中分配新的字符串。

  • 记住bytes包提供与strings包相同的操作有助于避免额外的字节/字符串转换。

  • 使用副本而不是子字符串可以防止内存泄漏,因为子字符串操作返回的字符串将由相同的字节数组支持。

六、函数和方法

本章涵盖

  • 何时使用值型或指针型接收器
  • 何时使用命名结果参数及其潜在的副作用
  • 返回nil接收器时避免常见错误
  • 为什么使用接受文件名的函数不是最佳实践
  • 处理defer参数

一个函数将一系列语句包装成一个单元,可以在其他地方调用。它可以接受一些输入并产生一些输出。另一方面,方法是附加到给定类型的函数。附加类型称为接收器接收器,可以是指针或值。本章一开始我们讨论如何选择一种接收机类型,因为这通常是一个争论的来源。然后我们讨论命名参数,何时使用它们,以及为什么它们有时会导致错误。我们还讨论了设计函数或返回特定值(如nil接收器)时的常见错误。

6.1 #42:不知道使用哪种类型的接收器

为一个方法选择一个接收器类型并不总是那么简单。什么时候我们应该使用值接收器?我们什么时候应该使用指针接收器?在这一节中,我们来看看做出正确决定的条件。

在第 12 章,我们将彻底讨论值和指针。因此,这一节将只涉及性能方面的皮毛。此外,在许多情况下,使用值或指针接收器不应该由性能决定,而是由我们将讨论的其他条件决定。但首先,让我们回忆一下接收器是如何工作的。

在 Go 中,我们可以给一个方法附加一个值或者一个指针接收器。使用值接收器,Go 复制该值并将其传递给方法。对对象的任何更改都保持在方法的本地。原始对象保持不变。

作为一个示例,下面的示例改变了一个值接收器:

type customer struct {
    balance float64
}

func (c customer) add(v float64) {              // ❶
    c.balance += v
}

func main() {
    c := customer{balance: 100.}
    c.add(50.)
    fmt.Printf("balance: %.2f\n", c.balance)    // ❷
}

❶ 值接收器

❷ 客户余额保持不变。

因为我们使用了一个值接收器,所以在add方法中增加余额不会改变原始customer结构的balance字段:

100.00

另一方面,使用指针接收器,Go 将对象的地址传递给方法。本质上,它仍然是一个副本,但我们只复制了一个指针,而不是对象本身(通过引用传递在 Go 中是不存在的)。对接收器的任何修改都是在原始对象上完成的。下面是同样的例子,但是现在接收器是一个指针:

type customer struct {
    balance float64
}

func (c *customer) add(operation float64) {    // ❶
    c.balance += operation
}

func main() {
    c := customer{balance: 100.0}
    c.add(50.0)
    fmt.Printf("balance: %.2f\n", c.balance)   // ❷
}

❶ 指针接收器

❷ 客户余额被更新。

因为我们使用指针接收器,增加余额会改变原始customer结构的balance字段:

150.00

在值接收器和指针接收器之间做出选择并不总是那么简单。让我们讨论一些条件来帮助我们选择。

接收器必须是一个指针

  • 如果方法需要改变接收器。如果接收器是一个片并且一个方法需要附加元素,这个规则也是有效的:
type slice []int

func (s *slice) add(element int) {
    *s = append(*s, element)
}
  • 如果方法接收器包含一个不能复制的字段:例如,sync包的类型部分(我们将在错误#74“复制同步类型”中讨论这一点)。

接收器应该是一个指针

  • 如果接收器是一个大物体。使用指针可以使调用更有效,因为这样做可以防止进行大范围的复制。当你不确定多大才算大的时候,标杆管理可以是解决方案;很难给出一个具体的尺寸,因为它取决于很多因素。

接收器必须是一个值

  • 如果我们必须强制一个接收器的不变性。

  • 如果接收器是映射、函数或通道。否则,会发生编译错误。

接收器应该是一个值

  • 如果接收器是一个不必改变的切片。

  • 如果接收器是一个小数组或者结构,自然是一个没有可变字段的值类型,比如time.Time

  • 如果接收器是基本型如intfloat64string

一个案例需要更多的讨论。假设我们设计了一个不同的customer结构。它的可变字段不直接是结构的一部分,而是在另一个结构中:

type customer struct {
    data *data                                   // ❶
}

type data struct {
    balance float64
}

func (c customer) add(operation float64) {       // ❷
    c.data.balance += operation
}

func main() {
    c := customer{data: &data{
        balance: 100,
    }}
    c.add(50.)
    fmt.Printf("balance: %.2f\n", c.data.balance)
}

❶ 余额不直接是客户结构的一部分,而是在指针字段引用的结构中。

❷ 使用值接受器

即使接收器是一个值,调用add最终也会改变实际余额:

150.00

在这种情况下,我们不需要接收器是一个指针来改变balance。然而,为了清楚起见,我们可能倾向于使用指针接收器来强调customer作为一个整体对象是可变的。

混合接收器类型

我们是否可以混合接收器类型,比如一个包含多个方法的结构,其中一些有指针接收器,另一些有值接收器?共识倾向于禁止它。不过标准库中也有一些反例,比如time.Time

设计者希望强制要求一个time.Time结构是不可变的。因此,大多数方法,如AfterIsZeroUTC,都有一个值接收器。但是为了符合现有的接口,如encoding.TextUnmarshalertime.Time必须实现UnmarshalBinary([]byte) error方法,该方法在给定一个字节切片的情况下改变接收器。因此,这个方法有一个指针接收器。

因此,通常应避免混合接收器类型,但在 100%的情况下并不禁止。

我们现在应该很好地理解是使用值接收器还是指针接收器。当然,不可能面面俱到,因为总会有边缘情况,但本节的目标是提供涵盖大多数情况的指导。默认情况下,我们可以选择使用值接收器,除非有很好的理由不这样做。如果有疑问,我们应该使用指针接收器。

在下一节中,我们将讨论命名结果参数:它们是什么以及何时使用它们。

6.2 #43:从不使用命名结果参数

命名结果参数是 Go 中不常用的选项。这一节将讨论何时使用命名结果参数来使我们的 API 更加方便。但首先,让我们回忆一下它们是如何工作的。

当我们在函数或方法中返回参数时,我们可以给这些参数附加名称,并将其作为常规变量使用。当结果参数被命名时,它在函数/方法开始时被初始化为零值。有了命名的结果参数,我们还可以调用一个裸return语句(不带参数)。在这种情况下,结果参数的当前值被用作返回值。

下面是一个使用命名结果参数b的例子:

func f(a int) (b int) {    // ❶
    b = a
    return                 // ❷
}

❶ 将int结果参数命名为b

❷ 返回b的当前值

在这个例子中,我们给结果参数附加了一个名称:b。当我们不带参数调用return时,它返回b的当前值。

何时建议我们使用命名结果参数?首先,让我们考虑下面的接口,它包含一个从给定地址获取坐标的方法:

type locator interface {
    getCoordinates(address string) (float32, float32, error)
}

因为这个接口是未导出的,所以文档不是强制性的。光是看这段代码,你能猜出这两个float32结果是什么吗?也许它们是一个纬度和一个经度,但顺序是什么呢?根据惯例,纬度并不总是第一要素。因此,我们必须检查实现才能了解结果。

在这种情况下,我们可能应该使用命名的结果参数,以使代码更容易阅读:

type locator interface {
    getCoordinates(address string) (lat, lng float32, err error)
}

有了这个新版本,我们可以通过查看接口来理解方法签名的含义:首先是纬度,其次是经度。

现在,让我们探讨一下在方法实现中何时使用命名结果参数的问题。我们还应该使用命名结果参数作为实现本身的一部分吗?

func (l loc) getCoordinates(address string) (
    lat, lng float32, err error) {
    // ...
}

在这种特定的情况下,拥有一个表达性的方法签名也可以帮助代码读者。因此,我们可能也想使用命名的结果参数。

注如果我们需要返回同一类型的多个结果,我们也可以考虑用有意义的字段名创建一个特别的结构。然而,这并不总是可能的:例如,当满足我们不能更新的现有接口时。

接下来,让我们考虑另一个函数签名,它允许我们在数据库中存储一个Customer类型:

func StoreCustomer(customer Customer) (err error) {
    // ...
}

在这里,命名error参数err是没有帮助的,对读者没有帮助。在这种情况下,我们应该倾向于不使用命名结果参数。

因此,何时使用命名结果参数取决于上下文。在大多数情况下,如果不清楚使用它们是否会使我们的代码更易读,我们就不应该使用命名的结果参数。

还要注意,在某些上下文中,已经初始化的结果参数可能非常方便,即使它们不一定有助于可读性。下面这个例子提出的中的《高效 Go 编程》 (go.dev/doc/effective_go)是受io.ReadFull函数的启发:

func ReadFull(r io.Reader, buf []byte) (n int, err error) {
    for len(buf) > 0 && err == nil {
        var nr int
        nr, err = r.Read(buf)
        n += nr
        buf = buf[nr:]
    }
    return
}

在这个例子中,命名结果参数并没有真正增加可读性。然而,因为nerr都被初始化为它们的零值,所以实现更短。另一方面,这个函数乍一看可能会让读者有点困惑。同样,这是一个找到正确平衡的问题。

关于裸返回(无参数返回)的一个注意事项:它们在短函数中被认为是可接受的;否则,它们会损害可读性,因为读者必须记住整个函数的输出。我们还应该在函数范围内保持一致,要么只使用裸返回,要么只使用带参数的返回。

那么关于命名结果参数的规则是什么呢?在大多数情况下,在接口定义的上下文中使用命名结果参数可以增加可读性,而不会导致任何副作用。但是在方法实现的上下文中没有严格的规则。在某些情况下,命名的结果参数也可以增加可读性:例如,如果两个参数具有相同的类型。在其他情况下,它们也可以方便地使用。因此,当有明显的好处时,我们应该谨慎地使用命名结果参数。

注意在错误#54“不处理延迟错误”中,我们将讨论在defer调用的上下文中使用命名结果参数的另一个用例。

此外,如果我们不够小心,使用命名结果参数可能会导致副作用和意想不到的后果,正如我们在下一节中看到的。

6.3 #44:命名结果参数的意外副作用

我们提到了为什么命名的结果参数在某些情况下是有用的。但是当这些结果参数被初始化为它们的零值时,如果我们不够小心,使用它们有时会导致微妙的错误。本节举例说明了这样一种情况。

让我们增强前面的示例,它是一个从给定地址返回纬度和经度的方法。因为我们返回两个float32,所以我们决定使用命名的结果参数来明确纬度和经度。这个函数将首先验证给定的地址,然后获取坐标。在此期间,它将对输入上下文执行检查,以确保它没有被取消,并且它的截止日期还没有过去。

请注意,我们将在错误#60“误解 Go 上下文”中深入探究 Go 中的上下文概念如果你不熟悉上下文,简而言之,上下文可以携带取消信号或截止日期。我们可以通过调用的Err方法并测试返回的错误是否不为零来检查这些错误。

下面是getCoordinates方法的新实现。你能找出这段代码的错误吗?

func (l loc) getCoordinates(ctx context.Context, address string) (
    lat, lng float32, err error) {
    isValid := l.validateAddress(address)          // ❶
    if !isValid {
        return 0, 0, errors.New("invalid address")
    }

    if ctx.Err() != nil {                          // ❷
        return 0, 0, err
    }

    // Get and return coordinates
}

❶ 验证了该地址

❷ 检查上下文是否被取消或截止日期是否已过

乍一看,这个错误可能并不明显。这里,if ctx.Err() != nil范围内返回的错误是err。但是我们没有给变量err赋值。它仍然被赋值给和error类型:nil的零值。因此,这段代码将总是返回一个nil错误。

此外,这段代码可以编译,因为err由于命名的结果参数而被初始化为零值。如果不附加名称,我们会得到以下编译错误:

Unresolved reference 'err'

一种可能的解决方法是将ctx.Err()分配给err,如下所示:

if err := ctx.Err(); err != nil {
    return 0, 0, err
}

我们一直返回err,但是我们首先把它赋给ctx.Err()的结果。注意,本例中的err隐藏了结果变量。

使用裸返回语句

另一种选择是使用裸return语句:

if err = ctx.Err(); err != nil {
    return
}

然而,这样做将打破规则,即我们不应该混合裸返回和带参数的返回。在这种情况下,我们可能应该坚持第一种选择。请记住,使用命名结果参数并不一定意味着使用裸返回。有时我们可以使用命名的结果参数来使签名更清晰。

我们通过强调命名结果参数在某些情况下可以提高代码的可读性(比如多次返回相同的类型),而在其他情况下非常方便,来结束这个讨论。但是我们必须记住,每个参数都被初始化为零值。正如我们在本节中看到的,这可能会导致微妙的错误,在阅读代码时并不总是容易发现。因此,在使用命名结果参数时,让我们保持谨慎,以避免潜在的副作用。

在下一节中,我们将讨论 Go 开发者在函数返回接口时会犯的一个常见错误。

6.4 #45:返回nil接收器

在本节中,我们将讨论返回接口的影响,以及为什么在某些情况下这样做会导致错误。这个错误可能是GO中最普遍的错误之一,因为它可能被认为是违反直觉的,至少在我们犯这个错误之前是这样。

让我们考虑下面的例子。我们将处理一个Customer结构并实现一个Validate方法来执行健全性检查。我们不想返回第一个错误,而是想返回一个错误列表。为此,我们将创建一个自定义错误类型来传达多个错误:

type MultiError struct {
    errs []string
}

func (m *MultiError) Add(err error) {      // ❶
    m.errs = append(m.errs, err.Error())
}

func (m *MultiError) Error() string {      // ❷
    return strings.Join(m.errs, ";")
}

❶ 补充错误

❷ 实现了Error接口

MultiError满足error接口,因为实现了Error() string。同时,它公开了一个Add方法来附加一个错误。使用这个结构,我们可以以下面的方式实现一个Customer.Validate方法来检查客户的年龄和姓名。如果健全性检查正常,我们希望返回一个零错误:

func (c Customer) Validate() error {
    var m *MultiError                           // ❶

    if c.Age < 0 {
        m = &MultiError{}
        m.Add(errors.New("age is negative"))    // ❷
    }
    if c.Name == "" {
        if m == nil {
            m = &MultiError{}
        }
        m.Add(errors.New("name is nil"))        // ❸
    }

    return m
}

❶ 实例化一个空的*MultiError

❷ 如果年龄为负,会附加一个错误

❸ 如果名称为空,会追加一个错误

在该实现中,m被初始化为*MultiError的零值:因此为nil。当健全性检查失败时,如果需要,我们分配一个新的MultiError,然后附加一个错误。最后,我们返回m,它可以是一个空指针,也可以是一个指向MultiError结构的指针,这取决于检查。

现在,让我们通过使用有效的Customer运行一个案例来测试这个实现:

customer := Customer{Age: 33, Name: "John"}
if err := customer.Validate(); err != nil {
    log.Fatalf("customer is invalid: %v", err)
}

以下是输出:

2021/05/08 13:47:28 customer is invalid: <nil>

这个结果可能相当令人惊讶。Customer有效,但err != nil条件为真,并记录打印的错误<nil>。那么,问题是什么?

在GO中,我们要知道一个指针接收器可以是nil。让我们通过创建一个虚拟类型并调用一个具有nil指针接收器的方法来进行实验:

type Foo struct{}

func (foo *Foo) Bar() string {
    return "bar"
}

func main() {
    var foo *Foo
    fmt.Println(foo.Bar())     // ❶
}

Foonil

foo初始化为指针的零值:nil。但是这段代码可以编译,如果我们运行它,它会打印出bar。零指针是一个有效的接收器。

但是为什么会这样呢?在 Go 中,方法只是函数的语法糖,函数的第一个参数是接收器。因此,我们看到的Bar方法类似于这个函数:

func Bar(foo *Foo) string {
    return "bar"
}

我们知道向函数传递一个空指针是有效的。因此,使用nil指针作为接收器也是有效的。

让我们回到最初的例子:

func (c Customer) Validate() error {
    var m *MultiError

    if c.Age < 0 {
        // ...
    }
    if c.Name == "" {
        // ...
    }

    return m
}

m被初始化为指针的零值:nil。然后,如果所有的检查都有效,那么提供给return语句的参数不是直接提供给nil,而是一个空指针。因为nil指针是一个有效的接收器,所以将结果转换成接口不会产生nil值。换句话说,Validate的调用者总是会得到一个非零的错误。

为了明确这一点,让我们记住在 Go 中,接口是一个调度包装器。这里,被包装对象是nil(MultiError指针),而包装器不是(error接口);参见图 6.1。

图 6.1error包装器不是nil

因此,不管提供了什么样的Customer,这个函数的调用者总是会收到一个非零的错误。理解这种行为是必要的,因为这是一个普遍的错误。

那么,我们应该做些什么来修正这个例子呢?最简单的解决方案是仅当它不是nil时才返回m:

func (c Customer) Validate() error {
    var m *MultiError

    if c.Age < 0 {
        // ...
    }
    if c.Name == "" {
        // ...
    }

    if m != nil {
        return m     // ❶
    }
    return nil       // ❷
}

❶ 只有当至少有一个错误时,才返回m

❷ 否则返回nil

在方法的最后,我们检查m是否不是nil。如果这是真的,我们返回m;否则,我们显式返回nil。因此,在有效的Customer的情况下,我们返回一个nil接口,而不是一个转换成非零接口的nil接收器。

我们在这一节已经看到,在 Go 中,允许有一个nil接收器,并且从nil指针转换的接口不是nil接口。因此,当我们必须返回一个接口时,我们不应该返回一个空指针,而应该直接返回一个空值。一般来说,拥有一个空指针并不是一个理想的状态,这意味着可能有 bug。

我们在本节中看到了一个错误示例,因为这是导致该错误的最常见情况。但是这个问题不仅仅与错误有关:任何使用指针接收器实现的接口都会发生这个问题。

下一节讨论使用文件名作为函数输入时的一个常见设计错误。

6.5 #46:使用文件名作为函数输入

当创建一个需要读取文件的新函数时,传递文件名被认为不是一个最佳实践,而且可能会产生负面影响,比如使单元测试更难编写。让我们深入研究这个问题,了解如何克服它。

假设我们想实现一个函数来计算文件中空行的数量。实现该函数的一种方法是接受一个文件名,并使用bufio.NewScanner扫描和检查每一行:

func countEmptyLinesInFile(filename string) (int, error) {
    file, err := os.Open(filename)       // ❶
    if err != nil {
        return 0, err
    }
    // Handle file closure

    scanner := bufio.NewScanner(file)    // ❷
    for scanner.Scan() {                 // ❸
        // ...
    }
}

❶ 打开filename

❷ 从*os.File创建了一个扫描器,将输入按行拆分

❸ 迭代每一行

我们从文件名中打开一个文件。然后我们使用bufio.NewScanner扫描每一行(默认情况下,它会将输入拆分为每行)。

这个函数会做我们期望它做的事情。事实上,只要提供的文件名有效,我们就会从中读取并返回空行的数量。那么问题出在哪里?

假设我们想要实现单元测试来覆盖以下情况:

  • 一个常见案例

  • 一个空文件

  • 只包含空行的文件

每个单元测试都需要在我们的 Go 项目中创建一个文件。函数越复杂,我们想要添加的案例就越多,我们要创建的文件也就越多。在某些情况下,我们可能需要创建几十个文件,这很快就会变得难以管理。

此外,该函数不可重用。例如,如果我们必须实现相同的逻辑,但是计算一个 HTTP 请求的空行数量,我们就必须复制主要的逻辑:

func countEmptyLinesInHTTPRequest(request http.Request) (int, error) {
    scanner := bufio.NewScanner(request.Body)
    // Copy the same logic
}

克服这些限制的一个方法可能是让函数接受一个*bufio.Scanner(由bufio.NewScanner返回的输出)。从我们创建scanner变量的那一刻起,这两个函数就有相同的逻辑,所以这种方法是可行的。但在GO中,惯用的方式是从读者的抽象出发。

让我们编写一个新版本的countEmptyLines函数,它接收一个io.Reader抽象:

func countEmptyLines(reader io.Reader) (int, error) {     // ❶
    scanner := bufio.NewScanner(reader)                   // ❷
    for scanner.Scan() {
        // ...
    }
}

❶ 接受了一个io.Reader作为输入

❷ 从io.Reader创建了bufio.NewScanner。而不是*os.File

因为bufio.NewScanner接受一个io.Reader,所以我们可以直接传递reader变量。

这种方法的好处是什么?首先,这个函数抽象了数据源。是文件吗?一个 HTTP 请求?一个插座输入?对于函数来说不重要。因为*os.Filehttp.RequestBody字段实现了io.Reader,所以不管输入类型如何,我们都可以重用同一个函数。

另一个好处与测试有关。我们提到过为每个测试用例创建一个文件会很快变得很麻烦。既然countEmptyLines接受了一个io.Reader,我们可以通过从字符串创建一个io.Reader来实现单元测试:

func TestCountEmptyLines(t *testing.T) {
    emptyLines, err := countEmptyLines(strings.NewReader(    // ❶
        `foo
            bar

            baz
            `))
    // Test logic
}

❶ 向strings.NewReader传递字符串

在这个测试中,我们直接从字符串中使用strings.NewReader创建一个io.Reader。因此,我们不必为每个测试用例创建一个文件。每个测试用例都是独立的,提高了测试的可读性和可维护性,因为我们不必打开另一个文件来查看内容。

在大多数情况下,接受一个文件名作为函数输入来读取文件应该被认为是一种代码味道(除了在特定的函数中,比如os.Open)。正如我们所看到的,这使得单元测试更加复杂,因为我们可能需要创建多个文件。它还降低了函数的可重用性(尽管并不是所有的函数都应该被重用)。使用io.Reader接口抽象数据源。不管输入是一个文件、一个字符串、一个 HTTP 请求还是一个 gRPC 请求,这个实现都可以被重用和容易地测试。

在本章的最后一节,让我们讨论一个与defer相关的常见错误:函数/方法参数和方法接收器是如何计算的。

6.6 #47:忽略如何求值延迟参数和接收器

我们在上一节提到过defer语句会延迟调用的执行,直到周围的函数返回。Go 开发人员的一个常见错误是不理解参数是如何计算的。我们将用两个小节来研究这个问题:一个与函数和方法参数有关,另一个与方法接收器有关。

6.6.1 参数求值

为了说明如何用defer对参数求值,我们来看一个具体的例子。一个函数需要调用两个函数foobar。同时,它必须处理关于执行的状态:

  • StatusSuccess如果foobar都没有返回错误

  • StatusErrorFoo如果foo返回错误

  • StatusErrorBar如果bar返回错误

我们将在多个操作中使用这个状态:例如,通知另一个 goroutine 和增加计数器。为了避免在每个return语句的之前重复这些调用,我们将使用defer。这是我们的第一个实现:

const (
    StatusSuccess  = "success"
    StatusErrorFoo = "error_foo"
    StatusErrorBar = "error_bar"
)

func f() error {
    var status string
    defer notify(status)               // ❶
    defer incrementCounter(status)     // ❷

    if err := foo(); err != nil {
        status = StatusErrorFoo        // ❸
        return err
    }

    if err := bar(); err != nil {
        status = StatusErrorBar        // ❹
        return err
    }

    status = StatusSuccess             // ❺
    return nil
}

❶ 延迟调用notify

❷ 延迟调用incrementCounter

❸ 将状态设置为StatusErrorFoo

❹ 将状态设置为StatusErrorBar

❺ 将状态设置为成功

首先我们声明一个status变量。然后我们使用defer将调用延迟到notifyincrementCounter。在这个函数中,根据执行路径,我们相应地更新status

然而,如果我们尝试一下这个函数,我们会发现不管执行路径如何,notifyincrementCounter总是以相同的状态被调用:一个空字符串。这怎么可能?

我们需要理解一个defer函数中参数求值的关键之处:参数被立即求值,而不是在周围的函数返回之后。在我们的例子中,我们调用notify(status)incrementCounter(status)作为defer函数。因此,一旦在我们使用defer的阶段f返回status的当前值,Go 将延迟这些调用的执行,从而传递一个空字符串。想继续用defer怎么解决这个问题?有两种主要的解决方案。

第一种解决方案是将一个字符串指针传递给defer函数的:

func f() error {
    var status string
    defer notify(&status)                // ❶
    defer incrementCounter(&status)      // ❷

    // The rest of the function is unchanged
    if err := foo(); err != nil {
        status = StatusErrorFoo
        return err
    }

    if err := bar(); err != nil {
        status = StatusErrorBar
        return err
    }

    status = StatusSuccess
    return nil
}

❶ 传递一个字符串指针给notify

❷ 将一个字符串指针传递给incrementCounter

我们根据情况不断更新status,但是现在notifyincrementCounter接收一个字符串指针。这种方法为什么有效?

使用defer立即计算参数:这里是status的地址。是的,status本身在整个函数中被修改,但是它的地址保持不变,不管赋值如何。因此,如果notifyincrementCounter使用字符串指针引用的值,它将按预期工作。但是这种解决方案需要改变两个函数的签名,这并不总是可能的。

还有另一个解决方案:调用一个闭包作为一个defer语句。提醒一下,闭包是一个匿名的函数值,它从自身外部引用变量。传递给defer函数的参数会被立即计算。但是我们必须知道由一个defer闭包引用的变量在闭包执行期间被求值(因此,当周围的函数返回时)。

这里有一个例子来说明defer闭包是如何工作的。一个闭包引用两个变量,一个作为函数参数,另一个作为其正文之外的变量:

func main() {
    i := 0
    j := 0
    defer func(i int) {       // ❶
        fmt.Println(i, j)     // ❷
    }(i)                      // ❸
    i++
    j++
}

❶ 延迟调用接受整数作为输入的闭包

i是函数输入,j是外部变量。

❸ 将i传给了闭包(立即求值)

这里,闭包使用了ij变量。i是作为函数参数传递的,所以它会被立即计算。相反,j引用了闭包体外部的变量,所以在执行闭包时会对它进行求值。如果我们运行这个例子,它将打印出0 1

因此,我们可以使用闭包来实现函数的新版本:

func f() error {
    var status string
    defer func() {                   // ❶
        notify(status)               // ❷
        incrementCounter(status)     // ❸
    }()

    // The rest of the function is unchanged
}

❶ 将闭包作为延迟函数调用

❷ 在闭包和引用状态内调用notify

❸ 在闭包和引用状态内调用incrementCounter

这里,我们将对notifyincrementCounter的调用包装在一个闭包中。这个闭包从变量体的外部引用了status变量。因此,一旦闭包被执行,status就被求值,而不是当我们调用defer时。这个解决方案也有效,并且不需要notifyincrementCounter改变它们的签名。

现在,在带有指针或值接收器的方法上使用defer怎么样?我们来看看这些问题。

6.6.2 指针和值接收器

在错误#42“不知道使用哪种类型的接收器”中,我们说接收器可以是值,也可以是指针。当我们在一个方法上使用defer时,与参数求值相关的相同逻辑也适用:接收器也被立即求值。让我们来了解这两种接收器类型的影响。

首先,这里有一个例子,它使用defer调用一个值接收器上的方法,但是后来改变了这个接收器:

func main() {
    s := Struct{id: "foo"}
    defer s.print()           // ❶
    s.id = "bar"              // ❷
}

type Struct struct {
    id string
}

func (s Struct) print() {
    fmt.Println(s.id)         // ❸
}

❶ 立即被求值。

❷ 更新s.id(不可见)

"foo"

我们将调用延迟到print方法。与参数一样,调用defer会立即对接收器进行求值。因此,defer用一个包含等于fooid字段的结构来延迟方法的执行。因此,这个例子打印了foo

相反,如果指针是接收器,那么在调用defer之后接收器的潜在变化是可见的:

func main() {
    s := &Struct{id: "foo"}
    defer s.print()            // ❶
    s.id = "bar"               // ❷
}

type Struct struct {
    id string
}

func (s *Struct) print() {
    fmt.Println(s.id)          // ❸
}

s是一个指针,所以它会被立即求值,但在执行defer方法时可能会引用另一个变量。

❷ 更新s.id(可见)

"bar"

s接收器也会被立即求值。但是,调用方法会导致复制指针接收器。因此,对指针引用的结构所做的更改是可见的。这个例子打印了bar

总之,当我们在函数或方法上调用defer时,调用的参数会立即被计算。如果我们后来想改变提供给defer的参数,我们可以使用指针或闭包。对于一个方法,接收器也立即被求值;因此,行为取决于接收器是值还是指针。

总结

  • 应该根据诸如类型、是否必须改变、是否包含不能复制的字段以及对象有多大之类的因素来决定是使用值还是指针接收器。如有疑问,使用指针接收器。

  • 使用命名结果参数是提高函数/方法可读性的有效方法,尤其是在多个结果参数具有相同类型的情况下。在某些情况下,这种方法也很方便,因为命名结果参数被初始化为零值。但是要小心潜在的副作用。

  • 当返回一个接口时,小心不要返回一个空指针,而是一个显式的空值。否则,可能会导致意想不到的后果,因为调用方将收到一个非零值。

  • 设计接收io.Reader类型而不是文件名的函数提高了函数的可重用性,并使测试更容易。

  • 传递一个指向defer函数的指针和将一个调用封装在闭包里是两种可能的解决方案,可以克服参数和接收器的即时求值。

七、错误管理

本章涵盖

  • 理解何时该恐慌
  • 知道何时包装错误
  • 从 Go 1.13 开始有效比较错误类型和错误值
  • 习惯性地处理错误
  • 了解如何忽略错误
  • 处理defer调用中的错误

错误管理是构建健壮且可观察的应用的一个基本方面,它应该和代码库的其他部分一样重要。在 Go 中,错误管理不像大多数编程语言那样依赖于传统的try/catch机制。相反,错误作为正常返回值返回。

本章将涵盖与错误相关的最常见的错误。

7.1 #48:恐慌

对于 Go 新手来说,对错误处理有些困惑是很常见的。在 Go 中,错误通常由返回和error类型作为最后一个参数的函数或方法来管理。但是一些开发人员可能会发现这种方法令人惊讶,并试图使用panicrecover在 Java 或 Python 等语言中重现异常处理。所以,让我们重温一下恐慌的概念,讨论一下什么时候恐慌是合适的,什么时候不恐慌。

在 Go 中,panic是一个停止普通流程的内置函数:

func main() {
    fmt.Println("a")
    panic("foo")
    fmt.Println("b")
}

该代码打印a,然后在打印b之前停止:

a
panic: foo

goroutine 1 [running]:
main.main()
        main.go:7 +0xb3

一旦恐慌被触发,它将继续在调用栈中向上运行,直到当前的 goroutine 返回或者panicrecover捕获:

func main() {
    defer func() {                       // ❶
        if r := recover(); r != nil {
            fmt.Println("recover", r)
        }
    }()

    f()                                  // ❷
}

func f() {
    fmt.Println("a")
    panic("foo")
    fmt.Println("b")
}

❶ 延迟闭包内调用recover

❷ 调用ff恐慌。这种恐慌被前面的recover所抓住。

f函数中,一旦panic被调用,就停止当前函数的执行,并向上调用栈:main。在main中,因为恐慌是由recover引起的,所以并不停止 goroutine:

a
recover foo

注意,调用recover()来捕获 goroutine 恐慌只在一个defer函数内部有用;否则,该函数将返回nil并且没有其他作用。这是因为defer函数也是在周围函数恐慌时执行的。

现在,让我们来解决这个问题:什么时候恐慌是合适的?在 Go 中,panic用来表示真正的异常情况,比如程序员出错。例如,如果我们查看net/http包,我们会注意到在WriteHeader方法中,有一个对checkWriteHeaderCode函数的调用,用于检查状态代码是否有效:

func checkWriteHeaderCode(code int) {
    if code < 100 || code > 999 {
        panic(fmt.Sprintf("invalid WriteHeader code %v", code))
    }
}

如果状态码无效,此函数会出现混乱,这纯粹是程序员错误。

另一个基于程序员错误的例子可以在注册数据库驱动时的database/sql包中找到:

func Register(name string, driver driver.Driver) {
    driversMu.Lock()
    defer driversMu.Unlock()
    if driver == nil {
        panic("sql: Register driver is nil")                     // ❶
    }
    if _, dup := drivers[name]; dup {
        panic("sql: Register called twice for driver " + name)   // ❷
    }
    drivers[name] = driver
}

如果司机是零,❶就恐慌

如果司机已经注册,❷会感到恐慌

如果驱动程序是nil ( driver.Driver是一个接口)或者已经被注册,这个函数就会恐慌。这两种情况都会被认为是程序员的错误。此外,在大多数情况下(例如,使用最流行的 MySQL 驱动程序go-sql-driver/mysqlgithub.com/go-sql-driver/mysql】),Register通过调用一个init函数,这限制了错误处理。出于所有这些原因,设计者在出现错误的情况下使函数变得混乱。

另一个令人恐慌的用例是当我们的应用需要一个依赖项,但是无法初始化它。例如,假设我们公开一个服务来创建新的客户帐户。在某个阶段,该服务需要验证所提供的电子邮件地址。为了实现这一点,我们决定使用正则表达式。

在 Go 中,regexp包公开了两个函数来从字符串创建正则表达式:CompileMustCompile。前者返回一个*regexp.Regexp和一个错误,而后者只返回一个*regexp.Regexp但在出错时会恐慌。在这种情况下,正则表达式是一个强制依赖项。事实上,如果我们不能编译它,我们将永远无法验证任何电子邮件输入。因此,我们可能倾向于使用MustCompile并在出错时惊慌失措。

GO中的恐慌应该少用。我们已经看到了两个突出的例子,一个是程序员出错的信号,另一个是我们的应用不能创建一个强制依赖。因此,存在导致我们停止应用的异常情况。在大多数其他情况下,错误管理应该通过一个函数来完成,该函数返回一个合适的类型作为最后一个返回参数。

现在让我们开始讨论错误。在下一节中,我们将看到何时包装一个错误。

7.2 #49:忽略何时包装错误

从 Go 1.13 开始,%w指令让我们可以方便地包装错误。但是一些开发人员可能不知道什么时候包装错误(或者不包装)。因此,让我们提醒自己什么是错误包装,以及何时使用它。

错误包装是将一个错误包装或打包到一个包装容器中,这样也可以得到错误源(见图 7.1)。通常,错误包装的两个主要用例如下:

  • 向错误添加附加上下文

  • 将错误标记为特定错误

图 7.1 将错误包装在包装器中。

关于添加上下文,让我们考虑下面的例子。我们收到一个来自特定用户的访问数据库资源的请求,但是在查询过程中我们得到一个“权限被拒绝”的错误。出于调试目的,如果最终记录了错误,我们希望添加额外的上下文。在这种情况下,我们可以包装错误以表明用户是谁以及正在访问什么资源,如图 7.2 所示。

图 7.2 向“权限被拒绝”错误添加附加上下文

现在假设我们不添加上下文,而是要标记错误。例如,我们希望实现一个 HTTP 处理器,它检查在调用函数时收到的所有错误是否都属于Forbidden类型,这样我们就可以返回一个 403 状态代码。在这种情况下,我们可以将这个错误包装在Forbidden中(见图 7.3)。

图 7.3 标记错误Forbidden

在这两种情况下,源错误仍然存在。因此,调用者也可以通过解开错误并检查错误源来处理错误。还要注意,有时我们希望将两种方法结合起来:添加上下文和标记错误。

现在我们已经阐明了包装错误的主要用例,让我们看看在 Go 中返回我们收到的错误的不同方法。我们将考虑下面这段代码,并探索if err != nil块中的不同选项:

func Foo() error {
    err := bar()
    if err != nil {
        // ?          // ❶
    }
    // ...
}

❶ 我们如何返回错误?

第一种选择是直接返回这个错误。如果我们不想标记错误,并且没有想要添加的有用上下文,这种方法很好:

if err != nil {
    return err
}

图 7.4 显示我们返回了与bar相同的错误。

图 7.4 我们可以直接返回错误。

在 Go 1.13 之前,要包装一个错误,唯一不使用外部库的选项是创建一个自定义错误类型:

type BarError struct {
    Err error
}

func (b BarError) Error() string {
    return "bar failed:" + b.Err.Error()
}

然后,我们没有直接返回err,而是将错误包装成一个BarError(见图 7.5):

if err != nil {
    return BarError{Err: err}
}

图 7.5 将错误包裹在BarError内部

这个选项的好处是它的灵活性。因为BarError是一个定制结构,如果需要,我们可以添加任何额外的上下文。然而,如果我们想要重复这个操作,被迫创建一个特定的错误类型会很快变得很麻烦。

为了克服这种情况,Go 1.13 引入了%w指令:

if err != nil {
    return fmt.Errorf("bar failed: %w", err)
}

这段代码包装了源错误以添加额外的上下文,而不必创建另一种错误类型,如图 7.6 所示。

图 7.6 将一个错误包装成一个标准错误。

因为源错误仍然可用,所以客户端可以解开父错误,然后检查源错误是否是特定的类型或值(我们将在下面的部分中讨论这些问题)。

我们将讨论的最后一个选项是使用%v指令:

if err != nil {
    return fmt.Errorf("bar failed: %v", err)
}

区别在于错误本身没有被包装。我们将其转换为另一个错误来添加上下文,源错误不再可用,如图 7.7 所示。

图 7.7 转换错误

关于问题来源的信息仍然可用。然而,调用者不能解开这个错误并检查来源是否是bar error。所以,从某种意义上来说,这个选项比%w更具限制性。既然%w指令已经发布,我们应该阻止吗?不一定。

包装错误使调用者可以使用源错误。因此,这意味着引入潜在耦合。例如,假设我们使用包装,Foo的调用者检查源错误是否为bar error。现在,如果我们改变我们的实现,并使用另一个函数将返回另一种类型的错误呢?它将破坏调用者进行的错误检查。

为了确保我们的客户不依赖于我们认为是实现细节的东西,返回的错误应该被转换,而不是包装。在这种情况下,使用%v而不是%w可能是正确的选择。

让我们回顾一下我们处理过的所有不同选项。

选项 额外上下文 标记错误 源错误可用
直接返回错误
自定义错误类型 可能(例如,如果错误类型包含字符串字段) 可能(如果源错误是通过方法导出或访问的)
fmt.Errorf%w
fmt.Errorf%v

总而言之,当处理一个错误时,我们可以决定包装它。包装是向错误添加额外的上下文和/或将错误标记为特定类型。如果我们需要标记一个错误,我们应该创建一个自定义的错误类型。然而,如果我们只是想添加额外的上下文,我们应该使用带有%w指令的fmt.Errorf,因为它不需要创建新的错误类型。然而,错误包装会产生潜在的耦合,因为它使调用者可以获得源错误。如果我们想防止它,我们不应该使用错误包装,而应该使用错误转换,例如,将fmt.Errorf%v指令一起使用。

本节展示了如何用%w指令包装错误。但是一旦我们开始使用它,检查一个错误类型会有什么影响?

7.3 #50:检查错误类型不准确

上一节介绍了一种使用%w指令包装错误的可能方法。然而,当我们使用这种方法时,改变我们检查特定错误类型的方式也是必要的;否则,我们可能会不准确地处理错误。

我们来讨论一个具体的例子。我们将编写一个 HTTP 处理器,从一个 ID 返回交易金额。我们的处理器将解析请求以获取 ID,并从数据库(DB)中检索金额。我们的实现可能在两种情况下失败:

  • 如果 ID 无效(字符串长度不是五个字符)

  • 如果查询数据库失败

在前一种情况下,我们希望返回StatusBadRequest (400),而在后一种情况下,我们希望返回ServiceUnavailable (503)。为此,我们将创建一个transientError类型来标记错误是暂时的。父处理器将检查错误类型。如果错误是一个transientError,将返回一个 503 状态码;否则,它将返回 400 状态代码。

让我们首先关注错误类型定义和处理器将调用的函数:

type transientError struct {
    err error
}

func (t transientError) Error() string {              // ❶
    return fmt.Sprintf("transient error: %v", t.err)
}

func getTransactionAmount(transactionID string) (float32, error) {
    if len(transactionID) != 5 {
        return 0, fmt.Errorf("id is invalid: %s",
            transactionID)                            // ❷
    }

    amount, err := getTransactionAmountFromDB(transactionID)
    if err != nil {
        return 0, transientError{err: err}            // ❸
    }
    return amount, nil
}

❶ 创建一个自定义的transientError

❷ 如果事务 ID 无效,将返回一个简单的错误

❸ 如果我们无法查询数据库,会返回一个transientError

如果标识符无效,使用fmt.Errorf返回一个错误。但是,如果从数据库获取交易金额失败,getTransactionAmount将错误封装到transientError类型中。

现在,让我们编写 HTTP 处理器来检查错误类型,以返回适当的 HTTP 状态代码:

func handler(w http.ResponseWriter, r *http.Request) {
    transactionID := r.URL.Query().Get("transaction")      // ❶

    amount, err := getTransactionAmount(transactionID)     // ❷
    if err != nil {
        switch err := err.(type) {                         // ❸
        case transientError:
            http.Error(w, err.Error(), http.StatusServiceUnavailable)
        default:
            http.Error(w, err.Error(), http.StatusBadRequest)
        }
        return
    }

    // Write response
}

❶ 提取交易 ID

❷ 调用包含所有逻辑的getTransactionAmount

❸ 检查错误类型,如果错误是暂时的,则返回 503;否则,一个 400

在错误类型上使用一个switch,我们返回适当的 HTTP 状态代码:在错误请求的情况下返回 400,在暂时错误的情况下返回 503。

这段代码完全有效。然而,让我们假设我们想要对getTransactionAmount进行一个小的重构。transientError将由getTransactionAmountFromDB而不是getTransactionAmount返回。getTransactionAmount现在使用%w指令包装该错误:

func getTransactionAmount(transactionID string) (float32, error) {
    // Check transaction ID validity

    amount, err := getTransactionAmountFromDB(transactionID)
    if err != nil {
        return 0, fmt.Errorf("failed to get transaction %s: %w",
            transactionID, err)                // ❶
    }
    return amount, nil
}

func getTransactionAmountFromDB(transactionID string) (float32, error) {
    // ...
    if err != nil {
        return 0, transientError{err: err}     // ❷
    }
    // ...
}

❶ 包装错误,而不是直接返回transientError

❷ 这个函数现在返回transientError

如果我们运行这段代码,不管错误情况如何,它总是返回 400,所以永远不会遇到case Transient错误。我们如何解释这种行为?

重构之前,getTransactionAmount返回了transientError(见图 7.8)。重构后,transientError现在由getTransactionAmountFromDB返回(图 7.9)。

图 7.8 因为如果 DB 失败的话getTransactionAmount会返回一个transientError,所以情况是真的。

图 7.9 现在getTransactionAmount返回一个包装错误。于是,case transientError是假的。

getTransactionAmount返回的不是一个直接的transientError:它是一个错误包装transientError。因此case transientError现在为假。

正是为了这个目的,Go 1.13 提供了一个封装错误的指令,以及一种检查被封装的错误是否属于带有errors.As的某种类型的方法。这个函数递归地展开一个错误,如果链中的错误与预期的类型匹配,则返回true

让我们使用errors.As重写调用者的实现:

func handler(w http.ResponseWriter, r *http.Request) {
    // Get transaction ID

    amount, err := getTransactionAmount(transactionID)
    if err != nil {
        if errors.As(err, &transientError{}) {      // ❶
            http.Error(w, err.Error(),
                http.StatusServiceUnavailable)      // ❷
        } else {
            http.Error(w, err.Error(),
                http.StatusBadRequest)              // ❸
        }
        return
    }

    // Write response
}

❶ 通过提供指向transientError的指针调用errors.As

❷ 如果错误是暂时的,返回 503

❸ 否则返回一个 400

在这个新版本中,我们去掉了switch案例类型,现在使用errors.As。这个函数要求第二个参数(目标错误)是一个指针。否则,该函数将会编译,但在运行时会恐慌。无论运行时错误是直接类型transientError还是错误包装transientErrorerrors.As都返回true;因此,处理器将返回 503 状态代码。

综上所述,如果我们依赖 Go 1.13 错误包装,我们必须使用errors.As来检查错误是否属于特定类型。这样,不管错误是由我们调用的函数直接返回,还是包装在错误中,errors.As将能够递归地打开我们的主错误,并查看其中一个错误是否是特定的类型。

我们刚刚看到了如何比较错误类型;现在是时候比较一个错误值了。

7.4 #51:检查错误值不准确

本节与上一节相似,但有标记错误(错误值)。首先,我们将定义一个哨兵错误传达了什么。然后,我们将会看到如何比较一个错误和一个值。

标记错误是定义为全局变量的错误:

import "errors"

var ErrFoo = errors.New("foo")

一般来说,约定是从Err开始,后面跟着错误类型:这里是ErrFoo。标记错误传达一个预期的错误。但是我们所说的预期错误是什么意思呢?让我们在 SQL 库的上下文中讨论它。

我们想设计一个Query方法,允许我们执行对数据库的查询。此方法返回一部分行。当没有找到行时,我们应该如何处理这种情况?我们有两个选择:

  • 返回一个标记值:例如,一个nil切片(想想strings.Index,如果一个子串不存在,它返回标记值-1)。

  • 返回客户端可以检查的特定错误。

让我们采用第二种方法:如果没有找到行,我们的方法可以返回一个特定的错误。我们可以将这归类为一个预期的错误,因为传递一个不返回任何行的请求是被允许的。相反,像网络问题和连接轮询错误这样的情况是意外的错误。这并不意味着我们不想处理意外的错误;这意味着语义上,这些错误传达了不同的意思。

如果我们看一下标准库,我们可以找到许多标记错误的例子:

  • sql.ErrNoRows——当查询没有返回任何行时返回(这正是我们的情况)

  • io.EOF——当没有更多输入可用时,由io.Reader返回

这是哨兵错误背后的一般原则。它们传达了客户希望检查的预期错误。因此,作为一般准则,

  • 预期错误应设计为错误值(哨兵错误):var ErrFoo = errors.New("foo")

  • 意外错误应设计为错误类型:type BarError struct { ... },用BarError实现error接口。

让我们回到常见的错误。我们如何将错误与特定值进行比较?通过使用==操作符:

err := query()
if err != nil {
    if err == sql.ErrNoRows {     // ❶
        // ...
    } else {
        // ...
    }
}

❶ 根据sql.ErrNoRows变量检查错误。

这里,我们调用一个query函数,得到一个错误。使用==操作符检查错误是否为sql.ErrNoRows

然而,正如我们在上一节中讨论的,也可以包装一个标记错误。如果使用fmt.Errorf%w指令包装sql.ErrNoRowserr == sql.ErrNoRows将始终为假。

还是那句话,Go 1.13 提供了答案。我们已经看到了如何使用errors.As来检查一个类型的错误。有了错误值,我们可以用它的对应物: errors.Is。让我们重写前面的例子:

err := query()
if err != nil {
    if errors.Is(err, sql.ErrNoRows) {
        // ...
    } else {
        // ...
    }
}

使用errors.Is而不是==操作符允许进行比较,即使使用%w包装了错误。

总之,如果我们在应用中使用带有%w指令和fmt.Errorf的错误包装,那么应该使用errors.Is而不是==来检查特定值的错误。因此,即使标记错误被包装,errors.Is也可以递归地展开它,并将链中的每个错误与提供的值进行比较。

现在是时候讨论错误处理最重要的一个方面了:不要两次处理一个错误。

7.5 #52:处理一个错误两次

多次处理一个错误是开发人员经常犯的错误,而不是 Go 中特有的错误。让我们来理解为什么这是一个问题,以及如何有效地处理错误。

为了说明问题,让我们编写一个GetRoute函数来获得从一对源到一对目标坐标的路线。让我们假设这个函数将调用一个未导出的getRoute函数,该函数包含计算最佳路线的业务逻辑。在调用getRoute之前,我们必须使用validateCoordinates验证源和目标坐标。我们还希望记录可能的错误。下面是一个可能的实现:

func GetRoute(srcLat, srcLng, dstLat, dstLng float32) (Route, error) {
    err := validateCoordinates(srcLat, srcLng)
    if err != nil {
        log.Println("failed to validate source coordinates")    // ❶
        return Route{}, err
    }

    err = validateCoordinates(dstLat, dstLng)
    if err != nil {
        log.Println("failed to validate target coordinates")    // ❶
        return Route{}, err
    }

    return getRoute(srcLat, srcLng, dstLat, dstLng)
}

func validateCoordinates(lat, lng float32) error {
    if lat > 90.0 || lat < -90.0 {
        log.Printf("invalid latitude: %f", lat)                 // ❶
        return fmt.Errorf("invalid latitude: %f", lat)
    }
    if lng > 180.0 || lng < -180.0 {
        log.Printf("invalid longitude: %f", lng)                // ❶
        return fmt.Errorf("invalid longitude: %f", lng)
    }
    return nil
}

❶ 记录并返回错误

这个代码有什么问题?首先,在validateCoordinates中,在日志记录和返回的错误中重复invalid latitudeinvalid longitude错误消息是很麻烦的。此外,例如,如果我们使用无效的纬度运行代码,它将记录以下行:

2021/06/01 20:35:12 invalid latitude: 200.000000
2021/06/01 20:35:12 failed to validate source coordinates

一个错误有两个日志行是一个问题。为什么?因为这使得调试更加困难。例如,如果同时多次调用此函数,日志中的两条消息可能不会一个接一个,从而使调试过程更加复杂。

根据经验,一个错误应该只处理一次。记录错误就是处理错误,返回错误也是如此。因此,我们应该记录或返回一个错误,而不是两者都记录。

让我们重写实现,只处理一次错误:

func GetRoute(srcLat, srcLng, dstLat, dstLng float32) (Route, error) {
    err := validateCoordinates(srcLat, srcLng)
    if err != nil {
        return Route{}, err                                 // ❶
    }

    err = validateCoordinates(dstLat, dstLng)
    if err != nil {
        return Route{}, err                                 // ❶
    }

    return getRoute(srcLat, srcLng, dstLat, dstLng)
}

func validateCoordinates(lat, lng float32) error {
    if lat > 90.0 || lat < -90.0 {
        return fmt.Errorf("invalid latitude: %f", lat)      // ❶
    }
    if lng > 180.0 || lng < -180.0 {
        return fmt.Errorf("invalid longitude: %f", lng)     // ❶
    }
    return nil
}

❶ 只返回一个错误

在这个版本中,通过直接返回,每个错误只被处理一次。然后,假设GetRoute的调用者正在处理可能的日志错误,在纬度无效的情况下,代码将输出以下消息:

2021/06/01 20:35:12 invalid latitude: 200.000000

这个新的 Go 版本代码是否完美?不完全是。例如,在纬度无效的情况下,第一个实现导致两个日志。尽管如此,我们知道哪个对validateCoordinates的调用失败了:要么是源坐标,要么是目标坐标。在这里,我们丢失了这些信息,所以我们需要向错误添加额外的上下文。

让我们使用 Go 1.13 错误包装重写我们代码的最新版本(我们省略了validateCoordinates,因为它保持不变):

func GetRoute(srcLat, srcLng, dstLat, dstLng float32) (Route, error) {
    err := validateCoordinates(srcLat, srcLng)
    if err != nil {
        return Route{},
            fmt.Errorf("failed to validate source coordinates: %w",
                err)      // ❶
    }

    err = validateCoordinates(dstLat, dstLng)
    if err != nil {
        return Route{},
            fmt.Errorf("failed to validate target coordinates: %w",
                err)      // ❶
    }

    return getRoute(srcLat, srcLng, dstLat, dstLng)
}

❶ 返回一个包装错误

validateCoordinates返回的每个错误现在都被包装起来,为错误提供额外的上下文:它是与源坐标相关还是与目标坐标相关。因此,如果我们运行这个新版本,在源纬度无效的情况下,调用者会记录以下内容:

2021/06/01 20:35:12 failed to validate source coordinates:
    invalid latitude: 200.000000

在这个版本中,我们涵盖了所有不同的情况:一个日志,没有丢失任何有价值的信息。此外,每个错误只处理一次,这简化了我们的代码,例如,避免重复的错误消息。

处理一个错误应该只做一次。正如我们所见,记录错误就是处理错误。因此,我们应该记录或返回一个错误。通过这样做,我们简化了代码,并更好地了解了错误情况。使用错误包装是最方便的方法,因为它允许我们传播源错误并向错误添加上下文。

在下一节中,我们将看到在 Go 中忽略错误的适当方法。

7.6 #53:不处理错误

在某些情况下,我们可能想忽略函数返回的错误。在GO中应该只有一种方法可以做到这一点;我们来了解一下原因。

我们将考虑下面的例子,其中我们调用一个返回单个error参数的notify函数。我们对这个错误不感兴趣,所以我们故意忽略任何错误处理:

func f() {
    // ...
    notify()     // ❶
}

func notify() error {
    // ...
}

省略了❶错误处理。

因为我们想忽略这个错误,所以在这个例子中,我们只调用了notify,而没有将其输出赋给一个经典的err变量。从功能的角度来看,这段代码没有任何问题:它按照预期编译和运行。

然而,从可维护性的角度来看,代码可能会导致一些问题。让我们考虑一个新读者看它。这个读者注意到notify返回了一个错误,但是这个错误不是由父函数处理的。他们如何猜测处理错误是否是有意的呢?他们怎么知道是之前的开发者忘记处理了还是故意的?

由于这些原因,当我们想要忽略 Go 中的错误时,只有一种方法来编写它:

_ = notify()

我们不是将错误分配给变量,而是将其分配给空白标识符。就编译和运行时间而言,与第一段代码相比,这种方法没有任何改变。但是这个新版本明确表示我们对错误不感兴趣。

这样的代码也可以附带一条注释,但不要像下面这样提到忽略错误的注释:

// Ignore the error
_ = notify()

这个注释只是重复了代码所做的事情,应该避免。但是,写一个注释来说明错误被忽略的原因可能是个好主意,如下所示:

// At-most once delivery.
// Hence, it's accepted to miss some of them in case of errors.
_ = notify()

忽略 Go 中的错误应该是个例外。在许多情况下,我们仍然倾向于记录它们,即使是在低日志级别。但是如果我们确定一个错误能够并且应该被忽略,我们必须通过将它分配给空白标识符来明确地做到这一点。这样,未来的读者会理解我们故意忽略了这个错误。

本章的最后一节讨论了如何处理由defer函数返回的错误。

7.7 #54:不处理延迟错误

不处理defer语句中的错误是 Go 开发者经常犯的错误。我们来了解一下问题是什么,以及可能的解决方案。

在下面的例子中,我们将实现一个函数来查询数据库,以获得给定客户 ID 的余额。我们将使用database/sqlQuery方法。

注意,我们不会在这里深入探究这个包是如何工作的;我们在错误#78“常见的 SQL 错误”中这样做

下面是一个可能的实现(我们关注查询本身,而不是结果的解析):

const query = "..."

func getBalance(db *sql.DB, clientID string) (
    float32, error) {
    rows, err := db.Query(query, clientID)
    if err != nil {
        return 0, err
    }
    defer rows.Close()     // ❶

    // Use rows
}

❶ 延迟调用rows.Close

rows是一种*sql.Rows类型。它实现了的Closer接口:

type Closer interface {
    Close() error
}

这个接口包含一个返回错误的方法(我们也将在错误#79“不关闭瞬态资源”中看到这个主题)。我们在上一节中提到,错误应该总是被处理。但是在这种情况下,由defer调用返回的错误被忽略:

defer rows.Close()

如前一节所述,如果我们不想处理错误,我们应该使用空白标识符显式忽略它:

defer func() { _ = rows.Close() }()

这个版本更详细,但是从可维护性的角度来看更好,因为我们明确地标记了我们正在忽略这个错误。

但是在这种情况下,我们不应该盲目地忽略来自defer调用的所有错误,而是应该问自己这是否是最好的方法。在这种情况下,当调用Close()无法从池中释放 DB 连接时,会返回一个错误。因此,忽略这个错误可能不是我们想要做的。更好的选择是记录一条消息:

defer func() {
    err := rows.Close()
    if err != nil {
        log.Printf("failed to close rows: %v", err)
    }
}()

现在,如果关闭rows失败,代码会记录一条消息,这样我们就知道了。

如果我们不处理错误,而是将它传播给getBalance的调用者,这样他们就可以决定如何处理它,那该怎么办?

defer func() {
    err := rows.Close()
    if err != nil {
        return err
    }
}()

这个实现不能编译。的确,return语句是与匿名func()函数相关联的,而不是getBalance

如果我们想将由getBalance返回的错误与在调用defer中捕获的错误联系起来,我们必须使用命名的结果参数。让我们写第一个版本:

func getBalance(db *sql.DB, clientID string) (
    balance float32, err error) {
    rows, err := db.Query(query, clientID)
    if err != nil {
        return 0, err
    }
    defer func() {
        err = rows.Close()    // ❶
    }()

    if rows.Next() {
        err := rows.Scan(&balance)
        if err != nil {
            return 0, err
        }
        return balance, nil
    }
    // ...
}

❶ 将错误赋值给输出命名参数

一旦正确创建了rows变量,我们就在匿名函数中延迟对rows.Close()的调用。该函数将错误分配给err变量,该变量使用命名结果参数进行初始化。

这段代码看起来可能没问题,但是有一个问题。如果rows.Scan返回一个错误,无论如何都要执行rows.Close;但是因为这个调用覆盖了getBalance返回的错误,如果rows.Close成功返回,我们可能会返回一个空错误,而不是返回一个错误。换句话说,如果对db.Query的调用成功(函数的第一行),那么getBalance返回的错误将永远是rows.Close返回的错误,这不是我们想要的。

我们需要实现的逻辑并不简单:

  • 如果rows.Scan成功,

    • 如果rows.Close成功,不返回错误。
    • 如果rows.Close失败,返回此错误。

如果rows.Scan失败,逻辑会更复杂一点,因为我们可能需要处理两个错误:

  • 如果rows.Scan失败,

    • 如果rows.Close成功,返回rows.Scan的错误。
    • 如果rows.Close失败。。。然后呢?

如果rows.Scanrows.Close都失败了,我们该怎么办?有几种选择。例如,我们可以返回一个传达两个错误的自定义错误。我们将实现的另一个选项是返回rows.Scan错误,但记录rows.Close错误。下面是匿名函数的最终实现:

defer func() {
    closeErr := rows.Close()     // ❶
    if err != nil {              // ❷
        if closeErr != nil {
            log.Printf("failed to close rows: %v", err)
        }
        return
    }
    err = closeErr               // ❸
}()

❶ 将错误rows.Close赋值给另一个变量

❷ 如果错误已经不为nil,我们优先考虑它。

❸ 否则,我们还会走得更近。

rows.Close错误被分配给另一个变量:closeErr。在将其分配给err之前,我们检查errnil是否不同。如果是这种情况,那么getBalance已经返回了一个错误,所以我们决定记录err并返回现有的错误。

如前所述,错误应该总是被处理。对于由defer调用返回的错误,我们最起码应该做的是显式忽略它们。如果这还不够,我们可以通过记录错误或将错误传播给调用者来直接处理错误,如本节所示。

总结

  • 使用panic是处理GO中错误的一个选项。但是,只有在不可恢复的情况下才应该谨慎使用它:例如,向程序员发出错误信号,或者当您未能加载强制依赖项时。

  • 包装错误允许您标记错误和/或提供额外的上下文。但是,错误包装会产生潜在的耦合,因为它使调用者可以获得源错误。如果您想防止这种情况,请不要使用错误包装。

  • 如果将 Go 1.13 错误包装与%w指令和fmt.Errorf一起使用,必须分别使用errors.Aserrors.Is将错误与类型或值进行比较。否则,如果要检查的返回错误被包装,检查将失败。

  • 为了传达一个预期的错误,使用错误标记(错误值)。意外错误应该是特定的错误类型。

  • 在大多数情况下,一个错误应该只处理一次。记录错误就是处理错误。因此,您必须在记录或返回错误之间做出选择。在许多情况下,错误包装是解决方案,因为它允许您为错误提供额外的上下文并返回错误源。

  • 忽略错误,无论是在函数调用期间还是在defer函数中,都应该使用空白标识符明确完成。否则,未来的读者可能会搞不清这是有意为之还是失手。

  • 在很多情况下,你不应该忽略由defer函数返回的错误。根据上下文,可以直接处理它,也可以将它传播给调用者。如果您想忽略它,请使用空白标识符。

八、并发基础

本章涵盖

  • 了解并发和并行
  • 为什么并发并不总是更快
  • CPU 受限和 I/O 受限工作负载的影响
  • 使用通道与互斥
  • 理解数据竞争和竞争条件之间的差异
  • 使用 Go 上下文

近几十年来,CPU 厂商不再只关注时钟速度。相反,现代 CPU 设计有多个内核和超线程(同一个物理内核上有多个逻辑内核)。因此,为了利用这些架构,并发性对于软件开发人员来说变得至关重要。尽管 Go 提供了简单的原语,但这并不意味着编写并发代码变得容易了。本章讨论与并发性相关的基本概念;第 9 章将关注实践。

8.1 #55:混淆并发性和并行性

即使经过多年的并发编程,开发者也不一定清楚并发和并行的区别。在深入研究特定于 Go 的主题之前,首先必须理解这些概念,这样我们就有了一个共同的词汇表。本节用一个真实的例子来说明:一家咖啡店。

在这家咖啡店,一名服务员负责接受订单,并使用一台咖啡机准备订单。顾客点餐,然后等待他们的咖啡(见图 8.1)。

图 8.1 一个简单的咖啡店

如果服务员很难服务所有的顾客,而咖啡店想加快整个过程,一个想法可能是有第二个服务员和第二个咖啡机。队列中的顾客会等待服务员过来(图 8.2)。

图 8.2 复制咖啡店里的一切

在这个新过程中,系统的每个部分都是独立的。咖啡店应该以两倍的速度为消费者服务。这是一个咖啡店的并行实现。

如果我们想扩大规模,我们可以一遍又一遍地复制服务员和咖啡机。然而,这不是唯一可能的咖啡店设计。另一种方法可能是将服务员的工作进行分工,让一个人负责接受订单,另一个人负责研磨咖啡豆,然后在一台机器中冲泡。此外,我们可以为等待订单的顾客引入另一个队列(想想星巴克),而不是阻塞顾客队列直到顾客得到服务(图 8.3)。

图 8.3 拆分服务员的角色

有了这个新的设计,我们不再把事情平行化。但是整体结构受到了影响:我们将一个给定的角色分成两个角色,并引入了另一个队列。与并行性不同,并行性是指一次多次做同一件事,并发性是关于结构的。

假设一个线程代表服务员接受订单,另一个线程代表咖啡机,我们引入了另一个线程来研磨咖啡豆。每个线程都是独立的,但必须与其他线程协调。在这里,接受订单的服务员线程必须传达要研磨哪些咖啡豆。同时,咖啡研磨线程必须与咖啡机线程连通。

如果我们想通过每小时服务更多的客户来提高吞吐量,该怎么办?因为磨咖啡豆比接受订单花费的时间更长,一个可能的改变是雇佣另一个磨咖啡的服务员(图 8.4)。

图 8.4 雇佣另一个服务员研磨咖啡豆

这里,结构保持不变。依然是三步走的设计:接受、研磨、冲泡咖啡。因此,在并发性方面没有变化。但是我们又回到了添加并行性,这里是针对一个特定的步骤:订单准备。

现在,让我们假设减慢整个过程的部分是咖啡机。使用单个咖啡机会引起咖啡研磨线程的争用,因为它们都在等待咖啡机线程可用。什么是解决方案?添加更多咖啡机线程(图 8.5)。

图 8.5 添加更多咖啡机

我们引入了更多的机器,而不是单一的咖啡机,从而提高了并行度。同样,结构没有改变。它仍然是一个三步设计。但是吞吐量应该会增加,因为咖啡研磨线程的争用程度应该会降低。

通过这种设计,我们可以注意到的重要之处:并发支持并行。事实上,并发性提供了一种结构来解决可能被并行化的部分的问题。

并发是指同时处理大量的事情。并行就是同时做很多事情。

——罗布·派克

总之,并发和并行是不同的。并发是关于结构的,我们可以通过引入独立并发线程可以处理的不同步骤,将顺序实现更改为并发实现。同时,并行是关于执行的,我们可以通过添加更多的并行线程在步骤级别使用它。理解这两个概念是成为一个熟练的 Go 开发者的基础。

下一节讨论一个普遍的错误:认为并发永远是正确的。

8.2 #56:认为并发总是更快

许多开发人员的一个误解是相信并发解决方案总是比顺序解决方案更快。这真是大错特错。解决方案的整体性能取决于许多因素,例如我们的结构的效率(并发性),哪些部分可以并行处理,以及计算单元之间的争用程度。本节提醒我们一些 Go 中并发的基础知识;然后我们将看到一个具体的例子,其中并发解决方案并不一定更快。

8.2.1 调度

线程是操作系统能够执行的最小处理单元。如果一个进程想要同时执行多个动作,它就会旋转多个线程。这些线程可以是

  • 并发——两个或两个以上的线程可以在重叠的时间段内启动、运行、完成,就像上一节的服务员线程和咖啡机线程。

  • 并行——同一任务可以一次执行多次,就像多个等待线程。

操作系统负责优化调度线程的进程,以便

  • 所有线程都可以消耗 CPU 周期,而不会饥饿太长时间。

  • 工作负载尽可能均匀地分布在不同的 CPU 内核中。

注意线程这个词在 CPU 级别上也可以有不同的含义。每个物理核心可以由多个逻辑核心组成(超线程的概念),一个逻辑核心也称为线程。在本节中,当我们使用字线程时,我们指的是处理单元,而不是逻辑核心。

一个 CPU 内核执行不同的线程。当它从一个线程切换到另一个线程时,它执行一个叫做上下文切换的操作。消耗 CPU 周期的活动线程处于执行状态,并转移到可运行状态,这意味着它已准备好执行,等待可用内核。上下文切换被认为是一种开销很大的操作,因为操作系统需要在切换之前保存线程的当前执行状态(如当前寄存器值)。

作为 Go 开发者,我们不能直接创建线程,但是可以创建 goroutines,可以认为是应用级线程。然而,操作系统线程是由操作系统根据上下文切换到 CPU 内核的,而 goroutine 是由 Go 运行时根据上下文切换到操作系统线程的。此外,与 OS 线程相比,goroutine 的内存占用更小:Go 1.4 中的 Goroutine 为 2 KB。一个操作系统线程依赖于操作系统,但是,例如,在 Linux/x86-32 上,默认大小是 2 MB(参见 mng.bz/DgMw )。尺寸越小,上下文切换越快。

注意上下文切换一个 goroutine 比一个线程快大约 80%到 90%,这取决于架构。

现在让我们讨论 Go scheduler 是如何工作的,以概述 goroutines 是如何处理的。在内部,Go 调度器使用以下术语(参见 mng.bz/N611 ):

  • G——goroutines

  • M——OS 线程(代表机器

  • P——CPU 内核(代表处理器

操作系统调度器将每个操作系统线程(M)分配给一个 CPU 内核(P)。然后,每个 goroutine (G)在一个 M 上运行。GOMAXPROCS变量定义了负责同时执行用户级代码的 M 的限制。但是,如果一个线程在系统调用(例如 I/O)中被阻塞,调度器可以加速更多的 M。

goroutine 的生命周期比 OS 线程更简单。它可以执行以下操作之一:

  • 执行——goroutine 在 M 上调度并执行其指令。

  • 可执行——goroutine 正在等待进入执行状态。

  • 等待——goroutine 被停止,等待某些事情的完成,如系统调用或同步操作(如获取互斥)。

关于 Go 调度的实现还有最后一个需要理解的阶段:当一个 goroutine 被创建但还不能被执行时;例如,所有其他 M 都已经在执行 G 了,在这种情况下,Go 运行时会做什么呢?答案是排队。Go 运行时处理两种队列:每个 P 一个本地队列和所有 P 共享的全局队列。

图 8.6 显示了在一台四核机器上给定的调度情况,其中GOMAXPROCS等于4。这些部分是逻辑核心(Ps)、goroutines (Gs)、OS 线程(Ms)、本地队列和全局队列。

首先,我们可以看到五个 Ms,而GOMAXPROCS被设置为4。但是正如我们提到的,如果需要,Go 运行时可以创建比GOMAXPROCS值更多的 OS 线程。

图 8.6 在四核机器上执行的 Go 应用的当前状态示例。不处于执行状态的 Goroutines 要么是可运行的(等待执行),要么是等待的(等待阻塞操作)。

P0、P1 和 P3 目前正忙于执行 Go 运行时线程。但是 P2 现在很闲,因为 M3 离开了 P2,而且也没有戈鲁廷被处决。这不是一个好的情况,因为有六个可运行的 goroutines 正在等待执行,一些在全局队列中,一些在其他本地队列中。Go 运行时将如何处理这种情况?下面是用伪代码实现的调度(参见 mng.bz/lxY8 ):

runtime.schedule() {
    // Only 1/61 of the time, check the global runnable queue for a G.
    // If not found, check the local queue.
    // If not found,
    //     Try to steal from other Ps.
    //     If not, check the global runnable queue.
    //     If not found, poll network.
}

每执行 61 次, Go 调度器将检查全局队列中的 goroutines 是否可用。如果没有,它将检查其本地队列。同时,如果全局和本地队列都是空的,Go 调度器可以从其他本地队列中提取 goroutines。调度中的这个原理叫做偷工,它允许一个未被充分利用的处理器主动寻找另一个处理器的 goroutines 并一些。

最后要提到的一件重要的事情是:在 Go 1.14 之前,调度器是合作的,这意味着只有在特定的阻塞情况下(例如,通道发送或接收、I/O、等待获取互斥锁),goroutine 才可以从线程的上下文中切换出来。从 Go 1.14 开始,Go 调度器现在是抢占式的:当一个 goroutine 运行了一段特定的时间(10 ms)时,它将被标记为可抢占的,并且可以在上下文中关闭,由另一个 goroutine 替换。这允许长时间运行的作业被强制共享 CPU 时间。

现在我们已经理解了 Go 中调度的基本原理,让我们看一个具体的例子:以并行方式实现归并排序。

8.2.2 并行归并排序

首先,我们简单回顾一下归并排序算法是如何工作的。然后我们将实现一个并行版本。请注意,我们的目标不是实现最有效的版本,而是支持一个具体的示例,展示为什么并发并不总是更快。

归并排序算法的工作原理是将一个列表重复分成两个子列表,直到每个子列表包含一个元素,然后合并这些子列表,这样结果就是一个排序后的列表(见图 8.7)。每个分割操作将列表分割成两个子列表,而合并操作将两个子列表合并成一个排序列表。

图 8.7 应用归并排序算法重复地将每个列表分成两个子列表。然后,该算法使用合并操作,从而对结果列表进行排序。

下面是该算法的顺序实现。我们没有包括所有的代码,因为这不是本节的重点:

func sequentialMergesort(s []int) {
    if len(s) <= 1 {
        return
    }

    middle := len(s) / 2
    sequentialMergesort(s[:middle])     // ❶
    sequentialMergesort(s[middle:])     // ❷
    merge(s, middle)                    // ❸
}

func merge(s []int, middle int) {
    // ...
}

❶ 前半部分

❷ 后半部分

❸ 合并了两半

这个算法有一个结构,使它对并发开放。事实上,由于每个sequentialMergesort操作都处理一组不需要完全复制的独立数据(这里是使用切片的底层数组的独立视图),我们可以通过在不同的 goroutine 中加速每个sequentialMergesort操作,在 CPU 内核之间分配这个工作负载。让我们编写第一个并行实现:

func parallelMergesortV1(s []int) {
    if len(s) <= 1 {
        return
    }

    middle := len(s) / 2

    var wg sync.WaitGroup
    wg.Add(2)

    go func() {             // ❶
        defer wg.Done()
        parallelMergesortV1(s[:middle])
    }()

    go func() {             // ❷
        defer wg.Done()
        parallelMergesortV1(s[middle:])
    }()

    wg.Wait()
    merge(s, middle)        // ❸
}

❶ 使用 goroutine 加快前半部分

❷ 使用 goroutine 加快后半部分

❸ 合并了两半

在这个版本中,工作负载的每一半都在一个单独的 goroutine 中处理。父 goroutine 通过使用sync.WaitGroup来等待两个部分。因此,我们在合并操作之前调用Wait方法。

注意,如果你还不熟悉sync.WaitGroup,我们将在错误#71“误用 sync.WaitGroup”中更详细地了解它。简而言之,它允许我们等待n操作完成:通常是 goroutines,就像前面的例子一样。

我们现在有了归并排序算法的并行版本。因此,如果我们运行一个基准来比较这个版本和顺序版本,并行版本应该更快,对吗?让我们在具有 10,000 个元素的四核计算机上运行它:

Benchmark_sequentialMergesort-4       2278993555 ns/op
Benchmark_parallelMergesortV1-4      17525998709 ns/op

令人惊讶的是,并行版本几乎慢了一个数量级。我们如何解释这个结果?在四个内核之间分配工作负载的并行版本怎么可能比运行在单台机器上的顺序版本慢?我们来分析一下问题。

如果我们有一个 1024 个元素的切片,父 goroutine 将旋转两个 goroutine,每个负责处理由 512 个元素组成的另一半。这些 goroutine 中的每一个都将增加两个新的 goroutine,负责处理 256 个元素,然后是 128 个,依此类推,直到我们增加一个 goroutine 来计算一个元素。

如果我们想要并行化的工作负载太小,这意味着我们将计算得太快,那么跨内核分布作业的好处就会被破坏:与直接合并当前 goroutine 中的少量项目相比,创建 goroutine 并让调度器执行它所花费的时间太长了。尽管 goroutines 是轻量级的,启动速度比线程快,但我们仍然会遇到工作负载太小的情况。

注我们将讨论如何识别错误#98“没有使用 Go 诊断工具”中的执行并行性差的情况

那么我们能从这个结果中得出什么结论呢?这是否意味着归并排序算法不能并行化?等等,别这么快。

让我们尝试另一种方法。因为在一个新的 goroutine 中合并少量的元素效率不高,所以让我们定义一个阈值。该阈值将表示为了以并行方式处理,一半应该包含多少元素。如果一半中的元素数小于这个值,我们将按顺序处理。这是一个新版本:

const max = 2048                      // ❶

func parallelMergesortV2(s []int) {
    if len(s) <= 1 {
        return
    }

    if len(s) <= max {
        sequentialMergesort(s)        // ❷
    } else {                          // ❸
        middle := len(s) / 2

        var wg sync.WaitGroup
        wg.Add(2)

        go func() {
            defer wg.Done()
            parallelMergesortV2(s[:middle])
        }()

        go func() {
            defer wg.Done()
            parallelMergesortV2(s[middle:])
        }()

        wg.Wait()
        merge(s, middle)
    }
}

❶ 定义了阈值

❷ 调用我们最初的串行版本

❸ 如果大于阈值,则保持并行版本

如果s切片中的元素数量小于max,我们称之为顺序版本。否则,我们继续调用我们的并行实现。这种方法会影响结果吗?是的,确实如此:

Benchmark_sequentialMergesort-4       2278993555 ns/op
Benchmark_parallelMergesortV1-4      17525998709 ns/op
Benchmark_parallelMergesortV2-4       1313010260 ns/op

我们的 v2 并行实现比顺序实现快 40%以上,这要归功于定义一个阈值来指示并行何时应该比顺序更高效的想法。

请注意,为什么我将阈值设置为 2,048?因为这是我的机器上这个特定工作负载的最佳值。一般来说,这种神奇的值应该用基准仔细定义(在类似于生产的执行环境中运行)。有趣的是,在没有实现 goroutines 概念的编程语言中运行相同的算法会对值产生影响。例如,在 Java 中使用线程运行相同的示例意味着最佳值接近 8,192。这有助于说明 goroutines 比线程更高效。

在本章中,我们已经看到了 Go 中调度的基本概念:线程和 goroutine 之间的区别,以及 Go 运行时如何调度 goroutine。同时,使用并行归并排序的例子,我们说明了并发并不总是更快。正如我们所看到的,让 goroutines 运行来处理最少的工作负载(只合并一小部分元素)会破坏我们从并行性中获得的好处。

那么,我们该何去何从呢?我们必须记住,并发并不总是更快,也不应该被认为是解决所有问题的默认方式。首先,它使事情变得更加复杂。此外,现代 CPU 在执行顺序代码和可预测代码方面已经变得非常高效。例如,超标量处理器可以在单个内核上高效地并行执行指令。

这是否意味着我们不应该使用并发性?当然不是。然而,记住这些结论是很重要的。例如,如果我们不确定并行版本会更快,正确的方法可能是从简单的顺序版本开始,然后使用概要分析(错误#98,“没有使用 Go 诊断工具”)和基准测试(错误#89,“编写不准确的基准测试”)进行构建。这可能是确保并发性值得的唯一方法。

下一节讨论一个常见的问题:什么时候应该使用通道或互斥体?

8.3 #57:对何时使用通道或互斥感到困惑

给定一个并发问题,我们是否可以使用通道或互斥来实现一个解决方案可能并不总是很清楚。因为 Go 提倡通过通信共享内存,所以一个错误可能是总是强制使用通道,而不管用例是什么。然而,我们应该把这两种选择看作是互补的。这一节阐明了我们应该在什么时候选择一个选项。我们的目标不是讨论每一个可能的用例(这可能需要一整章的时间),而是给出可以帮助我们做出决定的一般准则。

首先,简单提醒一下 Go 中的通道:通道是一种沟通机制。在内部,通道是一个我们可以用来发送和接收值的管道,它允许我们连接并发的 goroutines。通道可以是以下任意一种:

  • 未缓冲——发送方 goroutine 阻塞,直到接收方 goroutine 准备就绪。

  • 缓冲——发送方仅在缓冲区已满时阻塞。

让我们回到我们最初的问题。我们什么时候应该使用通道或互斥?我们将使用图 8.8 中的例子作为主干。我们的示例中有三种不同的 goroutines,它们具有特定的关系:

  • G1 和 G2 是平行的两条路线。它们可能是两个执行相同函数的 goroutine,该函数从一个通道接收消息,或者可能是两个 goroutine 同时执行相同的 HTTP 处理器。

  • 另一方面,G1 和 G3 是并发的 goroutines,G2 和 G3 也是。所有的 goroutines 都是整个并发结构的一部分,但是 G1 和 G2 执行第一步,而 G3 执行下一步。

图 8.8 和 G2 是并行的,而 G2 和 G3 是并发的。

一般来说,并行 goroutines 必须同步:例如,当它们需要访问或改变一个共享资源(比如一个片)时。同步是通过互斥体实现的,而不是通过任何通道类型(不是缓冲通道)实现的。因此,一般来说,并行 goroutines 之间的同步应该通过互斥来实现。

相反,一般来说,并发的 goroutines 必须协调和编排。例如,如果 G3 需要汇总来自 G1 和 G2 的结果,则 G1 和 G2 需要向 G3 发出信号,告知有新的中间结果可用。这种协调属于沟通的范畴,因此属于通道的范畴。

关于并发 goroutines,也有这样的情况,我们希望将资源的所有权从一个步骤(G1 和 G2)转移到另一个步骤(G3);例如,如果 G1 和 G2 正在丰富一个共享资源,在某个时刻,我们认为这项工作已经完成。这里,我们应该使用通道来表示特定的资源已经准备好,并处理所有权转移。

互斥体和通道有不同的语义。每当我们想要共享一个状态或访问一个共享资源时,互斥锁确保对这个资源的独占访问。相反,通道是一种机制,用于发送有数据或无数据的信号(chan struct{}或无数据)。协调或所有权转移应通过通道实现。了解 goroutine 是并行的还是并发的很重要,因为一般来说,我们需要为并行的 goroutine 使用互斥体,为并发的 goroutine 使用通道。

现在让我们讨论一个关于并发性的普遍问题:竞争问题。

8.4 #58:不理解竞争问题

竞争问题可能是程序员面临的最困难和最阴险的错误之一。作为 Go 开发人员,我们必须理解关键的方面,比如数据竞争和竞争条件,它们可能的影响,以及如何避免它们。我们将讨论这些主题,首先讨论数据竞争和竞争条件,然后研究 go 内存模型及其重要性。

8.4.1 数据竞争与竞争条件

我们先来关注一下数据竞争。当两个或多个 goroutines 同时访问同一个内存位置,并且至少有一个正在写入时,就会发生数据竞争。以下是两个 goroutines 递增一个共享变量的示例:

i := 0

go func() {
    i++       // ❶
}()

go func() {
    i++
}()

❶ 递增i

如果我们使用 Go 竞争检测器(-race选项)运行这段代码,它会警告我们发生了数据竞争:

==================
WARNING: DATA RACE
Write at 0x00c00008e000 by goroutine 7:
  main.main.func2()

Previous write at 0x00c00008e000 by goroutine 6:
  main.main.func1()
==================

i的最终值也是无法预测的。有时候可以是1,有时候是2

这段代码有什么问题?i++语句可以分解成三个操作:

  1. 读取i

  2. 递增数值。

  3. 写回i

如果第一个 goroutine 在第二个之前执行并完成,会发生以下情况。

Goroutine 1 Goroutine 2 操作 i
0
读取 <- 0
递增 0
响应 -> 1
读取 <- 1
递增 1
响应 -> 2

第一个 goroutine 读取、递增并将值1写回i。然后第二个 goroutine 执行相同的一组动作,但是从1开始。因此,写入i的最终结果是2

然而,在前面的例子中,不能保证第一个 goroutine 会在第二个之前开始或完成。我们还可以面对交叉执行的情况,其中两个 goroutines 同时运行并竞争访问i。这是另一种可能的情况。

Goroutine 1 Goroutine 2 操作 i
0
读取 <- 0
读取 <- 0
递增 0
递增 0
响应 -> 1
响应 -> 1

首先,两个 goroutines 从i读取并获得值0。然后,两者都将其递增,并写回它们的本地结果:1,这不是预期的结果。

这是数据竞争可能带来的影响。如果两个 goroutines 同时访问同一个内存位置,并且至少对该内存位置进行一次写入,结果可能是危险的。更糟糕的是,在某些情况下,内存位置最终可能会保存一个包含无意义的位组合的值。

请注意,在错误#83“未启用-race标志”中,我们将看到 Go 如何帮助我们检测数据竞争。

我们如何防止数据竞争的发生?让我们看看一些不同的技术。这里的范围不是展示所有可能的选项(例如,我们将省略atomic.Value),而是展示主要的选项。

第一种选择是使增量操作原子化,这意味着它在单个操作中完成。这防止了纠缠的运行操作。

Goroutine 1 Goroutine 2 操作 i
0
读取并递增 <-> 1
读取并递增 <-> 2

即使第二个 goroutine 在第一个之前运行,结果仍然是2

原子操作可以在 Go 中使用T2 包来完成。这里有一个我们如何自动增加一个int64的例子:

var i int64

go func() {
    atomic.AddInt64(&i, 1)    // ❶
}()

go func() {
    atomic.AddInt64(&i, 1)    // ❷
}()

❶ 原子地递增i

❷ 相同

两个 goroutines 都自动更新i。原子操作不能被中断,从而防止同时进行两次访问。不管 goroutines 的执行顺序如何,i最终将等于2

注意sync/atomic包为int32int64uint32uint64提供了原语,但不为int提供原语。这就是为什么在这个例子中i是一个int64

另一个选择是用一个类似互斥的特殊数据结构来同步两个 goroutines。Mutex代表互斥(Mutation Exclusion);互斥体确保最多一个 goroutine 访问一个所谓的临界区。在 Go 中,sync包提供了一个Mutex类型:

i := 0
mutex := sync.Mutex{}

go func() {
    mutex.Lock()        // ❶
    i++                 // ❷
    mutex.Unlock()      // ❸
}()

go func() {
    mutex.Lock()
    i++
    mutex.Unlock()
}()

❶ 进入临界区

❷ 递增i

❸ 退出临界区

在本例中,递增i是临界区。不管 goroutines 的顺序如何,这个例子也为i : 2产生一个确定值。

哪种方法效果最好?界限很简单。正如我们提到的,sync/atomic包只对特定类型的有效。如果我们想要别的东西(例如,切片、映射和结构),我们不能依赖sync/atomic

另一个可能的选择是避免共享同一个内存位置,而是支持跨 goroutines 的通信。例如,我们可以创建一个通道,每个 goroutine 使用该通道来产生增量值:

i := 0
ch := make(chan int)

go func() {
    ch <- 1     // ❶
}()

go func() {
    ch <- 1
}()

i += <-ch       // ❷
i += <-ch

❶ 通知 goroutine 增加 1

❷ 从通道接收到的信息中增加i

每个 goroutine 通过通道发送一个通知,告诉我们应该将i增加1。父 goroutine 收集通知并增加i。因为这是唯一写入i的 goroutine,这个解决方案也没有数据竞争。

让我们总结一下到目前为止我们所看到的。当多个 goroutines 同时访问同一个内存位置(例如,同一个变量)并且其中至少有一个正在写入时,就会发生数据争用。我们还看到了如何通过三种同步方法来防止这个问题:

  • 使用原子操作

  • 用互斥体保护临界区

  • 使用通信和通道来确保一个变量只由一个例程更新

通过这三种方法,i的值最终将被设置为2,而不考虑两个 goroutines 的执行顺序。但是根据我们想要执行的操作,无数据竞争的应用一定意味着确定性的结果吗?让我们用另一个例子来探讨这个问题。

不是让两个 goroutines 递增一个共享变量,而是每个都做一个赋值。我们将遵循使用互斥体来防止数据竞争的方法:

i := 0
mutex := sync.Mutex{}

go func() {
    mutex.Lock()
    defer mutex.Unlock()
    i = 1                 // ❶
}()

go func() {
    mutex.Lock()
    defer mutex.Unlock()
    i = 2                 // ❷
}()

❶ 第一次把 1 赋值给i

❷ 第二次把 2 赋值给i

第一个 goroutine 分配1i,而第二个分配2

这个例子中有数据竞争吗?不,没有。两个 goroutines 访问同一个变量,但不是同时,因为互斥体保护它。但是这个例子是确定性的吗?不,不是的。

根据执行顺序,i最终将等于12。这个例子不会导致数据竞争。但是它有一个竞争条件。当行为依赖于无法控制的事件顺序或时间时,就会出现竞争情况。在这里,事件的时间是 goroutines 的执行顺序。

确保 goroutines 之间特定的执行顺序是一个协调和编排的问题。如果我们想确保我们首先从状态 0 到状态 1,然后从状态 1 到状态 2,我们应该找到一种方法来保证 goroutines 按顺序执行。通道可以是解决这个问题的一种方式。协调和编排还可以确保一个特定的部分只被一个 goroutine 访问,这也意味着删除前面例子中的互斥体。

总之,当我们在并发应用中工作时,必须理解数据竞争不同于竞争条件。当多个 goroutines 同时访问同一个内存位置,并且其中至少有一个正在写入时,就会发生数据竞争。数据竞争意味着意外的行为。然而,无数据竞争的应用并不一定意味着确定的结果。一个应用可以没有数据竞争,但仍然具有依赖于不受控事件的行为(例如 goroutine 执行、消息发布到通道的速度,或者对数据库的调用持续多长时间);这是一个竞争条件。理解这两个概念对于熟练设计并发应用至关重要。

现在让我们检查 Go 内存模型,并理解它为什么重要。

8.4.2 Go 内存模型

上一节讨论了同步 goroutines 的三种主要技术:原子操作、互斥和通道。然而,作为 Go 开发者,我们应该了解一些核心原则。例如,缓冲和无缓冲通道提供不同的保证。为了避免由于缺乏对语言核心规范的理解而导致的意外竞争,我们必须看看 Go 内存模型。

Go 内存模型(golang.org/ref/mem)是一种规范,它定义了在写入不同 goroutine 中的相同变量后,从一个 goroutine 中的变量读取数据的条件。换句话说,它提供了开发人员应该记住的保证,以避免数据竞争和强制确定性输出。

在单个 goroutine 中,不存在不同步的访问。事实上,我们的程序所表达的顺序保证了先发生顺序。

然而,在多个 goroutines 中,我们应该记住其中的一些保证。我们将使用符号A < B来表示事件A发生在事件B之前。让我们检查一下这些保证(有些是从Go 内存模型复制过来的):

  • 创建一个 goroutine 发生在 goroutine 执行开始之前。因此,读取一个变量,然后启动一个新的 goroutine 写入该变量,不会导致数据竞争:

    i := 0
    go func() {
        i++
    }()
    
  • 相反,一个 goroutine 的退出并不能保证发生在任何事件之前。因此,以下示例存在数据竞争:

    i := 0
    go func() {
        i++
    }()
    fmt.Println(i)
    

    同样,如果我们想防止数据竞争的发生,我们应该同步这些 goroutines。

  • 通道上的发送发生在该通道的相应接收完成之前。在下一个示例中,父 goroutine 在发送前递增变量,而另一个 goroutine 在通道读取后读取变量:

    i := 0
    ch := make(chan struct{})
    go func() {
        <-ch
        fmt.Println(i)
    }()
    i++
    ch <- struct{}{}
    

    顺序如下:

    variable increment < channel send < channel receive < variable read
    

    通过传递性,我们可以确保对i的访问是同步的,因此没有数据竞争。

  • 关闭通道发生在接收到该关闭之前。下一个例子与上一个类似,只是我们没有发送消息,而是关闭了通道:

    i := 0
    ch := make(chan struct{})
    go func() {
        <-ch
        fmt.Println(i)
    }()
    i++
    close(ch)
    

    因此,这个例子也没有数据竞争。

  • 关于通道的最后一个保证乍一看可能是违反直觉的:来自无缓冲通道的接收发生在该通道上的发送完成之前。

    首先,我们来看一个用缓冲通道代替无缓冲通道的例子。我们有两个 goroutines,父节点发送消息并读取一个变量,而子节点更新这个变量并从通道接收:

    i := 0
    ch := make(chan struct{}, 1)
    go func() {
        i = 1
        <-ch
    }()
    ch <- struct{}{}
    fmt.Println(i)
    

    这个例子导致了一场数据竞争。我们可以在图 8.9 中看到,对i的读取和写入可能同时发生;因此,i并不同步。

    图 8.9 如果通道被缓冲,就会导致数据竞争。

    现在,让我们将通道改为无缓冲通道,以说明内存模型保证:

    i := 0
    ch := make(chan struct{})   // ❶
    go func() {
        i = 1
        <-ch
    }()
    ch <- struct{}{}
    fmt.Println(i)
    

    ❶使通道无缓冲

    改变通道类型使本例无数据竞争(见图 8.10)。在这里,我们可以看到主要的区别:写操作肯定发生在读操作之前。注意,箭头不代表因果关系(当然,接收是由发送引起的);它们代表 Go 内存模型的排序保证。因为来自无缓冲通道的接收发生在发送之前,所以对i的写入总是发生在读取之前。

    图 8.10 如果通道是无缓冲的,它不会导致数据竞争。

在本节中,我们已经讨论了 Go 内存模型的主要保证。在编写并发代码时,理解这些保证应该是我们核心知识的一部分,并且可以防止我们做出可能导致数据竞争和/或竞争条件的错误假设。

下一节讨论了理解工作负载类型的重要性。

8.5 #59:不了解工作负载类型的并发影响

本节介绍了并行实现中工作负载类型的影响。根据工作负载是受 CPU 限制还是受 I/O 限制,我们可能需要以不同的方式处理这个问题。让我们首先定义这些概念,然后讨论影响。

在编程中,工作负荷的执行时间受以下因素的限制:

  • CPU 的速度——例如,运行归并排序算法。这个工作负载被称为 CPU 限制

  • I/O 的速度——例如,进行 REST 调用或数据库查询。工作负载称为 I/O 限制

  • 可用内存量——工作负载称为内存限制

注意,鉴于近几十年来内存变得非常便宜,最后一种是现在最罕见的。因此,本节重点介绍前两种工作负载类型:CPU 和 I/O 负载。

为什么在并发应用环境中对工作负载进行分类很重要?让我们通过一个并发模式来理解这一点:工作器池。

下面的例子实现了一个read函数,它接受一个io.Reader并从中重复读取 1024 个字节。我们将这 1024 字节传递给一个执行某些任务的task函数(稍后我们将看到是什么类型的任务)。这个task函数返回一个整数,我们要返回所有结果的和。下面是一个顺序实现:

func read(r io.Reader) (int, error) {
    count := 0
    for {
        b := make([]byte, 1024)
        _, err := r.Read(b)     // ❶
        if err != nil {
            if err == io.EOF {  // ❷
                break
            }
            return 0, err
        }
        count += task(b)        // ❸
    }
    return count, nil
}

❶ 读取 1024 字节

❷ 当我们到达终点时,停止循环

❸ 根据任务函数的结果增加计数

该函数创建一个count变量,从读取io.Reader输入,调用task,并递增count。现在,如果我们想以并行的方式运行所有的和task函数,该怎么办呢?

一种选择是使用所谓的 工作器统筹模式。这样做涉及到创建固定大小的工作器(goroutines ),这些工作器从一个公共通道轮询任务(见图 8.11)。

图 8.11 来自固定池的每个 goroutine 从共享通道接收。

首先,我们构建一个固定的 goroutines 池(稍后我们将讨论有多少)。然后我们创建一个共享通道,在每次读取到io.Reader之后,我们将任务发布到这个通道。池中的每个 goroutine 从这个通道接收数据,执行它的工作,然后自动更新一个共享计数器。

这里有一种在 Go 中写这个的可能方法,池大小为 10 个 goroutines。每个 goroutine 自动更新一个共享计数器:

func read(r io.Reader) (int, error) {
    var count int64
    wg := sync.WaitGroup{}
    var n = 10

    ch := make(chan []byte, n)        // ❶
    wg.Add(n)                         // ❷
    for i := 0; i < n; i++ {          // ❸
        go func() {
            defer wg.Done()           // ❹
            for b := range ch {       // ❺
                v := task(b)
                atomic.AddInt64(&count, int64(v))
            }
        }()
    }

    for {
        b := make([]byte, 1024)
        // Read from r to b
        ch <- b                       // ❻
    }
    close(ch)
    wg.Wait()                         // ❼
    return int(count), nil
}

❶ 创建一个容量等于池容量的通道

❷ 将n添加到等待组中

❸ 创建了n个 goroutine 池

❹ 一旦 goroutine 从通道收到消息,就调用Done方法

❺ 每个 goroutine 从共享通道接收。

❻ 每次读取后,都会向通道发布一个新任务

❼ 在返回之前等待等待组完成

在这个例子中,我们使用n来定义池的大小。我们创建一个容量与池相同的通道和一个增量为n的等待组。这样,我们在发布消息时减少了父 goroutine 中的潜在争用。我们迭代n次来创建一个从共享通道接收的新的 goroutine。收到的每条消息都通过执行task和自动递增共享计数器来处理。从通道中读取数据后,每个 goroutine 都会递减等待组。

在父 goroutine 中,我们一直从io.Reader开始读取,并将每个任务发布到通道。最后但同样重要的是,我们关闭通道,等待等待组完成(意味着所有的子 goroutines 都完成了它们的任务)再返回。

拥有固定数量的 goroutines 限制了我们讨论过的缺点;它缩小了资源的影响,并防止外部系统被淹没。现在的关键问题是:池大小的值应该是多少?答案取决于工作负载类型。

如果工作负载是 I/O 受限的,那么答案主要取决于外部系统。如果我们想要最大化吞吐量,系统可以处理多少个并发访问?

如果工作负载受 CPU 限制,最佳实践是依赖GOMAXPROCSGOMAXPROCS是变量,设置分配给正在运行的 goroutines 的 OS 线程数。默认情况下,该值设置为逻辑 CPU 的数量。

使用runtime.GOMAXPROCS

我们可以使用runtime.GOMAXPROCS(int)函数来更新GOMAXPROCS的值。用0作为参数调用它不会改变值;它只返回当前值:

n := runtime.GOMAXPROCS(0)

那么,将池的大小映射到GOMAXPROCS的基本原理是什么?我们举一个具体的例子,说我们将在四核机器上运行我们的应用;因此,Go 将实例化四个 OS 线程,其中 goroutines 将被执行。起初,事情可能并不理想:我们可能面临一个场景,有四个 CPU 核心和四个 goroutine,但是只有一个 goroutine 被执行,如图 8.12 所示。

图 8.12 最多运行一个 goroutine。

M0 目前正在运行工作器池的 goroutine。因此,这些 goroutines 开始从通道接收消息并执行它们的作业。但是池中的其他三个 goroutines 还没有分配给 M;因此,它们处于可运行状态。M1、M2 和 M3 没有任何 goroutines 要运行,所以它们仍然没有核心。因此只有一个 goroutine 在运行。

最终,考虑到我们已经描述过的窃取工作的概念,P1 可能会从本地 P0 队列中窃取 goroutines。在图 8.13 中,P1 从 P0 那里偷了三个 goroutines。在这种情况下,Go 调度器也可能最终将所有的 goroutines 分配给不同的 OS 线程,但是不能保证这应该在什么时候发生。然而,由于 Go 调度器的主要目标之一是优化资源(这里是 goroutines 的分布),考虑到工作负载的性质,我们应该以这样的场景结束。

图 8.13 最多运行两个 goroutines。

这个场景仍然不是最佳的,因为最多运行两个 goroutines。假设机器只运行我们的应用(而不是操作系统进程),那么 P2 和 P3 是自由的。最终,操作系统应该移动 M2 和 M3,如图 8.14 所示。

图 8.14 现在最多运行四个 goroutines。

在这里,操作系统调度器决定将 M2 移到 P2,将 M3 移到 P3。同样,无法保证这种情况何时会发生。但是假设一台机器只执行我们的四线程应用,这应该是最终的画面。

情况发生了变化;它已经变得最优。四个 goroutines 运行在不同的线程中,线程运行在不同的内核上。这种方法减少了 goroutine 和线程级别的上下文切换量。

这个全局图不是我们(Go 开发者)能设计和要求的。然而,正如我们所看到的,我们可以在 CPU 受限的工作负载的情况下以有利的条件启用它:拥有一个基于GOMAXPROCS的工作池。

注意,如果给定特定的条件,我们希望将 goroutines 的数量绑定到 CPU 内核的数量,为什么不依赖于返回逻辑 CPU 内核数量的runtime.NumCPU()?我们提到过,GOMAXPROCS是可以改变的,可以小于 CPU 核心的数量。在 CPU 受限的工作负载的情况下,如果内核的数量是四个,但我们只有三个线程,我们应该增加三个 goroutines,而不是四个。否则,一个线程将在两个 goroutines 之间共享其执行时间,从而增加上下文切换的次数。

在实现 worker-pooling 模式时,我们已经看到池中 goroutines 的最佳数量取决于工作负载类型。如果工作线程执行的工作负载是 I/O 受限的,那么这个值主要取决于外部系统。相反,如果工作负载是 CPU 受限的,那么 goroutines 的最佳数量接近可用线程的数量。设计并发应用时,了解工作负载类型(I/O 或 CPU)至关重要。

最后但同样重要的是,让我们记住,在大多数情况下,我们应该通过基准来验证我们的假设。并发并不简单,很容易做出草率的假设,结果证明是无效的。

在本章的最后一节,我们将讨论一个要精通 Go 必须了解的重要话题:上下文。

8.6 #60:误解 Go 上下文

开发人员有时会误解context.Context类型,尽管它是该语言的关键概念之一,也是 Go 中并发代码的基础。让我们看看这个概念,并确保我们理解为什么以及如何有效地使用它。

根据官方文档(pkg.go.dev/context):

上下文携带截止日期、取消信号和其他跨 API 边界的值。

让我们检查一下这个定义,并理解与 Go 上下文相关的所有概念。

8.6.1 截止日期

截止日期是指由以下某一项确定的特定时间点:

  • 从现在起一个time.Duration(例如,在 250 毫秒内)

  • A time.Time(例如世界协调时2023-02-07 00:00:00)

截止日期的语义表明,如果符合截止日期,正在进行的活动应该停止。例如,一个活动是一个 I/O 请求或一个等待从通道接收消息的 goroutine。

让我们考虑一个每四秒钟从雷达接收一次飞行位置的应用。一旦我们收到一个职位,我们希望与只对最新职位感兴趣的其他应用共享它。我们拥有一个包含单一方法的publisher接口:

type publisher interface {
    Publish(ctx context.Context, position flight.Position) error
}

此方法接受上下文和位置。我们假设具体实现调用一个函数向代理发布消息(比如使用 Sarama 发布 Kafka 消息)。这个函数是上下文感知的,这意味着一旦上下文被取消,它就可以取消请求。

假设我们没有收到一个现有的上下文,我们应该为上下文参数的Publish方法提供什么?我们已经提到,申请人只对最新的职位感兴趣。因此,我们构建的上下文应该传达这样的信息:4 秒钟后,如果我们无法发布航班位置,我们应该停止对Publish的调用:

type publishHandler struct {
    pub publisher
}

func (h publishHandler) publishPosition(position flight.Position) error {
    ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) // ❶
    defer cancel()                                                          // ❷
    return h.pub.Publish(ctx, position)                                     // ❸
}

❶ 创建的上下文将在 4 秒钟后超时

❷ 延迟调用cancel

❸ 传递了创建的上下文

这段代码使用函数context.WithTimeout创建一个上下文。该函数接受超时和上下文。在这里,由于publishPosition没有接收现有的上下文,我们用context.Background从一个空的上下文中创建一个。同时,context.WithTimeout返回两个变量:创建的上下文和一个取消func()函数,该函数将在调用后取消上下文。将创建的上下文传递给Publish方法应该会使它在最多 4 秒内返回。

cancel函数为函数的基本原理是什么?在内部,context.WithTimeout创建一个 goroutine,该 goroutine 将在内存中保留 4 秒钟或直到cancel被调用。因此,调用cancel作为一个defer函数意味着当我们退出父函数时,上下文将被取消,创建的 goroutine 将被停止。这是一种保护措施,这样当我们返回时,不会在内存中留下保留的对象。

现在让我们转到 Go 上下文的第二个方面:取消信号。

8.6.2 取消信号

Go 上下文的另一个用例是携带取消信号。假设我们想要创建一个在另一个 goroutine 中调用CreateFileWatcher(ctx context.Context, filename string)的应用。这个函数创建了一个特定的文件监视器,它不断读取文件并捕捉更新。当提供的上下文过期或被取消时,该函数处理它以关闭文件描述符。

最后,当main返回时,我们希望通过关闭这个文件描述符来优雅地处理事情。因此,我们需要传播一个信号。

一种可能的方法是使用context.WithCancel,它返回一个上下文(返回的第一个变量),一旦调用了cancel函数(返回的第二个变量),它将取消:

func main() {
    ctx, cancel := context.WithCancel(context.Background())    // ❶
    defer cancel()                                             // ❷

    go func() {
        CreateFileWatcher(ctx, "foo.txt")                      // ❸
    }()

    // ...
}

❶ 创建了一个可取消的环境

❷ 延迟调用cancel

❸ 使用创建的上下文调用该函数

main返回时,它调用cancel函数来取消传递给CreateFileWatcher的上下文,以便文件描述符被优雅地关闭。

接下来,让我们讨论 Go 上下文的最后一个方面:值。

8.6.3 上下文值

Go 上下文的最后一个用例是携带一个键值列表。在了解其背后的原理之前,我们先来看看如何使用它。

传达值的上下文可以这样创建:

ctx := context.WithValue(parentCtx, "key", "value")

context.WithTimeoutcontext.WithDeadlinecontext.WithCancel一样,context.WithValue是从父上下文(这里是parentCtx)中创建的。在这种情况下,我们创建一个新的ctx上下文,它包含与parentCtx相同的特征,但也传递一个键和值。

我们可以使用Value方法访问该值:

ctx := context.WithValue(context.Background(), "key", "value")
fmt.Println(ctx.Value("key"))
value

提供的键和值是any类型。事实上,对于值,我们希望传递any类型。但是为什么键也应该是一个空接口,而不是一个字符串呢?这可能会导致冲突:来自不同包的两个函数可能使用相同的字符串值作为键。因此,后者将覆盖前者的值。因此,处理上下文键的最佳实践是创建一个未导出的自定义类型:

package provider

type key string

const myCustomKey key = "key"

func f(ctx context.Context) {
    ctx = context.WithValue(ctx, myCustomKey, "foo")
    // ...
}

myCustomKey常量未导出。因此,使用相同上下文的另一个包不会覆盖已经设置的值。即使另一个包也基于一个key类型创建了相同的myCustomKey,它也将是一个不同的键。

那么,让上下文携带一个键值列表有什么意义呢?因为 Go 上下文是通用的和主流的,所以有无限的用例。

例如,如果我们使用跟踪,我们可能希望不同的子函数共享相同的关联 ID。一些开发人员可能认为这个 ID 太具侵入性,不适合作为函数签名的一部分。在这方面,我们也可以决定将其作为所提供的上下文的一部分。

另一个例子是如果我们想要实现一个 HTTP 中间件。如果你不熟悉这个概念,中间件是在服务请求之前执行的中间功能。例如,在图 8.15 中,我们已经配置了两个中间件,它们必须在执行处理器本身之前执行。如果我们想要中间件通信,它们必须通过*http.Request中处理的上下文。

图 8.15 在到达处理器之前,一个请求通过配置好的中间件。

让我们编写一个标记源主机是否有效的中间件示例:

type key string

const isValidHostKey key = "isValidHost"                                    // ❶

func checkValid(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        validHost := r.Host == "acme"                                       // ❷
        ctx := context.WithValue(r.Context(), isValidHostKey, validHost)    // ❸

        next.ServeHTTP(w, r.WithContext(ctx))                               // ❹
    })
}

❶ 创建上下文键

❷ 检查主机是否有效

❸ 创建一个新的上下文,其中包含一个值来表示源主机是否有效

❹ 在新环境下的调用next.ServeHTTP

首先,我们定义一个名为isValidHostKey的特定上下文键。然后checkValid中间件检查源主机是否有效。这个信息在新的上下文中传递,使用next.ServeHTTP传递到下一个 HTTP 步骤(下一个步骤可以是另一个 HTTP 中间件或最终的 HTTP 处理器)。

这个例子展示了如何在具体的 Go 应用中使用带有值的上下文。在前面的章节中,我们已经看到了如何创建一个上下文来承载截止日期、取消信号和/或值。我们可以使用这个上下文,并将其传递给上下文感知库,这意味着库公开了接受上下文的函数。但是现在,假设我们必须创建一个库,并且我们希望外部客户端提供一个可以被取消的上下文。

8.6.4 捕捉上下文取消

context.Context类型导出返回一个只收通知通道的Done方法:<-chan struct{}。当与上下文相关联的工作应该被取消时,该通道被关闭。举个例子,

  • 调用cancel函数时,与context.WithCancel创建的上下文相关的Done通道关闭。

  • 当截止日期到期时,与用context.WithDeadline创建的上下文相关的Done通道关闭。

需要注意的一点是,内部通道应该在上下文被取消或达到截止日期时关闭,而不是在它收到特定值时关闭,因为通道的关闭是所有消费者 goroutines 将收到的唯一通道操作。这样,一旦上下文被取消或截止日期到了,所有的消费者都会得到通知。

此外,context.Context导出一个Err方法,如果Done通道尚未关闭,则返回nil。否则,它返回一个非零错误,解释为什么Done通道被关闭:例如,

  • 一个context.Canceled错误,如果通道被取消

  • 如果上下文的截止日期已过,则出现context.DeadlineExceeded错误

让我们看一个具体的例子,在这个例子中,我们希望不断地从一个通道接收消息。同时,我们的实现应该是上下文感知的,如果所提供的上下文完成了,就返回:

func handler(ctx context.Context, ch chan Message) error {
    for {
        select {
        case msg := <-ch:               // ❶
            // Do something with msg
        case <-ctx.Done():              // ❷
            return ctx.Err()
        }
    }
}

❶ 不断接收来自通道的消息

❷ 如果上下文完成,返回与之相关的错误

我们创建一个for循环,并在两种情况下使用select:从ch接收消息或接收一个信号,表明上下文已经完成,我们必须停止我们的作业。在处理通道时,这是一个如何让函数感知上下文的例子。

实现接收上下文的函数

在接收传达可能的取消或超时的上下文的函数中,接收或发送消息到通道的操作不应该以阻塞方式完成。例如,在下面的函数中,我们向一个通道发送消息,并从另一个通道接收消息:

func f(ctx context.Context) error {
    // ...
    ch1 <- struct{}{}     // ❶

    v := <-ch2            // ❷
    // ...
}

❶ 接收

❷ 发送

这个函数的问题是,如果上下文被取消或超时,我们可能不得不等待消息被发送或接收,而没有好处。相反,我们应该使用select来等待通道动作完成或者等待上下文取消:

func f(ctx context.Context) error {
    // ...
    select {              // ❶
    case <-ctx.Done():
        return ctx.Err()
    case ch1 <- struct{}{}:
    }

    select {              // ❷
    case <-ctx.Done():
        return ctx.Err()
    case v := <-ch2:
        // ...
    }
}

❶ 向ch1发送消息或者等待上下文被取消

❷ 从ch2接收消息或者等待上下文被取消

在这个新版本中,如果ctx被取消或超时,我们会立即返回,而不会阻塞通道发送或接收。

总之,要成为一名精通GO的开发人员,我们必须了解什么是上下文以及如何使用它。在GO中,context.Context在标准库和外部库中随处可见。正如我们提到的,上下文允许我们携带截止日期、取消信号和/或键值列表。一般来说,用户等待的函数应该获取上下文,因为这样做允许上游调用者决定何时应该中止调用该函数。

当不确定使用哪个上下文时,我们应该使用context.TODO(),而不是用context.Background传递一个空上下文。context.TODO()返回一个空的上下文,但是从语义上来说,它表示要使用的上下文要么不清楚,要么还不可用(例如,还没有被父节点传播)。

最后,让我们注意标准库中的可用上下文对于多个 goroutines 的并发使用都是安全的。

总结

  • 理解并发和并行之间的根本区别是 Go 开发人员知识的基石。并发是关于结构的,而并行是关于执行的。

  • 要成为一名熟练的开发人员,你必须承认并发并不总是更快。涉及最小工作量并行化的解决方案不一定比顺序实现更快。对顺序解决方案和并发解决方案进行基准测试应该是验证假设的方法。

  • 了解 goroutine 交互也有助于在通道和互斥之间做出决定。一般来说,并行 goroutines 需要同步,因此也需要互斥。相反,并发 goroutines 通常需要协调和编排,因此也需要通道。

  • 精通并发也意味着理解数据竞争和竞争条件是不同的概念。当多个 goroutines 同时访问同一个内存位置,并且其中至少有一个正在写入时,就会发生数据争用。同时,无数据竞争并不一定意味着确定性执行。当一个行为依赖于无法控制的事件顺序或时间时,这就是一个竞争条件。

  • 了解 Go 内存模型以及排序和同步方面的底层保证对于防止可能的数据争用和/或争用情况至关重要。

  • 在创建一定数量的 goroutines 时,要考虑工作负载类型。创建 CPU 绑定的 goroutines 意味着将这个数字绑定到接近于GOMAXPROCS变量的位置(默认情况下基于主机上 CPU 核心的数量)。创建 I/O 绑定的 goroutines 取决于其他因素,例如外部系统。

  • Go 上下文也是 Go 中并发的基石之一。上下文允许你携带截止日期、取消信号和/或键值列表。

九、并发实践

本章涵盖

  • 防止 goroutines 和通道的常见错误
  • 了解使用标准数据结构和并发代码的影响
  • 使用标准库和一些扩展
  • 避免数据竞争和死锁

在前一章中,我们讨论了并发的基础。现在是时候看看 Go 开发人员在使用并发原语时所犯的实际错误了。

9.1 #61:传播不适当的上下文

在 Go 中处理并发时,上下文无处不在,在许多情况下,可能建议传播它们。然而,上下文传播有时会导致细微的错误,阻止子函数的正确执行。

让我们考虑下面的例子。我们公开一个 HTTP 处理器,它执行一些任务并返回一个响应。但是就在返回响应之前,我们还想把它发送到一个kafka主题。我们不想降低 HTTP 消费者的延迟,所以我们希望在新的 goroutine 中异步处理发布操作。我们假设我们有一个接受上下文的publish函数,例如,如果上下文被取消,发布消息的操作就会被中断。下面是一个可能的实现:

func handler(w http.ResponseWriter, r *http.Request) {
    response, err := doSomeTask(r.Context(), r)         // ❶
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    go func() {                                         // ❷
        err := publish(r.Context(), response)
        // Do something with err
    }()

    writeResponse(response)                             // ❸
}

❶ 执行一些任务来 HTTP 响应

❷ 创建了一个goroutine来向kafka发送响应

❸ 编写 HTTP 响应

首先我们调用一个doSomeTask函数来获得一个response变量。它在调用publish的 goroutine 中使用,并格式化 HTTP 响应。此外,当调用publish时,我们传播附加到 HTTP 请求的上下文。你能猜出这段代码有什么问题吗?

我们必须知道附加到 HTTP 请求的上下文可以在不同的情况下取消:

  • 当客户端连接关闭时

  • 在 HTTP/2 请求的情况下,当请求被取消时

  • 当响应被写回客户端时

在前两种情况下,我们可能会正确处理事情。例如,如果我们从doSomeTask得到一个响应,但是客户端已经关闭了连接,那么调用publish时可能已经取消了一个上下文,所以消息不会被发布。但是最后一种情况呢?

当响应被写入客户端时,与请求相关联的上下文将被取消。因此,我们面临着一种竞争状态:

  • 如果响应是在 Kafka 发布之后写的,我们都返回响应并成功发布消息。

  • 然而,如果响应是在kafka发表之前或发表期间写的,则该消息不应被发表。

在后一种情况下,调用publish将返回一个错误,因为我们快速返回了 HTTP 响应。

我们如何解决这个问题?一种想法是不传播父上下文。相反,我们会用一个空的上下文调用publish:

err := publish(context.Background(), response)    // ❶

❶ 使用空上下文代替 HTTP 请求上下文

在这里,这将工作。不管写回 HTTP 响应需要多长时间,我们都可以调用publish

但是如果上下文包含有用的值呢?例如,如果上下文包含用于分布式跟踪的关联 ID,我们可以将 HTTP 请求和 Kafka 发布关联起来。理想情况下,我们希望有一个新的上下文,它与潜在的父取消无关,但仍然传达值。

标准包没有提供这个问题的直接解决方案。因此,一个可能的解决方案是实现我们自己的 Go 上下文,类似于所提供的上下文,只是它不携带取消信号。

一个context.Context是一个接口,包含四个方法:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

通过Deadline方法管理上下文的截止时间,通过DoneErr方法管理取消信号。当截止时间已过或上下文已被取消时,Done应该返回一个关闭的通道,而Err应该返回一个错误。最后,通过Value方法传送这些值。

让我们创建一个自定义上下文,将取消信号从父上下文中分离出来:

type detach struct {                  // ❶
    ctx context.Context
}

func (d detach) Deadline() (time.Time, bool) {
    return time.Time{}, false
}

func (d detach) Done() <-chan struct{} {
    return nil
}

func (d detach) Err() error {
    return nil
}

func (d detach) Value(key any) any {
    return d.ctx.Value(key)           // ❷
}

❶ 自定义结构充当初始上下文顶部的包装

❷ 将获取值的调用委托给父上下文

除了调用父上下文获取值的Value方法之外,其他方法都返回默认值,因此上下文永远不会被视为过期或取消。

由于我们的自定义上下文,我们现在可以调用publish并分离取消信号:

err := publish(detach{ctx: r.Context()}, response)    // ❶

❶ 在 HTTP 上下文上使用detach

现在传递给publish的上下文将永远不会过期或被取消,但是它将携带父上下文的值。

总之,传播一个上下文要谨慎。在本节中,我们用一个基于与 HTTP 请求相关联的上下文处理异步操作的例子来说明这一点。因为一旦我们返回响应,上下文就会被取消,所以异步操作也可能会意外停止。让我们记住传播给定上下文的影响,如果有必要,总是可以为特定的操作创建自定义上下文。

下一节讨论一个常见的并发错误:启动一个 goroutine 而没有计划停止它。

9.2 #62:启动一个 goroutine 而不知道何时停止它

启动 goroutine 既容易又便宜——如此容易又便宜,以至于我们可能没有必要计划何时停止新的 goroutine,这可能会导致泄漏。不知道何时停止 goroutine 是一个设计问题,也是 Go 中常见的并发错误。我们来了解一下为什么以及如何预防。

首先,让我们量化一下 goroutine 泄漏意味着什么。在内存方面,一个 goroutine 的最小栈大小为 2 KB,可以根据需要增加和减少(最大栈大小在 64 位上是 1 GB,在 32 位上是 250 MB)。在内存方面,goroutine 还可以保存分配给堆的变量引用。与此同时,goroutine 可以保存 HTTP 或数据库连接、打开的文件和网络套接字等资源,这些资源最终应该被正常关闭。如果一个 goroutine 被泄露,这些类型的资源也会被泄露。

让我们看一个例子,其中 goroutine 停止的点不清楚。这里,父 goroutine 调用一个返回通道的函数,然后创建一个新的 goroutine,它将继续从该通道接收消息:

ch := foo()
go func() {
    for v := range ch {
        // ...
    }
}()

ch关闭时,创建的 goroutine 将退出。但是我们知道这个通道什么时候会关闭吗?这可能不明显,因为ch是由foo函数创建的。如果通道从未关闭,那就是泄漏。因此,我们应该始终保持警惕,确保最终到达一个目标。

我们来讨论一个具体的例子。我们将设计一个需要观察一些外部配置的应用(例如,使用数据库连接)。这是第一个实现:

func main() {
    newWatcher()

    // Run the application
}

type watcher struct { /* Some resources */ }

func newWatcher() {
    w := watcher{}
    go w.watch()      // ❶
}

❶ 创建了一个监视外部配置的 goroutine

我们调用newWatcher,它创建一个watcher结构,并启动一个负责监视配置的 goroutine。这段代码的问题是,当主 goroutine 退出时(可能是因为 OS 信号或者因为它的工作负载有限),应用就会停止。因此,由watcher创建的资源没有被优雅地关闭。如何才能防止这种情况发生?

一种选择是传递给newWatcher一个当main返回时将被取消的上下文:

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    newWatcher(ctx)      // ❶

    // Run the application
}

func newWatcher(ctx context.Context) {
    w := watcher{}
    go w.watch(ctx)      // ❷
}

❶ 传递给newWatcher一个最终会取消的上下文

❷ 传播这一上下文

我们将创建的上下文传播给watch方法。当上下文被取消时,watcher结构应该关闭它的资源。然而,我们能保证watch有时间这样做吗?绝对不是——这是一个设计缺陷。

问题是,我们使用信号来传达必须停止 goroutine。直到资源关闭后,我们才阻塞父 goroutine。让我们确保做到:

func main() {
    w := newWatcher()
    defer w.close()     // ❶

    // Run the application
}

func newWatcher() watcher {
    w := watcher{}
    go w.watch()
    return w
}

func (w watcher) close() {
    // Close the resources
}

❶ 延迟调用close方法

watcher有了新方法:close。我们现在调用这个close方法,使用defer来保证在应用退出之前关闭资源,而不是用信号通知watcher该关闭它的资源了。

总之,我们要注意的是,goroutine 和任何其他资源一样,最终都必须被关闭以释放内存或其他资源。启动 goroutine 而不知道何时停止是一个设计问题。无论什么时候开始,我们都应该有一个明确的计划,知道它什么时候会停止。最后但同样重要的是,如果一个 goroutine 创建资源,并且它的生命周期与应用的生命周期绑定在一起,那么在退出应用之前等待这个 goroutine 完成可能更安全。这样,我们可以确保释放资源。

现在让我们讨论在 Go 中工作时最常见的错误之一:错误处理 goroutines 和循环变量。

9.3 #63:对 goroutines 和循环变量不够小心

错误处理 goroutines 和循环变量可能是 Go 开发者在编写并发应用时最常犯的错误之一。我们来看一个具体的例子;然后我们将定义这种 bug 的条件以及如何防止它。

在下面的例子中,我们初始化一个切片。然后,在作为新的 goroutine 执行的闭包中,我们访问这个元素:

s := []int{1, 2, 3}

for _, i := range s {      // ❶
    go func() {
        fmt.Print(i)       // ❷
    }()
}

❶ 迭代每个元素

❷ 访问循环变量

我们可能希望这段代码不按特定的顺序打印123(因为不能保证创建的第一个 goroutine 会首先完成)。然而,这段代码的输出是不确定的。比如有时候打印233有时候打印333。原因是什么?

在这个例子中,我们从一个闭包创建新的 goroutines。提醒一下,闭包是一个从其正文外部引用变量的函数值:这里是i变量。我们必须知道,当执行闭包 goroutine 时,它不会捕获创建 goroutine 时的值。相反,所有的 goroutines 都引用完全相同的变量。当一个 goroutine 运行时,它在执行fmt.Print时打印出i的值。因此,i可能在 goroutine 上市后被修改过。

图 9.1 显示了代码打印233时可能的执行情况。随着时间的推移,i的值会发生变化:12,然后是3。在每次迭代中,我们都会旋转出一个新的 goroutine。因为不能保证每个 goroutine 什么时候开始和完成,所以结果也会不同。在这个例子中,第一个 goroutine 在i等于2时打印它。然后,当值已经等于3时,其他 goroutines 打印i。因此,本例打印233。这段代码的行为是不确定的。

图 9.1 goroutines 访问一个不固定但随时间变化的i变量。

如果我们想让每个闭包在创建 goroutine 时访问i的值,有什么解决方案?如果我们想继续使用闭包,第一个选项包括创建一个新变量:

for _, i := range s {
    val := i            // ❶
    go func() {
        fmt.Print(val)
    }()
}

❶ 为每次迭代创建一个局部变量

为什么这段代码会起作用?在每次迭代中,我们创建一个新的局部变量val。该变量在创建 goroutine 之前捕获i的当前值。因此,当每个闭包 goroutine 执行 print 语句时,它会使用预期的值。这段代码打印123(同样,没有特别的顺序)。

第二个选项不再依赖于闭包,而是使用一个实际的函数:

for _, i := range s {
    go func(val int) {     // ❶
        fmt.Print(val)
    }(i)                   // ❷
}

❶ 执行一个以整数为参数的函数

❷ 调用这个函数并传递i的当前值

我们仍然在新的 goroutine 中执行匿名函数(例如,我们不运行go f(i)),但这一次它不是闭包。该函数没有从其正文外部引用val作为变量;val现在是函数输入的一部分。通过这样做,我们在每次迭代中修正了i,并使我们的应用按预期工作。

我们必须小心 goroutines 和循环变量。如果 goroutine 是一个访问从其正文外部声明的迭代变量的闭包,那就有问题了。我们可以通过创建一个局部变量(例如,我们已经看到在执行 goroutine 之前使用val := i)或者使函数不再是一个闭包来修复它。两种选择都可行,我们不应该偏向其中一种。一些开发人员可能会发现闭包方法更方便,而其他人可能会发现函数方法更具表现力。

在多个通道上使用select语句会发生什么?让我们找出答案。

9.4 #64:使用select和通道预期确定性行为

Go 开发人员在使用通道时犯的一个常见错误是对select如何使用多个通道做出错误的假设。错误的假设会导致难以识别和重现的细微错误。

假设我们想要实现一个需要从两个通道接收数据的 goroutine:

  • messageCh为待处理的新消息。

  • disconnectCh接收传达断线的通知。在这种情况下,我们希望从父函数返回。

这两个通道,我们要优先考虑messageCh。例如,如果发生断开连接,我们希望在返回之前确保我们已经收到了所有的消息。

我们可以决定这样处理优先级:

for {
    select {                         // ❶
    case v := <-messageCh:           // ❷
        fmt.Println(v)
    case <-disconnectCh:             // ❸
        fmt.Println("disconnection, return")
        return
    }
}

❶ 使用select语句从多个通道接收

❷ 接收新消息

❸ 断开连接

我们使用select从多个通道接收。因为我们想要区分messageCh的优先级,我们可以假设我们应该首先编写messageCh案例,然后是disconnectCh案例。但是这些代码真的有用吗?让我们通过编写一个发送 10 条消息然后发送一个断开通知的伪生产者 goroutine 来尝试一下:

for i := 0; i < 10; i++ {
    messageCh <- i
}
disconnectCh <- struct{}{}

如果我们运行这个例子,如果messageCh被缓冲,这里是一个可能的输出:

0
1
2
3
4
disconnection, return

我们没有收到这 10 条信息,而是收到了其中的 5 条。原因是什么?它在于规范的多通道的select语句(go.dev/ref/spec):

如果一个或多个通信可以进行,则通过统一的伪随机选择来选择可以进行的单个通信。

switch语句不同,在语句中,第一个匹配的案例获胜,如果有多个选项,则select语句随机选择。

这种行为乍一看可能很奇怪,但有一个很好的理由:防止可能的饥饿。假设选择的第一个可能的通信是基于源顺序的。在这种情况下,我们可能会陷入这样一种情况,例如,由于发送者速度快,我们只能从一个通道接收。为了防止这种情况,语言设计者决定使用随机选择。

回到我们的例子,即使case v := <-messageCh在源代码顺序中排在第一位,如果messageChdisconnectCh中都有消息,也不能保证哪种情况会被选中。因此,这个例子的行为是不确定的。我们可能会收到 0 条、5 条或 10 条消息。

如何才能克服这种情况?如果我们想在断线情况下返回之前接收所有消息,有不同的可能性。

如果只有一个制片人,我们有两个选择:

  • 使messageCh成为非缓冲通道,而不是缓冲通道。因为发送方 goroutine 阻塞,直到接收方 goroutine 准备好,所以这种方法保证了在从disconnectCh断开连接之前,接收到来自messageCh的所有消息。

  • 用单通道代替双通道。例如,我们可以定义一个struct来传递一个新消息或者一个断开。通道保证发送消息的顺序与接收消息的顺序相同,因此我们可以确保最后接收到断开连接。

如果我们遇到有多个生产者 goroutines 的情况,可能无法保证哪一个先写。因此,无论我们有一个无缓冲的messageCh通道还是一个单一的通道,都会导致生产者之间的竞争。在这种情况下,我们可以实现以下解决方案:

  1. messageChdisconnectCh接收。

  2. 如果接收到断开连接

    • 阅读messageCh中所有已有的信息,如果有的话。
    • 然后返回。

以下是解决方案:

for {
    select {
    case v := <-messageCh:
        fmt.Println(v)
    case <-disconnectCh:
        for {                          // ❶
            select {
            case v := <-messageCh:     // ❷
                fmt.Println(v)
            default:                   // ❸
                fmt.Println("disconnection, return")
                return
            }
        }
    }
}

❶ 内部for/select

❷ 读取剩下的信息

❸ 然后返回

该解决方案使用带有两个外壳的内部for/select:一个在messageCh上,一个在default外壳上。在中使用default,只有当其他情况都不匹配时,才选择select语句。在这种情况下,这意味着我们只有在收到了messageCh中所有剩余的消息后才会返回。

让我们来看一个代码如何工作的例子。我们将考虑这样的情况,在messageCh中有两个消息,在disconnectCh中有一个断开,如图 9.2 所示。

图 9.2 初始状态

在这种情况下,正如我们已经说过的,select随机选择一种情况或另一种情况。假设select选择第二种情况;参见图 9.3。

图 9.3 接收断开连接

因此,我们接收到断开连接并进入内部select(图 9.4)。这里,只要消息还在messageCh中,select将总是优先于default(图 9.5)。

图 9.4 内部select

图 9.5 接收剩余消息

一旦我们收到来自messageCh的所有消息,select不会阻塞并选择default的情况(图 9.6)。因此,我们返回并阻止 goroutine。

图 9.6 默认情况

这是一种确保我们通过多个通道上的接收器从一个通道接收所有剩余消息的方法。当然,如果在 goroutine 返回后发送了一个messageCh(例如,如果我们有多个生产者 goroutine),我们将错过这个消息。

当使用多通道的select时,我们必须记住,如果有多个选项,源顺序中的第一种情况不会自动胜出。相反,Go 随机选择,所以不能保证哪个选项会被选中。为了克服这种行为,在单个生产者 goroutine 的情况下,我们可以使用无缓冲通道或单个通道。在多个生产者 goroutines 的情况下,我们可以使用内部选择和default来处理优先级。

下一节讨论一种常见的通道类型:通知通道。

9.5 #65:不使用通知通道

通道是一种通过信号进行跨例程通信的机制。信号可以有数据,也可以没有数据。但是对于 Go 程序员来说,如何处理后一种情况并不总是那么简单。

我们来看一个具体的例子。我们将创建一个通道,当某个连接断开时,它会通知我们。一种想法是将它作为一个chan bool来处理:

disconnectCh := make(chan bool)

现在,假设我们与一个为我们提供这样一个通道的 API 进行交互。因为这是一个布尔通道,我们可以接收truefalse消息。大概很清楚true传达的是什么。但是false是什么意思呢?是不是说明我们没有断线?在这种情况下,我们收到这种信号的频率有多高?是不是意味着我们又重新联系上了?

我们应该期待收到false吗?也许我们应该只期待收到true消息。如果是这样的话,意味着我们不需要特定的值来传达一些信息,我们需要一个没有数据的通道。惯用的处理方式是一个空的结构的通道:chan struct{}

在 Go 中,空结构是没有任何字段的结构。无论架构如何,它都不占用任何字节的存储空间,我们可以使用unsafe.Sizeof来验证这一点:

var s struct{}
fmt.Println(unsafe.Sizeof(s))
0

注意为什么不用空接口(var i interface{})?因为空接口不是免费的;它在 32 位架构上占用 8 个字节,在 64 位架构上占用 16 个字节。

一个空的结构是一个事实上的标准来表达没有意义。例如,如果我们需要一个散列集合结构(唯一元素的集合),我们应该使用一个空结构作为值:map[K]struct{}

应用于通道,如果我们想要创建一个通道来发送没有数据的通知,在 Go 中这样做的合适方法是一个chan struct{}。空结构通道的一个最著名的应用是 Go 上下文,我们将在本章中讨论。

通道可以有数据,也可以没有数据。如果我们想设计一个关于 Go 标准的惯用 API,让我们记住没有数据的通道应该用achan类型来表示。这样,它向接收者阐明了他们不应该从信息的内容中期待任何意义——仅仅是他们已经收到信息的事实。在 Go 中,这样的通道称为通知通道

下一节将讨论 Go 如何处理nil通道以及使用它们的基本原理。

9.6 #66:不使用nil通道

在使用 Go 和通道时,一个常见的错误是忘记了nil通道有时是有帮助的。那么什么是nil通道,我们为什么要关心它们呢?这是本节的范围。

让我们从创建一个nil通道并等待接收消息的 goroutine 开始。这段代码应该做什么?

var ch chan int     // ❶
<-ch

nil通道

chchan int型。通道的零值为零,chnil。goroutine 不会惊慌;但是,会永远屏蔽。

如果我们向nil通道发送消息,原理是相同的。这条路永远不通:

var ch chan int
ch <- 0

那么 Go 允许从nil通道接收消息或者向nil通道发送消息的目的是什么呢?我们将用一个具体的例子来讨论这个问题。

我们将实现一个func merge(ch1, ch2 <-chan int) <-chan int函数来将两个通道合并成一个通道。通过合并它们(参见图 9.7),我们的意思是在ch1ch2中接收的每个消息都将被发送到返回的通道。

图 9.7 将两个通道合并为一个

在GO中如何做到这一点?让我们首先编写一个简单的实现,它启动一个 goroutine 并从两个通道接收数据(得到的通道将是一个包含一个元素的缓冲通道):

func merge(ch1, ch2 <-chan int) <-chan int {
    ch := make(chan int, 1)

    go func() {
        for v := range ch1 {    // ❶
            ch <- v
        }
        for v := range ch2 {    // ❷
            ch <- v
        }
        close(ch)
    }()

    return ch
}

❶ 从ch1接收并发布到合并的通道

❷ 从ch2接收并发布到合并的通道

在另一个 goroutine 中,我们从两个通道接收信息,每条信息最终都在ch中发布。

这个第一个版本的主要问题是我们从ch1接收,然后从ch2接收。这意味着在ch1关闭之前,我们不会收到来自ch2的信息。这不符合我们的用例,因为ch1可能会永远打开,所以我们希望同时从两个通道接收。

让我们使用select编写一个带有并发接收者的改进版本:

func merge(ch1, ch2 <-chan int) <-chan int {
    ch := make(chan int, 1)

    go func() {
        for {
            select {          // ❶
            case v := <-ch1:
                ch <- v
            case v := <-ch2:
                ch <- v
            }
        }
        close(ch)
    }()

    return ch
}

❶ 同时接收ch1ch2

select语句让一个 goroutine 同时等待多个操作。因为我们将它包装在一个for循环中,所以我们应该重复地从一个或另一个通道接收消息,对吗?但是这些代码真的有用吗?

一个问题是close(ch)语句是不可达的。当通道关闭时,使用range操作符在通道上循环中断。然而,当ch1ch2关闭时,我们实现for / select的方式并不适用。更糟糕的是,如果在某个点ch1ch2关闭,当记录值时,合并通道的接收器将接收到以下内容:

received: 0
received: 0
received: 0
received: 0
received: 0
...

所以接收器会重复接收一个等于零的整数。为什么?从封闭通道接收是一种非阻塞操作:

ch1 := make(chan int)
close(ch1)
fmt.Print(<-ch1, <-ch1)

尽管我们可能认为这段代码会恐慌或阻塞,但是它会运行并打印出0 0。我们在这里捕获的是闭包事件,而不是实际的消息。要检查我们是否收到消息或结束信号,我们必须这样做:

ch1 := make(chan int)
close(ch1)
v, open := <-ch1        // ❶
fmt.Print(v, open)

无论通道是否打开,❶都会指定打开

使用open布尔值,我们现在可以看到ch1是否仍然打开:

0 false

同时,我们也将0赋给v,因为它是一个整数的零值。

让我们回到我们的第二个解决方案。我们说ch1关了不太好用;例如,因为select案例是case v := <-ch1,所以我们会一直输入这个案例,并向合并后的通道发布一个零整数。

让我们后退一步,看看处理这个问题的最佳方法是什么(见图 9.8)。我们必须从两个通道接收。那么,要么

  • ch1是先关闭的,所以我们要从ch2开始接收,直到它关闭。

  • ch2先关闭,所以我们要从ch1接收,直到它关闭。

图 9.8 根据先关闭ch1还是先关闭ch2来处理不同情况

如何在 Go 中实现这一点?让我们编写一个版本,就像我们可能使用状态机方法和布尔函数所做的那样:

func merge(ch1, ch2 <-chan int) <-chan int {
    ch := make(chan int, 1)
    ch1Closed := false
    ch2Closed := false

    go func() {
        for {
            select {
            case v, open := <-ch1:
                if !open {               // ❶
                    ch1Closed = true
                    break
                }
                ch <- v
            case v, open := <-ch2:
                if !open {               // ❷
                    ch2Closed = true
                    break
                }
                ch <- v
            }

            if ch1Closed && ch2Closed {  // ❸
                close(ch)
                return
            }
        }
    }()

    return ch
}

❶ 处理ch1是否关闭

❷ 处理ch2是否关闭

❸ 如果两个通道都关闭,将关闭ch并返回

我们定义了两个布尔值ch1Closedch2Closed。一旦我们从一个通道接收到一个消息,我们就检查它是否是一个关闭信号。如果是,我们通过将通道标记为关闭来处理(例如,ch1Closed = true)。两个通道都关闭后,我们关闭合并的通道并停止 goroutine。

这段代码除了开始变得复杂之外,还有什么问题呢?有一个主要问题:当两个通道中的一个关闭时,for循环将充当一个忙等待循环,这意味着即使在另一个通道中没有接收到新消息,它也将继续循环。在我们的例子中,我们必须记住语句的行为。假设ch1关闭(所以我们在这里不会收到任何新消息);当我们再次到达select时,它将等待以下三个条件之一发生:

  • ch1关闭。

  • ch2有新消息。

  • ch2关闭。

第一个条件ch1是关闭的,将永远有效。因此,只要我们在ch2中没有收到消息,并且这个通道没有关闭,我们将继续循环第一个案例。这将导致浪费 CPU 周期,必须避免。因此,我们的解决方案不可行。

我们可以尝试增强状态机部分,并在每种情况下实现子for/select循环。但是这将使我们的代码更加复杂和难以理解。

是时候回到nil通道了。正如我们提到的,从nil通道接收将永远阻塞。在我们的解决方案中使用这个想法怎么样?我们将把这个通道赋值为nil,而不是在一个通道关闭后设置一个布尔值。让我们写出最终版本:

func merge(ch1, ch2 <-chan int) <-chan int {
    ch := make(chan int, 1)

    go func() {
        for ch1 != nil || ch2 != nil {    // ❶
            select {
            case v, open := <-ch1:
                if !open {
                    ch1 = nil             // ❷
                    break
                }
                ch <- v
            case v, open := <-ch2:
                if !open {
                    ch2 = nil             // ❸
                    break
                }
                ch <- v
            }
        }
        close(ch)
    }()

    return ch
}

❶ 如果至少有一个通道不为nil,将继续

❷一旦关闭,将nil通道分配给ch1

❷一旦关闭,将nil通道分配给ch2

首先,只要至少一个通道仍然打开,我们就循环。然后,例如,如果ch1关闭,我们将ch1赋值为零。因此,在下一次循环迭代期间,select语句将只等待两个条件:

  • ch2有新消息。

  • ch2关闭。

ch1不再是等式的一部分,因为它是一个nil通道。同时,我们为ch2保留相同的逻辑,并在它关闭后将其赋值为nil。最后,当两个通道都关闭时,我们关闭合并的通道并返回。图 9.9 显示了这种实现的模型。

图 9.9 从两个通道接收。如果一个是关闭的,我们把它赋值为 0,这样我们只从一个通道接收。

这是我们一直在等待的实现。我们涵盖了所有不同的情况,并且不需要会浪费 CPU 周期的繁忙循环。

总之,我们已经看到,等待或发送到一个nil通道是一个阻塞行为,这种行为是有用的。正如我们在合并两个通道的例子中所看到的,我们可以使用nil通道来实现一个优雅的状态机,该状态机将从一个select语句中移除一个case。让我们记住这个想法:nil通道在某些情况下是有用的,在处理并发代码时应该成为 Go 开发者工具集的一部分。

在下一节中,我们将讨论创建通道时应设置的大小。

9.7 #67:对通道大小感到困惑

当我们使用make内置函数创建通道时,通道可以是无缓冲的,也可以是缓冲的。与这个话题相关,有两个错误经常发生:不知道什么时候使用这个或那个;如果我们使用缓冲通道,应该使用多大的缓冲通道。让我们检查一下这几点。

首先,让我们记住核心概念。无缓冲通道是没有任何容量的通道。它可以通过省略尺寸或提供一个0尺寸来创建:

ch1 := make(chan int)
ch2 := make(chan int, 0)

使用无缓冲通道(有时称为同步通道),发送方将阻塞,直到接收方从该通道接收到数据。

相反,缓冲通道有容量,必须创建大于或等于1的大小:

ch3 := make(chan int, 1)

使用缓冲通道,发送方可以在通道未满时发送消息。一旦通道满了,它就会阻塞,直到接收者或路由器收到消息。例如:

ch3 := make(chan int, 1)
ch3 <-1                   // ❶
ch3 <-2                   // ❷

❶ 无阻塞

❷ 阻塞

第一个发送没有阻塞,而第二个阻塞了,因为这个阶段通道已满。

让我们后退一步,讨论这两种通道类型之间的根本区别。通道是一种并发抽象,用于支持 goroutines 之间的通信。但是同步呢?在并发中,同步意味着我们可以保证多个 goroutines 在某个时刻处于已知状态。例如,互斥锁提供同步,因为它确保同一时间只有一个 goroutine 在临界区。关于通道:

  • 无缓冲通道支持同步。我们保证两个 goroutines 将处于已知状态:一个接收消息,另一个发送消息。

  • 缓冲通道不提供任何强同步。事实上,如果通道未满,生产者 goroutine 可以发送消息,然后继续执行。唯一的保证是 goroutine 在消息发送之前不会收到消息。但这只是一个保证,因为因果关系(你不喝你的咖啡之前,你准备好了)。

牢记这一基本区别至关重要。两种通道类型都支持通信,但只有一种提供同步。如果我们需要同步,我们必须使用无缓冲通道。无缓冲通道也可能更容易推理:缓冲通道可能会导致不明显的死锁,而无缓冲通道会立即显现出来。

在其他情况下,无缓冲通道更可取:例如,在通知通道的情况下,通知是通过通道关闭(close(ch))来处理的。这里,使用缓冲通道不会带来任何好处。

但是如果我们需要一个缓冲通道呢?我们应该提供多大的尺寸?我们应该为缓冲通道使用的默认值是它的最小值:1。因此,我们可以从这个角度来处理这个问题:有什么好的理由使用1的值吗?这里列出了我们应该使用另一种尺寸的可能情况:

  • 使用类似工作器池的模式,意味着旋转固定数量的 goroutines,这些 goroutines 需要将数据发送到共享通道。在这种情况下,我们可以将通道大小与创建的 goroutines 的数量联系起来。

  • 使用通道进行限速问题时。例如,如果我们需要通过限制请求数量来加强资源利用率,我们应该根据限制来设置通道大小。

如果我们在这些情况之外,使用不同的通道尺寸应该谨慎。使用幻数设置通道大小的代码库非常常见:

ch := make(chan int, 40)

为什么是40?有什么道理?为什么不是50甚至1000?设置这样的值应该有充分的理由。也许这是在基准测试或性能测试之后决定的。在许多情况下,对这样一个值的基本原理进行注释可能是一个好主意。

让我们记住,决定一个准确的队列大小并不是一个简单的问题。首先,这是 CPU 和内存之间的平衡。值越小,我们面临的 CPU 争用就越多。但是值越大,需要分配的内存就越多。

另一个需要考虑的问题是 2011 年关于 LMAX Disruptor 的白皮书中提到的问题(马丁·汤普森等人; lmax-exchange.github.io/disruptor/files/Disruptor-1.0.pdf ):

由于消费者和生产者之间的速度差异,队列通常总是接近满或接近空。他们很少在一个平衡的中间地带运作,在那里生产和消费的比率是势均力敌的。

因此,很难找到一个稳定准确的通道大小,这意味着一个不会导致太多争用或内存分配浪费的准确值。

这就是为什么,除了所描述的情况,通常最好从默认的通道大小1开始。例如,当不确定时,我们仍然可以使用基准来度量它。

与编程中的几乎任何主题一样,可以发现异常。因此,这一节的目标不是详尽无遗,而是给出创建通道时应该使用什么尺寸的指导。同步是无缓冲通道而非缓冲通道的保证。此外,如果我们需要一个缓冲通道,我们应该记住使用一个作为通道大小的默认值。我们应该通过精确的过程谨慎地决定使用另一个值,并且应该对基本原理进行注释。最后但并非最不重要的一点是,我们要记住,选择缓冲通道也可能导致不明显的死锁,而使用无缓冲通道更容易发现这种死锁。

在下一节中,我们将讨论处理字符串格式时可能出现的副作用。

9.8 #68:忘记字符串格式化可能带来的副作用

格式化字符串是开发者的常用操作,无论是返回错误还是记录消息。然而,在并发应用中工作时,很容易忘记字符串格式的潜在副作用。本节将看到两个具体的例子:一个来自 etcd 存储库,导致数据竞争,另一个导致死锁情况。

9.8.1 etcd 数据竞争

etcd 是在 Go 中实现的分布式键值存储。它被用于许多项目,包括 Kubernetes,来存储所有的集群数据。它提供了与集群交互的 API。例如,Watcher接口用于接收数据变更通知:

type Watcher interface {
    // Watch watches on a key or prefix. The watched events will be returned
    // through the returned channel.
    // ...
    Watch(ctx context.Context, key string, opts ...OpOption) WatchChan
    Close() error
}

API 依赖于 gRPC 流。如果你不熟悉它,它是一种在客户机和服务器之间不断交换数据的技术。服务器必须维护使用该函数的所有客户端的列表。因此,Watcher接口由包含所有活动流的watcher结构实现:

type watcher struct {
    // ...

    // streams hold all the active gRPC streams keyed by ctx value.
    streams map[string]*watchGrpcStream
}

该映射的键基于调用Watch方法时提供的上下文:

func (w *watcher) Watch(ctx context.Context, key string,
    opts ...OpOption) WatchChan {
    // ...
    ctxKey := fmt.Sprintf("%v", ctx)       // ❶
    // ...
    wgs := w.streams[ctxKey]
    // ...

❶ 根据提供的上下文格式化映射键

ctxKey是映射的键,由客户端提供的上下文格式化。当格式化由值(context.WithValue)创建的上下文中的字符串时,Go 将读取该上下文中的所有值。在这种情况下,etcd 开发人员发现提供给Watch的上下文在某些条件下是包含可变值(例如,指向结构的指针)的上下文。他们发现了一种情况,其中一个 goroutine 正在更新一个上下文值,而另一个正在执行Watch,因此读取这个上下文中的所有值。这导致了一场数据竞争。

修复(github.com/etcd-io/etcd/pull/7816)是不依赖fmt.Sprintf来格式化映射的键,以防止遍历和读取上下文中的包装值链。相反,解决方案是实现一个定制的streamKeyFromCtx函数,从特定的不可变的上下文值中提取键。

注意:上下文中潜在的可变值会引入额外的复杂性,以防止数据竞争。这可能是一个需要仔细考虑的设计决策。

这个例子说明了我们必须小心并发应用中字符串格式化的副作用——在这个例子中,是数据竞争。在下面的例子中,我们将看到导致死锁情况的副作用。

9.8.2 死锁

假设我们必须处理一个可以并发访问的Customer结构。我们将使用sync.RWMutex来保护访问,无论是读还是写。我们将实现一个UpdateAge方法来更新客户的年龄,并检查年龄是否为正数。同时,我们将实现和Stringer接口。

你能看出这段代码中的问题是什么吗?一个Customer结构公开了一个UpdateAge方法,而实现了fmt.Stringer接口。

type Customer struct {
    mutex sync.RWMutex                                    // ❶
    id    string
    age   int
}

func (c *Customer) UpdateAge(age int) error {
    c.mutex.Lock()                                        // ❷
    defer c.mutex.Unlock()

    if age < 0 {                                          // ❸
        return fmt.Errorf("age should be positive for customer %v", c)
    }

    c.age = age
    return nil
}

func (c *Customer) String() string {
    c.mutex.RLock()                                       // ❹
    defer c.mutex.RUnlock()
    return fmt.Sprintf("id %s, age %d", c.id, c.age)
}

❶ 使用sync.RWMutex保护并发访问

❷ 锁定并延迟解锁,因为我们更新客户

❸ 如果年龄为负,将返回错误

❹ 锁定和延迟解锁,因为我们读取客户

这里的问题可能并不简单。如果提供的age是负的,我们返回一个错误。因为错误被格式化了,使用接收者上的%s指令,它将调用String方法来格式化Customer。但是因为UpdateAge已经获得了互斥锁,所以String方法将无法获得互斥锁(见图 9.10)。

图 9.10 如果age为负,执行UpdateAge

因此,这会导致死锁情况。如果所有的 goroutines 也睡着了,就会导致恐慌:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
sync.runtime_SemacquireMutex(0xc00009818c, 0x10b7d00, 0x0)
...

这种情况应该怎么处理?首先,它说明了单元测试的重要性。在这种情况下,我们可能会认为创建一个负年龄的测试是不值得的,因为逻辑非常简单。然而,没有适当的测试覆盖,我们可能会错过这个问题。

这里可以改进的一点是限制互斥锁的范围。在UpdateAge中,我们首先获取锁并检查输入是否有效。我们应该反其道而行之:首先检查输入,如果输入有效,就获取锁。这有利于减少潜在的副作用,但也会对性能产生影响——仅在需要时才获取锁,而不是在此之前:

func (c *Customer) UpdateAge(age int) error {
    if age < 0 {
        return fmt.Errorf("age should be positive for customer %v", c)
    }

    c.mutex.Lock()          // ❶
    defer c.mutex.Unlock()

    c.age = age
    return nil
}

只有当输入被验证后,❶才会锁定互斥体

在我们的例子中,只有在检查了年龄之后才锁定互斥体可以避免死锁情况。如果年龄为负,则调用String而不事先锁定互斥体。

但是,在某些情况下,限制互斥锁的范围并不简单,也不可能。在这种情况下,我们必须非常小心字符串格式。也许我们想调用另一个不试图获取互斥体的函数,或者我们只想改变我们格式化错误的方式,这样它就不会调用的String方法。例如,下面的代码不会导致死锁,因为我们只在直接访问id字段时记录客户 ID:

func (c *Customer) UpdateAge(age int) error {
    c.mutex.Lock()
    defer c.mutex.Unlock()

    if age < 0 {
        return fmt.Errorf("age should be positive for customer id %s", c.id)
    }

    c.age = age
    return nil
}

我们已经看到了两个具体的例子,一个格式化上下文中的键,另一个返回格式化结构的错误。在这两种情况下,格式化字符串都会导致一个问题:数据竞争和死锁情况。因此,在并发应用中,我们应该对字符串格式化可能产生的副作用保持谨慎。

下一节讨论并发调用append时的行为。

9.9 #69:使用append创建数据竞争

我们之前提到过什么是数据竞争,有哪些影响。现在,让我们看看片,以及使用append向片添加元素是否是无数据竞争的。剧透?看情况。

在下面的例子中,我们将初始化一个切片并创建两个 goroutines,这两个 goroutines 将使用append创建一个带有附加元素的新切片:

s := make([]int, 1)

go func() {                // ❶
    s1 := append(s, 1)
    fmt.Println(s1)
}()

go func() {                // ❷
    s2 := append(s, 1)
    fmt.Println(s2)
}()

❶ 在一个新的 goroutine 中,在s上追加了一个新元素

❷ 相同

你相信这个例子有数据竞争吗?答案是否定的。

我们必须回忆一下第 3 章中描述的一些切片基础知识。切片由数组支持,有两个属性:长度和容量。长度是切片中可用元素的数量,而容量是后备数组中元素的总数。当我们使用append时,行为取决于切片是否已满(长度==容量)。如果是,Go 运行时创建一个新的后备数组来添加新元素;否则,运行库会将其添加到现有的后备数组中。

在这个例子中,我们用make([]int, 1)创建一个切片。该代码创建一个长度为一、容量为一的切片。因此,因为切片已满,所以在每个 goroutine 中使用append会返回一个由新数组支持的切片。它不会改变现有的数组;因此,它不会导致数据竞争。

现在,让我们运行同一个例子,只是在初始化s的方式上稍作改变。我们不是创建长度为1的切片,而是创建长度为0但容量为1的切片:

s := make([]int, 0, 1)      // ❶

// Same

❶ 改变了切片初始化的方式

这个新例子怎么样?是否包含数据竞争?答案是肯定的:

==================
WARNING: DATA RACE
Write at 0x00c00009e080 by goroutine 10:
  ...

Previous write at 0x00c00009e080 by goroutine 9:
  ...
==================

我们用make([]int, 0, 1)创建一个切片。因此,数组没有满。两个 goroutines 都试图更新后备数组的同一个索引(索引 1),这是一种数据竞争。

如果我们希望两个 goroutines 都在一个包含初始元素s和一个额外元素的片上工作,我们如何防止数据竞争?一种解决方案是创建s的副本:

s := make([]int, 0, 1)

go func() {
    sCopy := make([]int, len(s), cap(s))
    copy(sCopy, s)                          // ❶

    s1 := append(sCopy, 1)
    fmt.Println(s1)
}()

go func() {
    sCopy := make([]int, len(s), cap(s))
    copy(sCopy, s)                          // ❷

    s2 := append(sCopy, 1)
    fmt.Println(s2)
}()

❶ 制作了一个副本,并在拷贝的切片上使用了append

❷ 相同

两个 goroutines 都会制作切片的副本。然后他们在切片副本上使用append,而不是原始切片。这防止了数据竞争,因为两个 goroutines 都处理孤立的数据。

切片和映射的数据竞争

数据竞争对切片和映射的影响有多大?当我们有多个 goroutines 时,以下为真:

  • 用至少一个 goroutine 更新值来访问同一个片索引是一种数据竞争。goroutines 访问相同的内存位置。

  • 不管操作如何,访问不同的片索引不是数据竞争;不同的索引意味着不同的内存位置。

  • 用至少一个 goroutine 更新来访问同一个映射(不管它是相同的还是不同的键)是一种数据竞争。为什么这与切片数据结构不同?正如我们在第 3 章中提到的,映射是一个桶数组,每个桶是一个指向键值对数组的指针。哈希算法用于确定桶的数组索引。因为该算法在映射初始化期间包含一些随机性,所以一次执行可能导致相同的数组索引,而另一次执行可能不会。竞争检测器通过发出警告来处理这种情况,而不管实际的数据竞争是否发生。

当在并发上下文中使用片时,我们必须记住在片上使用append并不总是无竞争的。根据切片以及切片是否已满,行为会发生变化。如果切片已满,append是无竞争的。否则,多个 goroutines 可能会竞争更新同一个数组索引,从而导致数据竞争。

一般来说,我们不应该根据片是否已满而有不同的实现。我们应该考虑到在并发应用中的共享片上使用append会导致数据竞争。因此,应该避免使用它。

现在,让我们讨论一个切片和映射上不精确互斥锁的常见错误。

9.10 #70:对切片和映射不正确地使用互斥

在数据可变和共享的并发环境中工作时,我们经常需要使用互斥体来实现对数据结构的保护访问。一个常见的错误是在处理切片和贴图时不准确地使用互斥。让我们看一个具体的例子,了解潜在的问题。

我们将实现一个用于处理客户余额缓存的Cache结构。该结构将包含每个客户 ID 的余额映射和一个互斥体,以保护并发访问:

type Cache struct {
    mu       sync.RWMutex
    balances map[string]float64
}

注意这个解决方案使用一个sync.RWMutex来允许多个读者,只要没有作者。

接下来,我们添加一个AddBalance方法来改变balances图。改变是在一个临界区中完成的(在互斥锁和互斥解锁内):

func (c *Cache) AddBalance(id string, balance float64) {
    c.mu.Lock()
    c.balances[id] = balance
    c.mu.Unlock()
}

同时,我们必须实现一个方法来计算所有客户的平均余额。一种想法是这样处理最小临界区:

func (c *Cache) AverageBalance() float64 {
    c.mu.RLock()
    balances := c.balances                  // ❶
    c.mu.RUnlock()

    sum := 0.
    for _, balance := range balances {      // ❷
        sum += balance
    }
    return sum / float64(len(balances))
}

❶ 创建了balances的副本

❷ 在临界区之外迭代副本

首先,我们创建一个映射到本地balances变量的副本。仅在临界区中进行复制,以迭代每个余额,并计算临界区之外的平均值。这个解决方案有效吗?

如果我们使用带有两个并发 goroutines 的-race标志运行测试,一个调用AddBalance(因此改变balances),另一个调用AverageBalance,就会发生数据竞争。这里有什么问题?

在内部,映射是一个runtime.hmap结构,主要包含元数据(例如,计数器)和引用数据桶的指针。所以,balances := c.balances不会复制实际的数据。切片也是同样的原理:

s1 := []int{1, 2, 3}
s2 := s1
s2[0] = 42
fmt.Println(s1)

即使我们修改了s2,打印s1也会返回[42 2 3]。原因是s2 := s1创建了一个新的切片:s2s1有相同的长度和相同的容量,并由相同的数组支持。

回到我们的例子,我们给balances分配一个新的映射,引用与c.balances相同的数据桶。同时,两个 goroutines 对同一个数据集执行操作,其中一个对它进行了改变。因此,这是一场数据竞争。我们如何解决数据竞争?我们有两个选择。

如果迭代操作并不繁重(这里就是这种情况,因为我们执行增量操作),我们应该保护整个函数:

func (c *Cache) AverageBalance() float64 {
    c.mu.RLock()
    defer c.mu.RUnlock()       // ❶

    sum := 0.
    for _, balance := range c.balances {
        sum += balance
    }
    return sum / float64(len(c.balances))
}

函数返回时,❶解锁

临界区现在包含了整个函数,包括迭代。这可以防止数据竞争。

如果迭代操作不是轻量级的,另一个选择是处理数据的实际副本,并且只保护副本:

func (c *Cache) AverageBalance() float64 {
    c.mu.RLock()
    m := make(map[string]float64, len(c.balances))     // ❶
    for k, v := range c.balances {
        m[k] = v
    }
    c.mu.RUnlock()

    sum := 0.
    for _, balance := range m {
        sum += balance
    }
    return sum / float64(len(m))
}

❶ 复制了这个映射

一旦我们完成了深层拷贝,我们就释放互斥体。迭代是在临界区之外的副本上完成的。

让我们考虑一下这个解决方案。我们必须在映射值上迭代两次:一次是复制,一次是执行操作(这里是增量)。但关键部分只是映射副本。因此,当且仅当操作不是快速时,这种解决方案可能是一个很好的选择。例如,如果一个操作需要调用外部数据库,这个解决方案可能会更有效。在选择一个解决方案或另一个解决方案时,不可能定义一个阈值,因为选择取决于元素数量和结构的平均大小等因素。

总之,我们必须小心互斥锁的边界。在本节中,我们已经看到了为什么将一个现有的映射(或一个现有的片)分配给一个映射不足以防止数据竞争。无论是映射还是切片,新变量都由相同的数据集支持。有两种主要的解决方案可以防止这种情况:保护整个函数,或者处理实际数据的副本。在所有情况下,让我们在设计临界截面时保持谨慎,并确保准确定义边界。

现在让我们讨论一下使用sync.WaitGroup时的一个常见错误。

9.11 #71:误用sync.WaitGroup

sync.WaitGroup是一种等待n操作完成的机制;通常,我们使用它来等待ngoroutines 完成。我们先回忆一下公开的 API 然后,我们将看到一个导致非确定性行为的常见错误。

可以用零值sync.WaitGroup创建一个等待组:

wg := sync.WaitGroup{}

在内部,sync.WaitGroup保存默认初始化为0的内部计数器。我们可以使用Add(int)方法递增这个计数器,使用带有负值的Done()Add递减它。如果我们想等待计数器等于0,我们必须使用阻塞的Wait()方法。

注意计数器不能为负,否则 goroutine 将会恐慌。

在下面的例子中,我们将初始化一个等待组,启动三个自动更新计数器的 goroutines,然后等待它们完成。我们希望等待这三个 goroutines 打印计数器的值(应该是3)。你能猜出这段代码是否有问题吗?

wg := sync.WaitGroup{}
var v uint64

for i := 0; i < 3; i++ {
    go func() {                     // ❶
        wg.Add(1)                   // ❷
        atomic.AddUint64(&v, 1)     // ❸
        wg.Done()                   // ❹
    }()
}

wg.Wait()                           // ❺
fmt.Println(v)

❶ 创建了一个 goroutine

❷ 递增等待组计数器

❸ 原子地递增v

❹ 递减等待组计数器

❺ 一直等到所有的 goroutines 都递增了v才打印它

如果我们运行这个例子,我们会得到一个不确定的值:代码可以打印从03的任何值。同样,如果我们启用了-race标志,Go 甚至会发生数据竞争。考虑到我们正在使用sync/atomic包来更新v,这怎么可能呢?这个代码有什么问题?

问题是wg.Add(1)是在新创建的 goroutine 中调用的,而不是在父 goroutine 中。因此,不能保证我们已经向等待组表明我们想在调用wg.Wait()之前等待三次 goroutines。

图 9.11 显示了代码打印2时的可能场景。在这个场景中,主 goroutine 旋转了三个 goroutine。但是最后一个 goroutine 是在前两个 goroutine 已经调用了wg.Done()之后执行的,所以父 goroutine 已经解锁。因此,在这种情况下,当主 goroutine 读取v时,它等于2。竞争检测器还可以检测对v的不安全访问。

图 9.11 主 goroutine 已经解封后,最后一个 goroutine 调用wg.Add(1)

在处理 goroutines 时,关键是要记住,没有同步,执行是不确定的。例如,以下代码可以打印abba:

go func() {
    fmt.Print("a")
}()
go func() {
    fmt.Print("b")
}()

两个 goroutines 都可以分配给不同的线程,不能保证哪个线程会先被执行。

CPU 有来使用内存屏障(也称为内存屏障)来确保顺序。Go 为实现内存栅栏提供了不同的同步技术:例如,sync.WaitGroup支持wg.Addwg.Wait之间的先发生关系。

回到我们的例子,有两个选项来解决我们的问题。首先,我们可以用 3:

wg := sync.WaitGroup{}
var v uint64

wg.Add(3)
for i := 0; i < 3; i++ {
    go func() {
        // ...
    }()
}

// ...

或者,第二,我们可以在每次循环迭代中调用wg.Add,然后旋转子 goroutines:

wg := sync.WaitGroup{}
var v uint64

for i := 0; i < 3; i++ {
    wg.Add(1)
    go func() {
        // ...
    }()
}

// ...

两种解决方案都可以。如果我们想要最终设置给等待组计数器的值是预先知道的,那么第一个解决方案可以避免我们不得不多次调用wg.Add。然而,它需要确保在任何地方都使用相同的计数,以避免细微的错误。

让我们小心不要重现这种GO开发者常犯的错误。使用sync.WaitGroup时,Add操作必须在父 goroutine 中启动 goroutine 之前完成,而Done操作必须在 goroutine 中完成。

下面讨论的另一个原语sync包:sync.Cond

9.12 #72:忘记sync.Cond

sync包中的同步原语中,sync.Cond可能是使用和理解最少的。但是,它提供了我们用通道无法实现的功能。本节通过一个具体的例子来说明sync.Cond何时有用以及如何使用。

本节中的示例实现了一个捐赠目标机制:一个每当达到特定目标时就会发出警报的应用。我们将有一个 goroutine 负责增加余额(一个更新器 goroutine)。相反,其他 goroutines 将接收更新,并在达到特定目标时打印一条消息(监听 goroutines)。例如,一个 goroutine 正在等待 10 美元的捐赠目标,而另一个正在等待 15 美元的捐赠目标。

第一个简单的解决方案是使用互斥。更新程序 goroutine 每秒增加一次余额。另一方面,监听 goroutines 循环,直到达到它们的捐赠目标:

type Donation struct {               // ❶
    mu             sync.RWMutex
    balance int
}
donation := &Donation{}

// Listener goroutines
f := func(goal int) {                // ❷
    donation.mu.RLock()
    for donation.balance < goal {    // ❸
        donation.mu.RUnlock()
        donation.mu.RLock()
    }
    fmt.Printf("$%d goal reached\n", donation.balance)
    donation.mu.RUnlock()
}
go f(10)
go f(15)

// Updater goroutine
go func() {
    for {                            // ❹
        time.Sleep(time.Second)
        donation.mu.Lock()
        donation.balance++
        donation.mu.Unlock()
    }
}()

❶ 创建并实例化包含当前余额和互斥体的Donation结构

❷ 创建了一个目标

❸ 检查目标是否达到

❹ 不断增加余额

我们使用互斥来保护对共享的donation.balance变量的访问。如果我们运行这个示例,它会像预期的那样工作:

$10 goal reached
$15 goal reached

主要问题——也是使这种实现变得糟糕的原因——是繁忙循环。每个监听 goroutine 一直循环,直到达到它的捐赠目标,这浪费了大量的 CPU 周期,并使 CPU 的使用量巨大。我们需要找到一个更好的解决方案。

让我们后退一步。每当平衡被更新时,我们必须找到一种方法从更新程序发出信号。如果我们考虑GO中的信令,就要考虑通道。因此,让我们尝试使用通道原语的另一个版本:

type Donation struct {
    balance int
    ch      chan int                        // ❶
}

donation := &Donation{ch: make(chan int)}

// Listener goroutines
f := func(goal int) {
    for balance := range donation.ch {      // ❷
        if balance >= goal {
            fmt.Printf("$%d goal reached\n", balance)
            return
        }
    }
}
go f(10)
go f(15)

// Updater goroutine
for {
    time.Sleep(time.Second)
    donation.balance++
    donation.ch <- donation.balance         // ❸
}

❶ 更新Donation,所以它包含一个通道

❷ 从通道接收更新

❸ 每当余额更新时,都会发送一条消息

每个监听程序从一个共享的通道接收。与此同时,每当余额更新时,更新程序 goroutine 就会发送消息。但是,如果我们尝试一下这个解决方案,下面是一个可能的输出:

$11 goal reached
$15 goal reached

当余额为 10 美元而不是 11 美元时,应该通知第一个 goroutine。发生了什么事?

发送到通道的消息只能由一个 goroutine 接收。在我们的例子中,如果第一个 goroutine 在第二个之前从通道接收,图 9.12 显示了可能发生的情况。

图 9.12 第一个 goroutine 接收$1 消息,然后第二个 goroutine 接收$2 消息,然后第一个 goroutine 接收$3 消息,依此类推。

从共享通道接收多个 goroutines 的默认分发模式是循环调度。如果一个 goroutine 没有准备好接收消息(没有在通道上处于等待状态),它可能会改变;在这种情况下,Go 将消息分发到下一个可用的 goroutine。

每条消息都由一个单独的 goroutine 接收。因此,在这个例子中,第一个 goroutine 没有收到 10 美元消息,但是第二个收到了。只有一个通道关闭事件可以广播到多个 goroutines。但是这里我们不想关闭通道,因为那样的话更新程序 goroutine 就不能发送消息了。

在这种情况下使用通道还有另一个问题。只要达到了捐赠目标,监听器就会回来。因此,更新程序 goroutine 必须知道所有监听器何时停止接收到该通道的消息。否则,通道最终会变满,阻塞发送方。一个可能的解决方案是在组合中添加一个sync.WaitGroup,但是这样做会使解决方案更加复杂。

理想情况下,我们需要找到一种方法,每当余额更新到多个 goroutines 时,重复广播通知。好在 Go 有解:sync.Cond。我们先讨论理论;然后我们将看到如何使用这个原语解决我们的问题。

根据官方文档(pkg.go.dev/sync),

Cond 实现了一个条件变量,即等待或宣布事件发生的 goroutines 的集合点。

条件变量是等待特定条件的线程(这里是 goroutines)的容器。在我们的例子中,条件是余额更新。每当余额更新时,更新程序 gorroutine 就会广播一个通知,监听程序 gorroutine 会一直等到更新。此外,sync.Cond依靠一个sync.Locker(一个*sync .Mutex*sync.RWMutex)来防止数据竞争。下面是一个可能的实现:

type Donation struct {
    cond    *sync.Cond                    // ❶
    balance int
}

donation := &Donation{
    cond: sync.NewCond(&sync.Mutex{}),    // ❷
}

// Listener goroutines
f := func(goal int) {
    donation.cond.L.Lock()
    for donation.balance < goal {
        donation.cond.Wait()              // ❸
    }
    fmt.Printf("%d$ goal reached\n", donation.balance)
    donation.cond.L.Unlock()
}
go f(10)
go f(15)

// Updater goroutine
for {
    time.Sleep(time.Second)
    donation.cond.L.Lock()
    donation.balance++                    // ❹
    donation.cond.L.Unlock()
    donation.cond.Broadcast()             // ❺
}

❶ 添加一个*sync.Cond

*sync.Cond依赖于互斥体。

❸ 在锁定/解锁状态下等待条件(余额更新)

❹ 在锁定/解锁范围内增加余额

❺ 广播满足条件的事实(余额更新)

首先,我们使用sync.NewCond创建一个*sync.Cond,并提供一个*sync.Mutex。监听器和更新程序 goroutines 呢?

监听 goroutines 循环,直到达到捐赠余额。在循环中,我们使用Wait方法,该方法一直阻塞到满足条件。

注意,让我们确保术语条件在这里得到理解。在这种情况下,我们讨论的是更新余额,而不是捐赠目标条件。所以,这是两个监听器共享的一个条件变量。

Wait的调用必须发生在临界区内,这听起来可能有些奇怪。锁不会阻止其他 goroutines 等待相同的条件吗?实际上,Wait的实现是这样的:

  1. 解锁互斥体。

  2. 暂停 goroutine,并等待通知。

  3. 通知到达时锁定互斥体。

因此,监听 goroutines 有两个关键部分:

  • 访问for donation.balance < goal中的donation.balance

  • 访问fmt.Printf中的donation.balance

这样,对共享donation.balance变量的所有访问都受到保护。

现在,更新程序 goroutine 怎么样了?平衡更新在临界区内完成,以防止数据竞争。然后我们调用Broadcast方法,它在每次余额更新时唤醒所有等待条件的 goroutines。

因此,如果我们运行这个示例,它会打印出我们期望的结果:

10$ goal reached
15$ goal reached

在我们的实现中,条件变量基于正在更新的余额。因此,监听器变量在每次进行新的捐赠时都会被唤醒,以检查它们的捐赠目标是否达到。这种解决方案可以防止我们在重复检查中出现消耗 CPU 周期的繁忙循环。

让我们也注意一下使用sync.Cond时的一个可能的缺点。当我们发送一个通知时——例如,发送给一个chan struct——即使没有活动的接收者,消息也会被缓冲,这保证了这个通知最终会被接收到。使用sync.CondBroadcast方法唤醒当前等待该条件的所有 goroutines 如果没有,通知将被错过。这也是我们必须牢记的基本原则。

信号()与广播()

我们可以使用Signal()而不是Broadcast()来唤醒单个 goroutine。就语义而言,它与以非阻塞方式在chan struct中发送消息是一样的:

ch := make(chan struct{})
select {
case ch <- struct{}{}:
default:
}

GO中的信令可以用通道来实现。多个 goroutines 可以捕获的唯一事件是通道关闭,但这只能发生一次。因此,如果我们重复向多个 goroutines 发送通知,sync.Cond是一个解决方案。这个原语基于条件变量,这些变量设置了等待特定条件的线程容器。使用sync.Cond,我们可以广播信号来唤醒所有等待某个条件的 goroutines。

让我们使用golang.org/xerrgroup包来扩展我们关于并发原语的知识。

9.13 #73:不使用errgroup

不管什么编程语言,多此一举很少是个好主意。代码库重新实现如何旋转多个 goroutines 并聚合错误也很常见。但是 Go 生态系统中的一个包就是为了支持这种频繁的用例而设计的。让我们看看它,并理解为什么它应该成为 Go 开发者工具集的一部分。

是一个为标准库提供扩展的库。sync子库包含一个便利的包:errgroup

假设我们必须处理一个函数,我们接收一些数据作为参数,我们希望用这些数据来调用外部服务。由于条件限制,我们不能打一个电话;我们每次都用不同的子集打多个电话。此外,这些调用是并行进行的(参见图 9.13)。

图 9.13 每个圆圈导致一个并行调用。

万一通话过程中出现错误,我们希望返回。如果有多个错误,我们只想返回其中一个。让我们只使用标准的并发原语来编写实现的框架:

func handler(ctx context.Context, circles []Circle) ([]Result, error) {
    results := make([]Result, len(circles))
    wg := sync.WaitGroup{}                    // ❶
    wg.Add(len(results))

    for i, circle := range circles {
        i := i                                // ❷
        circle := circle                      // ❸

        go func() {                           // ❹
            defer wg.Done()                   // ❺
            result, err := foo(ctx, circle)
            if err != nil {
                // ?
            }
            results[i] = result               // ❻
        }()
    }

    wg.Wait()
    // ...
}

❶ 创建了一个等待组来等待我们旋转的所有 goroutines

❷ 在 goroutine 中创建了一个新的i变量(参见错误#63,“不小心使用 goroutine 和循环变量”)

❸ 同样适用于circle

❹ 每个循环触发一次 goroutine

❺ 指示 goroutine 何时完成

❻ 汇总了结果

我们决定使用一个sync.WaitGroup来等待所有的 goroutines 完成,并在一个片上处理聚合。这是做这件事的一种方法;另一种方法是将每个部分结果发送到一个通道,并在另一个 goroutine 中聚合它们。如果需要排序,主要的挑战将是重新排序传入的消息。因此,我们决定采用最简单的方法和共享切片。

注意因为每个 goroutine 都写入一个特定的索引,所以这个实现是无数据竞争的。

然而,有一个关键案例我们还没有解决。如果foo(在新的 goroutine 中进行的调用)返回一个错误怎么办?应该怎么处理?有各种选项,包括:

  • 就像results切片一样,我们可以在 goroutines 之间共享一个错误切片。每个 goroutine 都会在出错时写入这个片。我们必须在父 goroutine 中迭代这个切片,以确定是否发生了错误(O(n)时间复杂度)。

  • 我们可以通过一个共享互斥体让 goroutines 访问一个错误变量。

  • 我们可以考虑共享一个错误通道,父 goroutine 将接收并处理这些错误。

不管选择哪个选项,它都会使解决方案变得非常复杂。出于这个原因,errgroup包是设计和开发的。

它导出一个函数WithContext,这个函数返回一个给定上下文的*Group结构。该结构为一组 goroutines 提供同步、错误传播和上下文取消,并且只导出两种方法:

  • Go在新的 goroutine 中触发调用。

  • Wait阻塞,直到所有程序完成。它返回第一个非零错误(如果有)。

让我们使用errgroup重写解决方案。首先我们需要导入errgroup包:

$ go get golang.org/x/sync/errgroup

实现如下:

func handler(ctx context.Context, circles []Circle) ([]Result, error) {
    results := make([]Result, len(circles))
    g, ctx := errgroup.WithContext(ctx)      // ❶

    for i, circle := range circles {
        i := i
        circle := circle
        g.Go(func() error {                  // ❷
            result, err := foo(ctx, circle)
            if err != nil {
                return err
            }
            results[i] = result
            return nil
        })
    }

    if err := g.Wait(); err != nil {         // ❸
        return nil, err
    }
    return results, nil
}

❶ 创建了一个errgroup。给定父上下文的组

❷ 调用 Go 来提升处理错误的逻辑,并将结果聚合到一个新的 goroutine 中

❸ 调用Wait来等待所有的 goroutines

首先,我们通过提供父上下文来创建和*errgroup.Group。在每次迭代中,我们使用g.Go在新的 goroutine 中触发一个调用。这个方法将一个func() error作为输入,用一个闭包包装对foo的调用,并处理结果和错误。与我们第一个实现的主要区别是,如果我们得到一个错误,我们从这个闭包返回它。然后,g.Wait允许我们等待所有的 goroutines 完成。

这个解决方案本质上比第一个更简单(第一个是部分的,因为我们没有处理错误)。我们不必依赖额外的并发原语,并且errgroup.Group足以处理我们的用例。

我们还没有解决的另一个好处是共享环境。假设我们必须触发三个并行调用:

  • 第一个在 1 毫秒内返回一个错误。

  • 第二次和第三次调用在 5 秒内返回结果或错误。

我们想要返回一个错误,如果有的话。因此,没有必要等到第二次和第三次通话结束。使用errgroup.WithContext创建一个在所有并行调用中使用的共享上下文。因为第一个调用在 1 毫秒内返回一个错误,所以它将取消上下文,从而取消其他 goroutines。所以,我们不必等 5 秒钟就返回一个错误。这是使用errgroup的另一个好处。

注意由g.Go调用的流程必须是上下文感知的。否则,取消上下文不会有任何效果。

总之,当我们必须触发多个 goroutines 并处理错误和上下文传播时,可能值得考虑errgroup是否是一个解决方案。正如我们所看到的,这个包支持一组 goroutines 的同步,并提供了处理错误和共享上下文的答案。

本章的最后一节讨论了 Go 开发者在复制sync 类型时的一个常见错误。

9.14 #74:复制同步类型

sync包提供了基本的同步原语,比如互斥、条件变量和等待组。对于所有这些类型,有一个硬性规则要遵循:它们永远不应该被复制。让我们了解一下基本原理和可能出现的问题。

我们将创建一个线程安全的数据结构来存储计数器。它将包含一个代表每个计数器当前值的map[string]int。我们还将使用一个sync.Mutex,因为访问必须受到保护。让我们添加一个increment方法来递增给定的计数器名称:

type Counter struct {
    mu       sync.Mutex
    counters map[string]int
}

func NewCounter() Counter {                    // ❶
    return Counter{counters: map[string]int{}}
}

func (c Counter) Increment(name string) {
    c.mu.Lock()                                // ❷
    defer c.mu.Unlock()
    c.counters[name]++
}

❶ 工厂函数

❷ 在临界区增加计数器

增量逻辑在一个临界区完成:在c.mu.Lock()c.mu .Unlock()之间。让我们通过使用和-race选项运行下面的例子来尝试我们的方法,该例子加速两个 goroutines 并递增它们各自的计数器:

counter := NewCounter()

go func() {
    counter.Increment("foo")
}()
go func() {
    counter.Increment("bar")
}()

如果我们运行这个例子,它会引发一场数据竞争:

==================
WARNING: DATA RACE
...

我们的Counter实现中的问题是互斥体被复制了。因为Increment的接收者是一个值,所以每当我们调用Increment时,它执行Counter结构的复制,这也复制了互斥体。因此,增量不是在共享的临界区中完成的。

sync不应复制类型。此规则适用于以下类型:

  • sync.Cond

  • sync.Map

  • sync.Mutex

  • sync.RWMutex

  • sync.Once

  • sync.Pool

  • sync.WaitGroup

因此,互斥体不应该被复制。有哪些替代方案?

首先是修改Increment方法的接收器类型:

func (c *Counter) Increment(name string) {
    // Same code
}

改变接收器类型可避免调用Increment时复制Counter。因此,内部互斥体不会被复制。

如果我们想保留一个值接收器,第二个选项是将Counter中的mu字段的类型改为指针:

type Counter struct {
    mu       *sync.Mutex        // ❶
    counters map[string]int
}

func NewCounter() Counter {
    return Counter{
        mu: &sync.Mutex{},      // ❷
        counters: map[string]int{},
    }
}

❶ 改变了mu的类型

❷ 改变了Mutex的初始化方式

如果Increment有一个值接收器,它仍然复制Counter结构。然而,由于mu现在是一个指针,它将只执行指针复制,而不是sync.Mutex的实际复制。因此,这种解决方案也防止了数据竞争。

注意我们也改变了mu的初始化方式。因为mu是一个指针,如果我们在创建Counter的时候省略了它,那么它会被初始化为一个指针的零值:nil。这将导致调用c.mu.Lock()时 goroutine 恐慌。

在以下情况下,我们可能会面临无意中复制sync字段的问题:

  • 调用带有值接收器的方法(如我们所见)

  • 调用带有sync参数的函数

  • 调用带有包含sync字段的参数的函数

在每一种情况下,我们都应该非常谨慎。另外,让我们注意一些 linters 可以捕捉到这个问题——例如,使用go vet:

$ go vet .
./main.go:19:9: Increment passes lock by value: Counter contains sync.Mutex

根据经验,每当多个 goroutines 必须访问一个公共的sync元素时,我们必须确保它们都依赖于同一个实例。这个规则适用于包sync中定义的所有类型。使用指针是解决这个问题的一种方法:我们可以有一个指向sync元素的指针,或者一个指向包含sync元素的结构的指针。

总结

  • 在传播上下文时,理解可以取消上下文的条件应该很重要:例如,当响应已经发送时,HTTP 处理器取消上下文。

  • 避免泄露意味着无论何时启动 goroutine,你都应该有一个最终阻止它的计划。

  • 为了避免 goroutines 和循环变量的错误,创建局部变量或调用函数,而不是闭包。

  • 了解拥有多个通道的select在多个选项可能的情况下随机选择案例,可以防止做出错误的假设,从而导致微妙的并发错误。

  • 使用chan struct{}类型发送通知。

  • 使用nil通道应该是你的并发工具集的一部分,因为它允许你从select语句中移除用例。

  • 给定一个问题,仔细决定要使用的正确通道类型。只有无缓冲通道才能提供强同步保证。

  • 除了为缓冲通道指定通道尺寸之外,您应该有一个很好的理由来指定通道尺寸。

  • 意识到字符串格式化可能会导致调用现有函数意味着要小心可能的死锁和其他数据竞争。

  • 调用append并不总是无数据竞争的;因此,它不应该在共享片上并发使用。

  • 记住切片和图是指针可以防止常见的数据竞争。

  • 为了准确地使用sync.WaitGroup,在旋转 goroutines 之前调用Add方法。

  • 您可以使用sync.Cond向多个 goroutines 发送重复通知。

  • 你可以同步一组 goroutines,并用errgroup包处理错误和上下文。

  • sync不该复制的类型。

十、标准库

本章涵盖

  • 提供正确的持续时间
  • 使用time.After时了解潜在的内存泄漏
  • 避免 JSON 处理和 SQL 中的常见错误
  • 关闭暂态资源
  • 记住 HTTP 处理器中的return语句
  • 为什么生产级应用不应该使用默认的 HTTP 客户端和服务器

Go 标准库是一组增强和扩展该语言的核心包。例如,Go 开发人员可以编写 HTTP 客户端或服务器,处理 JSON 数据,或者与 SQL 数据库进行交互。所有这些特性都由标准库提供。然而,误用标准库是很容易的,或者我们可能对它的行为了解有限,这可能导致错误和编写不应该被认为是生产级的应用。让我们看看使用标准库时最常见的一些错误。

10.1 #75:提供了错误的持续时间

标准库提供了接受time.Duration的通用函数和方法。然而,因为time.Durationint64类型的别名,对这种语言的新来者可能会感到困惑,并提供错误的持续时间。例如,具有 Java 或 JavaScript 背景的开发人员习惯于传递数值类型。

为了说明这个常见的错误,让我们创建一个新的time.Ticker,它将提供每秒钟的时钟滴答声:

ticker := time.NewTicker(1000)
for {
    select {
    case <-ticker.C:
        // Do something
    }
}

如果我们运行这段代码,我们会注意到分笔成交点不是每秒都有;它们每微秒传送一次。

因为time.Duration基于int64类型,所以之前的代码是正确的,因为1000是有效的int64。但是time.Duration代表两个瞬间之间经过的时间,单位为纳秒。所以我们给NewTicker提供了 1000 纳秒= 1 微秒的持续时间。

这种错误经常发生。事实上,Java 和 JavaScript 等语言的标准库有时会要求开发人员以毫秒为单位提供持续时间。

此外,如果我们想有目的地创建一个间隔为 1 微秒的time.Ticker,我们不应该直接传递一个int64。相反,我们应该始终使用time.Duration API 来避免可能的混淆:

ticker = time.NewTicker(time.Microsecond)
// Or
ticker = time.NewTicker(1000 * time.Nanosecond)

这并不是本书中最复杂的错误,但是具有其他语言背景的开发人员很容易陷入这样一个陷阱,认为time包中的函数和方法应该是毫秒级的。我们必须记住使用time.Duration API 和提供一个int64和一个时间单位。

现在,让我们讨论一下在使用time.After和包时的一个常见错误。

10.2 #76:time.After和内存泄漏

time.After(time.Duration)是一个方便的函数,它返回一个通道,并在向该通道发送消息之前等待一段规定的时间。通常,它用在并发代码中;否则,如果我们想要睡眠给定的持续时间,我们可以使用time.Sleep(time.Duration)time.After的优势在于它可以用于实现这样的场景,比如“如果我在这个通道中 5 秒钟没有收到任何消息,我会...."但是代码库经常在循环中包含对time.After的调用,正如我们在本节中所描述的,这可能是内存泄漏的根本原因。

让我们考虑下面的例子。我们将实现一个函数,该函数重复使用来自通道的消息。如果我们超过 1 小时没有收到任何消息,我们也希望记录一个警告。下面是一个可能的实现:

func consumer(ch <-chan Event) {
    for {
        select {
        case event := <-ch:               // ❶
            handle(event)
        case <-time.After(time.Hour):     // ❷
            log.Println("warning: no messages received")
        }
    }
}

❶ 处理事件

❷ 递增空闲计数器

这里,我们在两种情况下使用select:从ch接收消息和 1 小时后没有消息(time.After在每次迭代中被求值,因此超时每次被重置)。乍一看,这段代码还不错。但是,这可能会导致内存使用问题。

我们说过,time.After返回一个通道。我们可能期望这个通道在每次循环迭代中都是关闭的,但事实并非如此。一旦超时,由time.After创建的资源(包括通道)将被释放,并使用内存直到超时结束。多少内存?在 Go 1.15 中,每次调用time.After大约使用 200 字节的内存。如果我们收到大量的消息,比如每小时 500 万条,我们的应用将消耗 1 GB 的内存来存储和time.After资源。

我们可以通过在每次迭代中以编程方式关闭通道来解决这个问题吗?不会。返回的通道是一个<-chan time.Time,意味着它是一个只能接收的通道,不能关闭。

我们有几个选择来修正我们的例子。第一种是使用上下文来代替time.After:

func consumer(ch <-chan Event) {
    for {                                                                   // ❶
        ctx, cancel := context.WithTimeout(context.Background(), time.Hour) // ❷
        select {
        case event := <-ch:
            cancel()                                                        // ❸
            handle(event)
        case <-ctx.Done():                                                  // ❹
            log.Println("warning: no messages received")
        }
    }
}

❶ 主循环

❷ 创建了一个超时的上下文

❸ 如果我们收到消息,取消上下文

❹ 上下文取消

这种方法的缺点是,我们必须在每次循环迭代中重新创建一个上下文。创建上下文并不是 Go 中最轻量级的操作:例如,它需要创建一个通道。我们能做得更好吗?

第二个选项来自time包:time.NewTimer。这个函数创建了一个结构,该结构导出了以下内容:

  • 一个C字段,它是内部计时器通道

  • 一种Reset(time.Duration)方法来重置持续时间

  • 一个Stop()方法来停止计时器

时间。内部构件后

我们要注意的是time.After也依赖于time.Timer。但是,它只返回C字段,所以我们无法访问Reset方法:

package time

func After(d Duration) <-chan Time {
    return NewTimer(d).C                // ❶
}

❶ 创建了一个新计时器并返回通道字段

让我们使用time.NewTimer实现一个新版本:

func consumer(ch <-chan Event) {
    timerDuration := 1 * time.Hour
    timer := time.NewTimer(timerDuration)     // ❶

    for {                                     // ❷
        timer.Reset(timerDuration)            // ❸
        select {
        case event := <-ch:
            handle(event)
        case <-timer.C:                       // ❹
            log.Println("warning: no messages received")
        }
    }
}

❶ 创建了一个新的计时器

❷ 主循环

❸ 重置持续时间

❹ 计时器到期

在这个实现中,我们在每次循环迭代中保持一个循环动作:调用Reset方法。然而,调用Reset比每次都创建一个新的上下文要简单得多。它速度更快,对垃圾收集器的压力更小,因为它不需要任何新的堆分配。因此,使用time.Timer是我们最初问题的最佳解决方案。

注意为了简单起见,在这个例子中,前面的 goroutine 没有停止。正如我们在错误#62 中提到的,“启动一个 goroutine 却不知道何时停止”,这不是一个最佳实践。在生产级代码中,我们应该找到一个退出条件,比如可以取消的上下文。在这种情况下,我们还应该记得使用defer timer.Stop()停止time.Timer,例如,在timer创建之后。

在循环中使用time.After并不是导致内存消耗高峰的唯一情况。该问题与重复调用的代码有关。循环是一种情况,但是在 HTTP 处理函数中使用time.After会导致同样的问题,因为该函数会被多次调用。

一般情况下,使用time.After时要谨慎。请记住,创建的资源只有在计时器到期时才会被释放。当重复调用time.After时(例如,在一个循环中,一个 Kafka 消费函数,或者一个 HTTP 处理器),可能会导致内存消耗的高峰。在这种情况下,我们应该倾向于time.NewTimer

下一节讨论 JSON 处理过程中最常见的错误。

10.3 #77:常见的 JSON 处理错误

Go 用encoding/json包对 JSON 有极好的支持。本节涵盖了与编码(编组)和解码(解组)JSON 数据相关的三个常见错误。

10.3.1 类型嵌入导致的意外行为

在错误#10“没有意识到类型嵌入可能存在的问题”中,我们讨论了与类型嵌入相关的问题。在 JSON 处理的上下文中,让我们讨论类型嵌入的另一个潜在影响,它会导致意想不到的封送/解封结果。

在下面的例子中,我们创建了一个包含 ID 和嵌入时间戳的Event结构:

type Event struct {
    ID int
    time.Time       // ❶
}

❶ 嵌入字段

因为time.Time是嵌入式的,以我们之前描述的方式,我们可以在Event级别直接访问和time.Time方法:例如,event .Second()

JSON 封送处理对嵌入式字段有哪些可能的影响?让我们在下面的例子中找出答案。我们将实例化一个Event,并将其封送到 JSON 中。这段代码的输出应该是什么?

event := Event{
    ID:   1234,
    Time: time.Now(),       // ❶
}

b, err := json.Marshal(event)
if err != nil {
    return err
}

fmt.Println(string(b))

❶ 结构实例化期间匿名字段的名称是结构的名称(时间)。

我们可能期望这段代码打印出如下内容:

{"ID":1234,"Time":"2021-05-18T21:15:08.381652+02:00"}

相反,它会打印以下内容:

"2021-05-18T21:15:08.381652+02:00"

我们如何解释这个输出?ID字段和1234值怎么了?因为此字段是导出的,所以它应该已被封送。要理解这个问题,我们必须强调两点。

首先,正如错误#10 中所讨论的,如果一个嵌入字段类型实现了一个接口,那么包含该嵌入字段的结构也将实现这个接口。其次,我们可以通过让一个类型实现json.Marshaler接口来改变默认的封送处理行为。该接口包含单个MarshalJSON函数:

type Marshaler interface {
    MarshalJSON() ([]byte, error)
}

下面是一个自定义封送处理的示例:

type foo struct{}                             // ❶

func (foo) MarshalJSON() ([]byte, error) {    // ❷
    return []byte(`"foo"`), nil               // ❸
}

func main() {
    b, err := json.Marshal(foo{})             // ❹
    if err != nil {
        panic(err)
    }
    fmt.Println(string(b))
}

❶ 定义了这个结构

❷ 实现了MarshalJSON方法

❸ 响应了一个静态响应

❹ 然后,json.Marshal依赖于自定义MarshalJSON实现。

因为我们通过实现和Marshaler接口改变了默认的 JSON 封送行为,所以这段代码打印出了"foo"

澄清了这两点之后,让我们回到最初关于Event结构的问题:

type Event struct {
    ID int
    time.Time
}

我们必须知道time.Time实现了json.Marshaler接口。因为time.TimeEvent的嵌入字段,所以编译器会提升它的方法。因此,Event也实现了json.Marshaler

因此,向json.Marshal传递一个Event会使用time.Time提供的封送处理行为,而不是默认行为。这就是为什么封送一个Event会导致忽略ID字段。

注意,如果我们使用json.Unmarshal解组一个Event,我们也会面临相反的问题。

要解决这个问题,有两种主要的可能性。首先,我们可以添加一个名称,这样time.Time字段就不再被嵌入:

type Event struct {
    ID   int
    Time time.Time      // ❶
}

time.Time不再是嵌入的。

这样,如果我们封送这个Event结构的一个版本,它将打印如下内容:

{"ID":1234,"Time":"2021-05-18T21:15:08.381652+02:00"}

如果我们希望或者必须保留嵌入的time.Time字段,另一个选择是让Event实现的json.Marshaler接口:

func (e Event) MarshalJSON() ([]byte, error) {
    return json.Marshal(
        struct {            // ❶
            ID   int
            Time time.Time
        }{
            ID:   e.ID,
            Time: e.Time,
        },
    )
}

❶ 创建了一个匿名结构

在这个解决方案中,我们实现了一个定制的MarshalJSON方法,而定义了一个反映Event结构的匿名结构。但是这种解决方案更麻烦,并且要求我们确保MarshalJSON方法和Event结构总是最新的。

我们应该小心嵌入字段。虽然提升嵌入字段类型的字段和方法有时会很方便,但它也会导致微妙的错误,因为它会使父结构在没有明确信号的情况下实现接口。还是那句话,在使用嵌入字段的时候,要清楚的了解可能产生的副作用。

在下一节中,我们将看到另一个与使用time.Time相关的常见 JSON 错误。

10.3.2 JSON 和单调时钟

当封送或解封一个包含time.Time类型的结构时,我们有时会面临意想不到的比较错误。检查time.Time有助于完善我们的假设并防止可能的错误。

一个操作系统处理两种不同的时钟类型:墙时钟和单调时钟。本节首先看这些时钟类型,然后看使用 JSON 和time.Time时可能产生的影响。

挂钟用来确定一天中的当前时间。这个钟可能会有变化。例如,如果使用网络时间协议(NTP)同步时钟,它可以在时间上向后或向前跳转。我们不应该使用挂钟来测量持续时间,因为我们可能会面临奇怪的行为,例如负持续时间。这就是操作系统提供第二种时钟类型原因:单调时钟。单调时钟保证时间总是向前移动,不受时间跳跃的影响。它会受到频率调整的影响(例如,如果服务器检测到本地石英钟的移动速度与 NTP 服务器不同),但不会受到时间跳跃的影响。

在下面的例子中,我们考虑一个包含单个time.Time字段(非嵌入式)的Event结构:

type Event struct {
    Time time.Time
}

我们实例化一个Event,将它封送到 JSON 中,并将其解包到另一个结构中。然后我们比较这两种结构。让我们看看编组/解组过程是否总是对称的:

t := time.Now()                    // ❶
event1 := Event{                   // ❷
    Time: t,
}

b, err := json.Marshal(event1)     // ❸
if err != nil {
    return err
}

var event2 Event
err = json.Unmarshal(b, &event2)   // ❹
if err != nil {
    return err
}

fmt.Println(event1 == event2)

❶ 得到当前的当地时间

❷ 实例化一个Event结构

❸ 编组 JSON

❹ 解组 JSON

这段代码的输出应该是什么?它打印的是false,不是true。我们如何解释这一点?

首先,让我们打印出event1event2的内容:

fmt.Println(event1.Time)
fmt.Println(event2.Time)
2021-01-10 17:13:08.852061 +0100 CET m=+0.000338660
2021-01-10 17:13:08.852061 +0100 CET

代码为event1event2打印不同的内容。除了m=+0.000338660部分,它们是一样的。这是什么意思?

在 Go 中,time.Time可能包含一个挂钟和一个单调时间,而不是将两个时钟分成两个不同的 API。当我们使用time.Now()获得本地时间时,它返回一个time.Time和两个时间:

2021-01-10 17:13:08.852061 +0100 CET m=+0.000338660
------------------------------------ --------------
             Wall time               Monotonic time

相反,当我们解组 JSON 时,time.Time字段不包含单调时间——只包含墙时间。因此,当我们比较这些结构时,由于单调的时间差,结果是false;这也是为什么我们在打印两个结构时会看到差异。我们如何解决这个问题?有两个主要选项。

当我们使用==操作符来比较两个time.Time字段时,它会比较所有的结构字段,包括单调部分。为了避免这种情况,我们可以使用Equal方法来代替:

fmt.Println(event1.Time.Equal(event2.Time))
true

Equal方法没有考虑单调时间;因此,这段代码打印了true。但是在这种情况下,我们只比较了time.Time字段,而不是父Event结构。

第二个选项是保留==来比较两个结构,但是使用和Truncate方法去除单调时间。该方法返回将time.Time值向下舍入到给定持续时间的倍数的结果。我们可以通过提供零持续时间来使用它,如下所示:

t := time.Now()
event1 := Event{
    Time: t.Truncate(0),             // ❶
}

b, err := json.Marshal(event1)
if err != nil {
    return err
}

var event2 Event
err = json.Unmarshal(b, &event2)
if err != nil {
    return err
}

fmt.Println(event1 == event2)        // ❷

❶ 剥离了单调的时间

❷ 使用==运算符执行比较

在这个版本中,两个time.Time字段是相等的。因此,这段代码打印了true

时间。时间和地点

我们还要注意,每个time.Time都与一个代表时区的time.Location相关联。例如:

t := time.Now() // 2021-01-10 17:13:08.852061 +0100 CET

这里,位置被设置为 CET,因为我使用了time.Now(),它返回我当前的本地时间。JSON 封送结果取决于位置。为了防止这种情况,我们可以坚持一个特定的位置:

location, err := time.LoadLocation("America/New_York")    // ❶
if err != nil {
    return err
}
t := time.Now().In(location) // 2021-05-18 22:47:04.155755 -0500 EST

❶ 获得"America/New_York"的当前位置

或者,我们可以获得 UTC 的当前时间:

t := time.Now().UTC() // 2021-05-18 22:47:04.155755 +0000 UTC

总之,编组/解组过程并不总是对称的,我们面对的这种情况是一个包含time.Time的结构。我们应该记住这个原则,这样我们就不会写错误的测试。

10.3.3 任何的映射

在解组数据的时候,我们可以提供一个映射来代替结构。基本原理是,当键和值不确定时,传递映射比传递静态结构更灵活。然而,有一个规则要记住,以避免错误的假设和可能的恐慌。

让我们编写一个将消息解组到映射中的示例:

b := getMessage()
var m map[string]any
err := json.Unmarshal(b, &m)    // ❶
if err != nil {
    return err
}

❶ 提供了映射指针

让我们为前面的代码提供以下 JSON:

{
    "id": 32,
    "name": "foo"
}

因为我们使用了一个通用的map[string]any,它会自动解析所有不同的字段:

map[id:32 name:foo]

然而,如果我们使用any的映射,有一个重要的问题需要记住:任何数值,不管它是否包含小数,都被转换为float64类型。我们可以通过打印m["id"]的类型来观察这一点:

fmt.Printf("%T\n", m["id"])
float64

我们应该确保我们没有做出错误的假设,并期望默认情况下没有小数的数值被转换为整数。例如,对类型转换做出不正确的假设可能会导致 goroutine 崩溃。

下一节讨论编写与 SQL 数据库交互的应用时最常见的错误。

10.4 #78:常见的 SQL 错误

database/sql包为 SQL(或类似 SQL 的)数据库提供了一个通用接口。在使用这个包时,看到一些模式或错误也是相当常见的。让我们深入探讨五个常见错误。

10.4.1 忘记了sql.Open不一定要建立到数据库的连接

使用sql.Open时,一个常见的误解是期望该函数建立到数据库的连接:

db, err := sql.Open("mysql", dsn)
if err != nil {
    return err
}

但这不一定是事实。据文献记载(pkg.go.dev/database/sql),

Open 可能只是验证它的参数,而不创建到数据库的连接。

实际上,行为取决于所使用的 SQL 驱动程序。对于某些驱动程序来说,sql.Open并不建立连接:这只是为以后使用做准备(例如,与db.Query)。因此,到数据库的第一个连接可能是延迟建立的。

为什么我们需要了解这种行为?例如,在某些情况下,我们希望只有在我们知道所有的依赖项都已正确设置并且可以访问之后,才准备好服务。如果我们不知道这一点,服务可能会接受流量,尽管配置是错误的。

如果我们想确保使用sql.Open的函数也保证底层数据库是可访问的,我们应该使用Ping方法:

db, err := sql.Open("mysql", dsn)
if err != nil {
    return err
}
if err := db.Ping(); err != nil {     // ❶
    return err
}

❶ 在sql.Open之后调用Ping方法

Ping强制代码建立一个连接,确保数据源名称有效并且数据库可访问。注意,Ping的另一种选择是PingContext,它要求一个额外的上下文来传达 ping 何时应该被取消或超时。

尽管可能违反直觉,但让我们记住sql.Open不一定建立连接,第一个连接可以被延迟打开。如果我们想测试我们的配置并确保数据库是可达的,我们应该在sql.Open之后调用PingPingContext方法。

10.4.2 忘记连接池

正如默认的 HTTP 客户端和服务器提供了在生产中可能无效的默认行为一样(参见错误#81,“使用默认的 HTTP 客户端和服务器”),理解 Go 中如何处理数据库连接是至关重要的。sql.Open返回一个*sql.DB结构。此结构不代表单个数据库连接;相反,它代表一个连接池。这是值得注意的,所以我们不会尝试手动实现它。池中的连接可以有两种状态:

  • 已被使用(例如,被另一个触发查询的 goroutine 使用)

  • 闲置(已经创建但暂时没有使用)

同样重要的是要记住,创建池会导致四个可用的配置参数,我们可能想要覆盖它们。这些参数中的每一个都是*sql.DB的导出方法:

  • SetMaxOpenConns——数据库的最大打开连接数(默认值:unlimited)

  • SetMaxIdleConns——最大空闲连接数(默认值:2)

  • SetConnMaxIdleTime——连接关闭前可以空闲的最长时间(默认值:unlimited)

  • SetConnMaxLifetime——连接关闭前可以保持打开的最长时间(默认值:unlimited)

图 10.1 显示了一个最多有五个连接的例子。它有四个正在进行的连接:三个空闲,一个在使用中。因此,仍有一个插槽可用于额外的连接。如果有新的查询进来,它将选择一个空闲连接(如果仍然可用)。如果没有更多的空闲连接,如果有额外的时隙可用,池将创建一个新的连接;否则,它将一直等到连接可用。

图 10.1 具有五个连接的连接池

那么,我们为什么要调整这些配置参数呢?

  • 设置SetMaxOpenConns对于生产级应用非常重要。因为默认值是无限制的,所以我们应该设置它以确保它适合底层数据库可以处理的内容。

  • 如果我们的应用生成大量并发请求,那么SetMaxIdleConns(默认:2)的值应该增加。否则,应用可能会经历频繁的重新连接。

  • 如果我们的应用可能面临突发的请求,设置SetConnMaxIdleTime是很重要的。当应用返回到一个更和平的状态时,我们希望确保创建的连接最终被释放。

  • 例如,如果我们连接到一个负载平衡的数据库服务器,设置SetConnMaxLifetime会很有帮助。在这种情况下,我们希望确保我们的应用不会长时间使用连接。

对于生产级应用,我们必须考虑这四个参数。如果一个应用面临不同的用例,我们也可以使用多个连接池。

10.4.3 不使用预准备语句

预准备语句是很多 SQL 数据库为了执行重复的 SQL 语句而实现的功能。在内部,SQL 语句被预编译并与提供的数据分离。有两个主要好处:

  • 效率——语句不用重新编译(编译就是解析+优化+翻译)。

  • 安全——这种方法降低了 SQL 注入攻击的风险。

因此,如果一个语句是重复的,我们应该使用预准备语句。我们还应该在不受信任的上下文中使用预准备语句(比如在互联网上公开一个端点,其中请求被映射到一个 SQL 语句)。

为了使用预准备语句,我们不调用*sql.DBQuery方法,而是调用Prepare:

stmt, err := db.Prepare("SELECT * FROM ORDER WHERE ID = ?")   // ❶
if err != nil {
    return err
}
rows, err := stmt.Query(id)                                   // ❷
// ...

❶ 预准备语句

❷ 执行准备好的查询

我们准备语句,然后在提供参数的同时执行它。Prepare方法的第一个输出是一个*sql.Stmt,它可以被重用和并发运行。当不再需要该语句时,必须使用和Close()方法将其关闭。

注意,PrepareQuery方法提供了另外一个上下文:PrepareContextQueryContext

为了效率和安全,我们需要记住在有意义的时候使用预准备语句。

10.4.4 错误处理空值

下一个错误是用查询错误处理空值。让我们写一个例子,其中我们检索雇员的部门和年龄:

rows, err := db.Query("SELECT DEP, AGE FROM EMP WHERE ID = ?", id)    // ❶
if err != nil {
    return err
}
// Defer closing rows

var (
    department string
    age int
)
for rows.Next() {
    err := rows.Scan(&department, &age)                               // ❷
    if err != nil {
        return err
    }
    // ...
}

❶ 执行查询

❷ 扫描每一行

我们使用Query来执行一个查询。然后,我们对行进行迭代,并使用Scan将列复制到由departmentage指针指向的值中。如果我们运行这个例子,我们可能会在调用Scan时得到以下错误:

2021/10/29 17:58:05 sql: Scan error on column index 0, name "DEPARTMENT":
converting NULL to string is unsupported

这里,SQL 驱动程序引发了一个错误,因为部门值等于NULL。如果一个列可以为空,有两个选项可以防止Scan返回错误。

第一种方法是将department声明为字符串指针:

var (
    department *string      // ❶
    age        int
)
for rows.Next() {
    err := rows.Scan(&department, &age)
    // ...
}

❶ 将类型从字符串更改为*string

我们给scan提供的是指针的地址,而不是直接字符串类型的地址。通过这样做,如果值为NULLdepartment将为nil

另一种方法是使用sql.NullXXX类型中的,如sql.NullString:

var (
    department sql.NullString    // ❶
    age        int
)
for rows.Next() {
    err := rows.Scan(&department, &age)
    // ...
}

❶ 将类型更改为sql.NullString

sql.NullString是字符串顶部的包装。它包含两个导出字段:String包含字符串值,Valid表示字符串是否不是NULL。可以访问以下包装器:

  • sql.NullString

  • sql.NullBool

  • sql.NullInt32

  • sql.NullFloat64

  • sql.NullTime

两个都采用的工作方式,用sql.NullXXX更清晰地表达的意图,正如核心GO维护者 Russ Cox(mng.bz/rJNX)所说:

没有有效的区别。我们认为人们可能想要使用NullString,因为它太常见了,并且可能比*string更清楚地表达了意图。但是这两种方法都可以。

因此,可空列的最佳实践是要么将其作为指针处理,要么使用和sql.NullXXX类型。

10.4.5 不处理行迭代错误

另一个常见的错误是在迭代行时漏掉可能的错误。让我们看一个错误处理被误用的函数:

func get(ctx context.Context, db *sql.DB, id string) (string, int, error) {
    rows, err := db.QueryContext(ctx,
        "SELECT DEP, AGE FROM EMP WHERE ID = ?", id)
    if err != nil {                                     // ❶
        return "", 0, err
    }
    defer func() {
        err := rows.Close()                             // ❷
        if err != nil {
            log.Printf("failed to close rows: %v\n", err)
        }
    }()

    var (
        department string
        age        int
    )
    for rows.Next() {
        err := rows.Scan(&department, &age)             // ❸
        if err != nil {
            return "", 0, err
        }
    }

    return department, age, nil
}

❶ 在执行查询时处理错误

❷ 在关闭行时处理错误

❸ 在扫描行时处理错误

在这个函数中,我们处理三个错误:执行查询时,关闭行,扫描行。但这还不够。我们必须知道for rows .Next() {}循环可以中断,无论是当没有更多的行时,还是当准备下一行时发生错误时。在行迭代之后,我们应该调用rows.Err来区分两种情况:

func get(ctx context.Context, db *sql.DB, id string) (string, int, error) {
    // ...
    for rows.Next() {
        // ...
    }

    if err := rows.Err(); err != nil {    // ❶
        return "", 0, err
    }

    return department, age, nil
}

❶ 检查rows.Err确定上一个循环是否因为错误而停止

这是要记住的最佳实践:因为rows.Next可能在我们迭代完所有行时停止,或者在准备下一行时发生错误时停止,所以我们应该在迭代后检查rows.Err

现在让我们讨论一个常见的错误:忘记关闭瞬态资源。

10.5 #79:不关闭瞬态资源

开发人员经常使用必须在代码中的某个点关闭的瞬态(临时)资源:例如,为了避免磁盘或内存中的泄漏。结构通常可以实现io.Closer接口来传达必须关闭瞬态资源。让我们来看三个常见的例子,看看当资源没有正确关闭时会发生什么,以及如何正确地处理它们。

10.5.1 HTTP 正文

首先,我们在 HTTP 的背景下讨论一下这个问题。我们将编写一个getBody方法,发出 HTTP GET 请求并返回 HTTP 正文响应。这是第一个实现:

type handler struct {
    client http.Client
    url    string
}

func (h handler) getBody() (string, error) {
    resp, err := h.client.Get(h.url)           // ❶
    if err != nil {
        return "", err
    }

    body, err := io.ReadAll(resp.Body)         // ❷
    if err != nil {
        return "", err
    }

    return string(body), nil
}

❶ 发出一个 HTTP GET 请求

❷ 读取resp.Body,并以[]byte的形式获取正文

我们使用http.Get并使用io.ReadAll解析响应。这个方法看起来不错,它正确地返回了 HTTP 响应体。然而,有一个资源泄漏。我们来了解一下在哪里。

resp是一个*http.Response型。它包含一个Body io.ReadCloser字段(io.ReadCloser实现了io.Readerio.Closer)。如果http.Get没有返回错误,这个正文必须关闭;否则就是资源泄露。在这种情况下,我们的应用将保留一些不再需要但不能被 GC 回收的内存,在最坏的情况下,可能会阻止客户端重用 TCP 连接。

处理体闭包最方便的方法是像这样处理defer语句:

defer func() {
    err := resp.Body.Close()
    if err != nil {
        log.Printf("failed to close response: %v\n", err)
    }
}()

在这个实现中,我们将正文资源闭包作为一个defer函数来处理,一旦getBody返回,就会执行。

注意在服务器端,在实现 HTTP 处理器时,我们不需要关闭请求正文,因为服务器会自动关闭请求正文。

我们还应该理解,无论我们是否读取响应体,它都必须是封闭的。例如,如果我们只对 HTTP 状态代码感兴趣,而对正文不感兴趣,那么无论如何都必须关闭它,以避免泄漏:

func (h handler) getStatusCode(body io.Reader) (int, error) {
    resp, err := h.client.Post(h.url, "application/json", body)
    if err != nil {
        return 0, err
    }

    defer func() {                // ❶
        err := resp.Body.Close()
        if err != nil {
            log.Printf("failed to close response: %v\n", err)
        }
    }()

    return resp.StatusCode, nil
}

即使我们不读,❶也会关闭响应正文

这个函数关闭了正文,即使我们没有读它。

另一件需要记住的重要事情是,当我们关闭身体时,行为是不同的,这取决于我们是否已经阅读了它:

  • 如果我们在没有读取的情况下关闭正文,默认的 HTTP 传输可能会关闭连接。

  • 如果我们在读取之后关闭正文,默认的 HTTP 传输不会关闭连接;因此,它可以重复使用。

因此,如果getStatusCode被重复调用并且我们想要使用保持活动的连接,我们应该读取正文,即使我们对它不感兴趣:

func (h handler) getStatusCode(body io.Reader) (int, error) {
    resp, err := h.client.Post(h.url, "application/json", body)
    if err != nil {
        return 0, err
    }

    // Close response body

    _, _ = io.Copy(io.Discard, resp.Body)     // ❶

    return resp.StatusCode, nil
}

❶ 阅读响应正文

在本例中,我们读取正文以保持连接的活力。注意,我们没有使用io.ReadAll,而是使用了io.Copyio.Discard,一个io.Writer实现。这段代码读取正文,但丢弃它,不进行任何复制,这比io.ReadAll更有效。

何时关闭响应体

通常,如果响应不为空,实现会关闭正文,而不是如果错误为nil:

resp, err := http.Get(url)
if resp != nil {                // ❶
    defer resp.Body.Close()     // ❷
}

if err != nil {
    return "", err
}

如果答案不是零,❶...

❷ ...作为延迟函数关闭响应正文。

这个实现不是必需的。这是基于这样一个事实:在某些情况下(比如重定向失败),无论是resp还是err都不会是nil。但是根据官方GO文档(pkg.go.dev/net/http),

出错时,任何响应都可以忽略。只有当CheckRedirect失败时,才会出现带有非零错误的非零响应,即使在这种情况下,返回的响应也是如此。身体已经关闭。

因此,没有必要进行if resp != nil {}检查。我们应该坚持最初的解决方案,只有在没有错误的情况下,才在defer函数中关闭正文。

关闭资源以避免泄漏不仅仅与 HTTP 正文管理相关。一般来说,所有实现io.Closer接口的结构都应该在某个时候关闭。该接口包含单个Close方法:

type Closer interface {
    Close() error
}

现在让我们看看sql.Rows的影响。

10.5.2 sql.Rows

sql.Rows是作为 SQL 查询结果使用的结构。因为这个结构实现了io.Closer,所以它必须被关闭。以下示例省略了行的关闭:

db, err := sql.Open("postgres", dataSourceName)
if err != nil {
    return err
}

rows, err := db.Query("SELECT * FROM CUSTOMERS")    // ❶
if err != nil {
    return err
}

// Use rows

return nil

❶ 执行 SQL 查询

忘记关闭行意味着连接泄漏,这会阻止数据库连接被放回连接池中。

我们可以将闭包作为跟在if err != nil块后面的defer函数来处理:

// Open connection

rows, err := db.Query("SELECT * FROM CUSTOMERS")     // ❶
if err != nil {
    return err
}

defer func() {                                       // ❷
    if err := rows.Close(); err != nil {
        log.Printf("failed to close rows: %v\n", err)
    }
}()

// Use rows

❶ 执行 SQL 查询

❷ 关闭一行

Query调用之后,如果没有返回错误,我们应该最终关闭rows来防止连接泄漏。

注如前一节所述,db变量(*sql.DB类型)代表一个连接池。它还实现了io.Closer接口。但是正如文档所示,很少关闭一个sql.DB,因为它应该是长期存在的,并且由许多 goroutines 共享。

接下来,让我们讨论在处理文件时关闭资源。

10.5.3 os.File

os.File代表一个打开的文件描述符。和sql.Rows一样,最终必须关闭:

f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, os.ModeAppend)   // ❶
if err != nil {
    return err
}

defer func() {
    if err := f.Close(); err != nil {                                     // ❷
        log.Printf("failed to close file: %v\n", err)
    }
}()

❶ 打开文件

❷ 关闭文件描述符

在这个例子中,我们使用defer来延迟对Close方法的调用。如果我们最终没有关闭一个os.File,它本身不会导致泄漏:当os.File被垃圾收集时,文件会自动关闭。但是,最好显式调用Close,因为我们不知道下一个 GC 将在何时被触发(除非我们手动运行它)。

显式调用Close还有另一个好处:主动监控返回的错误。例如,可写文件应该是这种情况。

写入文件描述符不是同步操作。出于性能考虑,数据被缓冲。close(2)的 BSD 手册页提到,一个闭包会导致在 I/O 错误期间遇到的先前未提交的写操作(仍在缓冲区中)出错。因此,如果我们想要写入文件,我们应该传播关闭文件时发生的任何错误:

func writeToFile(filename string, content []byte) (err error) {
    // Open file

    defer func() {             // ❶
        closeErr := f.Close()
        if err == nil {
            err = closeErr
        }
    }()

    _, err = f.Write(content)
    return
}

如果写入成功,❶将返回关闭错误

在本例中,我们使用命名参数,并在写入成功时将错误设置为f.Close的响应。通过这种方式,客户将会意识到这个函数是否出了问题,并做出相应的反应。

此外,成功关闭可写的os.File并不能保证文件将被写入磁盘。写操作仍然可以驻留在文件系统的缓冲区中,而不会刷新到磁盘上。如果持久性是一个关键因素,我们可以使用Sync()方法来提交变更。在这种情况下,来自Close的错误可以被安全地忽略:

func writeToFile(filename string, content []byte) error {
    // Open file

    defer func() {
        _ = f.Close()       // ❶
    }()

    _, err = f.Write(content)
    if err != nil {
        return err
    }

    return f.Sync()         // ❷
}

❶ 忽略了可能的错误

❷ 将写入提交到磁盘

这个例子是一个同步写函数。它确保内容在返回之前被写入磁盘。但是它的缺点是会影响性能。

总结这一节,我们已经看到关闭短暂的资源从而避免泄漏是多么重要。短暂的资源必须在正确的时间和特定的情况下关闭。事先并不总是清楚什么必须结束。我们只能通过仔细阅读 API 文档和/或通过经验来获取这些信息。但是我们应该记住,如果一个结构实现了io.Closer接口,我们最终必须调用Close方法。最后但并非最不重要的一点是,必须理解如果闭包失败了该怎么做:记录一条消息就够了吗,或者我们还应该传播它吗?适当的操作取决于实现,如本节中的三个示例所示。

现在让我们切换到与 HTTP 处理相关的常见错误:忘记return语句。

10.6 #80:响应 HTTP 请求后忘记返回语句

在编写 HTTP 处理器时,很容易忘记响应 HTTP 请求后的语句。这可能会导致一种奇怪的情况,我们应该在出错后停止处理器,但是我们没有。

我们可以在下面的例子中观察到这种情况:

func handler(w http.ResponseWriter, req *http.Request) {
    err := foo(req)
    if err != nil {
        http.Error(w, "foo", http.StatusInternalServerError)    // ❶
    }

    // ...
}

❶ 处理错误

如果foo返回一个错误,我们使用http.Error来处理它,它用foo错误消息和一个 500 内部服务器错误来响应请求。这段代码的问题是,如果我们进入if err != nil分支,应用将继续执行,因为http.Error不会停止处理器的执行。

这种错误的真正影响是什么?首先我们从 HTTP 层面来讨论一下。例如,假设我们通过添加一个步骤来编写成功的 HTTP 响应正文和状态代码,从而完成了前面的 HTTP 处理器:

func handler(w http.ResponseWriter, req *http.Request) {
    err := foo(req)
    if err != nil {
        http.Error(w, "foo", http.StatusInternalServerError)
    }

    _, _ = w.Write([]byte("all good"))
    w.WriteHeader(http.StatusCreated)
}

err != nil的情况下,HTTP 响应如下:

foo
all good

响应包含错误和成功消息。

我们将只返回第一个 HTTP 状态代码:在前面的例子中是 500。但是,Go 也会记录一个警告:

2021/10/29 16:45:33 http: superfluous response.WriteHeader call
from main.handler (main.go:20)

这个警告意味着我们试图多次写入状态代码,这样做是多余的。

就执行而言,主要影响是继续执行本应停止的函数。例如,如果foo在返回错误的同时还返回了一个指针,那么继续执行将意味着使用这个指针,这可能会导致一个空指针解引用(并因此导致一个 goroutine 崩溃)。

纠正这个错误的方法是继续考虑在http.Error之后添加return语句的:

func handler(w http.ResponseWriter, req *http.Request) {
    err := foo(req)
    if err != nil {
        http.Error(w, "foo", http.StatusInternalServerError)
        return    // ❶
    }

    // ...
}

❶ 补充了返回语句

由于的return语句,如果我们在if err != nil分支结束,函数将停止执行。

这个错误可能不是这本书最复杂的。然而,很容易忘记这一点,这种错误经常发生。我们总是需要记住http.Error不会停止一个处理器的执行,必须手动添加。如果我们有足够的覆盖率,这样的问题可以而且应该在测试中被发现。

本章的最后一节继续我们对 HTTP 的讨论。我们明白了为什么生产级应用不应该依赖默认的 HTTP 客户端和服务器实现。

10.7 #81:使用默认的 HTTP 客户端和服务器

http包提供了 HTTP 客户端和服务器实现。然而,开发人员很容易犯一个常见的错误:在最终部署到生产环境中的应用的上下文中依赖默认实现。让我们看看问题和如何克服它们。

10.7.1 HTTP 客户端

我们来定义一下默认客户端是什么意思。我们将使用一个 GET 请求作为例子。我们可以像这样使用http.Client结构的零值:

client := &http.Client{}
resp, err := client.Get("https://golang.org/")

或者我们可以使用http.Get函数:

resp, err := http.Get("https://golang.org/")

最后,两种方法都是一样的。http.Get函数使用http .DefaultClient,其也是基于http.Client的零值:

// DefaultClient is the default Client and is used by Get, Head, and Post.
var DefaultClient = &Client{}

那么,使用默认的 HTTP 客户端有什么问题呢?

首先,默认客户端没有指定任何超时。这种没有超时的情况并不是我们想要的生产级系统:它会导致许多问题,比如永无止境的请求会耗尽系统资源。

在深入研究发出请求时的可用超时之前,让我们回顾一下 HTTP 请求中涉及的五个步骤:

  1. 建立 TCP 连接。

  2. TLS 握手(如果启用)。

  3. 发送请求。

  4. 读取响应标题。

  5. 读取响应正文。

图 10.2 显示了这些步骤与主客户端超时的关系。

图 10.2 HTTP 请求期间的五个步骤,以及相关的超时

四种主要超时如下:

  • net.Dialer.Timeout——指定拨号等待连接完成的最长时间。

  • http.Transport.TLSHandshakeTimeout——指定等待 TLS 握手的最长时间。

  • http.Transport.ResponseHeaderTimeout——指定等待服务器响应头的时间。

  • http.Client.Timeout——指定请求的时限。它包括从步骤 1(拨号)到步骤 5(读取响应正文)的所有步骤。

HTTP 客户端超时

在指定http.Client .Timeout时,您可能会遇到以下错误:

net/http: request canceled (Client.Timeout exceeded while awaiting 
headers)

此错误意味着端点未能及时响应。我们得到这个关于头的错误是因为读取它们是等待响应的第一步。

下面是一个覆盖这些超时的 HTTP 客户端示例:

client := &http.Client{
    Timeout: 5 * time.Second,                  // ❶
    Transport: &http.Transport{
        DialContext: (&net.Dialer{
            Timeout: time.Second,              // ❷
        }).DialContext,
        TLSHandshakeTimeout:   time.Second,    // ❸
        ResponseHeaderTimeout: time.Second,    // ❹
    },
}

❶ 全局请求超时

❷ 拨号超时

❸ TLS 握手超时

❹ 响应标头超时

我们创建一个客户端,拨号、TLS 握手和读取响应头的超时时间为 1 秒。同时,每个请求都有一个 5 秒的全局超时。

关于默认 HTTP 客户端,要记住的第二个方面是如何处理连接。默认情况下,HTTP 客户端使用连接池。默认客户端重用连接(可以通过将http.Transport.DisableKeepAlives设置为true来禁用)。有一个额外的超时来指定空闲连接在池中保持多长时间:http.Transport.IdleConnTimeout。默认值是 90 秒,这意味着在此期间,连接可以被其他请求重用。之后,如果连接没有被重用,它将被关闭。

要配置池中的连接数,我们必须覆盖http.Transport.MaxIdleConns。该值默认设置为100。但是有一些重要的事情需要注意:每台主机的http.Transport.MaxIdleConnsPerHost限制,默认设置为 2。例如,如果我们向同一个主机触发100请求,那么在此之后,只有 2 个连接会保留在连接池中。因此,如果我们再次触发 100 个请求,我们将不得不重新打开至少 98 个连接。如果我们必须处理对同一台主机的大量并行请求,这种配置也会影响平均延迟。

对于生产级系统,我们可能希望覆盖默认超时。调整与连接池相关的参数也会对延迟产生重大影响。

10.7.2 HTTP 服务器

在实现 HTTP 服务器时,我们也应该小心。同样,可以使用零值http.Server创建默认服务器:

server := &http.Server{}
server.Serve(listener)

或者我们可以使用一个函数,比如http.Servehttp.ListenAndServehttp .ListenAndServeTLS,它们也依赖于默认的http.Server

一旦连接被接受,HTTP 响应就分为五个步骤:

  1. 等待客户端发送请求。

  2. TLS 握手(如果启用)。

  3. 读取请求标题。

  4. 读取请求正文。

  5. 写入响应。

注意,对于已经建立的连接,不必重复 TLS 握手。

图 10.3 显示了这些步骤与主服务器超时的关系。三种主要超时如下:

  • http.Server.ReadHeaderTimeout——字段,指定读取请求头的最大时间量

  • http.Server.ReadTimeout——指定读取整个请求的最长时间的字段

  • http.TimeoutHandler——一个包装器函数,指定处理器完成的最大时间

图 10.3 HTTP 响应的五个步骤,以及相关的超时

最后一个参数不是服务器参数,而是一个位于处理器之上的包装器,用于限制其持续时间。如果处理器未能及时响应,服务器将通过特定消息响应 503 服务不可用,传递给处理器的上下文将被取消。

注意我们故意省略了http.Server.WriteTimeout,因为http.TimeoutHandler已经发布(Go 1.8),所以没有必要。http.Server.WriteTimeout有一些问题。首先,它的行为取决于是否启用了 TLS,这使得理解和使用它变得更加复杂。如果超时,它还会关闭 TCP 连接,而不返回正确的 HTTP 代码。它不会将取消传播到处理器上下文,所以处理器可能会继续执行,而不知道 TCP 连接已经关闭。

当向不受信任的客户端公开我们的端点时,最佳实践是至少设置http.Server.ReadHeaderTimeout字段,并且使用http.TimeoutHandler包装函数。否则,客户端可能会利用此缺陷,例如,创建永无止境的连接,这可能会导致系统资源耗尽。

以下是如何设置具有这些超时的服务器:

s := &http.Server{
    Addr:              ":8080",
    ReadHeaderTimeout: 500 * time.Millisecond,
    ReadTimeout:       500 * time.Millisecond,
    Handler:           http.TimeoutHandler(handler, time.Second, "foo"),   // ❶
}

❶ 包装了 HTTP 处理器

http.TimeoutHandler包装提供的处理器。这里,如果handler在 1 秒内没有响应,服务器返回一个 503 状态码,用foo作为 HTTP 响应。

正如我们所描述的 HTTP 客户端一样,在服务器端,我们可以在激活 keep-alive 时为下一个请求配置最长时间。我们使用http.Server.IdleTimeout来完成:

s := &http.Server{
    // ...
    IdleTimeout: time.Second,
}

注意,如果没有设置http.Server.IdleTimeout,则http.Server .ReadTimeout的值用于空闲超时。如果两者都没有设置,则不会有任何超时,连接将保持打开状态,直到被客户端关闭。

对于生产级应用,我们需要确保不使用默认的 HTTP 客户端和服务器。否则,请求可能会因为没有超时而永远停滞不前,甚至恶意客户端会利用我们的服务器没有任何超时这一事实。

总结

  • 对接受time.Duration的函数保持谨慎。尽管传递整数是允许的,但还是要努力使用 time API 来防止任何可能的混淆。

  • 避免在重复的函数(比如循环或者 HTTP 处理器)中调用time.After可以避免内存消耗高峰。由time.After创建的资源只有在计时器到期时才会被释放。

  • 在 Go 结构中使用嵌入字段时要小心。这样做可能会导致偷偷摸摸的错误,比如实现json .Marshaler接口的嵌入式time.Time字段,因此会覆盖默认的封送处理行为。

  • 当比较两个time.Time结构时,回想一下time.Time包含一个挂钟和一个单调时钟,使用==操作符的比较是在两个时钟上进行的。

  • 为了避免在解组 JSON 数据时提供映射时的错误假设,请记住默认情况下 numerics 被转换为float64

  • 如果您需要测试您的配置并确保数据库可访问,请调用PingPingContext方法。

  • 为生产级应用配置数据库连接参数。

  • 使用 SQL 预准备语句使查询更高效、更安全。

  • 使用指针或sql.NullXXX类型处理表中可空的列。

  • 在行迭代后调用*sql.RowsErr方法,以确保在准备下一行时没有遗漏错误。

  • 最终关闭所有实现io.Closer的结构以避免可能的泄漏。

  • 为了避免 HTTP 处理器实现中的意外行为,如果您希望处理器在http.Error之后停止,请确保不要错过return语句。

  • 对于生产级应用,不要使用默认的 HTTP 客户端和服务器实现。这些实现缺少生产中应该强制的超时和行为。

十一、测试

本章涵盖

  • 对测试进行分类,使它们更加健壮
  • 使 Go 测试具有确定性
  • 使用实用工具包,如httptestiotest
  • 避免常见的基准错误
  • 改进测试流程

测试是项目生命周期的一个重要方面。它提供了无数的好处,比如建立对应用的信心,充当代码文档,以及使重构更容易。与其他一些语言相比,Go 拥有强大的编写测试的原语。在这一章中,我们将关注那些使测试过程变得脆弱、低效和不准确的常见错误。

11.1 #82:没有对测试进行分类

测试金字塔是一个将测试分成不同类别的模型(见图 11.1)。单元测试占据了金字塔的底部。大多数测试应该是单元测试:它们编写成本低,执行速度快,并且具有很高的确定性。通常,当我们走的时候

在金字塔的更高层,测试变得越来越复杂,运行越来越慢,并且更难保证它们的确定性。

图 11.1 测试金字塔的一个例子

一种常见的技术是明确要运行哪种测试。例如,根据项目生命周期的阶段,我们可能希望只运行单元测试或者运行项目中的所有测试。不对测试进行分类意味着潜在的浪费时间和精力,并且失去了测试范围的准确性。本节讨论了在 Go 中对测试进行分类的三种主要方法。

11.1.1 构建标签

分类测试最常见的方法是使用构建标签。构建标签是 Go 文件开头的特殊注释,后面跟一个空行。

例如,看看这个bar.go文件:

//go:build foo

package bar

这个文件包含了foo标签。请注意,一个包可能包含多个带有不同构建标记的文件。

注从 Go 1.17 开始,语法// +build foo//go:build foo取代。目前(Go 1.18),gofmt同步这两种形式来帮助迁移。

构建标签主要用于两种情况。首先,我们可以使用build标签作为构建应用的条件选项:例如,如果我们希望只有在启用了cgo的情况下才包含源文件(cgo是一种让包调用 C 代码的方法),我们可以添加//go:build cgo``build标签。第二,如果我们想要将一个测试归类为集成测试,我们可以添加一个特定的构建标志,比如integration

下面是一个db_test.go文件示例:

//go:build integration

package db

import (
    "testing"
)

func TestInsert(t *testing.T) {
    // ...
}

这里我们添加了integration``build标签来分类这个文件包含集成测试。使用构建标签的好处是我们可以选择执行哪种测试。例如,让我们假设一个包包含两个测试文件:

  • 我们刚刚创建的文件:db_test.go

  • 另一个不包含构建标签的文件:contract_test.go

如果我们在这个包中运行go test而没有任何选项,它将只运行没有构建标签的测试文件(contract_test.go):

$ go test -v .
=== RUN   TestContract
--- PASS: TestContract (0.01s)
PASS

然而,如果我们提供了integration标签,运行go test也将包括db_test.go:

$ go test --tags=integration -v .
=== RUN   TestInsert
--- PASS: TestInsert (0.01s)
=== RUN   TestContract
--- PASS: TestContract (2.89s)
PASS

因此,运行带有特定标签的测试包括没有标签的文件和匹配这个标签的文件。如果我们只想运行集成测试呢?一种可能的方法是在单元测试文件上添加一个否定标记。例如,使用!integration意味着只有当integration标志启用时,我们才想要包含测试文件(contract_test.go):

//go:build !integration

package db

import (
    "testing"
)

func TestContract(t *testing.T) {
    // ...
}

使用这种方法,

  • integration标志运行go test仅运行集成测试。

  • 在没有integration标志的情况下运行go test只会运行单元测试。

让我们讨论一个在单个测试层次上工作的选项,而不是一个文件。

11.1.2 环境变量

正如 Go 社区的成员 Peter Bourgon 所提到的,build标签有一个主要的缺点:缺少一个测试被忽略的信号(参见 mng.bz/qYlr )。在第一个例子中,当我们在没有构建标志的情况下执行go test时,它只显示了被执行的测试:

$ go test -v .
=== RUN   TestUnit
--- PASS: TestUnit (0.01s)
PASS
ok      db  0.319s

如果我们不小心处理标签的方式,我们可能会忘记现有的测试。出于这个原因,一些项目喜欢使用环境变量来检查测试类别的方法。

例如,我们可以通过检查一个特定的环境变量并可能跳过测试来实现TestInsert集成测试:

func TestInsert(t *testing.T) {
    if os.Getenv("INTEGRATION") != "true" {
        t.Skip("skipping integration test")
    }

    // ...
}

如果INTEGRATION环境变量没有设置为true,测试将被跳过,并显示一条消息:

$ go test -v .
=== RUN   TestInsert
    db_integration_test.go:12: skipping integration test     // ❶
--- SKIP: TestInsert (0.00s)
=== RUN   TestUnit
--- PASS: TestUnit (0.00s)
PASS
ok      db  0.319s

❶ 显示跳过测试的消息

使用这种方法的一个好处是明确哪些测试被跳过以及为什么。这种技术可能没有build标签使用得广泛,但是它值得了解,因为正如我们所讨论的,它提供了一些优势。

接下来,让我们看看另一种分类测试的方法:短模式。

11.1.3 短模式

另一种对测试进行分类的方法与它们的速度有关。我们可能必须将短期运行的测试与长期运行的测试分离开来。

作为一个例子,假设我们有一组单元测试,其中一个非常慢。我们希望对慢速测试进行分类,这样我们就不必每次都运行它(特别是当触发器是在保存一个文件之后)。短模式允许我们进行这种区分:

func TestLongRunning(t *testing.T) {
    if testing.Short() {                        // ❶
        t.Skip("skipping long-running test")
    }
    // ...
}

❶ 将测试标记为长期运行

使用testing.Short,我们可以在运行测试时检索是否启用了短模式。然后我们使用Skip来跳过测试。要使用短模式运行测试,我们必须通过-short:

% go test -short -v .
=== RUN   TestLongRunning
    foo_test.go:9: skipping long-running test
--- SKIP: TestLongRunning (0.00s)
PASS
ok      foo  0.174s

执行测试时,明确跳过TestLongRunning。请注意,与构建标签不同,该选项适用于每个测试,而不是每个文件。

总之,对测试进行分类是成功测试策略的最佳实践。在本节中,我们已经看到了三种对测试进行分类的方法:

  • 在测试文件级别使用构建标签

  • 使用环境变量来标记特定的测试

  • 基于使用短模式的测试步速

我们还可以组合方法:例如,如果我们的项目包含长时间运行的单元测试,使用构建标签或环境变量来分类测试(例如,作为单元或集成测试)和短模式。

在下一节中,我们将讨论为什么启用-race标志很重要。

11.2 #83:不启用竞争标志

在错误#58“不理解竞争问题”中,我们将数据竞争定义为当两个 goroutines 同时访问同一个变量时发生,至少有一个变量被写入。我们还应该知道,Go 有一个标准的竞争检测工具来帮助检测数据竞争。一个常见的错误是忘记了这个工具的重要性,没有启用它。这一节讨论竞争检测器捕捉什么,如何使用它,以及它的局限性。

在 Go 中,竞争检测器不是编译期间使用的静态分析工具;相反,它是一个发现运行时发生的数据竞争的工具。要启用它,我们必须在编译或运行测试时启用-race标志。例如:

$ go test -race ./...

一旦启用了竞争检测器,编译器就会检测代码来检测数据竞争。插装指的是编译器添加额外的指令:在这里,跟踪所有的内存访问并记录它们何时以及如何发生。在运行时,竞争检测器监视数据竞争。但是,我们应该记住启用竞争检测器的运行时开销:

  • 内存使用量可能会增加 5 到 10 倍。

  • 执行时间可能增加 2 到 20 倍。

由于这种开销,通常建议只在本地测试或持续集成(CI)期间启用竞争检测器。在生产中,我们应该避免使用它(或者只在金丝雀释放的情况下使用它)。

如果检测到竞争,Go 会发出警告。例如,这个例子包含了一个数据争用,因为i可以同时被读取和写入:

package main

import (
    "fmt"
)

func main() {
    i := 0
    go func() { i++ }()
    fmt.Println(i)
}

使用-race标志运行该应用会记录以下数据竞争警告:

==================
WARNING: DATA RACE
Write at 0x00c000026078 by goroutine 7:                // ❶
  main.main.func1()
      /tmp/app/main.go:9 +0x4e

Previous read at 0x00c000026078 by main goroutine:     // ❷
  main.main()
      /tmp/app/main.go:10 +0x88

Goroutine 7 (running) created at:                      // ❸
  main.main()
      /tmp/app/main.go:9 +0x7a
==================

❶ 指出由 goroutine 7 写入

❷ 指出由主 goroutine读取

❸ 指出了 goroutine 7 的创建时间

让我们确保阅读这些信息时感到舒适。Go 总是记录以下内容:

  • 被牵连的并发 goroutine:这里是主 goroutine 和 goroutine 7。

  • 代码中出现访问的地方:在本例中,是第 9 行和第 10 行。

  • 创建这些 goroutine 的时间:goroutine 7 是在main()中创建的。

注意在内部,竞争检测器使用向量时钟,这是一种用于确定事件部分顺序的数据结构(也用于分布式系统,如数据库)。每一个 goroutine 的创建都会导致一个向量时钟的产生。该工具在每次存储器访问和同步事件时更新向量时钟。然后,它比较向量时钟以检测潜在的数据竞争。

竞争检测器不能捕捉假阳性(一个明显的数据竞争,而不是真正的数据竞争)。因此,如果我们得到警告,我们知道我们的代码包含数据竞争。相反,它有时会导致假阴性(遗漏实际的数据竞争)。

关于测试,我们需要注意两件事。首先,竞争检测器只能和我们的测试一样好。因此,我们应该确保针对数据竞争对并发代码进行彻底的测试。其次,考虑到可能的假阴性,如果我们有一个测试来检查数据竞争,我们可以将这个逻辑放在一个循环中。这样做增加了捕获可能的数据竞争的机会:

func TestDataRace(t *testing.T) {
    for i := 0; i < 100; i++ {
        // Actual logic
    }
}

此外,如果一个特定的文件包含导致数据竞争的测试,我们可以使用!race``build标签将其从竞争检测中排除:

//go:build !race

package main

import (
    "testing"
)

func TestFoo(t *testing.T) {
    // ...
}

func TestBar(t *testing.T) {
    // ...
}

只有在禁用竞争检测器的情况下,才会构建该文件。否则,整个文件不会被构建,所以测试不会被执行。

总之,我们应该记住,如果不是强制性的,强烈推荐使用并发性为应用运行带有-race标志的测试。这种方法允许我们启用竞争检测器,它检测我们的代码来捕捉潜在的数据竞争。启用时,它会对内存和性能产生重大影响,因此必须在特定条件下使用,如本地测试或 CI。

下面讨论与和执行模式相关的两个标志:parallelshuffle

11.3 #84:不使用测试执行模式

在运行测试时,go命令可以接受一组标志来影响测试的执行方式。一个常见的错误是没有意识到这些标志,错过了可能导致更快执行或更好地发现可能的 bug 的机会。让我们来看看其中的两个标志:parallelshuffle

11.3.1 并行标志

并行执行模式允许我们并行运行特定的测试,这可能非常有用:例如,加速长时间运行的测试。我们可以通过调用t.Parallel来标记测试必须并行运行:

func TestFoo(t *testing.T) {
    t.Parallel()
    // ...
}

当我们使用t.Parallel标记一个测试时,它与所有其他并行测试一起并行执行。然而,在执行方面,Go 首先一个接一个地运行所有的顺序测试。一旦顺序测试完成,它就执行并行测试。

例如,以下代码包含三个测试,但其中只有两个被标记为并行运行:

func TestA(t *testing.T) {
    t.Parallel()
    // ...
}

func TestB(t *testing.T) {
    t.Parallel()
    // ...
}

func TestC(t *testing.T) {
    // ...
}

运行该文件的测试会产生以下日志:

=== RUN   TestA
=== PAUSE TestA           // ❶
=== RUN   TestB
=== PAUSE TestB           // ❷
=== RUN   TestC           // ❸
--- PASS: TestC (0.00s)
=== CONT  TestA           // ❹
--- PASS: TestA (0.00s)
=== CONT  TestB
--- PASS: TestB (0.00s)
PASS

❶ 暂停TestA

❷ 暂停TestB

❸ 运行TestC

❹ 恢复TestATestB

TestC第一个被处决。TestATestB首先被记录,但是它们被暂停,等待TestC完成。然后两者都被恢复并并行执行。

默认情况下,可以同时运行的最大测试数量等于GOMAXPROCS值。为了序列化测试,或者,例如,在进行大量 I/O 的长时间运行的测试环境中增加这个数字,我们可以使用的-parallel标志来改变这个值:

$ go test -parallel 16 .

这里,并行测试的最大数量被设置为 16。

现在让我们看看运行 Go 测试的另一种模式:shuffle

11.3.2 混洗标志

从 Go 1.17 开始,可以随机化测试和基准的执行顺序。有什么道理?编写测试的最佳实践是将它们隔离开来。例如,它们不应该依赖于执行顺序或共享变量。这些隐藏的依赖关系可能意味着一个可能的测试错误,或者更糟糕的是,一个在测试过程中不会被发现的错误。为了防止这种情况,我们可以使用和-shuffle标志来随机化测试。我们可以将其设置为onoff来启用或禁用测试混洗(默认情况下禁用):

$ go test -shuffle=on -v .

然而,在某些情况下,我们希望以相同的顺序重新运行测试。例如,如果在 CI 期间测试失败,我们可能希望在本地重现错误。为此,我们可以传递用于随机化测试的种子,而不是将on传递给-shuffle标志。我们可以通过启用详细模式(-v)在运行混洗测试时访问这个种子值:

$ go test -shuffle=on -v .
-test.shuffle 1636399552801504000     // ❶
=== RUN   TestBar
--- PASS: TestBar (0.00s)
=== RUN   TestFoo
--- PASS: TestFoo (0.00s)
PASS
ok      teivah  0.129s

❶ 种子值

我们随机执行测试,但是go test打印种子值:1636399552801504000。为了强制测试以相同的顺序运行,我们将这个种子值提供给shuffle:

$ go test -shuffle=1636399552801504000 -v .
-test.shuffle 1636399552801504000
=== RUN   TestBar
--- PASS: TestBar (0.00s)
=== RUN   TestFoo
--- PASS: TestFoo (0.00s)
PASS
ok      teivah  0.129s

测试以相同的顺序执行:TestBar然后是TestFoo

一般来说,我们应该对现有的测试标志保持谨慎,并随时了解最近 Go 版本的新特性。并行运行测试是减少运行所有测试的总执行时间的一个很好的方法。并且shuffle模式可以帮助我们发现隐藏的依赖关系,这可能意味着在以相同的顺序运行测试时的测试错误,甚至是看不见的 bug。

11.4 #85:不使用表驱动测试

表驱动测试是一种有效的技术,用于编写精简的测试,从而减少样板代码,帮助我们关注重要的东西:测试逻辑。本节通过一个具体的例子来说明为什么在使用 Go 时表驱动测试是值得了解的。

让我们考虑下面的函数,它从字符串中删除所有的新行后缀(\n\r\n):

func removeNewLineSuffixes(s string) string {
    if s == "" {
        return s
    }
    if strings.HasSuffix(s, "\r\n") {
        return removeNewLineSuffixes(s[:len(s)-2])
    }
    if strings.HasSuffix(s, "\n") {
        return removeNewLineSuffixes(s[:len(s)-1])
    }
    return s
}

这个函数递归地删除所有前导的\r\n\n后缀。现在,假设我们想要广泛地测试这个函数。我们至少应该涵盖以下情况:

  • 输入为空。

  • 输入以\n结束。

  • 输入以\r\n结束。

  • 输入以多个\n结束。

  • 输入结束时没有换行符。

以下方法为每个案例创建一个单元测试:

func TestRemoveNewLineSuffix_Empty(t *testing.T) {
    got := removeNewLineSuffixes("")
    expected := ""
    if got != expected {
        t.Errorf("got: %s", got)
    }
}

func TestRemoveNewLineSuffix_EndingWithCarriageReturnNewLine(t *testing.T) {
    got := removeNewLineSuffixes("a\r\n")
    expected := "a"
    if got != expected {
        t.Errorf("got: %s", got)
    }
}

func TestRemoveNewLineSuffix_EndingWithNewLine(t *testing.T) {
    got := removeNewLineSuffixes("a\n")
    expected := "a"
    if got != expected {
        t.Errorf("got: %s", got)
    }
}

func TestRemoveNewLineSuffix_EndingWithMultipleNewLines(t *testing.T) {
    got := removeNewLineSuffixes("a\n\n\n")
    expected := "a"
    if got != expected {
        t.Errorf("got: %s", got)
    }
}

func TestRemoveNewLineSuffix_EndingWithoutNewLine(t *testing.T) {
    got := removeNewLineSuffixes("a\n")
    expected := "a"
    if got != expected {
        t.Errorf("got: %s", got)
    }
}

每个函数都代表了我们想要涵盖的一个特定案例。然而,有两个主要缺点。首先,函数名更复杂(TestRemoveNewLineSuffix_EndingWithCarriageReturnNewLine有 55 个字符长),这很快会影响函数测试内容的清晰度。第二个缺点是这些函数之间的重复量,因为结构总是相同的:

  1. removeNewLineSuffixes

  2. 定义期望值。

  3. 比较数值。

  4. 记录错误信息。

如果我们想要改变这些步骤中的一个——例如,将期望值作为错误消息的一部分包含进来——我们将不得不在所有的测试中重复它。我们写的测试越多,代码就越难维护。

相反,我们可以使用表驱动测试,这样我们只需编写一次逻辑。表驱动测试依赖于子测试,一个测试函数可以包含多个子测试。例如,以下测试包含两个子测试:

func TestFoo(t *testing.T) {
    t.Run("subtest 1", func(t *testing.T) {    // ❶
        if false {
            t.Error()
        }
    })
    t.Run("subtest 2", func(t *testing.T) {    // ❷
        if 2 != 2 {
            t.Error()
        }
    })
}

❶ 进行第一个子测试,称为子测试 1

❷ 进行第二个子测试,称为子测试 2

TestFoo函数包括两个子测试。如果我们运行这个测试,它显示了subtest 1subtest 2的结果:

--- PASS: TestFoo (0.00s)
    --- PASS: TestFoo/subtest_1 (0.00s)
    --- PASS: TestFoo/subtest_2 (0.00s)
PASS

我们还可以使用和-run标志运行一个单独的测试,并将父测试名与子测试连接起来。例如,我们可以只运行subtest 1:

$ go test -run=TestFoo/subtest_1 -v      // ❶
=== RUN   TestFoo
=== RUN   TestFoo/subtest_1
--- PASS: TestFoo (0.00s)
    --- PASS: TestFoo/subtest_1 (0.00s)

❶ 使用-run标志只运行子测试 1

让我们回到我们的例子,看看如何使用子测试来防止重复测试逻辑。主要想法是为每个案例创建一个子测试。变化是存在的,但是我们将讨论一个映射数据结构,其中键代表测试名称,值代表测试数据(输入,预期)。

表驱动测试通过使用包含测试数据和子测试的数据结构来避免样板代码。下面是一个使用映射的可能实现:

func TestRemoveNewLineSuffix(t *testing.T) {
    tests := map[string]struct {                   // ❶
        input    string
        expected string
    }{
        `empty`: {                                 // ❷
            input:    "",
            expected: "",
        },
        `ending with \r\n`: {
            input:    "a\r\n",
            expected: "a",
        },
        `ending with \n`: {
            input:    "a\n",
            expected: "a",
        },
        `ending with multiple \n`: {
            input:    "a\n\n\n",
            expected: "a",
        },
        `ending without newline`: {
            input:    "a",
            expected: "a",
        },
    }
    for name, tt := range tests {                  // ❸
        t.Run(name, func(t *testing.T) {           // ❹
            got := removeNewLineSuffixes(tt.input)
            if got != tt.expected {
                t.Errorf("got: %s, expected: %s", got, tt.expected)
            }
        })
    }
}

❶ 定义了测试数据

❷ :映射中的每个条目代表一个子测试。

❸ 在映射上迭代

❹ 为每个映射条目运行一个新的子测试

tests变量是一个映射。关键是测试名称,值代表测试数据:在我们的例子中,输入和预期的字符串。每个映射条目都是我们想要覆盖的一个新的测试用例。我们为每个映射条目运行一个新的子测试。

这个测试解决了我们讨论的两个缺点:

  • 每个测试名现在是一个字符串,而不是 Pascal 大小写函数名,这使得它更容易阅读。

  • 该逻辑只编写一次,并在所有不同的情况下共享。修改测试结构或者增加一个新的测试需要最小的努力。

关于表驱动测试,我们需要提到最后一件事,它也可能是错误的来源:正如我们前面提到的,我们可以通过调用t.Parallel来标记一个并行运行的测试。我们也可以在提供给t.Run的闭包内的子测试中这样做:

for name, tt := range tests {
    t.Run(name, func(t *testing.T) {
        t.Parallel()                   // ❶
        // Use tt
    })
}

❶ 标记了并行运行的子测试

然而,这个闭包使用了一个循环变量。为了防止类似于错误#63 中讨论的问题,“不小心使用 goroutines 和循环变量”,这可能导致闭包使用错误的tt变量的值,我们应该创建另一个变量或影子tt:

for name, tt := range tests {
    tt := tt                          // ❶
    t.Run(name, func(t *testing.T) {
        t.Parallel()
        // Use tt
    })
}

❶ 跟踪tt,使其位于循环迭代的局部

这样,每个闭包都会访问它自己的tt变量。

总之,如果多个单元测试有相似的结构,我们可以使用表驱动测试来共同化它们。因为这种技术防止了重复,它使得改变测试逻辑变得简单,并且更容易添加新的用例。

接下来,我们来讨论如何在 Go 中防止片状测试。

11.5 #86:在单元测试中睡眠

古怪的测试是一个不需要任何代码改变就可以通过和失败的测试。古怪的测试是测试中最大的障碍之一,因为它们调试起来很昂贵,并且削弱了我们对测试准确性的信心。在 Go 中,在测试中调用time.Sleep可能是可能出现问题的信号。例如,并发代码经常使用睡眠进行测试。这一部分介绍了从测试中移除睡眠的具体技术,从而防止我们编写出易变的测试。

我们将用一个函数来说明这一部分,该函数返回值并启动一个在后台执行任务的 goroutine。我们将调用一个函数来获取一片Foo结构,并返回最佳元素(第一个)。与此同时,另一个 goroutine 将负责调用带有第nFoo元素的Publish方法:

type Handler struct {
    n         int
    publisher publisher
}

type publisher interface {
    Publish([]Foo)
}

func (h Handler) getBestFoo(someInputs int) Foo {
    foos := getFoos(someInputs)        // ❶
    best := foos[0]                    // ❷

    go func() {
        if len(foos) > h.n {           // ❸
            foos = foos[:h.n]
        }
        h.publisher.Publish(foos)      // ❹
    }()

    return best
}

❶ 得到Foo切片

❷ 保留第一个元素(为了简单起见,省略了检查foos的长度)

❸ 只保留前nFoo结构

❹ 调用Publish方法

Handler结构包含两个字段:一个n字段和一个用于发布第一个n Foo结构的publisher依赖项。首先我们得到一片Foo;但是在返回第一个元素之前,我们旋转一个新的 goroutine,过滤foos片,并调用Publish

我们如何测试这个函数?编写声明响应的部分非常简单。但是,如果我们还想检查传递给Publish的是什么呢?

我们可以模仿publisher接口来记录调用Publish方法时传递的参数。然后,我们可以在检查记录的参数之前睡眠几毫秒:

type publisherMock struct {
    mu  sync.RWMutex
    got []Foo
}

func (p *publisherMock) Publish(got []Foo) {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.got = got
}

func (p *publisherMock) Get() []Foo {
    p.mu.RLock()
    defer p.mu.RUnlock()
    return p.got
}

func TestGetBestFoo(t *testing.T) {
    mock := publisherMock{}
    h := Handler{
        publisher: &mock,
        n:         2,
    }

    foo := h.getBestFoo(42)
    // Check foo

    time.Sleep(10 * time.Millisecond)    // ❶
    published := mock.Get()
    // Check published
}

❶ 在检查传递给Publish的参数之前,睡眠了 10 毫秒

我们编写了一个对publisher的模拟,它依赖于一个互斥体来保护对published字段的访问。在我们的单元测试中,我们调用time.Sleep在检查传递给Publish的参数之前留出一些时间。

这种测试本来就不可靠。不能严格保证 10 毫秒就足够了(在本例中,有可能但不能保证)。

那么,有哪些选项可以改进这个单元测试呢?首先,我们可以使用重试来周期性地断言给定的条件。例如,我们可以编写一个函数,将一个断言作为参数,最大重试次数加上等待时间,定期调用该函数以避免繁忙循环:

func assert(t *testing.T, assertion func() bool,
    maxRetry int, waitTime time.Duration) {
    for i := 0; i < maxRetry; i++ {
        if assertion() {               // ❶
            return
        }
        time.Sleep(waitTime)           // ❷
    }
    t.Fail()                           // ❸
}

❶ 检查断言

❷ 在重试前睡眠

❸ 经过多次尝试后,最终失败了

该函数检查提供的断言,并在一定次数的重试后失败。我们也使用time.Sleep,但是我们可以用这段代码来缩短睡眠时间。

举个例子,让我们回到TestGetBestFoo:

assert(t, func() bool {
    return len(mock.Get()) == 2
}, 30, time.Millisecond)

我们不是睡眠 10 毫秒,而是每毫秒睡眠一次,并配置最大重试次数。如果测试成功,这种方法可以减少执行时间,因为我们减少了等待时间。因此,实现重试策略是比使用被动睡眠更好的方法。

注意一些测试库,如testify,提供重试功能。例如,在testify中,我们可以使用Eventually函数,它实现了最终应该成功的断言和其他特性,比如配置错误消息。

另一个策略是使用通道来同步发布Foo结构的 goroutine 和测试 goroutine。例如,在模拟实现中,我们可以将这个值发送到一个通道,而不是将接收到的切片复制到一个字段中:

type publisherMock struct {
    ch chan []Foo
}

func (p *publisherMock) Publish(got []Foo) {
    p.ch <- got                               // ❶
}

func TestGetBestFoo(t *testing.T) {
    mock := publisherMock{
        ch: make(chan []Foo),
    }
    defer close(mock.ch)

    h := Handler{
        publisher: &mock,
        n:         2,
    }
    foo := h.getBestFoo(42)
    // Check foo

    if v := len(<-mock.ch); v != 2 {          // ❷
        t.Fatalf("expected 2, got %d", v)
    }
}

❶ 发送收到的参数

❷ 比较了这些参数

发布者将接收到的参数发送到通道。同时,测试 goroutine 设置模拟并基于接收到的值创建断言。我们还可以实现一个超时策略,以确保如果出现问题,我们不会永远等待mock.ch。例如,我们可以将selecttime.After一起使用。

我们应该支持哪个选项:重试还是同步?事实上,同步将等待时间减少到最低限度,如果设计得好的话,可以使测试完全确定。

如果我们不能应用同步,我们也许应该重新考虑我们的设计,因为我们可能有一个问题。如果同步确实不可能,我们应该使用重试选项,这是比使用被动睡眠来消除测试中的不确定性更好的选择。

让我们继续讨论如何在测试中防止剥落,这次是在使用时间 API 的时候。

11.6 #87:没有有效地处理时间 API

一些函数必须依赖于时间 API:例如,检索当前时间。在这种情况下,编写脆弱的单元测试可能会很容易失败。在本节中,我们将通过一个具体的例子来讨论选项。我们的目标并不是涵盖所有的用例及技术,而是给出关于使用时间 API 编写更健壮的函数测试的指导。

假设一个应用接收到我们希望存储在内存缓存中的事件。我们将实现一个Cache结构来保存最近的事件。此结构将公开三个方法,这些方法执行以下操作:

  • 追加事件

  • 获取所有事件

  • 在给定的持续时间内修剪事件(我们将重点介绍这种方法)

这些方法中的每一个都需要访问当前时间。让我们使用time.Now()编写第三种方法的第一个实现(我们将假设所有事件都按时间排序):

type Cache struct {
    mu     sync.RWMutex
    events []Event
}

type Event struct {
    Timestamp time.Time
    Data string
}

func (c *Cache) TrimOlderThan(since time.Duration) {
    c.mu.RLock()
    defer c.mu.RUnlock()

    t := time.Now().Add(-since)               // ❶
    for i := 0; i < len(c.events); i++ {
        if c.events[i].Timestamp.After(t) {
            c.events = c.events[i:]           // ❷
            return
        }
    }
}

❶ 从当前时间中减去给定的持续时间

❷ 负责整理这些事件

我们计算一个t变量,它是当前时间减去提供的持续时间。然后,因为事件是按时间排序的,所以一旦到达时间在t之后的事件,我们就更新内部的events片。

我们如何测试这种方法?我们可以依靠当前时间使用time.Now来创建事件:

func TestCache_TrimOlderThan(t *testing.T) {
    events := []Event{                                        // ❶
        {Timestamp: time.Now().Add(-20 * time.Millisecond)},
        {Timestamp: time.Now().Add(-10 * time.Millisecond)},
        {Timestamp: time.Now().Add(10 * time.Millisecond)},
    }
    cache := &Cache{}
    cache.Add(events)                                         // ❷
    cache.TrimOlderThan(15 * time.Millisecond)                // ❸
    got := cache.GetAll()                                     // ❹
    expected := 2
    if len(got) != expected {
        t.Fatalf("expected %d, got %d", expected, len(got))
    }
}

❶ 利用time.Now()创建事件。

❷ 将这些事件添加到缓存中

❸ 整理了 15 毫秒前的事件

❹ 检索所有事件

我们使用time.Now()将一部分事件添加到缓存中,并增加或减少一些小的持续时间。然后,我们将这些事件调整 15 毫秒,并执行断言。

这种方法有一个主要缺点:如果执行测试的机器突然很忙,我们可能会修剪比预期更少的事件。我们也许能够增加提供的持续时间,以减少测试失败的机会,但这样做并不总是可能的。例如,如果时间戳字段是在添加事件时生成的未导出字段,该怎么办?在这种情况下,不可能传递特定的时间戳,最终可能会在单元测试中添加睡眠。

问题和TrimOlderThan的实现有关。因为它调用了time.Now(),所以实现健壮的单元测试更加困难。让我们讨论两种使我们的测试不那么脆弱的方法。

第一种方法是使检索当前时间的方法成为对Cache结构的依赖。在生产中,我们会注入真正的实现,而在单元测试中,我们会传递一个存根。

有多种技术可以处理这种依赖性,比如接口或函数类型。在我们的例子中,因为我们只依赖一个方法(time.Now()),我们可以定义一个函数类型:

type now func() time.Time

type Cache struct {
    mu     sync.RWMutex
    events []Event
    now    now
}

now类型是一个返回time.Time的函数。在工厂函数中,我们可以这样传递实际的time.Now函数:

func NewCache() *Cache {
    return &Cache{
        events: make([]Event, 0),
        now:    time.Now,
    }
}

因为now依赖项仍未导出,所以外部客户端无法访问它。此外,在我们的单元测试中,我们可以通过基于预定义的时间注入func() time.Time实现来创建一个Cache结构:

func TestCache_TrimOlderThan(t *testing.T) {
    events := []Event{                                         // ❶
        {Timestamp: parseTime(t, "2020-01-01T12:00:00.04Z")},
        {Timestamp: parseTime(t, "2020-01-01T12:00:00.05Z")},
        {Timestamp: parseTime(t, "2020-01-01T12:00:00.06Z")},
    }
    cache := &Cache{now: func() time.Time {                    // ❷
        return parseTime(t, "2020-01-01T12:00:00.06Z")
    }}
    cache.Add(events)
    cache.TrimOlderThan(15 * time.Millisecond)
    // ...
}

func parseTime(t *testing.T, timestamp string) time.Time {
    // ...
}

❶ 基于特定的时间戳创建事件

❷ 注入一个静态函数来固定时间

在创建新的Cache结构时,我们根据给定的时间注入now依赖。由于这种方法,测试是健壮的。即使在最坏的情况下,这个测试的结果也是确定的。

使用全局变量

我们可以通过一个全局变量来检索时间,而不是使用字段:

var now = time.Now      // ❶

❶ 定义了全局变量now

一般来说,我们应该尽量避免这种易变的共享状态。在我们的例子中,这将导致至少一个具体的问题:测试将不再是孤立的,因为它们都依赖于一个共享的变量。因此,举例来说,测试不能并行运行。如果可能的话,我们应该将这些情况作为结构依赖的一部分来处理,促进测试隔离。

这个解决方案也是可扩展的。比如函数调用time.After怎么办?我们可以添加另一个after依赖项,或者创建一个将两个方法NowAfter组合在一起的接口。然而,这种方法有一个主要的缺点:例如,如果我们从一个外部包中创建一个单元测试,那么now依赖就不可用(我们在错误 90“没有探索所有的 Go 测试特性”中探讨了这一点)。

在这种情况下,我们可以使用另一种技术。我们可以要求客户端提供当前时间,而不是将时间作为未报告的依赖项来处理:

func (c *Cache) TrimOlderThan(now time.Time, since time.Duration) {
    // ...
}

为了更进一步,我们可以将两个函数参数合并到一个单独的time.Time中,该参数代表一个特定的时间点,直到我们想要调整事件:

func (c *Cache) TrimOlderThan(t time.Time) {
    // ...
}

由调用者来计算这个时间点:

cache.TrimOlderThan(time.Now().Add(time.Second))

而在测试中,我们也必须通过相应的时间:

func TestCache_TrimOlderThan(t *testing.T) {
    // ...
    cache.TrimOlderThan(parseTime(t, "2020-01-01T12:00:00.06Z").
        Add(-15 * time.Millisecond))
    // ...
}

这种方法是最简单的,因为它不需要创建另一种类型和存根。

一般来说,我们应该谨慎测试使用time API 的代码。这可能是一扇为古怪的测试敞开的大门。在本节中,我们看到了两种处理方法。我们可以将time交互作为依赖的一部分,通过使用我们自己的实现或依赖外部库,我们可以在单元测试中伪造这种依赖;或者我们可以修改我们的 API,要求客户提供我们需要的信息,比如当前时间(这种技术更简单,但是更有限)。

现在让我们讨论两个与测试相关的有用的 Go 包:httptestiotest

11.7 #88:不使用测试实用工具包

标准库提供了用于测试的实用工具包。一个常见的错误是没有意识到这些包,并试图重新发明轮子或依赖其他不方便的解决方案。本节研究其中的两个包:一个在使用 HTTP 时帮助我们,另一个在进行 I/O 和使用读取器和写入器时使用。

11.7.1 httptest

httptest包(pkg.go.dev/net/http/httptest)为客户端和服务器端的 HTTP 测试提供了工具。让我们看看这两个用例。

首先,让我们看看httptest如何在编写 HTTP 服务器时帮助我们。我们将实现一个处理器,它执行一些基本的操作:编写标题和正文,并返回一个特定的状态代码。为了清楚起见,我们将省略错误处理:

func Handler(w http.ResponseWriter, r *http.Request) {
    w.Header().Add("X-API-VERSION", "1.0")
    b, _ := io.ReadAll(r.Body)
    _, _ = w.Write(append([]byte("hello "), b...))     // ❶
    w.WriteHeader(http.StatusCreated)
}

❶ 将hello与请求正文连接起来

HTTP 处理器接受两个参数:请求和编写响应的方式。httptest包为两者提供了实用工具。对于请求,我们可以使用 HTTP 方法、URL 和正文使用httptest.NewRequest构建一个*http.Request。对于响应,我们可以使用httptest.NewRecorder来记录处理器中的变化。让我们编写这个处理器的单元测试:

func TestHandler(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "http://localhost",     // ❶
        strings.NewReader("foo"))
    w := httptest.NewRecorder()                                        // ❷
    Handler(w, req)                                                    // ❸

    if got := w.Result().Header.Get("X-API-VERSION"); got != "1.0" {   // ❹
        t.Errorf("api version: expected 1.0, got %s", got)
    }

    body, _ := ioutil.ReadAll(wordy)                                   // ❺
    if got := string(body); got != "hello foo" {
        t.Errorf("body: expected hello foo, got %s", got)
    }

    if http.StatusOK != w.Result().StatusCode {                        // ❻
        t.FailNow()
    }
}

❶ 构建请求

❷ 创建了响应记录器

❸ 调用Handler

❹ 验证 HTTP 报头

❺ 验证 HTTP 正文

❻ 验证 HTTP 状态代码

使用httptest测试处理器并不测试传输(HTTP 部分)。测试的重点是用请求和记录响应的方法直接调用处理器。然后,使用响应记录器,我们编写断言来验证 HTTP 头、正文和状态代码。

让我们看看硬币的另一面:测试 HTTP 客户端。我们将编写一个负责查询 HTTP 端点的客户机,该端点计算从一个坐标开车到另一个坐标需要多长时间。客户端看起来像这样:

func (c DurationClient) GetDuration(url string,
    lat1, lng1, lat2, lng2 float64) (
    time.Duration, error) {
    resp, err := c.client.Post(
        url, "application/json",
        buildRequestBody(lat1, lng1, lat2, lng2),
    )
    if err != nil {
        return 0, err
    }

    return parseResponseBody(resp.Body)
}

这段代码对提供的 URL 执行 HTTP POST 请求,并返回解析后的响应(比如说,一些 JSON)。

如果我们想测试这个客户呢?一种选择是使用 Docker 并启动一个模拟服务器来返回一些预先注册的响应。然而,这种方法使得测试执行缓慢。另一个选择是使用httptest.NewServer来创建一个基于我们将提供的处理器的本地 HTTP 服务器。一旦服务器启动并运行,我们可以将它的 URL 传递给GetDuration:

func TestDurationClientGet(t *testing.T) {
    srv := httptest.NewServer(                                             // ❶
        http.HandlerFunc(
            func(w http.ResponseWriter, r *http.Request) {
                _, _ = w.Write([]byte(`{"duration": 314}`))                // ❷
            },
        ),
    )
    defer srv.Close()                                                      // ❸

    client := NewDurationClient()
    duration, err :=
        client.GetDuration(srv.URL, 51.551261, -0.1221146, 51.57, -0.13)   // ❹
    if err != nil {
        t.Fatal(err)
    }

    if duration != 314*time.Second {                                       // ❺
        t.Errorf("expected 314 seconds, got %v", duration)
    }
}

❶ 启动 HTTP 服务器

❷ 注册处理器来服务响应

❸ 关闭了服务器

❹ 提供了服务器 URL

❺ 验证了响应

在这个测试中,我们创建了一个带有返回314秒的静态处理器的服务器。我们还可以根据发送的请求做出断言。此外,当我们调用GetDuration时,我们提供启动的服务器的 URL。与测试处理器相比,这个测试执行一个实际的 HTTP 调用,但是它的执行只需要几毫秒。

我们还可以使用 TLS 和httptest.NewTLSServer启动一个新的服务器,并使用httptest.NewUnstartedServer创建一个未启动的服务器,这样我们就可以延迟启动它。

让我们记住在 HTTP 应用的上下文中工作时httptest是多么有用。无论我们是编写服务器还是客户端,httptest都可以帮助我们创建高效的测试。

11.7.2 iotest

iotest包(pkg.go.dev/testing/iotest)实现了测试读者和作者的实用工具。这是一个很方便的包,但 Go 开发者经常会忘记。

当实现一个自定义的io.Reader时,我们应该记得使用iotest.TestReader来测试它。这个实用函数测试读取器的行为是否正确:它准确地返回读取的字节数,填充提供的片,等等。如果提供的阅读器实现了像io.ReaderAt这样的接口,它还会测试不同的行为。

假设我们有一个自定义的LowerCaseReader,它从给定的输入io.Reader中流出小写字母。下面是如何测试这个读者没有行为不端:

func TestLowerCaseReader(t *testing.T) {
    err := iotest.TestReader(
        &LowerCaseReader{reader: strings.NewReader("aBcDeFgHiJ")},   // ❶
        []byte("acegi"),                                             // ❷
    )
    if err != nil {
        t.Fatal(err)
    }
}

❶ 提供了一个io.Reader

❷ 期望

我们通过提供自定义的LowerCaseReader和一个期望来调用iotest.TestReader:小写字母acegi

iotest包的另一个用例是,以确保使用读取器和写入器的应用能够容忍错误:

  • iotest.ErrReader创建一个io.Reader返回一个提供的错误。

  • iotest.HalfReader创建一个io.Reader,它只读取从io.Reader请求的一半字节。

  • iotest.OneByteReader创建一个io.Reader,用于从io.Reader中读取每个非空字节。

  • iotest.TimeoutReader创建一个io.Reader,在第二次读取时返回一个没有数据的错误。后续调用将会成功。

  • iotest.TruncateWriter创建一个io.Writer写入一个io.Writer,但在n字节后静默停止。

例如,假设我们实现了以下函数,该函数从读取器读取所有字节开始:

func foo(r io.Reader) error {
    b, err := io.ReadAll(r)
    if err != nil {
        return err
    }

    // ...
}

我们希望确保我们的函数具有弹性,例如,如果提供的读取器在读取期间失败(例如模拟网络错误):

func TestFoo(t *testing.T) {
    err := foo(iotest.TimeoutReader(            // ❶
        strings.NewReader(randomString(1024)),
    ))
    if err != nil {
        t.Fatal(err)
    }
}

❶ 使用iotest.TimeoutReader包装提供的io.Reader

我们用io.TimeoutReader包装一个io.Reader。正如我们提到的,二读会失败。如果我们运行这个测试来确保我们的函数能够容忍错误,我们会得到一个测试失败。实际上,io.ReadAll会返回它发现的任何错误。

知道了这一点,我们就可以实现我们的自定义readAll函数,它可以容忍多达n个错误:

func readAll(r io.Reader, retries int) ([]byte, error) {
    b := make([]byte, 0, 512)
    for {
        if len(b) == cap(b) {
            b = append(b, 0)[:len(b)]
        }
        n, err := r.Read(b[len(b):cap(b)])
        b = b[:len(b)+n]
        if err != nil {
            if err == io.EOF {
                return b, nil
            }
            retries--
            if retries < 0 {     // ❶
                return b, err
            }
        }
    }
}

❶ 容忍重试

这个实现类似于io.ReadAll,但是它也处理可配置的重试。如果我们改变初始函数的实现,使用自定义的readAll而不是io.ReadAll,测试将不再失败:

func foo(r io.Reader) error {
    b, err := readAll(r, 3)       // ❶
    if err != nil {
        return err
    }

    // ...
}

❶ 表示最多可重试三次

我们已经看到了一个例子,在从io.Reader中读取数据时,如何检查一个函数是否能够容忍错误。我们依靠的iotest包进行了测试。

当使用io.Readerio.Writer进行 I/O 和工作时,让我们记住iotest包有多方便。正如我们所看到的,它提供了测试自定义io.Reader行为的实用工具,并针对读写数据时出现的错误测试我们的应用。

下一节讨论一些可能导致编写不准确基准的常见陷阱。

11.8 #89:编写不准确的基准

一般来说,我们永远不要去猜测性能。当编写优化时,许多因素可能会发挥作用,即使我们对结果有强烈的意见,测试它们也不是一个坏主意。然而,编写基准并不简单。编写不准确的基准并基于它们做出错误的假设可能非常简单。本节的目标是检查导致不准确的常见和具体的陷阱。

在讨论这些陷阱之前,让我们简单回顾一下基准在 Go 中是如何工作的。基准的框架如下:

func BenchmarkFoo(b *testing.B) {
    for i := 0; i < b.N; i++ {
        foo()
    }
}

函数名以前缀Benchmark开头。被测函数(foo)在循环for中被调用。b.N代表可变的迭代次数。当运行一个基准时,Go 试图使它与请求的基准时间相匹配。基准时间默认设置为 1 秒,可通过-benchtime标志进行更改。b.N从 1 开始;如果基准在 1 秒内完成,b.N增加,基准再次运行,直到b.Nbenchtime大致匹配:

$ go test -bench=.
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkFoo-4                73          16511228 ns/op

在这里,基准测试花费了大约 1 秒钟,foo被执行了 73 次,平均执行时间为 16511228 纳秒。我们可以使用-benchtime改变基准时间:

$ go test -bench=. -benchtime=2s
BenchmarkFoo-4               150          15832169 ns/op

foo被执行死刑的人数大约是前一次基准期间的两倍。

接下来,我们来看看一些常见的陷阱。

11.8.1 不重置或暂停计时器

在某些情况下,我们需要在基准循环之前执行操作。这些操作可能需要相当长的时间(例如,生成大量数据),并且可能会显著影响基准测试结果:

func BenchmarkFoo(b *testing.B) {
    expensiveSetup()
    for i := 0; i < b.N; i++ {
        functionUnderTest()
    }
}

在这种情况下,我们可以在进入循环之前使用ResetTimer方法:

func BenchmarkFoo(b *testing.B) {
    expensiveSetup()
    b.ResetTimer()                // ❶
    for i := 0; i < b.N; i++ {
        functionUnderTest()
    }
}

❶ 重置基准计时器

调用ResetTimer将测试开始以来运行的基准时间和内存分配计数器清零。这样,可以从测试结果中丢弃昂贵的设置。

如果我们必须不止一次而是在每次循环迭代中执行昂贵的设置,那该怎么办?

func BenchmarkFoo(b *testing.B) {
    for i := 0; i < b.N; i++ {
        expensiveSetup()
        functionUnderTest()
    }
}

我们不能重置计时器,因为这将在每次循环迭代中执行。但是我们可以停止并恢复基准计时器,围绕对expensiveSetup的调用:

func BenchmarkFoo(b *testing.B) {
    for i := 0; i < b.N; i++ {
        b.StopTimer()                // ❶
        expensiveSetup()
        b.StartTimer()               // ❷
        functionUnderTest()
    }
}

❶ 暂停基准计时器

❷ 恢复基准计时器

这里,我们暂停基准计时器来执行昂贵的设置,然后恢复计时器。

注意,这种方法有一个问题需要记住:如果被测函数与设置函数相比执行速度太快,基准测试可能需要太长时间才能完成。原因是到达benchtime需要比 1 秒长得多的时间。基准时间的计算完全基于functionUnderTest的执行时间。因此,如果我们在每次循环迭代中等待很长时间,基准测试将会比 1 秒慢得多。如果我们想保持基准,一个可能的缓解措施是减少benchtime

我们必须确保使用计时器方法来保持基准的准确性。

11.8.2 对微观基准做出错误的假设

微基准测试测量一个微小的计算单元,并且很容易对它做出错误的假设。比方说,我们不确定是使用atomic.StoreInt32还是atomic.StoreInt64(假设我们处理的值总是适合 32 位)。我们希望编写一个基准来比较这两种函数:

func BenchmarkAtomicStoreInt32(b *testing.B) {
    var v int32
    for i := 0; i < b.N; i++ {
        atomic.StoreInt32(&v, 1)
    }
}

func BenchmarkAtomicStoreInt64(b *testing.B) {
    var v int64
    for i := 0; i < b.N; i++ {
        atomic.StoreInt64(&v, 1)
    }
}

如果我们运行该基准测试,下面是一些示例输出:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAtomicStoreInt32
BenchmarkAtomicStoreInt32-4       197107742             5.682 ns/op
BenchmarkAtomicStoreInt64
BenchmarkAtomicStoreInt64-4       213917528             5.134 ns/op

我们很容易认为这个基准是理所当然的,并决定使用atomic.StoreInt64,因为它似乎更快。现在,为了做一个公平的基准测试,我们颠倒一下顺序,先测试atomic.StoreInt64,再测试atomic.StoreInt32。以下是一些输出示例:

BenchmarkAtomicStoreInt64
BenchmarkAtomicStoreInt64-4       224900722             5.434 ns/op
BenchmarkAtomicStoreInt32
BenchmarkAtomicStoreInt32-4       230253900             5.159 ns/op

这一次,atomic.StoreInt32效果更好。发生了什么事?

在微基准的情况下,许多因素都会影响结果,例如运行基准时的机器活动、电源管理、散热以及指令序列的更好的高速缓存对齐。我们必须记住,许多因素,即使在我们的 Go 项目范围之外,也会影响结果。

注意,我们应该确保执行基准测试的机器是空闲的。但是,外部流程可能在后台运行,这可能会影响基准测试结果。出于这个原因,像perflock这样的工具可以限制基准测试消耗多少 CPU。例如,我们可以用总可用 CPU 的 70%来运行基准测试,将 30%分配给操作系统和其他进程,并减少机器活动因素对结果的影响。

一种选择是使用-benchtime选项增加基准时间。类似于概率论中的大数定律,如果我们运行基准测试很多次,它应该倾向于接近它的期望值(假设我们忽略了指令缓存和类似机制的好处)。

另一种选择是在经典的基准工具之上使用外部工具。例如,benchstat工具,是golang.org/x库的的一部分,它允许我们计算和比较关于基准执行的统计数据。

让我们使用和-count选项运行基准测试 10 次,并将输出传输到一个特定的文件:

$ go test -bench=. -count=10 | tee stats.txt
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAtomicStoreInt32-4     234935682                5.124 ns/op
BenchmarkAtomicStoreInt32-4     235307204                5.112 ns/op
// ...
BenchmarkAtomicStoreInt64-4     235548591                5.107 ns/op
BenchmarkAtomicStoreInt64-4     235210292                5.090 ns/op
// ...

然后我们可以对这个文件运行benchstat:

$ benchstat stats.txt
name                time/op
AtomicStoreInt32-4  5.10ns ± 1%
AtomicStoreInt64-4  5.10ns ± 1%

结果是一样的:两个函数平均需要 5.10 纳秒来完成。我们还可以看到给定基准的执行之间的百分比变化:1%。这个指标告诉我们,两个基准都是稳定的,让我们对计算出的平均结果更有信心。因此,对于我们测试的使用情况(在特定机器上的特定 Go 版本中),我们可以得出其执行时间与atomic .StoreInt64相似的结论,而不是得出atomic.StoreInt32更快或更慢的结论。

总的来说,我们应该对微基准保持谨慎。许多因素会显著影响结果,并可能导致错误的假设。增加基准测试时间或使用benchstat等工具重复执行基准测试并计算统计数据,可以有效地限制外部因素并获得更准确的结果,从而得出更好的结论。

我们还要强调的是,如果另一个系统最终运行了该应用,那么在使用在给定机器上执行的微基准测试的结果时,我们应该小心。生产系统的行为可能与我们运行微基准测试的系统大相径庭。

11.8.3 不注意编译器优化

另一个与编写基准相关的常见错误是被编译器优化所愚弄,这也可能导致错误的基准假设。在这一节中,我们来看看 Go issue 14813 ( github.com/golang/go/issues/14813 ,也是 Go 项目成员戴夫·切尼讨论过的)的人口计数函数(计算设置为1的位数的函数):

const m1 = 0x5555555555555555
const m2 = 0x3333333333333333
const m4 = 0x0f0f0f0f0f0f0f0f
const h01 = 0x0101010101010101

func popcnt(x uint64) uint64 {
    x -= (x >> 1) & m1
    x = (x & m2) + ((x >> 2) & m2)
    x = (x + (x >> 4)) & m4
    return (x * h01) >> 56
}

这个函数接受并返回一个uint64。为了对这个函数进行基准测试,我们可以编写以下代码:

func BenchmarkPopcnt1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        popcnt(uint64(i))
    }
}

然而,如果我们执行这个基准测试,我们得到的结果低得惊人:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkPopcnt1-4      1000000000               0.2858 ns/op

0.28 纳秒的持续时间大约是一个时钟周期,所以这个数字低得不合理。问题是开发人员对编译器优化不够仔细。在这种情况下,测试中的函数足够简单,可以作为内联的候选函数:这是一种用被调用函数的正文替换函数调用的优化,让我们可以避免函数调用,它占用的内存很小。一旦函数被内联,编译器会注意到该调用没有副作用,并将其替换为以下基准:

func BenchmarkPopcnt1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        // Empty
    }
}

基准现在是空的——这就是为什么我们得到了接近一个时钟周期的结果。为了防止这种情况发生,最佳实践是遵循以下模式:

  1. 在每次循环迭代中,将结果赋给一个局部变量(基准函数上下文中的局部变量)。

  2. 将最新结果赋给一个全局变量。

在我们的例子中,我们编写了以下基准:

var global uint64                         // ❶

func BenchmarkPopcnt2(b *testing.B) {
    var v uint64                          // ❷
    for i := 0; i < b.N; i++ {
        v = popcnt(uint64(i))             // ❸
    }
    global = v                            // ❹
}

❶ 定义了一个全局变量

❷ 定义了一个局部变量

❸ 将结果赋给局部变量

❹ 将结果赋给全局变量

global是全局变量,而v是局部变量,其作用域是基准函数。在每次循环迭代中,我们将popcnt的结果赋给局部变量。然后我们将最新的结果赋给全局变量。

注意为什么不把popcnt调用的结果直接分配给global来简化测试呢?写入一个全局变量比写入一个局部变量要慢(我们在错误#95“不理解栈和堆”中讨论了这些概念)。因此,我们应该将每个结果写入一个局部变量,以限制每次循环迭代期间的内存占用。

如果我们运行这两个基准测试,我们现在会得到显著不同的结果:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkPopcnt1-4      1000000000               0.2858 ns/op
BenchmarkPopcnt2-4      606402058                1.993 ns/op

BenchmarkPopcnt2是基准的准确版本。它保证我们避免了内联优化,内联优化会人为地降低执行时间,甚至会删除对被测函数的调用。依赖BenchmarkPopcnt1的结果可能会导致错误的假设。

让我们记住避免编译器优化愚弄基准测试结果的模式:将被测函数的结果赋给一个局部变量,然后将最新的结果赋给一个全局变量。这种最佳实践还可以防止我们做出不正确的假设。

11.8.4 被观察者效应所迷惑

在物理学中,观察者效应是观察行为对被观察系统的扰动。这种影响也可以在基准测试中看到,并可能导致对结果的错误假设。让我们看一个具体的例子,然后尝试减轻它。

我们想要实现一个函数来接收一个由int64元素组成的矩阵。这个矩阵有固定的 512 列,我们想计算前八列的总和,如图 11.2 所示。

图 11.2 计算前八列的总和

为了优化,我们还想确定改变列数是否有影响,所以我们还实现了第二个函数,有 513 列。实现如下:

func calculateSum512(s [][512]int64) int64 {
    var sum int64
    for i := 0; i < len(s); i++ {     // ❶
        for j := 0; j < 8; j++ {      // ❷
            sum += s[i][j]            // ❸
        }
    }
    return sum
}

func calculateSum513(s [][513]int64) int64 {
    // Same implementation as calculateSum512
}

❶ 遍历每一行

❷ 遍历前八列

❸ 增加sum

我们遍历每一行,然后遍历前八列,并增加一个返回的sum变量。calculateSum513中的实现保持不变。

我们希望对这些函数进行基准测试,以确定在给定固定行数的情况下哪一个函数的性能最高:

const rows = 1000

var res int64

func BenchmarkCalculateSum512(b *testing.B) {
    var sum int64
    s := createMatrix512(rows)       // ❶
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        sum = calculateSum512(s)     // ❷
    }
    res = sum
}

func BenchmarkCalculateSum513(b *testing.B) {
    var sum int64
    s := createMatrix513(rows)       // ❸
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        sum = calculateSum513(s)     // ❹
    }
    res = sum
}

❶ 创建了一个 512 列的矩阵

❷ 计算总数

❸ 创建了一个 513 列的矩阵

❹ 计算总数

我们希望只创建一次矩阵,以限制结果的影响。因此,我们在循环外调用createMatrix512createMatrix513。我们可能期望结果是相似的,因为我们只希望迭代前八列,但实际情况并非如此(在我的机器上):

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkCalculateSum512-4        81854             15073 ns/op
BenchmarkCalculateSum513-4       161479              7358 ns/op

具有 513 列的第二个基准测试快了大约 50%。同样,因为我们只迭代了前八列,所以这个结果相当令人惊讶。

为了理解这种差异,我们需要理解 CPU 缓存的基础知识。简而言之,CPU 由不同的缓存组成(通常是 L1、L2 和 L3)。这些高速缓存降低了从主存储器访问数据的平均成本。在某些情况下,CPU 可以从主存储器中取出数据,并将其复制到 L1。在这种情况下,CPU 试图将calculateSum感兴趣的矩阵子集(每行的前八列)读入 L1。但是,在一种情况下(513 列),矩阵适合内存,而在另一种情况下(512 列),则不适合。

注意解释原因不在本章的范围内,但是我们在错误#91“不理解 CPU 缓存”中来看这个问题

回到基准测试,主要问题是我们在两种情况下都重复使用相同的矩阵。因为函数重复了成千上万次,所以当它接收一个普通的新矩阵时,我们不测量函数的执行。相反,我们测量一个函数,该函数获取一个矩阵,该矩阵已经包含缓存中存在的单元的子集。因此,因为calculateSum513导致缓存未命中更少,所以它有更好的执行时间。

这是观察者效应的一个例子。因为我们一直在观察一个被反复调用的 CPU 绑定函数,所以 CPU 缓存可能会发挥作用并显著影响结果。在这个例子中,为了防止这种影响,我们应该在每个测试期间创建一个矩阵,而不是重用一个:

func BenchmarkCalculateSum512(b *testing.B) {
    var sum int64
    for i := 0; i < b.N; i++ {
        b.StopTimer()
        s := createMatrix512(rows)     // ❶
        b.StartTimer()
        sum = calculateSum512(s)
    }
    res = sum
}

❶ 在每次循环迭代中都会创建一个新矩阵

现在,在每次循环迭代中都会创建一个新矩阵。如果我们再次运行基准测试(并调整benchtime——否则执行时间太长),结果会更接近:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkCalculateSum512-4         1116             33547 ns/op
BenchmarkCalculateSum513-4          998             35507 ns/op

我们没有做出calculateSum513更快的错误假设,而是看到两个基准测试在接收新矩阵时会产生相似的结果。

正如我们在本节中看到的,因为我们重用了相同的矩阵,CPU 缓存显著影响了结果。为了防止这种情况,我们必须在每次循环迭代中创建一个新的矩阵。一般来说,我们应该记住,观察测试中的函数可能会导致结果的显著差异,特别是在低级别优化很重要的 CPU 绑定函数的微基准环境中。强制基准在每次迭代期间重新创建数据是防止这种影响的好方法。

在本章的最后一节,让我们看看一些关于GO测试的常见技巧。

11.9 #90:没有探索所有的 Go 测试功能

在编写测试时,开发人员应该了解 Go 的特定测试特性和选项。否则,测试过程可能不太准确,甚至效率更低。这一节讨论的主题可以让我们在编写 Go 测试时更加舒适。

11.9.1 代码覆盖率

在开发过程中,直观地看到测试覆盖了代码的哪些部分是很方便的。我们可以使用的-coverprofile标志来访问这些信息:

$ go test -coverprofile=coverage.out ./...

这个命令创建一个coverage.out文件,然后我们可以使用go tool cover打开它:

$ go tool cover -html=coverage.out

该命令打开 web 浏览器并显示每行代码的覆盖率。

默认情况下,只对当前被测试的包进行代码覆盖率分析。例如,假设我们有以下结构:

/myapp
  |_ foo
    |_ foo.go
    |_ foo_test.go
  |_ bar
    |_ bar.go
    |_ bar_test.go

如果foo.go的某个部分只在bar_test.go中测试,默认情况下,它不会显示在覆盖率报告中。要包含它,我们必须在myapp文件夹中,并且使用-coverpkg标志:

go test -coverpkg=./... -coverprofile=coverage.out ./...

我们需要记住这个特性来查看当前的代码覆盖率,并决定哪些部分值得更多的测试。

注意在跟踪代码覆盖率时要保持谨慎。拥有 100%的测试覆盖率并不意味着一个没有 bug 的应用。正确地推理我们的测试覆盖的内容比任何静态的阈值更重要。

11.9.2 不同包的测试

当编写单元测试时,一种方法是关注行为而不是内部。假设我们向客户端公开一个 API。我们可能希望我们的测试关注于从外部可见的东西,而不是实现细节。这样,如果实现发生变化(例如,如果我们将一个函数重构为两个),测试将保持不变。它们也更容易理解,因为它们展示了我们的 API 是如何使用的。如果我们想强制执行这种做法,我们可以使用不同的包。

在 Go 中,一个文件夹中的所有文件应该属于同一个包,只有一个例外:一个测试文件可以属于一个_test包。例如,假设下面的counter.go源文件属于counter包:

package counter

import "sync/atomic"

var count uint64

func Inc() uint64 {
    atomic.AddUint64(&count, 1)
    return count
}

测试文件可以存在于同一个包中,并访问内部文件,比如count变量。或者它可以存在于一个counter_test包中,比如这个counter_test.go文件:

package counter_test

import (
    "testing"

    "myapp/counter"
)

func TestCount(t *testing.T) {
    if counter.Inc() != 1 {
        t.Errorf("expected 1")
    }
}

在这种情况下,测试是在一个外部包中实现的,不能访问内部包,比如count变量。使用这种实践,我们可以保证测试不会使用任何未导出的元素;因此,它将着重于测试公开的行为。

11.9.3 实用函数

在编写测试时,我们可以用不同于生产代码的方式处理错误。例如,假设我们想要测试一个函数,它将一个Customer结构作为参数。因为Customer的创建将被重用,为了测试,我们决定创建一个特定的createCustomer函数。该函数将返回一个可能的错误,并附带一个Customer:

func TestCustomer(t *testing.T) {
    customer, err := createCustomer("foo")     // ❶
    if err != nil {
        t.Fatal(err)
    }
    // ...
}

func createCustomer(someArg string) (Customer, error) {
    // Create customer
    if err != nil {
        return Customer{}, err
    }
    return customer, nil
}

❶ 创建一个Customer并检查错误

我们使用createCustomer实用函数创建一个客户,然后我们执行剩下的测试。然而,在测试函数的上下文中,我们可以通过将*testing.T变量传递给实用函数来简化错误管理:

func TestCustomer(t *testing.T) {
    customer := createCustomer(t, "foo")     // ❶
    // ...
}

func createCustomer(t *testing.T, someArg string) Customer {
    // Create customer
    if err != nil {
        t.Fatal(err)                         // ❷
    }
    return customer
}

❶ 调用效用函数并提供t

❷ 如果我们不能创建一个客户,就直接失败了

如果不能创建一个Customer,那么createCustomer会直接测试失败,而不是返回一个错误。这使得TestCustomer写起来更小,读起来更容易。

让我们记住这个关于错误管理和测试的实践来改进我们的测试。

11.9.4 安装和拆卸

在某些情况下,我们可能需要准备一个测试环境。例如,在集成测试中,我们启动一个特定的 Docker 容器,然后停止它。我们可以为每个测试或每个包调用安装和拆卸函数。幸运的是,在GO中,两者都有可能。

为了每次测试都这样做,我们可以使用defer调用安装函数和拆卸函数作为预操作:

func TestMySQLIntegration(t *testing.T) {
    setupMySQL()
    defer teardownMySQL()
    // ...
}

也可以注册一个在测试结束时执行的函数。例如,让我们假设TestMySQLIntegration需要调用createConnection来创建数据库连接。如果我们希望这个函数也包含拆卸部分,我们可以使用t.Cleanup来注册一个清理函数:

func TestMySQLIntegration(t *testing.T) {
    // ...
    db := createConnection(t, "tcp(localhost:3306)/db")
    // ...
}

func createConnection(t *testing.T, dsn string) *sql.DB {
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        t.FailNow()
    }
    t.Cleanup(          // ❶
        func() {
            _ = db.Close()
        })
    return db
}

❶ 注册了一个要在测试结束时执行的函数

测试结束时,执行提供给t.Cleanup的关闭。这使得未来的单元测试更容易编写,因为它们不会负责关闭db变量。

注意,我们可以注册多个清理函数。在这种情况下,它们将被执行,就像我们使用defer一样:后进先出。

为了处理每个包的安装和拆卸,我们必须使用TestMain函数。下面是TestMain的一个简单实现:

func TestMain(m *testing.M) {
    os.Exit(m.Run())
}

这个特定的函数接受一个*testing.M参数,该参数公开了一个运行所有测试的Run方法。因此,我们可以用安装和拆卸数围绕这个调用:

func TestMain(m *testing.M) {
    setupMySQL()                 // ❶
    code := m.Run()              // ❷
    teardownMySQL()              // ❸
    os.Exit(code)
}

❶ 安装 MySQL

❷ 负责测试

❸ 拆卸 MySQL

这段代码在所有测试之前启动 MySQL 一次,然后将其关闭。

使用这些实践来添加安装和拆卸函数,我们可以为我们的测试配置一个复杂的环境。

总结

  • 使用构建标志、环境变量或者短模式对测试进行分类使得测试过程更加有效。您可以使用构建标志或环境变量来创建测试类别(例如,单元测试与集成测试),并区分短期和长期运行的测试,以决定执行哪种测试。

  • 在编写并发应用时,强烈建议启用-race标志。这样做可以让您捕捉到可能导致软件错误的潜在数据竞争。

  • 使用-parallel标志是加速测试的有效方法,尤其是长时间运行的测试。

  • 使用-shuffle标志来帮助确保测试套件不依赖于可能隐藏 bug 的错误假设。

  • 表驱动测试是一种有效的方法,可以将一组相似的测试分组,以防止代码重复,并使未来的更新更容易处理。

  • 使用同步来避免睡眠,以使测试不那么不稳定,更健壮。如果同步是不可能的,考虑重试的方法。

  • 理解如何使用时间 API 处理函数是使测试不那么容易出错的另一种方法。您可以使用标准技术,比如将时间作为隐藏依赖项的一部分来处理,或者要求客户端提供时间。

  • httptest包有助于处理 HTTP 应用。它提供了一组测试客户机和服务器的实用工具。

  • iotest包帮助编写io.Reader并测试应用是否能够容忍错误。

  • 关于基准:

    • 使用时间方法保持基准的准确性。
    • 增加benchtime或使用benchstat等工具在处理微基准时会有所帮助。
    • 如果最终运行应用的系统与运行微基准测试的系统不同,请小心微基准测试的结果。
    • 确保被测函数会导致副作用,防止编译器优化在基准测试结果上欺骗你。
    • 为了防止观察者效应,强制基准重新创建 CPU 绑定函数使用的数据。
  • 使用带有-coverprofile标志的代码覆盖率来快速查看哪部分代码需要更多的关注。

  • 将单元测试放在一个不同的包中,以强制编写关注于公开行为而不是内部的测试。

  • 使用*testing.T变量而不是经典的if err != nil来处理错误使得代码更短,更容易阅读。

  • 你可以使用安装和拆卸函数来配置一个复杂的环境,比如在集成测试的情况下。

十二、优化

本章涵盖

  • 研究机械同情心的概念
  • 了解堆与栈并减少分配
  • 使用标准 Go 诊断工具
  • 了解垃圾收集器的工作原理
  • 跑GO里面的 Docker 和 Kubernetes

在我们开始这一章之前,一个免责声明:在大多数情况下,编写可读、清晰的代码比编写优化但更复杂、更难理解的代码要好。优化通常是有代价的,我们建议您遵循软件工程师 Wes Dyer 的这句名言:

使其正确,使其清晰,使其简洁,使其快速,按此顺序。

这并不意味着禁止优化应用的速度和效率。例如,我们可以尝试识别需要优化的代码路径,因为有必要这样做,比如让我们的客户满意或者降低我们的成本。在本章中,我们将讨论常见的优化技术;有些是特定要去的,有些不是。我们还讨论了识别瓶颈的方法,这样我们就不会盲目工作。

12.1 #91:不了解 CPU 缓存

当赛车手不一定要当工程师,但一定要有机械同情心。

——三届 F1 世界冠军杰基·斯图瓦特创建的一个术语

简而言之,当我们了解一个系统是如何被设计使用的,无论是 F1 赛车、飞机还是计算机,我们都可以与设计保持一致,以获得最佳性能。在本节中,我们将讨论一些具体的例子,在这些例子中,对 CPU 缓存如何工作的机械同情可以帮助我们优化 Go 应用。

12.1.1 CPU 架构

首先让我们了解一下 CPU 架构的基础知识,以及为什么 CPU 缓存很重要。我们将以英特尔酷睿 i5-7300 为例。

现代 CPU 依靠缓存来加速内存访问,大多数情况下通过三个缓存级别:L1、L2 和 L3。在 i5-7300 上,这些高速缓存的大小如下:

  • L1: 64 KB

  • L2: 256 KB

  • 三级:4 MB

i5-7300 有两个物理内核,但有四个逻辑内核(也称为虚拟内核线程)。在英特尔家族中,将一个物理内核划分为多个逻辑内核称为超线程。

图 12.1 给出了英特尔酷睿 i5-7300 的概述(Tn代表线程n)。每个物理核心(核心 0 和核心 1)被分成两个逻辑核心(线程 0 和线程 1)。L1 缓存分为两个子缓存:L1D 用于数据,L1I 用于指令(每个 32 KB)。缓存不仅仅与数据相关,当 CPU 执行一个应用时,它也可以缓存一些指令,理由相同:加速整体执行。

图 12.1 i5-7300 具有三级高速缓存、两个物理内核和四个逻辑内核。

存储器位置越靠近逻辑核心,访问速度越快(参见 mng.bz/o29v ):

  • L1:大约 1 纳秒

  • L2:大约比 L1 慢 4 倍

  • L3:大约比 L1 慢 10 倍

CPU 缓存的物理位置也可以解释这些差异。L1 和 L2被称为片上,这意味着它们与处理器的其余部分属于同一块硅片。相反,L3 是片外,这部分解释了与 L1 和 L2 相比的延迟差异。

对于主内存(或 RAM),平均访问速度比 L1 慢 50 到 100 倍。我们可以访问存储在 L1 上的多达 100 个变量,只需支付一次访问主存储器的费用。因此,作为 Go 开发人员,一个改进的途径是确保我们的应用使用 CPU 缓存。

12.1.2 高速缓存行

理解高速缓存行的概念至关重要。但是在介绍它们是什么之前,让我们了解一下为什么我们需要它们。

当访问特定的内存位置时(例如,通过读取变量),在不久的将来可能会发生以下情况之一:

  • 相同的位置将被再次引用。

  • 将引用附近的存储位置。

前者指时间局部性,后者指空间局部性。两者都是称为引用位置的原则的一部分。

例如,让我们看看下面这个计算一个int64切片之和的函数:

func sum(s []int64) int64 {
    var total int64
    length := len(s)
    for i := 0; i < length; i++ {
        total += s[i]
    }
    return total
}

在这个例子中,时间局部性适用于多个变量:ilengthtotal。在整个迭代过程中,我们不断地访问这些变量。空间局部性适用于代码指令和切片s。因为一个片是由内存中连续分配的数组支持的,在这种情况下,访问s[0]也意味着访问s[1]s[2]等等。

时间局部性是我们需要 CPU 缓存的部分原因:加速对相同变量的重复访问。然而,由于空间局部性,CPU 复制我们称之为缓存行,而不是将单个变量从主内存复制到缓存。

高速缓存行是固定大小的连续内存段,通常为 64 字节(8 个int64变量)。每当 CPU 决定从 RAM 缓存内存块时,它会将内存块复制到缓存行。因为内存是一个层次结构,当 CPU 想要访问一个特定的内存位置时,它首先检查 L1,然后是 L2,然后是 L3,最后,如果该位置不在这些缓存中,则检查主内存。

让我们用一个具体的例子来说明获取内存块。我们第一次用 16 个int64元素的切片调用sum函数。当sum访问s[0]时,这个内存地址还不在缓存中。如果 CPU 决定缓存这个变量(我们在本章后面也会讨论这个决定),它会复制整个内存块;参见图 12.2。

图 12.2 访问s[0]使 CPU 复制 0x000 内存块。

首先,访问s[0]会导致缓存未命中,因为地址不在缓存中。这种错过被称为一种强制错过。但是,如果 CPU 获取 0x000 存储块,访问从 1 到 7 的元素会导致缓存命中。当sum访问s[8]时,同样的逻辑也适用(见图 12.3)。

图 12.3 访问s[8]使 CPU 复制 0x100 内存块。

同样,访问s8会导致强制未命中。但是如果将0x100内存块复制到高速缓存行中,也会加快对元素 9 到 15 的访问。最后,迭代 16 个元素导致 2 次强制缓存未命中和 14 次缓存命中。

CPU 缓存策略

你可能想知道当 CPU 复制一个内存块时的确切策略。例如,它会将一个块复制到所有级别吗?只去 L1?在这种情况下,L2 和 L3 怎么办?

我们必须知道存在不同的策略。有时缓存是包含性的(例如,L2 数据也存在于 L3 中),有时缓存是排他性的(例如,L3 被称为牺牲缓存,因为它只包含从 L2 逐出的数据)。

一般来说,这些策略都是 CPU 厂商隐藏的,知道了不一定有用。所以,这些问题我们就不深究了。

让我们看一个具体的例子来说明 CPU 缓存有多快。我们将实现两个函数,它们在迭代一片int64元素时计算总数。在一种情况下,我们将迭代每两个元素,在另一种情况下,迭代每八个元素:

func sum2(s []int64) int64 {
    var total int64
    for i := 0; i < len(s); i+=2 {     // ❶
        total += s[i]
    }
    return total
}

func sum8(s []int64) int64 {
    var total int64
    for i := 0; i < len(s); i += 8 {   // ❷
        total += s[i]
    }
    return total
}

❶ 迭代每两个元素

❷ 迭代每八个元素

除了迭代之外,这两个函数是相同的。如果我们对这两个函数进行基准测试,我们的直觉可能是第二个版本会快四倍,因为我们需要增加的元素少了四倍。然而,运行基准测试表明sum8在我的机器上只快了 10%:仍然更快,但是只快了 10%。

原因与缓存行有关。我们看到一个缓存行通常是 64 字节,包含多达 8 个int64变量。这里,这些循环的运行时间是由内存访问控制的,而不是增量指令。在第一种情况下,四分之三的访问导致缓存命中。因此,这两个函数的执行时间差异并不明显。这个例子展示了为什么缓存行很重要,以及如果我们缺乏机械的同情心,我们很容易被我们的直觉所欺骗——在这个例子中,是关于 CPU 如何缓存数据的。

让我们继续讨论引用的局部性,看一个使用空间局部性的具体例子。

12.1.3 结构切片与切片结构

本节看一个比较两个函数执行时间的例子。第一个将一部分结构作为参数,并对所有的a字段求和:

type Foo struct {
    a int64
    b int64
}

func sumFoo(foos []Foo) int64 {         // ❶
    var total int64
    for i := 0; i < len(foos); i++ {    // ❷
        total += foos[i].a
    }
    return total
}

❶ 获取Foo切片

❷ 对每个Foo进行迭代,并对每个字段求和

sumFoo接收Foo的一部分,并通过读取每个a域来增加total

第二个函数也计算总和。但是这一次,参数是一个包含片的结构:

type Bar struct {
    a []int64                           // ❶
    b []int64
}

func sumBar(bar Bar) int64 {            // ❷
    var total int64
    for i := 0; i < len(bar.a); i++ {   // ❸
        total += bar.a[i]               // ❹
    }
    return total
}

ab现在是切片。

❷ 接收单个结构

❸ 遍历bar

❹ 增加了total

sumBar接收一个包含两个切片的Bar结构:ab。它遍历a的每个元素来增加total

我们期望这两个函数在速度上有什么不同吗?在运行基准测试之前,让我们在图 12.4 中直观地看看内存的差异。两种情况的数据量相同:切片中有 16 个Foo元素,切片中有 16 个Bar元素。每个黑条代表一个被读取以计算总和的int64,而每个灰条代表一个被跳过的int64

图 12.4 切片结构更紧凑,因此需要迭代的缓存行更少。

sumFoo的情况下,我们收到一个包含两个字段ab的结构片。因此,我们在内存中有一连串的ab。相反,在sumBar的情况下,我们收到一个包含两个片的结构,ab。因此,a的所有元素都是连续分配的。

这种差异不会导致任何内存压缩优化。但是这两个函数的目标都是迭代每个a,这样做在一种情况下需要四个缓存行,在另一种情况下只需要两个缓存行。

如果对这两个函数进行基准测试,sumBar更快(在我的机器上大约快 20%)。主要原因是更好的空间局部性,这使得 CPU 从内存中获取更少的缓存行。

这个例子演示了空间局部性如何对性能产生重大影响。为了优化应用,我们应该组织数据,以从每个单独的缓存行中获得最大的价值。

但是,使用空间局部性就足以帮助 CPU 了吗?我们仍然缺少一个关键特征:可预测性。

12.1.4 可预测性

可预测性是指 CPU 预测应用将如何加速其执行的能力。让我们看一个具体的例子,缺乏可预测性会对应用性能产生负面影响。

再一次,让我们看两个对元素列表求和的函数。第一个循环遍历一个链表并对所有值求和:

type node struct {             // ❶
    value int64
    next  *node
}

func linkedList(n *node) int64 {
    var total int64
    for n != nil {             // ❷
        total += n.value       // ❸
        n = n.next
    }
    return total
}

❶ 链表数据结构

❷ 迭代每个节点

❸ 增加total

这个函数接收一个链表,遍历它,并增加一个总数。

另一方面,让我们再来看一下sum2函数,它迭代一个片,两个元素中的一个:

func sum2(s []int64) int64 {
    var total int64
    for i := 0; i < len(s); i+=2 {     // ❶
        total += s[i]
    }
    return total
}

❶ 迭代每两个元素

让我们假设链表是连续分配的:例如,由单个函数分配。在 64 位架构中,一个字的长度是 64 位。图 12.5 比较了函数接收的两种数据结构(链表或切片);深色的条代表

我们用来增加总数的int64元素。

图 12.5 在内存中,链表和切片以类似的方式压缩。

在这两个例子中,我们面临类似的压缩。因为链表是由一连串的值和 64 位指针元素组成的,所以我们使用两个元素中的一个来增加总和。同时,sum2的例子只读取了两个元素中的一个。

这两个数据结构具有相同的空间局部性,因此我们可以预期这两个函数的执行时间相似。但是在片上迭代的函数要快得多(在我的机器上大约快 70%)。原因是什么?

要理解这一点,我们得讨论一下大步走的概念。跨越与 CPU 如何处理数据有关。有三种不同类型的步幅(见图 12.6):

  • 单位步幅——我们要访问的所有值都是连续分配的:比如一片int64元素。这一步对于 CPU 来说是可预测的,也是最有效的,因为它需要最少数量的缓存行来遍历元素。

  • 恒定步幅——对于 CPU 来说仍然是可预测的:例如,每两个元素迭代一次的切片。这个步幅需要更多的缓存行来遍历数据,因此它的效率比单位步幅低。

  • 非单位步幅——CPU 无法预测的一个步幅:比如一个链表或者一片指针。因为 CPU 不知道数据是否是连续分配的,所以它不会获取任何缓存行。

图 12.6 三种类型的步幅

对于sum2,我们面对的是一个不变的大步。但是,对于链表来说,我们面临的是非单位跨步。即使我们知道数据是连续分配的,CPU 也不知道。因此,它无法预测如何遍历链表。

由于不同的步距和相似的空间局部性,遍历一个链表比遍历一个值要慢得多。由于更好的空间局部性,我们通常更喜欢单位步幅而不是常数步幅。但是,无论数据如何分配,CPU 都无法预测非单位步幅,从而导致负面的性能影响。

到目前为止,我们已经讨论了 CPU 缓存速度很快,但明显小于主内存。因此,CPU 需要一种策略来将内存块提取到缓存行。这种策略称为缓存放置策略和会显著影响性能。

12.1.5 缓存放置策略

在错误#89“编写不准确的基准测试”中,我们讨论了一个矩阵示例,其中我们必须计算前八列的总和。在这一点上,我们没有解释为什么改变列的总数会影响基准测试的结果。这听起来可能违反直觉:因为我们只需要读取前八列,为什么改变总列数会影响执行时间?让我们来看看这一部分。

提醒一下,实现如下:

func calculateSum512(s [][512]int64) int64 {     // ❶
    var sum int64
    for i := 0; i < len(s); i++ {
        for j := 0; j < 8; j++ {
            sum += s[i][j]
        }
    }
    return sum
}

func calculateSum513(s [][513]int64) int64 {     // ❷
    // Same implementation as calculateSum512
}

❶ 接收 512 列的矩阵

❷ 接收 513 列的矩阵

我们迭代每一行,每次对前八列求和。当这两个函数每次都用一个新矩阵作为基准时,我们没有观察到任何差异。然而,如果我们继续重用相同的矩阵,calculateSum513在我的机器上大约快 50%。原因在于 CPU 缓存以及如何将内存块复制到缓存行。让我们对此进行检查,以了解这种差异。

当 CPU 决定复制一个内存块并将其放入缓存时,它必须遵循特定的策略。假设 L1D 缓存为 32 KB,缓存行为 64 字节,如果将一个块随机放入 L1D,CPU 在最坏的情况下将不得不迭代 512 个缓存行来读取一个变量。这种缓存叫做全关联

为了提高从 CPU 缓存中访问地址的速度,设计人员在缓存放置方面制定了不同的策略。让我们跳过历史,讨论一下今天使用最广泛的选项:组关联缓存,其中依赖于缓存分区。

为了使下图更清晰,我们将简化问题:

  • 我们假设 L1D 缓存为 512 字节(8 条缓存线)。

  • 矩阵由 4 行 32 列组成,我们将只读取前 8 列。

图 12.7 显示了这个矩阵如何存储在内存中。我们将使用内存块地址的二进制表示。同样,灰色块代表我们想要迭代的前 8 个int64元素。剩余的块在迭代过程中被跳过。

图 12.7 存储在内存中的矩阵,以及用于执行的空缓存

每个存储块包含 64 个字节,因此有 8 个int64元素。第一个内存块从 0x000000000000 开始,第二个从 0001000000000(二进制 512)开始,依此类推。我们还展示了可以容纳 8 行的缓存。

请注意,我们将在错误#94“不知道数据对齐”中看到,切片不一定从块的开头开始。

使用组关联高速缓存策略,高速缓存被划分为多个组。我们假设高速缓存是双向组关联的,这意味着每个组包含两行。一个内存块只能属于一个集合,其位置由内存地址决定。为了理解这一点,我们必须将内存块地址分成三个部分:

  • 块偏移是基于块大小的。这里块的大小是 512 字节,512 等于2^9。因此,地址的前 9 位代表块偏移(BO)。

  • 集合索引表示一个地址所属的集合。因为高速缓存是双向组关联的,并且包含 8 行,所以我们有8 / 2 = 4个组。此外,4 等于2^2,因此接下来的两位表示集合索引(SI)。

  • 地址的其余部分由标签位(TB)组成。在图 12.7 中,为了简单起见,我们用 13 位来表示一个地址。为了计算 TB,我们使用13 - BO - SI。这意味着剩余的两位代表标签位。

假设该函数启动并试图读取属于地址 000000000000 的s[0][0]。因为这个地址还不在高速缓存中,所以 CPU 计算它的集合索引并将其复制到相应的高速缓存集合中(图 12.8)。

图 12.8 内存地址 000000000000 被复制到集合 0。

如前所述,9 位代表块偏移量:这是每个内存块地址的最小公共前缀。然后,2 位表示集合索引。地址为 0000000000000 时,SI 等于 00。因此,该存储块被复制到结合 0。

当函数从s[0][1]读取到s[0][7]时,数据已经在缓存中。CPU 是怎么知道的?CPU 计算存储块的起始地址,计算集合索引和标记位,然后检查集合 0 中是否存在 00。

接下来函数读取s[0][8],这个地址还没有被缓存。所以同样的操作发生在复制内存块 0100000000000(图 12.9)。

图 12.9 内存地址 010000000000 被复制到集合 0。

该存储器的集合索引等于 00,因此它也属于集合 0。高速缓存线被复制到组 0 中的下一个可用线。然后,再一次,从s[1][1]s[1][7]的读取导致缓存命中。

现在事情越来越有趣了。该函数读取s[2][0],该地址不在缓存中。执行相同的操作(图 12.10)。

图 12.10 内存地址 1000000000000 替换集合 0 中的现有缓存行。

设置的索引再次等于 00。但是,set 0 已满 CPU 做什么?将内存块复制到另一组?不会。CPU 会替换现有缓存线之一来复制内存块 1000000000000。

缓存替换策略依赖于 CPU,但它通常是一个伪 LRU 策略(真正的 LRU(最久未使用)会太复杂而难以处理)。在这种情况下,假设它替换了我们的第一个缓存行:000000000000。当迭代第 3 行时,这种情况重复出现:内存地址 1100000000000 也有一个等于 00 的集合索引,导致替换现有的缓存行。

现在,让我们假设基准程序用一个从地址 000000000000 开始指向同一个矩阵的片来执行函数。当函数读取s[0][0]时,地址不在缓存中。该块已被替换。

基准测试将导致更多的缓存未命中,而不是从一次执行到另一次执行都使用 CPU 缓存。这种类型的缓存未命中被称为冲突未命中:如果缓存没有分区,这种未命中就不会发生。我们迭代的所有变量都属于一个集合索引为 00 的内存块。因此,我们只使用一个缓存集,而不是分布在整个缓存中。

之前我们讨论了跨越的概念,我们将其定义为 CPU 如何遍历我们的数据。在这个例子中,这个步距被称为临界步距:它导致访问具有相同组索引的存储器地址,这些地址因此被存储到相同的高速缓存组。

让我们回到现实世界的例子,用两个函数calculateSum512calculateSum513。基准测试是在一个 32 KB 的八路组关联 L1D 缓存上执行的:总共 64 组。因为高速缓存行是 64 字节,所以关键步距等于64 × 64B = 4 KB。四 KB 的int64类型代表 512 个元素。因此,我们用 512 列的矩阵达到了一个临界步长,所以我们有一个差的缓存分布。同时,如果矩阵包含 513 列,它不会导致关键的一步。这就是为什么我们在两个基准测试中观察到如此巨大的差异。

总之,我们必须意识到现代缓存是分区的。根据步距的不同,在某些情况下只使用一组,这可能会损害应用性能并导致冲突未命中。这种跨步叫做临界跨步。对于性能密集型应用,我们应该避免关键步骤,以充分利用 CPU 缓存。

请注意,我们的示例还强调了为什么我们应该注意在生产系统之外的系统上执行微基准测试的结果。如果生产系统具有不同的缓存架构,性能可能会有很大不同。

让我们继续讨论 CPU 缓存的影响。这一次,我们在编写并发代码时看到了具体的效果。

12.2 #92:编写导致错误共享的并发代码

到目前为止,我们已经讨论了 CPU 缓存的基本概念。我们已经看到,一些特定的缓存(通常是 L1 和 L2)并不在所有逻辑内核之间共享,而是特定于一个物理内核。这种特殊性会产生一些具体的影响,比如并发性和错误共享的概念,这会导致性能显著下降。让我们通过一个例子来看看什么是虚假分享,然后看看如何防止它。

在这个例子中,我们使用了两个结构,InputResult:

type Input struct {
    a int64
    b int64
}

type Result struct {
    sumA int64
    sumB int64
}

目标是实现一个count函数,该函数接收Input的一部分并计算以下内容:

  • 所有Input.a字段的总和变成Result.sumA

  • 所有Input.b字段的总和变成Result.sumB

为了举例,我们实现了一个并发解决方案,其中一个 goroutine 计算sumA,另一个计算sumB:

func count(inputs []Input) Result {
    wg := sync.WaitGroup{}
    wg.Add(2)

    result := Result{}                        // ❶

    go func() {
        for i := 0; i < len(inputs); i++ {
            result.sumA += inputs[i].a        // ❷
        }
        wg.Done()
    }()

    go func() {
        for i := 0; i < len(inputs); i++ {
            result.sumB += inputs[i].b        // ❸
        }
        wg.Done()
    }()

    wg.Wait()
    return result
}

❶ 初始化Result结构

❷ 计算sumA

❸ 计算sumB

我们旋转了两个 goroutines:一个迭代每个a字段,另一个迭代每个b字段。从并发的角度来看,这个例子很好。例如,它不会导致数据竞争,因为每个 goroutine 都会增加自己的数据

可变。但是这个例子说明了降低预期性能的错误共享概念。

让我们看看主内存(见图 12.11)。因为sumAsumB是连续分配的,所以在大多数情况下(八分之七),两个变量都被分配到同一个内存块。

图 12.11 在这个例子中,sumAsumB是同一个内存块的一部分。

现在,让我们假设机器包含两个内核。在大多数情况下,我们最终应该在不同的内核上调度两个线程。因此,如果 CPU 决定将这个内存块复制到一个缓存行,它将被复制两次(图 12.12)。

图 12.12 每个块都被复制到核心 0 和核心 1 上的缓存行。

因为 L1D (L1 数据)是针对每个内核的,所以两条缓存线都是复制的。回想一下,在我们的例子中,每个 goroutine 更新它自己的变量:一边是sumA,另一边是sumB(图 12.13)。

图 12.13 每个 goroutine 更新它自己的变量。

因为这些缓存行是复制的,所以 CPU 的目标之一是保证缓存一致性。例如,如果一个 goroutine 更新sumA而另一个读取sumA(在一些同步之后),我们期望我们的应用获得最新的值。

然而,我们的例子并没有做到这一点。两个 goroutines 都访问它们自己的变量,而不是共享的变量。我们可能希望 CPU 知道这一点,并理解这不是冲突,但事实并非如此。当我们写缓存中的变量时,CPU 跟踪的粒度不是变量:而是缓存行。

当一个缓存行在多个内核之间共享,并且至少有一个 goroutine 是写线程时,整个缓存行都会失效。即使更新在逻辑上是独立的,也会发生这种情况(例如,sumAsumB)。这就是错误共享的问题,它降低了性能。

注意在内部,CPU 使用 MESI 协议来保证缓存一致性。它跟踪每个高速缓存行,标记它已修改、独占、共享或无效(MESI)。

关于内存和缓存,需要理解的最重要的一个方面是,跨内核共享内存是不真实的——这是一种错觉。这种理解来自于我们并不认为机器是黑匣子;相反,我们试图对潜在的层次产生机械的同情。

那么我们如何解决虚假分享呢?有两种主要的解决方案。

第一个解决方案是使用我们已经展示过的相同方法,但是确保sumAsumB不属于同一个缓存行。例如,我们可以更新Result结构,在字段之间添加填充。填充是一种分配额外内存的技术。因为int64需要 8 字节的分配和 64 字节长的缓存行,所以我们需要64–8 = 56字节的填充:

type Result struct {
    sumA int64
    _    [56]byte     // ❶
    sumB int64
}

❶ 填充

图 12.14 显示了一种可能的内存分配。使用填充,sumAsumB将总是不同存储块的一部分,因此是不同的高速缓存行。

图 12.14 sumAsumB是不同内存块的一部分。

如果我们对两种解决方案进行基准测试(有和没有填充),我们会发现填充解决方案明显更快(在我的机器上大约快 40%)。这是一个重要的改进,因为在两个字段之间添加了填充以防止错误的共享。

第二个解决方案是重新设计算法的结构。例如,不是让两个 goroutines 共享同一个结构,我们可以让它们通过通道交流它们的本地结果。结果基准与填充大致相同。

总之,我们必须记住,跨 goroutines 共享内存是最低内存级别的一种错觉。当至少有一个 goroutine 是写线程时,如果缓存行在两个内核之间共享,则会发生假共享。如果我们需要优化一个依赖于并发的应用,我们应该检查假共享是否适用,因为这种模式会降低应用的性能。我们可以通过填充或通信来防止错误共享。

下一节讨论 CPU 如何并行执行指令,以及如何利用这种能力。

12.3 #93:不考虑指令级并行性

指令级并行是另一个可以显著影响性能的因素。在定义这个概念之前,我们先讨论一个具体的例子,以及如何优化。

我们将编写一个接收两个int64元素的数组的函数。这个函数将迭代一定次数(一个常数)。在每次迭代期间,它将执行以下操作:

  • 递增数组的第一个元素。

  • 如果第一个元素是偶数,则递增数组的第二个元素。

这是 Go 版本:

const n = 1_000_000

func add(s [2]int64) [2]int64 {
    for i := 0; i < n; i++ {       // ❶
        s[0]++                     // ❷
        if s[0]%2 == 0 {           // ❸
            s[1]++
        }
    }
    return s
}

❶ 迭代n

❷ 递增s[0]

❸ 如果s[0]是偶数,递增s[1]

循环中执行的指令如图 12.15 所示(一个增量需要一个读操作和一个写操作)。指令的顺序是连续的:首先我们递增s[0];然后,在递增s[1]之前,我们需要再次读取s[0]

图 12.15 三个主要步骤:增量、检查、增量

注意这个指令序列与汇编指令的粒度不匹配。但是为了清楚起见,我们使用一个简化的视图。

让我们花点时间来讨论指令级并行(ILP)背后的理论。几十年前,CPU 设计师不再仅仅关注时钟速度来提高 CPU 性能。他们开发了多种优化,包括 ILP,它允许开发人员并行执行一系列指令。在单个虚拟内核中实现 ILP 的处理器称为超标量处理器。例如,图 12.16 显示了一个 CPU 执行一个由三条指令组成的应用,I1I2I3

*执行一系列指令需要不同的阶段。简而言之,CPU 需要解码指令并执行它们。执行由执行单元处理,执行单元执行各种操作和计算。

图 12.16 尽管是按顺序写的,但这三条指令是并行执行的。

在图 12.16 中,CPU 决定并行执行这三条指令。注意,并非所有指令都必须在单个时钟周期内完成。例如,读取已经存在于寄存器中的值的指令将在一个时钟周期内完成,但是读取必须从主存储器获取的地址的指令可能需要几十个时钟周期才能完成。

如果顺序执行,该指令序列将花费以下时间(函数t(x)表示 CPU 执行指令x所花费的时间):

total time = t(I1) + t(I2) + t(I3)

由于 ILP,总时间如下:

total time = max(t(I1), t(I2), t(I3))

理论上,ILP 看起来很神奇。但是这也带来了一些挑战叫做冒险

举个例子,如果I3将一个变量设置为 42,而I2是条件指令(例如if foo == 1)怎么办?理论上,这个场景应该防止并行执行I2I3。此称为 a 控制冒险分支冒险。在实践中,CPU 设计者使用分支预测来解决控制冒险。

例如,CPU 可以计算出在过去的 100 次中有 99 次条件为真;因此,它将并行执行I2I3。在错误预测(I2恰好为假)的情况下,CPU 将刷新其当前执行流水线,确保没有不一致。这种刷新会导致 10 到 20 个时钟周期的性能损失。

其他类型的冒险会阻止并行执行指令。作为软件工程师,我们应该意识到这一点。例如,让我们考虑下面两条更新寄存器(用于执行操作的临时存储区)的指令:

  • I1将寄存器 A 和 B 中的数字加到 C 中。

  • I2将寄存器 C 和 D 中的数字加到 D 中。

因为I2取决于关于寄存器 C 的值的I1的结果,所以两条指令不能同时执行。I1必须在I2前完成。这被称为一数据冒险。为了处理数据冒险,CPU 设计者想出了一个叫做转发的技巧,即基本上绕过了对寄存器的写入。这种技术不能解决问题,而是试图减轻影响。

请注意,当流水线中至少有两条指令需要相同的资源时,还有和结构冒险。作为 Go 开发人员,我们不能真正影响这些种类的冒险,所以我们不在本节讨论它们。

现在我们对 ILP 理论有了一个不错的理解,让我们回到我们最初的问题,把注意力集中在循环的内容上:

s[0]++
if s[0]%2 == 0 {
    s[1]++
}

正如我们所讨论的,数据冒险会阻止指令同时执行。让我们看看图 12.17 中的指令序列;这次我们强调说明之间的冒险。

图 12.17 说明之间的冒险类型

由于的if语句,该序列包含一个控制冒险。然而,正如所讨论的,优化执行和预测应该采取什么分支是 CPU 的范围。还有多重数据危害。正如我们所讨论的,数据冒险阻止 ILP 并行执行指令。图 12.18 从 ILP 的角度显示了指令序列:唯一独立的指令是s[0]检查和s[1]增量,因此这两个指令集可以并行执行,这要归功于分支预测。

图 12.18 两个增量都是顺序执行的。

增量呢?我们能改进代码以减少数据冒险吗?

让我们编写另一个版本(add2)来引入一个临时变量:

func add(s [2]int64) [2]int64 {     // ❶
    for i := 0; i < n; i++ {
        s[0]++
        if s[0]%2 == 0 {
            s[1]++
        }
    }
    return s
}

func add2(s [2]int64) [2]int64 {    // ❷
    for i := 0; i < n; i++ {
        v := s[0]                   // ❸
        s[0] = v + 1
        if v%2 != 0 {
            s[1]++
        }
    }
    return s
}

❶ 第一版

❷ 第二版

❸ 引入了一个新的变量来固定s[0]

在这个新版本中,我们将s[0]的值固定为一个新变量v。之前我们增加了s[0],并检查它是否是偶数。为了复制这种行为,因为v是基于s[0],为了增加s[1],我们现在检查v是否是奇数。

图 12.19 比较了两个版本的危害。步骤的数量是相同的。最大的区别是关于数据冒险:s[0]增量步骤和检查v步骤现在依赖于相同的指令(read s[0] into v)。

图 12.19 一个显著的区别:检查步骤v的数据冒险

为什么这很重要?因为它允许 CPU 提高并行度(图 12.20)。

图 12.20 在第二个版本中,两个增量步骤可以并行执行。

尽管有相同数量的步骤,第二个版本增加了可以并行执行的步骤数量:三个并行路径而不是两个。同时,应该优化执行时间,因为最长路径已经减少。如果我们对这两个函数进行基准测试,我们会看到第二个版本的速度有了显著的提高(在我的机器上大约提高了 20%),这主要是因为 ILP。

让我们后退一步来结束这一节。我们讨论了现代 CPU 如何使用并行性来优化一组指令的执行时间。我们还研究了数据冒险,它会阻止并行执行指令。我们还优化了一个 Go 示例,减少了数据冒险的数量,从而增加了可以并行执行的指令数量。

理解 Go 如何将我们的代码编译成汇编,以及如何使用 ILP 等 CPU 优化是另一个改进的途径。在这里,引入一个临时变量可以显著提高性能。这个例子演示了机械共鸣如何帮助我们优化 Go 应用。

让我们也记住对这种微优化保持谨慎。因为 Go 编译器一直在发展,所以当 Go 版本发生变化时,应用生成的程序集也可能发生变化。

下一节讨论数据对齐的效果。

12.4 #94:不知道数据对齐

数据对齐是一种安排如何分配数据的方式,以加速 CPU 的内存访问。不了解这个概念会导致额外的内存消耗,甚至降低性能。本节讨论这个概念,它适用的地方,以及防止代码优化不足的技术。

为了理解数据对齐是如何工作的,让我们首先讨论一下没有它会发生什么。假设我们分配了两个变量,一个int32 (32 字节)和一个int64 (64 字节):

var i int32
var j int64

在没有数据对齐的情况下,在 64 位架构上,这两个变量的分配如图 12.21 所示。j变量分配可以用两个词来概括。如果 CPU 想要读取j,它将需要两次内存访问,而不是一次。

图 12.21 j两个字上的分配

为了避免这种情况,变量的内存地址应该是其自身大小的倍数。这就是数据对齐的概念。在 Go 中,对齐保证如下:

  • byteuint8int8 : 1 字节

  • uint16int16 : 2 字节

  • uint32int32float32 : 4 字节

  • uint64int64float64complex64 : 8 字节

  • complex128 : 16 字节

所有这些类型都保证是对齐的:它们的地址是它们大小的倍数。例如,任何int32变量的地址都是 4 的倍数。

让我们回到现实世界。图 12.22 显示了ij在内存中分配的两种不同情况。

图 12.22 在这两种情况下,j都与自己的尺寸对齐。

在第一种情况下,就在i之前分配了一个 32 位变量。因此,ij被连续分配。第二种情况,32 位变量在i之前没有分配(例如,它是一个 64 位变量);所以,i是一个字的开头。考虑到数据对齐(地址是 64 的倍数),不能将ji一起分配,而是分配给下一个 64 的倍数。灰色框表示 32 位填充。

接下来,让我们看看填充何时会成为问题。我们将考虑以下包含三个字段的结构:

type Foo struct {
    b1 byte
    i  int64
    b2 byte
}

我们有一个byte类型(1 字节),一个int64 (8 字节),还有另一个byte类型(1 字节)。在 64 位架构上,该结构被分配在内存中,如图 12.23 所示。b1先分配。因为i是一个int64,所以它的地址必须是 8 的倍数。所以不可能在 0x01 和b1一起分配。下一个是 8 的倍数的地址是什么?0x08。b2分配给下一个可用地址,该地址是 1: 0x10 的倍数。

图 12.23 该结构总共占用 24 个字节。

因为结构的大小必须是字长的倍数(8 字节),所以它的地址不是 17 字节,而是总共 24 字节。在编译期间,Go 编译器添加填充以保证数据对齐:

type Foo struct {
    b1 byte
    _  [7]byte     // ❶
    i  int64
    b2 byte
    _  [7]byte     // ❶
}

❶ 由编译器添加

每次创建一个Foo结构,它都需要 24 个字节的内存,但是只有 10 个字节包含数据——剩下的 14 个字节是填充。因为结构是一个原子单元,所以它永远不会被重组,即使在垃圾收集(GC)之后;它将总是占用 24 个字节的内存。请注意,编译器不会重新排列字段;它只添加填充以保证数据对齐。

如何减少分配的内存量?经验法则是重新组织结构,使其字段按类型大小降序排列。在我们的例子中,int64类型首先是,然后是两个byte类型:

type Foo struct {
    i  int64
    b1 byte
    b2 byte
}

图 12.24 显示了这个新版本的Foo是如何在内存中分配的。i先分配,占据一个完整的字。主要的区别是现在b1b2可以在同一个单词中共存。

图 12.24 该结构现在占用了 16 个字节的内存。

同样,结构必须是字长的倍数;但是它只占用了 16 个字节,而不是 24 个字节。我们仅仅通过移动i到第一个位置就节省了 33%的内存。

如果我们使用第一个版本的Foo结构(24 字节)而不是压缩的,会有什么具体的影响?如果保留了Foo结构(例如,内存中的Foo缓存),我们的应用将消耗额外的内存。但是,即使没有保留Foo结构,也会有其他影响。例如,如果我们频繁地创建Foo变量并将它们分配给堆(我们将在下一节讨论这个概念),结果将是更频繁的 GC,影响整体应用性能。

说到性能,空间局部性还有另一个影响。例如,让我们考虑下面的sum函数,它将一部分Foo结构作为参数。该函数对切片进行迭代,并对所有的i字段(int64)求和:

func sum(foos []Foo) int64 {
    var s int64
    for i := 0; i < len(foos); i++ {
        s += foos[i].i                 // ❶
    }
    return s
}

❶ 对所有i字段求和

因为一个片由一个数组支持,这意味着一个Foo结构的连续分配。

让我们讨论一下两个版本的Foo的后备数组,并检查两个缓存行的数据(128 字节)。在图 12.25 中,每个灰色条代表 8 个字节的数据,较暗的条是i变量(我们要求和的字段)。

图 12.25 因为每个缓存行包含更多的i变量,迭代Foo的一个片需要更少的缓存行。

正如我们所见,在最新版本的Foo中,每条缓存线都更加有用,因为它平均包含 33%以上的i变量。因此,迭代一个Foo片来对所有的int64元素求和会更有效。

我们可以用一个基准来证实这一观察。如果我们使用 10,000 个元素的切片运行两个基准测试,使用最新的Foo结构的版本在我的机器上大约快 15%。与改变结构中单个字段的位置相比,速度提高了 15%。

让我们注意数据对齐。正如我们在本节中所看到的,重新组织 Go 结构的字段以按大小降序排列可以防止填充。防止填充意味着分配更紧凑的结构,这可能会导致优化,如减少 GC 的频率和更好的空间局部性。

下一节讨论栈和堆之间的根本区别以及它们为什么重要。

12.5 #95:不了解栈与堆

在 Go 中,一个变量既可以分配在栈上,也可以分配在堆上。这两种类型的内存有着根本的不同,会对数据密集型应用产生重大影响。让我们来看看这些概念和编译器在决定变量应该分配到哪里时所遵循的规则。

12.5.1 栈与堆

首先,让我们讨论一下栈和堆的区别。栈是默认内存;它是一种后进先出(LIFO)的数据结构,存储特定 goroutine 的所有局部变量。当一个 goroutine 启动时,它会获得 2 KB 的连续内存作为其栈空间(这个大小会随着时间的推移而变化,并且可能会再次改变)。但是,这个大小在运行时不是固定的,可以根据需要增加或减少(但是它在内存中始终保持连续,从而保持数据局部性)。

当 Go 进入一个函数时,会创建一个栈帧,表示内存中只有当前函数可以访问的区间。让我们看一个具体的例子来理解这个概念。这里,main函数将打印一个sumValue函数的结果:

func main() {
    a := 3
    b := 2

    c := sumValue(a, b)        // ❶
    println(c)                 // ❷
}

//go:noinline                  // ❸
func sumValue(x, y int) int {
    z := x + y
    return z
}

❶ 调用sumValue函数

❷ 打印了结果

❸ 禁用内联

这里有两点需要注意。首先,我们使用println内置函数代替fmt.Println,这将强制在堆上分配c变量。其次,我们在sumValue函数上禁用内联;否则,函数调用不会发生(我们在错误#97“不依赖内联”中讨论了内联)。

图 12.26 显示了ab分配后的栈。因为我们执行了main,所以为这个函数创建了一个栈框架。在这个栈帧中,两个变量ab被分配给栈。所有存储的变量都是有效的地址,这意味着它们可以被引用和访问。

图 12.26 ab分配在栈上。

图 12.27 显示了如果我们进入函数到语句会发生什么。Go 运行时创建一个新的栈框架,作为当前 goroutine 栈的一部分。xy被分配在当前栈帧的z旁边。

图 12.27 调用sumValue创建一个新的栈框架。

前一个栈帧(main)包含仍被视为有效的地址。我们不能直接访问ab;但是如果我们有一个指针在a上,例如,它将是有效的。我们不久将讨论指针。

让我们转到main函数的最后一条语句:println。我们退出了sumValue函数,那么它的栈框架会发生什么变化呢?参见图 12.28。

图 12.28 删除了sumValue栈框架,并用main中的变量代替。在本例中,x已被c擦除,而yz仍在内存中分配,但无法访问。

栈帧没有完全从内存中删除。当一个函数返回时,Go 不需要花时间去释放变量来回收空闲空间。但是这些先前的变量不能再被访问,当来自父函数的新变量被分配到栈时,它们替换了先前的分配。从某种意义上说,栈是自清洁的;它不需要额外的机制,比如 GC。

现在,让我们做一点小小的改变来理解栈的局限性。该函数将返回一个指针,而不是返回一个int:

func main() {
    a := 3
    b := 2

    c := sumPtr(a, b)
    println(*c)
}

//go:noinline
func sumPtr(x, y int) *int {     // ❶
    z := x + y
    return &z
}

❶ 返回了一个指针

main中的c变量现在是一个*int类型。在调用sumPtr之后,让我们直接转到最后一个println语句。如果z在栈上保持分配状态会发生什么(这不可能)?参见图 12.29。

图 12.29c变量引用一个不再有效的地址。

如果c引用的是z变量的地址,而z是在栈上分配的,我们就会遇到一个大问题。该地址将不再有效,加上main的栈帧将继续增长并擦除z变量。出于这个原因,栈是不够的,我们需要另一种类型的内存:堆。

内存堆是由所有 goroutines 共享的内存池。在图 12.30 中,三个 goroutineG1G2G3都有自己的栈。它们都共享同一个堆。

图 12.30 三个 goroutines 有自己的栈,但共享堆

在前面的例子中,我们看到z变量不能在栈上生存;因此,是逃逸到堆里。如果在函数返回后,编译器不能证明变量没有被引用,那么该变量将被分配到堆中。

我们为什么要关心?理解栈和堆的区别有什么意义?因为这对性能有很大的影响。

正如我们所说的,栈是自清洁的,由一个单独的 goroutine 访问。相反,堆必须由外部系统清理:GC。分配的堆越多,我们给 GC 的压力就越大。当 GC 运行时,它使用 25%的可用 CPU 容量,并可能产生毫秒级的“停止世界”延迟(应用暂停的阶段)。

我们还必须理解,在栈上分配对于 Go 运行时来说更快,因为它很简单:一个指针引用下面的可用内存地址。相反,在堆上分配需要更多的努力来找到正确的位置,因此需要更多的时间。

为了说明这些差异,让我们对sumValuesumPtr进行基准测试:

var globalValue int
var globalPtr *int

func BenchmarkSumValue(b *testing.B) {
    b.ReportAllocs()                    // ❶
    var local int
    for i := 0; i < b.N; i++ {
        local = sumValue(i, i)          // ❷
    }
    globalValue = local
}

func BenchmarkSumPtr(b *testing.B) {
    b.ReportAllocs()                    // ❸
    var local *int
    for i := 0; i < b.N; i++ {
        local = sumPtr(i, i)            // ❹
    }
    globalValue = *local
}

❶ 报告堆分配

❷ 按值求和

❸ 报告堆分配

❹ 用指针求和

如果我们运行这些基准测试(并且仍然禁用内联),我们会得到以下结果:

BenchmarkSumValue-4   992800992    1.261 ns/op   0 B/op   0 allocs/op
BenchmarkSumPtr-4     82829653     14.84 ns/op   8 B/op   1 allocs/op

sumPtrsumValue大约慢一个数量级,这是用堆代替栈的直接后果。

注意这个例子表明使用指针来避免复制并不一定更快;这要看上下文。到目前为止,在本书中,我们只通过语义的棱镜讨论了值和指针:当值必须被共享时使用指针。在大多数情况下,这应该是遵循的规则。还要记住,现代 CPU 复制数据的效率非常高,尤其是在同一个缓存行中。让我们避免过早的优化,首先关注可读性和语义。

我们还应该注意,在之前的基准测试中,我们调用了b.ReportAllocs(),它强调了堆分配(栈分配不计算在内):

  • B/op:每次操作分配多少字节

  • allocs/op:每次操作分配多少

接下来,我们来讨论变量逃逸到堆的条件。

12.5.2 逃逸分析

冒险分析是指编译器执行的决定一个变量应该分配在栈上还是堆上的工作。让我们看看主要的规则。

当一个分配不能在栈上完成时,它在堆上完成。尽管这听起来像是一个简单的规则,但记住这一点很重要。例如,如果编译器不能证明函数返回后变量没有被引用,那么这个变量就被分配到堆上。在上一节中,sumPtr函数返回了一个指向在函数作用域中创建的变量的指针。一般来说,向上共享会将冒险到堆中。

但是相反的情况呢?如果我们接受一个指针,如下例所示,会怎么样?

func main() {
    a := 3
    b := 2
    c := sum(&a, &b)
    println(c)
}

//go:noinline
func sum(x, y *int) int {     // ❶
    return *x + *y
}

❶ 接受指针

sum接受两个指针指向父级中创建的变量。如果我们移到sum函数中的return语句,图 12.31 显示了当前栈。

图 12.31xy变量引用有效地址。

尽管是另一个栈帧的一部分,xy变量引用有效地址。所以,ab就不用逃了;它们可以留在栈中。一般来说,向下共享停留在栈上。

以下是变量可以冒险到堆的其他情况:

  • 全局变量,因为多个 goroutines 可以访问它们。

  • 发送到通道的指针:

    type Foo struct{ s string }
    ch := make(chan *Foo, 1)
    foo := &Foo{s: "x"}
    ch <- foo
    

    在这里,foo逃到了垃圾堆里。

  • 发送到通道的值所引用的变量:

    type Foo struct{ s *string }
    ch := make(chan Foo, 1)
    s := "x"
    bar := Foo{s: &s}
    ch <- bar
    

    因为s通过它的地址被Foo引用,所以在这些情况下它会冒险到堆中。

  • 如果局部变量太大,无法放入栈。

  • 如果一个局部变量的大小未知。例如,s := make([]int, 10)可能不会冒险到堆中,但s := make([]int, n)会,因为它的大小是基于变量的。

  • 如果使用append重新分配切片的后备数组。

尽管这个列表为我们理解编译器的决定提供了思路,但它并不详尽,在未来的 Go 版本中可能会有所改变。为了确认一个假设,我们可以使用-gcflags来访问编译器的决定:

$ go build -gcflags "-m=2"
...
./main.go:12:2: z escapes to heap:

在这里,编译器通知我们z变量将逃逸到堆中。

理解堆和栈之间的根本区别对于优化 Go 应用至关重要。正如我们已经看到的,堆分配对于 Go 运行时来说更加复杂,需要一个带有 GC 的外部系统来释放数据。在一些数据密集型应用中,堆管理会占用高达 20%或 30%的总 CPU 时间。另一方面,栈是自清洁的,并且对于单个 goroutine 来说是本地的,这使得分配更快。因此,优化内存分配可以有很大的投资回报。

理解逸出分析的规则对于编写更高效的代码也是必不可少的。一般来说,向下共享停留在栈上,而向上共享则转移到堆上。这应该可以防止常见的错误,比如我们想要返回指针的过早优化,例如,“为了避免复制”让我们首先关注可读性和语义,然后根据需要优化分配。

下一节讨论如何减少分配。

12.6 不知道如何减少分配

减少分配是加速 Go 应用的常用优化技术。本书已经介绍了一些减少堆分配数量的方法:

  • 优化不足的字符串连接(错误#39):使用strings.Builder而不是+操作符来连接字符串。

  • 无用的字符串转换(错误#40):尽可能避免将[]byte转换成字符串。

  • 切片和图初始化效率低(错误#21 和#27):如果长度已知,则预分配切片和图。

  • 更好的数据结构对齐以减少结构大小(错误#94)。

作为本节的一部分,我们将讨论三种减少分配的常用方法:

  • 改变我们的 API

  • 依赖编译器优化

  • 使用sync.Pool等工具

12.6.1 API 的变化

第一个选择是在我们提供的 API 上认真工作。让我们举一个具体的例子io.Reader接口:

type Reader interface {
    Read(p []byte) (n int, err error)
}

Read方法接受一个片并返回读取的字节数。现在,想象一下如果io.Reader接口被反过来设计:传递一个表示需要读取多少字节的int并返回一个片:

type Reader interface {
    Read(n int) (p []byte, err error)
}

语义上,这没有错。但是在这种情况下,返回的片会自动逃逸到堆中。我们将处于上一节描述的共享情况。

Go 设计者使用向下共享的方法来防止自动将切片逃逸到堆中。因此,由调用者来提供切片。这并不一定意味着这个片不会被逃逸:编译器可能已经决定这个片不能留在栈上。然而,由调用者来处理它,而不是由调用的Read方法引起的约束。

有时,即使是 API 中的微小变化也会对分配产生积极的影响。当设计一个 API 时,让我们注意上一节描述的逃逸分析规则,如果需要,使用-gcflags来理解编译器的决定。

12.6.2 编译器优化

Go 编译器的目标之一就是尽可能优化我们的代码。这里有一个关于映射的具体例子。

在 Go 中,我们不能使用切片作为键类型来定义映射。在某些情况下,特别是在做 I/O 的应用中,我们可能会收到我们想用作关键字的[]byte数据。我们必须先将它转换成一个字符串,这样我们就可以编写下面的代码:

type cache struct {
    m map[string]int                                // ❶
}

func (c *cache) get(bytes []byte) (v int, contains bool) {
    key := string(bytes)                            // ❷
    v, contains = c.m[key]                          // ❸
    return
}

❶ 包含字符串的映射

❷ 将[]byte转换为字符串

❸ 使用字符串值查询映射

因为get函数接收一个[]byte切片,所以我们将其转换成一个key字符串来查询映射。

然而,如果我们使用string(bytes)查询映射,Go 编译器会实现一个特定的优化:

func (c *cache) get(bytes []byte) (v int, contains bool) {
    v, contains = c.m[string(bytes)]                         // ❶
    return
}

❶ 使用string(bytes)直接查询映射

尽管这是几乎相同的代码(我们直接调用string(bytes)而不是传递变量),编译器将避免进行这种字节到字符串的转换。因此,第二个版本比第一个快。

这个例子说明了看起来相似的函数的两个版本可能导致遵循 Go 编译器工作的不同汇编代码。我们还应该了解优化应用的可能的编译器优化。我们需要关注未来的 Go 版本,以检查是否有新的优化添加到语言中。

12.6.3 sync.Pool

如果我们想解决分配数量的问题,另一个改进的途径是使用sync.Pool。我们应该明白sync.Pool不是一个缓存:没有我们可以设置的固定大小或最大容量。相反,它是一个重用公共对象的池。

假设我们想要实现一个write函数,它接收一个io.Writer,调用一个函数来获取一个[]byte片,然后将它写入io.Writer。我们的代码如下所示(为了清楚起见,我们省略了错误处理):

func write(w io.Writer) {
    b := getResponse()       // ❶
    _, _ = w.Write(b)        // ❷
}

❶ 收到一个[]byte的响应

❷ 写入io.Writer

这里,getResponse在每次调用时返回一个新的[]byte片。如果我们想通过重用这个片来减少分配的次数呢?我们假设所有响应的最大大小为 1,024 字节。这种情况,我们可以用sync.Pool

创建一个sync.Pool需要一个func() any工厂函数;参见图 12.32。sync.Pool暴露两种方法:

  • Get() any——从池中获取一个对象

  • Put(any)——将对象返回到池中

图 12.32 定义了一个工厂函数,它在每次调用时创建一个新对象。

如果池是空的,使用Get创建一个新对象,否则重用一个对象。然后,在使用该对象之后,我们可以使用Put将它放回池中。图 12.33 显示了先前定义的工厂的一个例子,当池为空时有一个Get,当池不为空时有一个Put和一个Get

图 12.33 Get创建一个新对象或从池中返回一个对象。Put将对象返回到池中。

什么时候从水池中排出物体?没有特定的方法可以做到这一点:它依赖于 GC。每次 GC 之后,池中的对象都被销毁。

回到我们的例子,假设我们可以更新getResponse函数,将数据写入给定的片,而不是创建一个片,我们可以实现另一个版本的依赖于池的write方法:

var pool = sync.Pool{
    New: func() any {                // ❶
        return make([]byte, 1024)
    },
}

func write(w io.Writer) {
    buffer := pool.Get().([]byte)    // ❷
    buffer = buffer[:0]              // ❸
    defer pool.Put(buffer)           // ❹

    getResponse(buffer)              // ❺
    _, _ = w.Write(buffer)
}

❶ 创建了一个池并设置了工厂函数

❷ 从池中获取或创建[]byte

❸ 重置了缓冲区

❹ 把缓冲区放回池

❺ 将响应写入提供的缓冲区

我们使用sync.Pool结构定义一个新的池,并设置工厂函数来创建一个长度为 1024 个元素的新的[]byte。在write函数中,我们试图从池中检索一个缓冲区。如果池是空的,该函数创建一个新的缓冲区;否则,它从缓冲池中选择一个任意的缓冲区并返回它。关键的一步是使用buffer[:0]重置缓冲区,因为该片可能已经被使用。然后我们将调用Put将切片放回池中。

在这个新版本中,调用write不会导致为每个调用创建一个新的[]byte片。相反,我们可以重用现有的已分配片。在最坏的情况下——例如,在 GC 之后——该函数将创建一个新的缓冲区;但是,摊余分配成本会减少。

综上所述,如果我们频繁分配很多同类型的对象,可以考虑使用sync.Pool。它是一组临时对象,可以帮助我们避免重复重新分配同类数据。并且sync.Pool可供多个 goroutines 同时安全使用。

接下来,让我们讨论内联的概念,以了解这种计算机优化是值得了解的。

12.7 #97:不依赖内联

内联是指用函数体替换函数调用。现在,内联是由编译器自动完成的。理解内联的基本原理也是优化应用特定代码路径的一种方式。

让我们来看一个内联的具体例子,它使用一个简单的sum函数将两种int类型相加:

func main() {
    a := 3
    b := 2
    s := sum(a, b)
    println(s)
}

func sum(a int, b int) int {     // ❶
    return a + b
}

❶ 内联了这个函数

如果我们使用-gcflags运行go build,我们将访问编译器对sum函数做出的决定:

$ go build -gcflags "-m=2"
./main.go:10:6: can inline sum with cost 4 as:
    func(int, int) int { return a + b }
...
./main.go:6:10: inlining call to sum func(int, int) int { return a + b }

编译器决定将调用内联到sum。因此,前面的代码被替换为以下代码:

func main() {
    a := 3
    b := 2
    s := a + b     // ❶
    println(s)
}

❶ 用它的正文代替了对sum的调用

内联只对具有一定复杂性的函数有效,也称为内联预算。否则,编译器会通知我们该函数太复杂,无法内联:

./main.go:10:6: cannot inline foo: function too complex:
    cost 84 exceeds budget 80

内联有两个主要好处。首先,它消除了函数调用的开销(尽管自 Go 1.17 和基于寄存器的调用约定以来,开销已经有所减少)。其次,它允许编译器进行进一步的优化。例如,在内联一个函数后,编译器可以决定最初应该在堆上逃逸的变量可以留在栈上。

问题是,如果这种优化是由编译器自动应用的,那么作为 Go 开发者,我们为什么要关心它呢?答案在于中间栈内联的概念。

栈中内联是关于调用其他函数的内联函数。在 Go 1.9 之前,内联只考虑叶函数。现在,由于栈中内联,下面的foo函数也可以被内联:

func main() {
    foo()
}

func foo() {
    x := 1
    bar(x)
}

因为foo函数不太复杂,编译器可以内联它的调用:

func main() {
    x := 1       // ❶
    bar(x)
}

❶ 用正文代替

多亏了中间栈内联,作为 Go 开发者,我们现在可以使用快速路径内联的概念来区分快速和慢速路径,从而优化应用。让我们看一个在sync.Mutex实现中发布的具体例子来理解这是如何工作的。

在中间栈内联之前,Lock方法的实现如下:

func (m *Mutex) Lock() {
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        // Mutex isn't locked
        if race.Enabled {
            race.Acquire(unsafe.Pointer(m))
        }
        return
    }

    // Mutex is already locked
    var waitStartTime int64
    starving := false
    awoke := false
    iter := 0
    old := m.state
    for {
        // ...    // ❶
    }
    if race.Enabled {
        race.Acquire(unsafe.Pointer(m))
    }
}

❶ 复杂逻辑

我们可以区分两条主要路径:

  • 如果互斥没有被锁定(atomic.CompareAndSwapInt32为真),快速路径

  • 如果互斥体已经锁定(atomic.CompareAndSwapInt32为假),慢速路径

然而,无论采用哪种方法,由于函数的复杂性,它都不能内联。为了使用中间栈内联,Lock方法被重构,因此慢速路径位于一个特定的函数中:

func (m *Mutex) Lock() {
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        if race.Enabled {
            race.Acquire(unsafe.Pointer(m))
        }
        return
    }
    m.lockSlow()     // ❶
}

func (m *Mutex) lockSlow() {
    var waitStartTime int64
    starving := false
    awoke := false
    iter := 0
    old := m.state
    for {
        // ...
    }

    if race.Enabled {
        race.Acquire(unsafe.Pointer(m))
    }
}

互斥体已经锁定的❶路径

由于这一改变,Lock方法可以被内联。好处是没有被锁定的互斥体现在被锁定了,而不需要支付调用函数的开销(速度提高了 5%左右)。当互斥体已经被锁定时,慢速路径不会改变。以前它需要一个函数调用来执行这个逻辑;它仍然是一个函数调用,这次是对lockSlow的调用。

这种优化技术是关于区分快速和慢速路径。如果快速路径可以内联,而慢速路径不能内联,我们可以在专用函数中提取慢速路径。因此,如果没有超出内联预算,我们的函数是内联的候选函数。

内联不仅仅是我们不应该关心的不可见的编译器优化。正如在本节中所看到的,理解内联是如何工作的以及如何访问编译器的决定是使用快速路径内联技术进行优化的一条途径。如果执行快速路径,在专用函数中提取慢速路径可以防止函数调用。

下一节将讨论常见的诊断工具,这些工具可以帮助我们理解在我们的 Go 应用中应该优化什么。

12.8 #98:不使用 Go 诊断工具

Go 提供了一些优秀的诊断工具,帮助我们深入了解应用的执行情况。这一节主要关注最重要的部分:概要分析和执行跟踪器。这两个工具都非常重要,应该成为任何对优化感兴趣的 Go 开发者的核心工具集的一部分。我们先讨论侧写。

12.8.1 概要分析

评测提供了对应用执行的深入了解。它允许我们解决性能问题、检测竞争、定位内存泄漏等等。这些见解可以通过以下几个方面收集:

  • CPU——决定应用的时间花在哪里

  • Goroutine——报告正在进行的 goroutines 的栈跟踪

  • Heap——报告堆内存分配,以监控当前内存使用情况并检查可能的内存泄漏

  • Mutex——报告锁争用,以查看我们代码中使用的互斥体的行为,以及应用是否在锁定调用上花费了太多时间

  • Block——显示 goroutines 阻塞等待同步原语的位置

剖析是通过使用一个叫做剖析器的工具来实现的。先来了解一下如何以及何时启用pprof;然后,我们讨论最重要的概要文件类型。

启用pprof

启用pprof有几种方法。例如,我们可以使用net/http/pprof包通过 HTTP:

package main

import (
    "fmt"
    "log"
    "net/http"
    _ "net/http/pprof"                                                      // ❶
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {     // ❷
        fmt.Fprintf(w, "")
    })
    log.Fatal(http.ListenAndServe(":80", nil))
}

❶ 空白导入pprof

❷ 公开了一个 HTTP 端点

导入net/http/pprof会导致一个副作用,即允许我们到达pprof URL,http://host/debug/pprof。注意启用pprof即使在生产中也是安全的(go.dev/doc/diagnostics#profiling)。影响性能的配置文件,如 CPU 配置文件,默认情况下不会启用,也不会连续运行:它们只在特定的时间段内激活。

既然我们已经看到了如何公开一个pprof端点,让我们讨论一下最常见的概要文件。

CPU 分析

CPU 性能分析器依赖于 OS 和信令。当它被激活时,默认情况下,应用通过SIGPROF信号要求操作系统每隔 10 ms 中断一次。当应用接收到一个SIGPROF时,它会挂起当前的活动,并将执行转移到分析器。分析器收集数据,例如当前的 goroutine 活动,并聚合我们可以检索的执行统计信息。然后停止,继续执行,直到下一个SIGPROF

我们可以访问/debug/pprof/profile端点来激活 CPU 分析。默认情况下,访问此端点会执行 30 秒的 CPU 分析。在 30 秒内,我们的应用每 10 毫秒中断一次。注意,我们可以更改这两个默认值:我们可以使用seconds参数向端点传递分析应该持续多长时间(例如,/debug/pprof/profile?seconds=15),并且我们可以改变中断率(甚至到小于 10 ms)。但是在大多数情况下,10 ms 应该足够了,在减小这个值(意味着增加速率)时,我们应该小心不要损害性能。30 秒钟后,我们下载了 CPU 分析器的结果。

基准测试期间的 CPU 性能分析

我们还可以使用的-cpuprofile标志来启用 CPU 分析器,比如在运行基准测试时:

$ go test -bench=. -cpuprofile profile.out

该命令生成的文件类型与可以通过/debug/pprof/profile 下载的文件类型相同。

从这个文件中,我们可以使用go tool导航到结果:

$ go tool pprof -http=:8080 <file>

该命令打开一个显示调用图的 web UI。图 12.34 显示了一个来自应用的例子。箭头越大,说明这条路越热。然后,我们可以浏览该图表,获得执行洞察。

图 12.34 30 秒内应用的调用图

例如,图 12.35 中的图表告诉我们,在 30 秒内,decode方法(*FetchResponse接收器)花费了 0.06 秒。在这 0.06 秒中,RecordBatch.decode用了 0.02 秒,makemap(创建映射)用了 0.01 秒。

图 12.35 示例调用图

我们还可以通过不同的表示从 web 用户界面访问这类信息。例如,顶视图按执行时间对函数进行排序,而火焰图可视化了执行时间层次结构。UI 甚至可以逐行显示源代码中昂贵的部分。

注意,我们还可以通过命令行深入分析数据。然而,在这一节中,我们将重点放在 web UI 上。

借助这些数据,我们可以大致了解应用的行为方式:

  • 太多对runtime.mallogc的调用意味着过多的小堆分配,我们可以尽量减少。

  • 花在通道操作或互斥锁上的时间太多,可能表明存在过多的争用,这会损害应用的性能。

  • syscall.Readsyscall.Write上花费太多时间意味着应用在内核模式下花费大量时间。致力于 I/O 缓冲可能是一条改进的途径。

这些是我们可以从 CPU 性能分析器中获得的洞察。理解最热门的代码路径并识别瓶颈是很有价值的。但是它不会确定超过配置的速率,因为 CPU 性能分析器是以固定的速度执行的(默认为 10 毫秒)。为了获得更细粒度的洞察力,我们应该使用跟踪,我们将在本章后面讨论。

注:我们还可以给不同的函数贴上标签。例如,想象一个从不同客户端调用的公共函数。为了跟踪两个客户端花费的时间,我们可以使用pprof.Labels

堆分析

堆分析允许我们获得关于当前堆使用情况的统计数据。与 CPU 分析一样,堆分析也是基于样本的。我们可以改变这个速率,但是我们不应该太细,因为我们降低的速率越多,堆分析收集数据的工作量就越大。默认情况下,对于每 512 KB 的堆分配,对样本进行一次分析。

如果我们到达/debug/pprof/heap/但是,我们可以使用debug/pprof/heap/?debug=0,然后用go tool(与上一节相同的命令)打开它,使用 web UI 导航到数据。

图 12.36 堆积图

图 12.36 显示了一个堆图的例子。调用MetadataResponse .decode方法导致分配 1536 KB 的堆数据(占总堆的 6.32%)。然而,这 1536 KB 中有 0 个是由这个函数直接分配的,所以我们需要检查第二个调用。TopicMetadata.decode方法分配了 1536 KB 中的 512 KB 其余的 1024 KB 用另一种方法分配。

这就是我们如何浏览调用链,以了解应用的哪个部分负责大部分堆分配。我们还可以看看不同的样本类型:

  • alloc_objects——分配的对象总数

  • alloc_space——分配的内存总量

  • inuse_objects——已分配未释放的对象数量

  • inuse_space——已分配但尚未释放的内存量

堆分析的另一个非常有用的功能是跟踪内存泄漏。对于基于 GC 的语言,通常的过程如下:

  1. 触发 GC。

  2. 下载堆数据。

  3. 等待几秒钟/几分钟。

  4. 触发另一个 GC。

  5. 下载另一个堆数据。

  6. 比较。

在下载数据之前强制执行 GC 是防止错误假设的一种方法。例如,如果我们在没有首先运行 GC 的情况下看到保留对象的峰值,我们就不能确定这是一个泄漏还是下一个 GC 将收集的对象。

使用pprof,我们可以下载一个堆概要文件,同时强制执行 GC。Go 中的过程如下:

  1. 转到/debug/pprof/heap?gc=1(触发 GC 并下载堆配置文件)。

  2. 等待几秒钟/几分钟。

  3. 再次转到/debug/pprof/heap?gc=1

  4. 使用go tool比较两个堆配置文件:

$ go tool pprof -http=:8080 -diff_base <file2> <file1>

图 12.37 显示了我们可以访问的数据类型。例如,newTopicProducer方法(左上)持有的堆内存量已经减少了(–513 KB)。相比之下,updateMetadata(右下角)持有的数量增加了(+512 KB)。缓慢增加是正常的。例如,第二个堆配置文件可能是在服务调用过程中计算出来的。我们可以重复这个过程或等待更长时间;重要的部分是跟踪特定对象分配的稳定增长。

图 12.37 两种堆配置文件的区别

注意,与堆相关的另一种类型的分析是allocs,它报告分配情况。堆分析显示了堆内存的当前状态。为了深入了解应用启动以来的内存分配情况,我们可以使用分配分析。如前所述,因为栈分配的成本很低,所以它们不是这种分析的一部分,这种分析只关注堆。

Goroutines 剖析

goroutine配置文件报告应用中所有当前 goroutines 的栈跟踪。我们可以用debug/pprof/goroutine/?debug=0,再次使用go tool。图 12.38 显示了我们能得到的信息种类。

图 12.38 Goroutine 图

我们可以看到应用的当前状态以及每个函数创建了多少个 goroutines。在这种情况下,withRecover创建了 296 个正在进行的 goroutine(63%),其中 29 个与对responseFeeder的调用相关。

如果我们怀疑 goroutine 泄密,这种信息也是有益的。我们可以查看 goroutine 性能分析器数据,了解系统的哪个部分是可疑的。

块剖析

block配置文件报告正在进行的 goroutines 阻塞等待同步原语的位置。可能性包括

  • 在无缓冲通道上发送或接收

  • 发送到完整通道

  • 从空通道接收

  • 互斥竞争

  • 网络或文件系统等待

块分析还记录了一个 goroutine 等待的时间,可以通过debug/pprof/block访问。如果我们怀疑阻塞调用损害了性能,这个配置文件会非常有用。

默认情况下,block配置文件是不启用的:我们必须调用runtime.SetBlockProfileRate来启用它。此函数控制报告的 goroutine 阻塞事件的比例。一旦启用,分析器将继续在后台收集数据,即使我们不调用debug/pprof/block端点。如果我们想设置一个较高的比率,我们就要谨慎,以免影响性能。

完整的 goroutine 栈转储

如果我们面临死锁或者怀疑 goroutine 处于阻塞状态,那么完整的 goroutine 栈转储(debug/pprof/goroutine/?debug=2)创建所有当前 goroutine 栈跟踪的转储。作为第一个分析步骤,这可能很有帮助。例如,以下转储显示 Sarama goroutine 在通道接收操作中被阻塞了 1,420 分钟:

goroutine 2494290 [chan receive, 1420 minutes]:
github.com/Shopify/sarama.(*syncProducer).SendMessages(0xc00071a090,
➥{0xc0009bb800, 0xfb, 0xfb})
    /app/vendor/github.com/Shopify/sarama/sync_producer.go:117 +0x149

互斥剖析

最后一种配置文件类型与阻塞有关,但仅与互斥有关。如果我们怀疑我们的应用花费大量时间等待锁定互斥体,从而损害执行,我们可以使用互斥体分析。可以通过/debug/pprof/mutex 访问它。

该配置文件的工作方式类似于阻塞。默认情况下它是禁用的:我们必须使用runtime.SetMutexProfileFraction来启用它,它控制所报告的互斥争用事件的比例。

以下是关于概要分析的一些附加说明:

  • 我们没有提到threadcreate剖面,因为从 2013 年开始就坏了(github.com/golang/go/issues/6104)。

  • 确保一次只启用一个分析器:例如,不要同时启用 CPU 和堆分析。这样做会导致错误的观察。

  • pprof是可扩展的,我们可以使用pprof.Profile创建自己的自定义概要文件。

我们已经看到了最重要的配置文件,它们可以帮助我们了解应用的性能以及可能的优化途径。一般来说,建议启用pprof,即使是在生产环境中,因为在大多数情况下,它在它的占用空间和我们可以从中获得的洞察力之间提供了一个极好的平衡。一些配置文件,比如 CPU 配置文件,会导致性能下降,但只在它们被启用的时候。

现在让我们看看执行跟踪器。

12.8.2 执行跟踪器

执行跟踪器是一个工具,它用go tool捕捉广泛的运行时事件,使它们可用于可视化。这有助于:

  • 了解运行时事件,例如 GC 如何执行

  • 了解 goroutines 如何执行

  • 识别并行性差的执行

让我们用错误#56 中给出的一个例子来试试,“思考并发总是更快。”我们讨论了归并排序算法的两个并行版本。第一个版本的问题是并行性差,导致创建了太多的 goroutines。让我们看看跟踪器如何帮助我们验证这一陈述。

我们将为第一个版本编写一个基准,并使用-trace标志来执行它,以启用执行跟踪器:

$ go test -bench=. -v -trace=trace.out

注意我们还可以使用/debug/pprof/trace?debug=0pprof端点下载远程跟踪文件。 。

这个命令创建一个trace.out文件,我们可以使用go tool打开它:

$ go tool trace trace.out
2021/11/26 21:36:03 Parsing trace...
2021/11/26 21:36:31 Splitting trace...
2021/11/26 21:37:00 Opening browser. Trace viewer is listening on
    http://127.0.0.1:54518

web 浏览器打开,我们可以单击 View Trace 查看特定时间段内的所有跟踪,如图 12.39 所示。这个数字代表大约 150 毫秒,我们可以看到多个有用的指标,比如 goroutine 计数和堆大小。堆大小稳定增长,直到触发 GC。我们还可以观察每个 CPU 内核的 Go 应用的活动。时间范围从用户级代码开始;然后执行“停止世界”,占用四个 CPU 内核大约 40 毫秒。

图 12.39 显示了 goroutine 活动和运行时事件,如 GC 阶段

关于并发,我们可以看到这个版本使用了机器上所有可用的 CPU 内核。然而,图 12.40 放大了 1 毫秒的一部分,每个条形对应于一次 goroutine 执行。拥有太多的小竖条看起来不太好:这意味着执行的并行性很差。

图 12.40 太多的小横条意味着并行执行效果不佳。

图 12.41 放大到更近,以查看这些 goroutines 是如何编排的。大约 50%的 CPU 时间没有用于执行应用代码。空白表示 Go 运行时启动和编排新的 goroutines 所需的时间。

图 12.41 大约 50%的 CPU 时间用于处理 goroutine 开关。

让我们将其与第二种并行实现进行比较,后者大约快一个数量级。图 12.42 再次放大到 1 毫秒的时间范围。

图 12.42 空格数量明显减少,证明 CPU 被更充分的占用。

每个 goroutine 都需要更多的时间来执行,并且空格的数量已经显著减少。因此,与第一个版本相比,CPU 执行应用代码的时间要多得多。每一毫秒的 CPU 时间都得到了更有效的利用,这解释了基准测试的差异。

请注意,跟踪的粒度是每个例程,而不是像 CPU 分析那样的每个函数。然而,可以使用包来定义用户级任务,以获得每个函数或函数组的洞察力。

例如,假设一个函数计算一个斐波那契数,然后使用atomic将其写入一个全局变量。我们可以定义两种不同的任务:

var v int64
ctx, fibTask := trace.NewTask(context.Background(), "fibonacci")     // ❶
trace.WithRegion(ctx, "main", func() {
    v = fibonacci(10)
})
fibTask.End()
ctx, fibStore := trace.NewTask(ctx, "store")                         // ❷
trace.WithRegion(ctx, "main", func() {
    atomic.StoreInt64(&result, v)
})
fibStore.End()

❶ 创建了一个斐波那契任务

❷ 创建一个存储任务

使用go tool,我们可以获得关于这两个任务如何执行的更精确的信息。在前面的 trace UI 中(图 12.42),我们可以看到每个 goroutine 中每个任务的边界。在用户定义的任务中,我们可以遵循持续时间分布(见图 12.43)。

图 12.43 用户级任务的分布

我们看到,在大多数情况下,fibonacci任务的执行时间不到 15 微秒,而store任务的执行时间不到 6309 纳秒。

在上一节中,我们讨论了我们可以从 CPU 概要分析中获得的各种信息。与我们可以从用户级跟踪中获得的数据相比,主要的区别是什么?

  • CPU 性能分析:

    • 以样本为基础。
    • 每个函数。
    • 不会低于速率(默认为 10 毫秒)。
  • 用户级跟踪:

    • 不基于样本。
    • 逐例程执行(除非我们使用runtime/trace包)。
    • 时间执行不受任何速率的约束。

总之,执行跟踪器是理解应用如何执行的强大工具。正如我们在归并排序示例中看到的,我们可以识别出并行性差的执行。然而,跟踪器的粒度仍然是每一个例程,除非我们手动使用runtime/trace与 CPU 配置文件进行比较。在优化应用时,我们可以同时使用概要分析和执行跟踪器来充分利用标准的 Go 诊断工具。

下一节讨论 GC 如何工作以及如何调优。

12.9 #99:不了解 GC 如何工作

垃圾收集器(GC)是简化开发人员生活的 Go 语言的关键部分。它允许我们跟踪和释放不再需要的堆分配。因为我们不能用栈分配来代替每个堆分配,所以理解 GC 如何工作应该是 Go 开发人员优化应用的工具集的一部分。

12.9.1 概念

GC 保存了一个对象引用树。Go GC 基于标记-清除算法,该算法依赖于两个阶段:

  • 标记阶段——遍历堆中的所有对象,并标记它们是否仍在使用

  • 清除阶段——从根开始遍历引用树,并释放不再被引用的对象块

当 GC 运行时,它首先执行一组动作,导致停止世界(准确地说,每个 GC 两次停止世界)。也就是说,所有可用的 CPU 时间都用于执行 GC,从而暂停了我们的应用代码。按照这些步骤,它再次启动这个世界,恢复我们的应用,同时运行一个并发阶段。出于这个原因,Go GC 被称为并发标记和清除:它的目标是减少每个 GC 周期的停止世界操作的数量,并且主要与我们的应用并发运行。
清理器
Go GC 还包括一种在消耗高峰后释放内存的方法。假设我们的应用基于两个阶段:

  • 导致频繁分配和大量堆的初始化阶段

  • 具有适度分配和小堆的运行时阶段

如何处理大堆只在应用启动时有用,而在那之后没有用的事实呢?这是作为 GC 的一部分使用所谓的定期清理器来处理的。一段时间后,GC 检测到不再需要这么大的堆,所以它释放一些内存并将其返回给操作系统。

注意如果清理器不够快,我们可以使用debug.FreeOSMemory()手动强制将内存返回给操作系统。

重要的问题是,GC 周期什么时候运行?与 Java 等其他语言相比,Go 配置仍然相当简单。它依赖于单个环境变量:GOGC。该变量定义了在触发另一个 GC 之前,自上次 GC 以来堆增长的百分比;默认值为 100%。

让我们看一个具体的例子,以确保我们理解。让我们假设刚刚触发了一个 GC,当前的堆大小是 128 MB。如果GOGC=100,当堆大小达到 256 MB 时,触发下一次垃圾收集。默认情况下,每当堆大小加倍时,就会执行一次 GC。此外,如果在最后 2 分钟内没有执行 GC,Go 将强制运行一个 GC。

如果我们用生产负载分析我们的应用,我们可以微调GOGC:

  • 减少它会导致堆增长更慢,增加 GC 的压力。

  • 相反,碰撞它会导致堆增长得更快,从而减轻 GC 的压力。

GC 痕迹

我们可以通过设置GODEBUG环境变量来打印 GC 轨迹,比如在运行基准测试时:

$ GODEBUG=gctrace=1 go test -bench=. -v

启用gctrace会在每次 GC 运行时向stderr写入跟踪。

让我们通过一些具体的例子来理解 GC 在负载增加时的行为。

12.9.2 示例

假设我们向用户公开一些公共服务。在中午 12:00 的高峰时段,有 100 万用户连接。然而,联网用户在稳步增长。图 12.44 表示平均堆大小,以及当我们将GOGC设置为100时何时触发 GC。

图 12.44 联网用户的稳步增长

因为GOGC被设置为100,所以每当堆大小加倍时,GC 都会被触发。在这种情况下,由于用户数量稳步增长,我们应该全天面对可接受数量的 GC(图 12.45)。

图 12.45 GC 频率从未达到大于中等的状态。

在一天开始的时候,我们应该有适度数量的 GC 周期。当我们到达中午 12:00 时,当用户数量开始减少时,GC 周期的数量也应该稳步减少。在这种情况下,保持GOGC100应该没问题。

现在,让我们考虑第二个场景,100 万用户中的大多数在不到一个小时内连接;参见图 12.46。上午 8:00,平均堆大小迅速增长,大约一小时后达到峰值。

图 12.46 用户突然增加

在这一小时内,GC 周期的频率受到严重影响,如图 12.47 所示。由于堆的显著和突然的增加,我们在短时间内面临频繁的 GC 循环。即使 Go GC 是并发的,这种情况也会导致大量的停顿期,并会造成一些影响,例如增加用户看到的平均延迟。

图 12.47 在一个小时内,我们观察到高频率的 GCs。

在这种情况下,我们应该考虑将GOGC提高到一个更高的值,以减轻 GC 的压力。注意,增加GOGC并不会带来线性的好处:堆越大,清理的时间就越长。因此,使用生产负载时,我们在配置GOGC时应该小心。

在颠簸更加严重的特殊情况下,调整GOGC可能还不够。例如,我们不是在一个小时内从 0 到 100 万用户,而是在几秒钟内完成。在这几秒钟内,GC 的数量可能会达到临界状态,导致应用的性能非常差。

如果我们知道堆的峰值,我们可以使用一个技巧,强制分配大量内存来提高堆的稳定性。例如,我们可以在main.go中使用一个全局变量强制分配 1 GB:

var min = make([]byte, 1_000_000_000) // 1 GB

这样的分配有什么意义?如果GOGC保持在100,而不是每次堆翻倍时触发一次 GC(同样,这在这几秒钟内发生得非常频繁),那么 Go 只会在堆达到 2 GB 时触发一次 GC。这应该会减少所有用户连接时触发的 GC 周期数,从而减少对平均延迟的影响。

我们可以说,当堆大小减小时,这个技巧会浪费大量内存。但事实并非如此。在大多数操作系统上,分配这个min变量不会让我们的应用消耗 1 GB 的内存。调用make会导致对mmap()的系统调用,从而导致惰性分配。例如,在 Linux 上,内存是通过页表虚拟寻址和映射的。使用mmap()在虚拟地址空间分配 1 GB 内存,而不是物理空间。只有读取或写入会导致页面错误,从而导致实际的物理内存分配。因此,即使应用在没有任何连接的客户端的情况下启动,它也不会消耗 1 GB 的物理内存。

注意,我们可以使用ps这样的工具来验证这种行为。

为了优化 GC,理解它的行为是很重要的。作为 Go 开发者,我们可以使用GOGC来配置何时触发下一个 GC 周期。大多数情况下,保持在100应该就够了。但是,如果我们的应用可能面临导致频繁 GC 和延迟影响的请求高峰,我们可以增加这个值。最后,在出现异常请求高峰时,我们可以考虑使用将虚拟堆大小保持在最小的技巧。

本章最后一节讨论了在 Docker 和 Kubernetes 中运行 Go 的影响。

12.10 #100:不了解在 Docker 和 Kubernetes 中运行GO的影响

根据 2021 年 Go 开发者调查(go.dev/blog/survey2021-results),用 Go 编写服务是最常见的用法。同时,Kubernetes 是部署这些服务最广泛使用的平台。理解在 Docker 和 Kubernetes 中运行 Go 的含义是很重要的,这样可以防止常见的情况,比如 CPU 节流。

我们在错误#56“思考并发总是更快”中提到,GOMAXPROCS变量定义了负责同时执行用户级代码的操作系统线程的限制。默认情况下,它被设置为操作系统可见的逻辑 CPU 内核的数量。这在 Docker 和 Kubernetes 的上下文中意味着什么?

假设我们的 Kubernetes 集群由八核节点组成。当在 Kubernetes 中部署一个容器时,我们可以定义一个 CPU 限制来确保应用不会消耗所有的主机资源。例如,以下配置将 cpu 的使用限制为 4,000 个毫 CPU(或毫核心),因此有四个 CPU 核心:

spec:
  containers:
  - name: myapp
    image: myapp
    resources:
      limits:
        cpu: 4000m

我们可以假设,当部署我们的应用时,GOMAXPROCS将基于这些限制,因此将具有值4。但事实并非如此;它被设置为主机上逻辑核心的数量:8。那么,有什么影响呢?

Kubernetes 使用完全公平调度器(CFS)作为进程调度器。CFS 还用于强制执行 Pod 资源的 CPU 限制。在管理 Kubernetes 集群时,管理员可以配置这两个参数:

  • cpu.cfs_period_us(全局设置)

  • cpu.cfs_quota_us(设定每 Pod)

前者规定了一个期限,后者规定了一个配额。默认情况下,周期设置为 100 毫秒。同时,默认配额值是应用在 100 毫秒内可以消耗的 CPU 时间。限制设置为四个内核,这意味着 400 毫秒(4 × 100毫秒)。因此,CFS 将确保我们的应用在 100 毫秒内不会消耗超过 400 毫秒的 CPU 时间。

让我们想象一个场景,其中多个 goroutines 当前正在四个不同的线程上执行。每个线程被调度到不同的内核(1、3、4 和 8);参见图 12.48。

图 12.48 每 100 毫秒,应用消耗的时间不到 400 毫秒

在第一个 100 毫秒期间,有四个线程处于忙碌状态,因此我们消耗了 400 毫秒中的 400 毫秒:100%的配额。在第二阶段,我们消耗 400 毫秒中的 360 毫秒,以此类推。一切都很好,因为应用消耗的资源少于配额。

但是,我们要记住GOMAXPROCS是设置为8的。因此,在最坏的情况下,我们可以有八个线程,每个线程被安排在不同的内核上(图 12.49)。

图 12.49 在每 100 毫秒期间,CPU 在 50 毫秒后被节流。

每隔 100 毫秒,配额设置为 400 毫秒,如果 8 个线程忙于执行 goroutines,50 毫秒后,我们达到 400 毫秒的配额(8 × 50 毫秒 = 400 毫秒)。会有什么后果?CFS 将限制 CPU 资源。因此,在下一个周期开始之前,不会再分配 CPU 资源。换句话说,我们的应用将被搁置 50 毫秒。

例如,平均延迟为 50 毫秒的服务可能需要 150 毫秒才能完成。这可能会对延迟造成 300%的损失。

那么,有什么解决办法呢?先关注 Go 第 33803 期(github.com/golang/go/issues/33803)。也许在 Go 的未来版本中,GOMAXPROCS将会支持 CFS。

今天的一个解决方案是依靠由github.com/uber-go/automaxprocs制作的名为automaxprocs的库。我们可以通过向main.go中的go.uber.org/automaxprocs添加一个空白导入来使用这个库;它会自动设置GOMAXPROCS来匹配 Linux 容器的 CPU 配额。在前面的例子中,GOMAXPROCS将被设置为4而不是8,因此我们将无法达到 CPU 被抑制的状态。

总之,让我们记住,目前,Go 并不支持 CFS。GOMAXPROCS基于主机,而不是基于定义的 CPU 限制。因此,我们可能会达到 CPU 被抑制的状态,从而导致长时间的暂停和重大影响,例如显著的延迟增加。在 Go 能够感知 CFS 之前,一种解决方案是依靠automaxprocs自动将GOMAXPROCS设置为定义的配额。

总结

  • 了解如何使用 CPU 缓存对于优化 CPU 密集型应用非常重要,因为 L1 缓存比主内存快 50 到 100 倍。

  • 了解缓存线概念对于理解如何在数据密集型应用中组织数据至关重要。CPU 不会一个字一个字地获取内存;相反,它通常将内存块复制到 64 字节的缓存行。要充分利用每个单独的缓存行,请实现空间局部性。

  • 使代码对 CPU 可预测也是优化某些函数的有效方法。例如,CPU 的单位步幅或常量步幅是可预测的,但是非单位步幅(例如,一个链表)是不可预测的。

  • 为了避免关键的一步,因此只利用缓存的一小部分,请注意缓存是分区的。

  • 知道较低级别的 CPU 缓存不会在所有内核之间共享有助于避免性能下降的模式,例如在编写并发代码时的错误共享。分享内存是一种错觉。

  • 使用指令级并行(ILP)来优化代码的特定部分,以允许 CPU 执行尽可能多的并行指令。识别数据冒险是主要步骤之一。

  • 记住在GO中,基本类型是根据它们自己的大小排列的,这样可以避免常见的错误。例如,请记住,按大小降序重新组织结构的字段可以产生更紧凑的结构(更少的内存分配和潜在的更好的空间局部性)。

  • 在优化 Go 应用时,理解堆和栈之间的根本区别也应该是您核心知识的一部分。栈分配几乎是免费的,而堆分配速度较慢,并且依赖 GC 来清理内存。

  • 减少分配也是优化 Go 应用的一个重要方面。这可以通过不同的方式来实现,比如仔细设计 API 以防止共享,理解常见的 Go 编译器优化,以及使用sync.Pool

  • 使用快速路径内联技术有效减少调用函数的分摊时间。

  • 依靠分析和执行跟踪器来了解应用的执行情况以及需要优化的部分。

  • 了解如何调优 GC 可以带来多种好处,比如更有效地处理突然增加的负载。

  • 为了帮助避免部署在 Docker 和 Kubernetes 中时的 CPU 节流,请记住 Go 不支持 CFS。

最后的话

恭喜你完成了《100 个 Go 错误以及如何避免它们》。我真诚地希望你喜欢读这本书,它将对你的个人和/或专业项目有所帮助。

记住,犯错是学习过程的一部分,正如我在序言中强调的,它也是本书灵感的重要来源。归根结底,重要的是我们从中学习的能力。

如果你想继续讨论,可以在推特上关注我:@teivah。***

posted @ 2025-11-09 18:00  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报