Go-学习指南第二版-五-

90 阅读1小时+

Go 学习指南第二版(五)

原文:zh.annas-archive.org/md5/49044fc3671d64b7c4ed200e906a7f6d

译者:飞龙

协议:CC BY-NC-SA 4.0

第十二章《Go 语言并发》

并发是计算机科学术语,用于将单个进程分解为独立组件,并指定这些组件如何安全地共享数据。大多数语言通过操作系统级线程提供并发,通过尝试获取锁来共享数据。但 Go 语言不同。它的主要并发模型,可以说是 Go 语言最著名的特性,基于通信顺序进程(CSP)。这种并发风格在 1978 年由发明快速排序算法的 Tony Hoare 在一篇paper中描述。使用 CSP 实现的模式与标准模式一样强大,但更易于理解。

在这一章中,您将快速回顾 Go 语言并发的核心特性:goroutines(协程)、channels(通道)和select关键字。然后,您将查看一些常见的 Go 并发模式,并了解在何种情况下使用底层技术是更好的方法。

何时使用并发

让我们先谨慎一点。确保您的程序从并发中获益。当新手 Go 开发人员开始尝试并发时,他们往往会经历一系列阶段:

  1. 这太神奇了;我要把一切都放进 goroutines 中!

  2. 我的程序并没有更快。我正在向我的 channels 添加缓冲区。

  3. 我的 channels 在阻塞,我遇到了死锁。我将使用带有非常大缓冲区的 buffered channels。

  4. 我的 channels 仍然在阻塞。我将使用互斥锁。

  5. 算了吧,我放弃并发了。

人们被并发吸引,因为他们认为并发程序运行更快。不幸的是,这并不总是如此。更多的并发并不自动意味着事情更快,它可能会使代码更难理解。关键在于理解并发不等于并行。并发是一个工具,用来更好地解构您试图解决的问题。

并发代码是否并行(同时运行)取决于硬件和算法是否允许。1967 年,计算机科学先驱之一 Gene Amdahl 推导出了 Amdahl 定律。这是一个用于确定并行处理可以提升性能多少的公式,考虑了有多少工作必须顺序执行。如果您想深入了解 Amdahl 定律的细节,您可以在并发的艺术一书中了解更多,作者是 Clay Breshears(O’Reilly)。对于我们的目的,您只需理解更多的并发并不意味着更快。

广义上讲,所有程序都遵循相同的三步过程:获取数据,转换数据,然后输出结果。您是否应在程序中使用并发取决于数据在程序步骤之间的流动方式。有时候两个步骤可以并发进行,因为一个步骤的数据不需要等待另一个步骤才能继续进行,而其他时候两个步骤必须按顺序进行,因为一个步骤依赖于另一个步骤的输出。当您想要结合多个可以独立操作的操作的数据时,请使用并发。

另一个重要的注意事项是,并发不值得使用,如果运行并发的进程所花费的时间不多。并发并非免费;许多常见的内存算法非常快速,通过并发传递值的开销超过了通过并行运行并发代码可能获得的任何潜在时间节省。这就是为什么并发操作通常用于 I/O 的原因;读取或写入磁盘或网络比除了最复杂的内存过程以外的所有过程慢数千倍。如果您不确定并发是否有帮助,请首先将代码串行化,然后编写基准测试来比较并发实现的性能。(参见“使用基准测试”了解如何对您的代码进行基准测试。)

让我们来考虑一个例子。假设您正在编写一个调用其他三个网络服务的 Web 服务。您的程序将数据发送到其中两个服务,然后将这两个调用的结果发送给第三个服务,并返回结果。整个过程必须在 50 毫秒内完成,否则应返回错误。这是并发的一个很好的使用案例,因为代码中有需要执行 I/O 的部分,可以独立运行而无需相互交互,还有一个部分用于合并结果,并且代码需要运行的时间有限。在本章末尾,您将看到如何实现此代码。

Goroutines

Go 语言并发模型的核心概念是goroutine。要理解 goroutine,让我们先定义几个术语。首先是进程。进程是计算机操作系统正在运行的程序的实例。操作系统会为进程分配一些资源,如内存,并确保其他进程无法访问这些资源。一个进程由一个或多个线程组成。线程是操作系统分配运行时间的执行单位。同一进程内的线程共享资源。根据处理器核心数量,CPU 可以同时执行一个或多个线程的指令。操作系统的一个任务是调度线程在 CPU 上运行,以确保每个进程(及其内部的每个线程)都有运行的机会。

将 Goroutine 看作是由 Go 运行时管理的轻量级线程。当 Go 程序启动时,Go 运行时会创建一些线程,并启动一个单独的 Goroutine 来运行您的程序。您的程序创建的所有 Goroutine(包括初始的)都会由 Go 运行时调度器自动分配到这些线程中,就像操作系统在 CPU 核心之间调度线程一样。这看起来可能是额外的工作,因为底层操作系统已经包含了管理线程和进程的调度器,但它有几个好处:

  • Goroutine 的创建速度比线程快,因为您不需要创建一个操作系统级别的资源。

  • Goroutine 的初始栈大小比线程栈大小小,并且可以根据需要增长。这使得 Goroutine 更加内存高效。

  • 在 Goroutine 之间切换比在线程之间切换更快,因为它完全在进程内部进行,避免了(相对而言)缓慢的操作系统调用。

  • Goroutine 调度器能够优化其决策,因为它是 Go 进程的一部分。调度器与网络轮询器一起工作,当 Goroutine 因 I/O 阻塞时检测到可以取消调度。它还与垃圾回收器集成,确保工作在分配给 Go 进程的所有操作系统线程之间得到适当平衡。

这些优势使得 Go 程序能够同时启动数百、数千甚至数万个 Goroutine。如果在具有本地线程的语言中尝试启动数千个线程,您的程序将变得非常缓慢。

提示

如果你对了解调度器如何工作感兴趣,请观看 Kavya Joshi 在 GopherCon 2018 上的演讲 “调度器传奇”

在函数调用前加上 go 关键字可以启动一个 Goroutine。与任何其他函数一样,您可以传递参数来初始化其状态。但是,函数返回的任何值都会被忽略。

任何函数都可以作为 Goroutine 启动。这与 JavaScript 不同,JavaScript 中只有在函数使用 async 关键字声明时,该函数才会异步运行。然而,在 Go 中,习惯上使用一个包装业务逻辑的闭包来启动 Goroutine。闭包负责并发的簿记工作。以下示例代码演示了这个概念:

func process(val int) int {
    // do something with val
}

func processConcurrently(inVals []int) []int {
    // create the channels
    in := make(chan int, 5)
    out := make(chan int, 5)
    // launch processing goroutines
    for i := 0; i < 5; i++ {
        go func() {
            for val := range in {
                out <- process(val)
            }
        }()
    }
    // load the data into the in channel in another goroutine
    // read the data from the out channel
    // return the data
}

在这段代码中,processConcurrently 函数创建了一个闭包,从通道中读取值,并将它们传递给 process 函数中的业务逻辑。process 函数完全不知道它是在一个 goroutine 中运行。闭包然后将 process 的结果写回到另一个通道中。(我将在下一节简要概述通道。)这种责任分离使得你的程序模块化和可测试,并将并发性从你的 API 中分离出去。选择使用类似线程的模型来进行并发意味着 Go 程序避免了 Bob Nystrom 在他著名博客文章 "你的函数是什么颜色?" 中描述的“函数着色”问题。

你可以在 The Go Playground 上或 第十二章存储库 中的 sample_code/goroutine 目录中找到完整的示例。

通道

Goroutine 使用 通道 进行通信。与切片和映射类似,通道是使用 make 函数创建的内置类型:

ch := make(chan int)

像映射一样,通道也是引用类型。当你将一个通道传递给一个函数时,你实际上是传递了一个指向该通道的指针。与映射和切片一样,通道的零值是 nil

读取、写入和缓冲

使用 <- 操作符与通道交互。通过将 <- 操作符放置在通道变量的左侧来从通道中读取,通过将其放置在右侧来向通道中写入:

a := <-ch // reads a value from ch and assigns it to a
ch <- b   // write the value in b to ch

每个写入到通道的值只能被读取一次。如果有多个 goroutine 从同一个通道读取,那么写入到通道的值只会被其中一个读取。

单个 goroutine 很少会同时从同一个通道读取和写入。当将通道分配给变量或字段,或者将其传递给函数时,请在 chan 关键字之前使用箭头(ch <-chan int)来指示该 goroutine 只从通道中 读取。在 chan 关键字之后使用箭头(ch chan<- int)来指示该 goroutine 只 写入 通道。这样做可以让 Go 编译器确保通道只能被函数读取或写入。

默认情况下,通道是 无缓冲 的。每次向打开的无缓冲通道写入时,写入 goroutine 都会暂停,直到另一个 goroutine 从同一通道读取。同样地,从打开的无缓冲通道读取时,读取 goroutine 也会暂停,直到另一个 goroutine 向同一通道写入。这意味着你不能在没有至少两个并发运行的 goroutine 的情况下写入或读取无缓冲通道。

Go 语言还拥有 缓冲 通道。这些通道可以缓冲有限数量的写入操作而不会阻塞。如果在从通道读取之前缓冲区已满,那么对通道的后续写入会使写入 goroutine 暂停,直到有读取操作发生。与写入到满缓冲区的通道会阻塞一样,从空缓冲区读取的通道也会阻塞。

创建缓冲通道时,可以在创建通道时指定缓冲区的容量:

ch := make(chan int, 10)

内置函数lencap返回有关缓冲通道的信息。使用len可以查找当前缓冲区中有多少个值,使用cap可以查找缓冲区的最大容量。缓冲区的容量不能更改。

注意

将无缓冲通道传递给lencap都将返回 0。这是有道理的,因为按定义,无缓冲通道没有缓冲区来存储值。

大多数情况下,应该使用无缓冲通道。在“了解何时使用缓冲和非缓冲通道”,我将讨论使用缓冲通道的情况。

使用for-range和通道

你也可以通过使用for-range循环从通道中读取:

for v := range ch {
    fmt.Println(v)
}

不像其他for-range循环,这里只声明了一个变量用于通道的值,即v。如果通道打开并且通道上有值可用,则将其赋给v并执行循环体。如果通道上没有可用值,则 goroutine 暂停,直到通道有值可用或通道关闭。循环会继续,直到通道关闭,或达到breakreturn语句。

关闭通道

当你完成向通道写入数据后,使用内置的close函数关闭它:

close(ch)

一旦通道关闭,任何尝试向其写入或再次关闭它的操作都会引发恐慌。有趣的是,尝试从关闭的通道读取始终成功。如果通道是带缓冲的,并且还有未读取的值,它们将按顺序返回。如果通道是无缓冲的或缓冲通道没有更多值,则通道的类型的零值将返回。

这引出了一个问题,这个问题在你使用映射时可能会很熟悉:当你的代码从通道中读取时,如何区分是因为通道关闭而返回的零值,还是因为写入的零值?由于 Go 语言试图保持一致性,这里有一个熟悉的答案——使用逗号-ok 惯用法来检测通道是否已关闭:

v, ok := <-ch

如果ok设置为true,则通道是打开的。如果设置为false,则通道已关闭。

提示

每当从可能已关闭的通道读取时,请使用逗号-ok 惯用法确保通道仍然打开。

关闭通道的责任属于写入通道的 goroutine。请注意,只有当有 goroutine 在等待通道关闭时(例如使用for-range循环从通道读取数据时),才需要关闭通道。由于通道只是另一个变量,Go 的运行时可以检测到不再被引用的通道并对其进行垃圾回收。

通道是 Go 并发模型的两大特色之一。它们引导你将代码视为一系列阶段,并清晰地表达数据依赖关系,这使得推理并发变得更容易。其他语言依赖于全局共享状态来在线程之间通信。这种可变的共享状态使得理解数据如何在程序中流动变得困难,进而难以确定两个线程是否真的是独立的。

理解通道的行为方式

通道有多种状态,在读取、写入或关闭时有不同的行为。使用 表 12-1 来理清它们。

表 12-1. 通道的行为方式

无缓冲,开放状态无缓冲,关闭状态带缓冲,开放状态带缓冲,关闭状态
读取等待直到有东西被写入返回零值(使用 comma ok 来查看是否关闭)如果缓冲区为空则等待返回缓冲区中的剩余值;如果缓冲区为空则返回零值(使用 comma ok 来查看是否关闭)永久挂起
写入等待直到有东西被读取PANIC如果缓冲区满则等待PANIC永久挂起
关闭可行PANIC可行,剩余值仍在PANICPANIC

你必须避免导致 Go 程序引发 panic 的情况。如前所述,标准模式是在写入 goroutine 写入完毕后关闭通道。当多个 goroutine 向同一通道写入时,情况会变得更复杂,因为在同一通道上调用两次 close 会导致 panic。此外,如果一个 goroutine 中关闭了通道,另一个 goroutine 中写入该通道也会触发 panic。解决这个问题的方法是使用 sync.WaitGroup,你将在 “使用 WaitGroups” 中看到一个示例。

nil 通道在某些情况下也可能存在风险,但在其他情况下也是有用的。你将在 “关闭 select 中的 case” 中了解更多相关内容。

select

select 语句是 Go 并发模型的另一大特色。它是 Go 中的并发控制结构,优雅地解决了一个常见问题:如果可以执行两个并发操作,那么应该先执行哪一个?你不能偏袒某个操作,否则有些情况将永远不会被处理。这被称为 饥饿 问题。

select 关键字允许一个 goroutine 从多个通道中读取或写入其中一个。它看起来与空的 switch 语句非常相似:

select {
case v := <-ch:
    fmt.Println(v)
case v := <-ch2:
    fmt.Println(v)
case ch3 <- x:
    fmt.Println("wrote", x)
case <-ch4:
    fmt.Println("got value on ch4, but ignored it")
}

select 中的每个 case 都是对通道的读取或写入。如果某个 case 可以读取或写入,它将与 case 的主体一起执行。与 switch 类似,select 中的每个 case 都创建了自己的代码块。

如果多个情况都有可以读取或写入的通道会发生什么?select算法很简单:它从可以继续的任何情况中随机选择;顺序不重要。这与switch语句非常不同,后者总是选择第一个解析为truecase。它还清楚地解决了饥饿问题,因为没有一个case优先于另一个,并且所有情况同时被检查。

select随机选择的另一个优势是它防止了最常见的死锁原因之一:以不一致的顺序获取锁。如果有两个 goroutine 都访问相同的两个通道,它们必须在两个 goroutine 中以相同的顺序访问,否则它们将死锁。这意味着两者都无法继续,因为它们正在等待对方。如果您的 Go 应用程序中的每个 goroutine 都死锁,Go 运行时将终止您的程序(参见示例 12-1)。

示例 12-1. 死锁的 goroutine
func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    go func() {
        inGoroutine := 1
        ch1 <- inGoroutine
        fromMain := <-ch2
        fmt.Println("goroutine:", inGoroutine, fromMain)
    }()
    inMain := 2
    ch2 <- inMain
    fromGoroutine := <-ch1
    fmt.Println("main:", inMain, fromGoroutine)
}

如果您在Go Playground第十二章库的 sample_code/deadlock 目录中运行此程序,您将看到以下错误:

fatal error: all goroutines are asleep - deadlock!

请记住,main在启动时由 Go 运行时在一个 goroutine 中运行。显式启动的 goroutine 在ch1被读取之前无法继续,主 goroutine 在ch2被读取之前也无法继续。

如果主 goroutine 中的通道读取和通道写入被包裹在select中,则避免了死锁(参见示例 12-2)。

示例 12-2. 使用select避免死锁
func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    go func() {
        inGoroutine := 1
        ch1 <- inGoroutine
        fromMain := <-ch2
        fmt.Println("goroutine:", inGoroutine, fromMain)
    }()
    inMain := 2
    var fromGoroutine int
    select {
    case ch2 <- inMain:
    case fromGoroutine = <-ch1:
    }
    fmt.Println("main:", inMain, fromGoroutine)
}

如果您在Go Playground第十二章库的 sample_code/select 目录中运行此程序,您将获得如下输出:

main: 2 1

因为select检查其所有情况是否能继续,所以避免了死锁。显式启动的 goroutine 将值 1 写入了ch1,因此主 goroutine 中对ch1的读取到fromGoroutine是成功的。

尽管此程序不会死锁,但仍未能完成正确操作。在启动的 goroutine 中,fmt.Println语句永远不会执行,因为该 goroutine 已暂停,等待从ch2读取值。当主 goroutine 退出时,程序也退出并终止所有剩余的 goroutine,这在技术上解决了暂停的问题。然而,您应确保所有 goroutine 都能正确退出,以免泄漏它们。我在“始终清理您的 goroutine”中详细讨论了这个问题。

注意

使此程序正常运行需要一些您将在本章后面学到的技术。您可以在Go Playground上找到一个有效的解决方案。

由于select负责在多个通道之间进行通信,因此通常嵌入在for循环中:

for {
    select {
    case <-done:
        return
    case v := <-ch:
        fmt.Println(v)
    }
}

这种组合通常被称为 for-select 循环。在使用 for-select 循环时,你必须包含一种退出循环的方法。你会在 “使用上下文终止 Goroutines” 中看到一种方法。

就像 switch 语句一样,select 语句也可以有一个 default 子句。和 switch 类似,当没有可以读取或写入的通道时,会选择 default。如果你想在通道上实现非阻塞读取或写入,使用带有 defaultselect。下面的代码在 ch 中没有可读取的值时不会等待,而是立即执行 default 的主体:

select {
case v := <-ch:
    fmt.Println("read from ch:", v)
default:
    fmt.Println("no value written to ch")
}

你将会在 “实现反压力” 中看到 default 的用法。

注意

for-select 循环内部有一个 default 案例几乎总是不正确的。当循环中任何一个案例没有读取或写入时,它会在每次循环时触发。这会使得你的 for 循环持续运行,消耗大量的 CPU。

并发实践和模式

现在你已经了解了 Go 提供的并发基本工具,让我们来看看一些并发的最佳实践和模式。

保持你的 API 不受并发的影响

并发是一个实现细节,良好的 API 设计应尽可能隐藏实现细节。这样可以在不改变代码调用方式的情况下改变代码的工作方式。

实际上,这意味着你不应该在 API 的类型、函数和方法中暴露通道或互斥锁(我将在 “何时使用互斥锁而不是通道” 中详细讨论互斥锁)。如果你暴露一个通道,你将把通道管理的责任放在 API 用户身上。用户则需要担心诸如通道是缓冲的、已关闭或 nil 的问题。他们也可能通过以意外的顺序访问通道或互斥锁来触发死锁。

注意

这并不意味着你不应该将通道作为函数参数或结构体字段。它意味着它们不应该被导出。

这条规则有一些例外。如果你的 API 是一个带有并发辅助函数的库,通道将成为其 API 的一部分。

Goroutines、for 循环和变量变化

大多数情况下,用于启动 Goroutine 的闭包没有参数。相反,它捕获了在声明它的环境中的值。在 Go 1.22 之前,有一个常见的情况是这种方式不起作用:尝试捕获 for 循环的索引或值。正如 “for-range 值是副本” 和 “使用 go.mod” 中提到的,Go 1.22 引入了一个破坏性变化,改变了 for 循环的行为,使其在每次迭代时为索引和值创建新变量,而不是重复使用单个变量。

以下代码演示了这个变更值得的原因。你可以在 GitHub 上的 goroutine_for_loop 仓库 找到它,这个仓库属于《学习 Go 第二版》组织。

如果你在 Go 1.21 或更早版本运行以下代码(或在 Go 1.22 或更新版本中,go.mod 文件的 go 指令设置为 1.21 或更早版本),你会看到一个微妙的 bug:

func main() {
    a := []int{2, 4, 6, 8, 10}
    ch := make(chan int, len(a))
    for _, v := range a {
        go func() {
            ch <- v * 2
        }()
    }
    for i := 0; i < len(a); i++ {
        fmt.Println(<-ch)
    }
}

对于 a 中的每个值,都会启动一个 goroutine。看起来每个 goroutine 接收到的值都不同,但实际运行代码显示的情况却不同:

20
20
20
20
20

在早期的 Go 版本中,每个 goroutine 都向 ch 写入 20 的原因是,每个 goroutine 的闭包捕获了相同的变量。for 循环中的索引和数值变量在每次迭代中都被重用。变量 v 最后被赋的值是 10。当 goroutine 运行时,它们看到的就是这个值。

升级到 Go 1.22 或更高版本,并将 go.mod 中的 go 指令值改为 1.22 或更高版本,将会改变 for 循环的行为,使其在每次迭代时创建新的索引和数值变量。这样会得到预期的结果,每个 goroutine 接收到不同的值:

20
8
4
12
16

如果你无法升级到 Go 1.22,可以通过两种方式解决这个问题。首先是在循环内部复制数值来遮蔽数值:

for _, v := range a {
    v := v
    go func() {
        ch <- v * 2
    }()
}

如果你想避免遮蔽并使数据流更加清晰,也可以将该值作为参数传递给 goroutine:

for _, v := range a {
    go func(val int) {
        ch <- val * 2
    }(v)
}

虽然 Go 1.22 可以避免 for 循环中索引和数值变量的问题,但对于闭包中捕获的其他变量仍需小心。每当闭包依赖可能会改变的变量值时,无论是否用作 goroutine,都必须将该值传递给闭包,或者确保为每个引用该变量的闭包创建一个独立的变量副本。

小贴士

每当闭包使用一个可能会改变的变量时,使用参数将该变量的当前值传递给闭包。

始终清理你的 Goroutines

每当启动一个 goroutine 函数时,一定要确保它最终会退出。与变量不同,Go 运行时无法检测出 goroutine 是否会再次被使用。如果一个 goroutine 没有退出,那么分配给其栈上变量的所有内存都将保留,任何根据 goroutine 栈上变量分配的堆上内存也无法被垃圾回收。这被称为 goroutine 泄漏

一个 goroutine 并不一定能保证退出。例如,假设你将 goroutine 用作生成器:

func countTo(max int) <-chan int {
    ch := make(chan int)
    go func() {
        for i := 0; i < max; i++ {
            ch <- i
        }
        close(ch)
    }()
    return ch
}

func main() {
    for i := range countTo(10) {
        fmt.Println(i)
    }
}
注意

这只是一个简短的示例;不要使用 goroutine 生成数字列表。这太简单了,违反了我们“何时使用并发”的指导原则之一。

在通常情况下,当你使用完所有值时,goroutine 会退出。但是,如果你提前退出循环,goroutine 就会永远阻塞,等待从通道中读取值:

func main() {
    for i := range countTo(10) {
        if i > 5 {
            break
        }
        fmt.Println(i)
    }
}

使用上下文终止 Goroutines

要解决countTo goroutine 泄漏的问题,你需要一种方法告诉 goroutine 现在是停止处理的时候了。在 Go 中,你通过使用上下文来解决这个问题。下面是一个重写的countTo示例,演示了这种技术。你可以在第十二章存储库sample_code/context_cancel目录中找到这段代码。

func countTo(ctx context.Context, max int) <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        for i := 0; i < max; i++ {
            select {
            case <-ctx.Done():
                return
            case ch <- i:
            }
        }
    }()
    return ch
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    ch := countTo(ctx, 10)
    for i := range ch {
        if i > 5 {
            break
        }
        fmt.Println(i)
    }
}

countTo函数修改为除了max之外还接受一个context.Context参数。goroutine 中的for循环也已更改。现在是一个带有两个 case 的for-select循环。一个尝试向ch写入。另一个 case 检查上下文的Done方法返回的通道。如果它返回一个值,你退出for-select循环和 goroutine。现在,你有了一种方法可以在读取每个值时防止 goroutine 泄漏。

这就引出了一个问题,你如何让Done通道返回一个值?它通过上下文取消来触发。在main函数中,你使用context包中的WithCancel函数创建了一个上下文和一个取消函数。接下来,你使用defermain函数退出时调用cancel。这关闭了Done返回的通道,并且由于关闭的通道始终返回一个值,它确保运行countTo的 goroutine 退出。

使用上下文来终止 goroutine 是一个非常常见的模式。它允许你基于调用堆栈中较早的某些东西来停止 goroutine。在“取消”,你将详细了解如何使用上下文告诉一个或多个 goroutine 现在是时候关闭了。

了解何时使用有缓冲和无缓冲通道

在 Go 并发中掌握最复杂的技术之一是决定何时使用缓冲通道。默认情况下,通道是无缓冲的,并且很容易理解:一个 goroutine 写入并等待另一个 goroutine 接管它的工作,就像接力赛中的接力棒一样。缓冲通道则复杂得多。你必须选择一个大小,因为缓冲通道永远不会有无限的缓冲区。正确使用缓冲通道意味着你必须处理缓冲区已满并且你的写入 goroutine 因等待读取 goroutine 而阻塞的情况。那么,什么是正确使用缓冲通道的方式呢?

缓冲通道的情况很微妙。简单地说:当你知道自己启动了多少个 goroutine 时,想要限制你将要启动的 goroutine 数量,或者想要限制排队的工作量时,缓冲通道是有用的。

缓冲通道在你想要从启动的一组 goroutine 中收集数据或者想要限制并发使用时非常有效。它们还有助于管理系统排队的工作量,防止你的服务落后并变得不堪重负。以下是几个示例,展示了它们的使用方式。

在第一个示例中,您正在处理通道上的前 10 个结果。为此,您启动 10 个 goroutine,每个 goroutine 将其结果写入缓冲通道:

func processChannel(ch chan int) []int {
    const conc = 10
    results := make(chan int, conc)
    for i := 0; i < conc; i++ {
        go func() {
            v := <- ch
            results <- process(v)
        }()
    }
    var out []int
    for i := 0; i < conc; i++ {
        out = append(out, <-results)
    }
    return out
}

您确切地知道启动了多少个 goroutine,并且希望每个 goroutine 在完成其工作后立即退出。这意味着您可以为每个启动的 goroutine 创建一个带有一个空间的缓冲通道,并让每个 goroutine 向该 goroutine 写入数据而不阻塞。然后,您可以循环遍历缓冲通道,读取写入的值。当所有值都已读取时,您返回结果,知道没有泄漏任何 goroutine。

您可以在第十二章存储库sample_code/buffered_channel_work目录中找到此代码。

实现背压

另一种可以使用缓冲通道实现的技术是背压。这似乎违反直觉,但是当系统组件限制其愿意执行的工作量时,系统整体表现更佳。您可以使用缓冲通道和select语句来限制系统中同时请求的数量:

type PressureGauge struct {
    ch chan struct{}
}

func New(limit int) *PressureGauge {
    return &PressureGauge{
        ch: make(chan struct{}, limit),
    }
}

func (pg *PressureGauge) Process(f func()) error {
    select {
    case pg.ch <- struct{}{}:
        f()
        <-pg.ch
        return nil
    default:
        return errors.New("no more capacity")
    }
}

在此代码中,您创建一个包含可以容纳多个“令牌”的缓冲通道和要运行的函数的结构体。每次 goroutine 想要使用函数时,它调用Process。这是同一个 goroutine 读取和写入同一个通道的少数例子之一。select尝试向通道写入令牌。如果可以,函数运行,然后从缓冲通道读取一个令牌。如果无法写入令牌,则运行default case,并返回错误。以下是一个快速示例,使用此代码与内置 HTTP 服务器(您将在“服务器”中学习更多关于与 HTTP 的工作):

func doThingThatShouldBeLimited() string {
    time.Sleep(2 * time.Second)
    return "done"
}

func main() {
    pg := New(10)
    http.HandleFunc("/request", func(w http.ResponseWriter, r *http.Request) {
        err := pg.Process(func() {
            w.Write([]byte(doThingThatShouldBeLimited()))
        })
        if err != nil {
            w.WriteHeader(http.StatusTooManyRequests)
            w.Write([]byte("Too many requests"))
        }
    })
    http.ListenAndServe(":8080", nil)
}

您可以在第十二章存储库sample_code/backpressure目录中找到此代码。

关闭select中的 case

当您需要从多个并发源合并数据时,select关键字非常有用。但是,您需要正确处理关闭的通道。如果select中的某个 case 正在读取一个关闭的通道,则它总是成功的,返回零值。每次选择该 case 时,您需要检查确保该值有效并跳过该 case。如果读取被分散,您的程序将浪费大量时间读取垃圾值。即使非关闭通道上有大量活动,您的程序仍会花费一部分时间从关闭的通道读取,因为select随机选择一个 case。

当发生这种情况时,你依赖于看起来像是错误的东西:读取一个nil通道。正如你之前看到的,从或写入nil通道会导致你的代码永远挂起。虽然如果由于 bug 触发是不好的,但你可以使用nil通道来禁用select中的case。当检测到通道已关闭时,将通道的变量设置为nil。相关的 case 将不再运行,因为从nil通道读取永远不会返回值。这里是一个for-select循环,从两个通道中读取直到两个通道都关闭:

// in and in2 are channels
for count := 0; count < 2; {
    select {
    case v, ok := <-in:
        if !ok {
            in = nil // the case will never succeed again!
            count++
            continue
        }
        // process the v that was read from in
    case v, ok := <-in2:
        if !ok {
            in2 = nil // the case will never succeed again!
            count++
            continue
        }
        // process the v that was read from in2
    }
}

你可以在Go Playgroundsample_code/close_case目录中的第十二章存储库中尝试这段代码。

超时代码

大多数交互式程序必须在一定时间内返回响应。在 Go 语言中,使用并发可以管理请求(或请求的一部分)运行的时间。其他语言在 promise 或 future 之上引入了额外的功能来添加这种功能,但 Go 语言的超时习惯表明了如何从现有部件构建复杂功能。让我们来看一下:

func timeLimitT any T, limit time.Duration) (T, error) {
    out := make(chan T, 1)
    ctx, cancel := context.WithTimeout(context.Background(), limit)
    defer cancel()
    go func() {
        out <- worker()
    }()
    select {
    case result := <-out:
        return result, nil
    case <-ctx.Done():
        var zero T
        return zero, errors.New("work timed out")
    }
}

每当你需要在 Go 中限制操作花费的时间时,你会看到这种模式的变体。我在第十四章中讨论上下文,并详细介绍了如何使用超时在“带截止日期的上下文”中。现在,你只需要知道达到超时会取消上下文。上下文的Done方法返回一个通道,在上下文由于超时或调用上下文的取消方法而被取消时返回一个值。你可以通过使用context包中的WithTimeout函数创建一个定时上下文,并使用time包中的常量指定等待时间(我将在time中更多地讨论time包)。

一旦设置了上下文,就在一个 goroutine 中运行工作程序,然后使用select选择两种情况之间的一种。第一种情况在工作完成时从out通道读取值。第二种情况等待Done方法返回的通道返回一个值,就像在“使用上下文终止 Goroutines”中看到的那样。如果是这样,就返回超时错误。你可以写入一个大小为 1 的缓冲通道,以便即使Done首先触发,goroutine 中的通道写入也会完成。

你可以在Go Playgroundsample_code/time_out目录中的第十二章存储库中尝试这段代码。

注意

如果 timeLimit 在 goroutine 完成处理之前退出,那么 goroutine 会继续运行,最终将返回的值写入缓冲通道并退出。你只是不处理返回的结果。如果想在不再等待 goroutine 完成时停止其工作,可以使用上下文取消,我将在 “Cancellation” 中讨论。

使用 WaitGroups

有时一个 goroutine 需要等待多个 goroutine 完成它们的工作。如果你等待单个 goroutine,可以使用之前看到的上下文取消模式。但如果你正在等待多个 goroutine,则需要使用 WaitGroup,它位于标准库中的 sync 包中。这里是一个简单的例子,你可以在 The Go Playground 运行,或者在 第十二章仓库sample_code/waitgroup 目录中运行:

func main() {
    var wg sync.WaitGroup
    wg.Add(3)
    go func() {
        defer wg.Done()
        doThing1()
    }()
    go func() {
        defer wg.Done()
        doThing2()
    }()
    go func() {
        defer wg.Done()
        doThing3()
    }()
    wg.Wait()
}

sync.WaitGroup 不需要初始化,只需声明,因为它的零值是有用的。sync.WaitGroup 上有三个方法:Add,用于增加等待的 goroutine 计数器;Done,在 goroutine 完成时减少计数器,并且当它完成时调用;Wait,暂停其 goroutine 直到计数器为零。通常只调用一次 Add,并指定将要启动的 goroutine 数量。在 goroutine 内部调用 Done。为了确保即使 goroutine 恐慌也会调用 Done,可以使用 defer

你会注意到,你并没有显式地传递 sync.WaitGroup。有两个原因。第一个是你必须确保每个使用 sync.WaitGroup 的地方都使用同一个实例。如果将 sync.WaitGroup 传递给 goroutine 函数并且不使用指针,则该函数有一个副本,调用 Done 不会减少原始的 sync.WaitGroup。通过使用闭包捕获 sync.WaitGroup,你确保每个 goroutine 引用的是同一个实例。

第二个原因是设计。记住,你应该将并发性从你的 API 中分离出去。正如你之前在通道中看到的,通常的模式是使用一个闭包启动一个 goroutine,该闭包封装业务逻辑周围的并发问题,而函数则提供算法。

让我们看一个更现实的例子。正如我之前提到的,当你有多个 goroutine 向同一个通道写入时,你需要确保只关闭被写入的通道一次。sync.WaitGroup 就非常适合这种情况。让我们看看它在一个函数中的运作方式,该函数并发处理通道中的值,将结果收集到一个切片中,并返回该切片:

func processAndGatherT, R any R, num int) []R {
    out := make(chan R, num)
    var wg sync.WaitGroup
    wg.Add(num)
    for i := 0; i < num; i++ {
        go func() {
            defer wg.Done()
            for v := range in {
                out <- processor(v)
            }
        }()
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    var result []R
    for v := range out {
        result = append(result, v)
    }
    return result
}

在这个示例中,你启动了一个监控 goroutine,等待所有处理 goroutine 退出。当它们退出时,监控 goroutine 在输出通道上调用close。当out关闭并且缓冲区为空时,for-range通道循环退出。最后,函数返回处理后的值。你可以在第十二章存储库sample_code/waitgroup_close_once目录中尝试此代码。

虽然WaitGroups很方便,但在协调 goroutine 时不应该是你的首选。仅在所有工作 goroutine 退出后需要进行清理(例如关闭它们写入的通道)时才使用它们。

仅运行代码一次

正如我在“避免 init 函数(如果可能的话)”中所述,init应该保留用于有效不可变的包级别状态的初始化。然而,有时候你想要延迟加载,或者在程序启动后仅调用某些初始化代码一次。这通常是因为初始化相对缓慢,并且可能并非每次程序运行都需要。sync包包含一个称为Once的方便类型,可以实现这种功能。让我们快速看一下它是如何工作的。假设你有一些需要长时间初始化的代码:

type SlowComplicatedParser interface {
    Parse(string) string
}

func initParser() SlowComplicatedParser {
    // do all sorts of setup and loading here
}

下面是如何使用sync.Once延迟初始化SlowComplicatedParser的方法:

var parser SlowComplicatedParser
var once sync.Once

func Parse(dataToParse string) string {
    once.Do(func() {
        parser = initParser()
    })
    return parser.Parse(dataToParse)
}

有两个包级别的变量:parser,类型为SlowComplicatedParser,以及once,类型为sync.Once。与sync.WaitGroup类似,你无需配置sync.Once的实例。这是一个例子,使零值变得有用,这是 Go 语言中的一种常见模式。

sync.WaitGroup类似,你必须确保不复制sync.Once的实例,因为每个副本都有自己的状态来指示它是否已经被使用过。通常在函数内声明sync.Once实例是错误的做法,因为每次函数调用都会创建一个新实例,并且不会记住先前的调用。

在这个示例中,你需要确保parser仅初始化一次,因此你将parser的值设置在传递给onceDo方法的闭包中。如果调用Parse超过一次,once.Do将不会再次执行闭包。

你可以在The Go Playground第十二章存储库中的sample_code/sync_once目录中尝试此代码。

Go 1.21 添加了一些辅助函数,使得执行函数仅一次变得更加容易:sync.OnceFuncsync.OnceValuesync.OnceValues。这三个函数之间唯一的区别是传入函数的返回值数量(分别为零、一个或两个)。sync.OnceValuesync.OnceValues函数是通用的,因此它们适应原始函数返回值的类型。

使用这些函数非常简单。你将原始函数传递给辅助函数,然后得到一个仅调用原始函数一次的函数。原始函数返回的值会被缓存。以下是如何使用sync.OnceValue重写上一个示例中的Parse函数:

var initParserCached func() SlowComplicatedParser = sync.OnceValue(initParser)

func Parse(dataToParse string) string {
    parser := initParserCached()
    return parser.Parse(dataToParse)
}

initParserCached变量在包级别被赋值为sync.OnceValue返回的函数时,说明initParser被传递给它了。第一次调用initParserCached时,也会调用initParser,并缓存它的返回值。每次后续调用initParserCached时,都会返回缓存的值。这意味着你可以去掉包级别的parser变量。

你可以在Go Playground上尝试这段代码,或者在第十二章的代码库sample_code/sync_value目录中尝试。

将你的并发工具整合起来

让我们回到本章第一节的示例中。你有一个调用三个 Web 服务的函数。你向其中两个服务发送数据,然后将这两次调用的结果发送给第三个服务,并返回结果。整个过程必须在 50 毫秒内完成,否则将返回错误。

你将从你调用的函数开始:

func GatherAndProcess(ctx context.Context, data Input) (COut, error) {
    ctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
    defer cancel()

    ab := newABProcessor()
    ab.start(ctx, data)
    inputC, err := ab.wait(ctx)
    if err != nil {
        return COut{}, err
    }

    c := newCProcessor()
    c.start(ctx, inputC)
    out, err := c.wait(ctx)
    return out, err
}

首先要做的是设置一个超时为 50 毫秒的context.Context,就像你在“Time Out Code”中看到的一样。

创建完 context 后,使用defer确保调用 context 的cancel函数。正如我在“Cancellation”中会讨论的,你必须调用这个函数,否则资源会泄漏。

你将AB作为两个并行调用的服务名称,所以你将创建一个新的abProcessor来调用它们。然后你通过调用start方法开始处理,并通过调用wait方法等待结果。

wait返回时,你进行标准的错误检查。如果一切顺利,你调用第三个服务,称之为C。逻辑与之前相同。通过在cProcessor上调用start方法开始处理,然后通过在cProcessor上调用wait方法等待结果。然后返回wait方法调用的结果。

这看起来很像标准的顺序代码,没有并发。让我们看看abProcessorcProcessor中是如何进行并发的:

type abProcessor struct {
    outA chan aOut
    outB chan bOut
    errs chan error
}

func newABProcessor() *abProcessor {
    return &abProcessor{
        outA: make(chan aOut, 1),
        outB: make(chan bOut, 1),
        errs: make(chan error, 2),
    }
}

abProcessor有三个字段,全部都是通道。它们是outAoutBerrs。接下来你将看到如何使用这些通道。注意每个通道都是有缓冲的,这样写入它们的 goroutine 在写完后就可以退出,而不必等待读取。errs通道的缓冲大小为2,因为最多可能会有两个错误写入其中。

接下来是start方法的实现:

func (p *abProcessor) start(ctx context.Context, data Input) {
    go func() {
        aOut, err := getResultA(ctx, data.A)
        if err != nil {
            p.errs <- err
            return
        }
        p.outA <- aOut
    }()
    go func() {
        bOut, err := getResultB(ctx, data.B)
        if err != nil {
            p.errs <- err
            return
        }
        p.outB <- bOut
    }()
}

start 方法启动了两个 goroutine。第一个调用 getResultA 来与 A 服务通信。如果调用返回错误,就向 errs 通道写入。否则,向 outA 通道写入。由于这些通道是有缓冲的,所以无论写入哪个通道,goroutine 都不会挂起。同时注意,你将上下文传递给 getResultA,这允许它在超时时取消处理。

第二个 goroutine 与第一个完全相同,只是调用 getResultB 并在成功时向 outB 通道写入。

让我们看看 ABProcessorwait 方法是什么样的:

func (p *abProcessor) wait(ctx context.Context) (cIn, error) {
    var cData cIn
    for count := 0; count < 2; count++ {
        select {
        case a := <-p.outA:
            cData.a = a
        case b := <-p.outB:
            cData.b = b
        case err := <-p.errs:
            return cIn{}, err
        case <-ctx.Done():
            return cIn{}, ctx.Err()
        }
    }
    return cData, nil
}

abProcessor 上的 wait 方法是你需要实现的最复杂方法。它填充了一个 cIn 类型的结构体,该结构体保存从调用 A 服务和 B 服务返回的数据。你将输出变量 cData 定义为 cIn 类型。然后有一个 for 循环,计数到两个,因为你需要从两个通道读取以成功完成。在循环内部,有一个 select 语句。如果从 outA 通道读取到一个值,就设置 cDataa 字段。如果从 outB 通道读取到一个值,就设置 cDatab 字段。如果从 errs 通道读取到一个值,就立即返回错误。最后,如果上下文超时,就从上下文的 Err 方法立即返回错误。

一旦从 p.outA 通道和 p.outB 通道都读取到一个值,你就退出循环并返回输入,用于 cProcessor 使用。

cProcessor 看起来像是 abProcessor 的简化版本:

type cProcessor struct {
    outC chan COut
    errs chan error
}

func newCProcessor() *cProcessor {
    return &cProcessor{
        outC: make(chan COut, 1),
        errs: make(chan error, 1),
    }
}

func (p *cProcessor) start(ctx context.Context, inputC cIn) {
    go func() {
        cOut, err := getResultC(ctx, inputC)
        if err != nil {
            p.errs <- err
            return
        }
        p.outC <- cOut
    }()
}

func (p *cProcessor) wait(ctx context.Context) (COut, error) {
    select {
    case out := <-p.outC:
        return out, nil
    case err := <-p.errs:
        return COut{}, err
    case <-ctx.Done():
        return COut{}, ctx.Err()
    }
}

cProcessor 结构体有一个输出通道和一个错误通道。

cProcessorstart 方法看起来像是 abProcessorstart 方法。它启动一个 goroutine,调用 getResultC 处理输入数据,在错误时向 errs 通道写入,成功时向 outC 通道写入。

最后,cProcessor 上的 wait 方法是一个简单的 select 语句,检查是否有值可以从 outC 通道、errs 通道或上下文的 Done 通道读取。

通过使用 goroutine、通道和 select 语句来组织代码,你可以将各个步骤分离,允许独立部分以任何顺序运行和完成,并在依赖部分之间清晰地交换数据。此外,你确保程序的任何部分都不会挂起,并且正确处理既在此函数内设置的超时,也在调用历史中的较早函数内设置的超时。如果你不确信这是否是实现并发的更好方法,请尝试在另一种语言中实现它。你可能会惊讶地发现它有多么困难。

你可以在第十二章代码库sample_code/pipeline 目录中找到这个并发流水线的代码。

何时使用互斥锁而不是通道

如果你曾经在其他编程语言中协调线程间数据访问,你可能使用过互斥锁。这是互斥排除的缩写,互斥锁的作用是限制某些代码的并发执行或对共享数据的访问。受保护的部分称为临界区

Go 语言的创建者设计通道和select来管理并发有很好的原因。互斥锁的主要问题是它们会混淆程序中的数据流。当值通过一系列通道从 goroutine 传递到 goroutine 时,数据流是清晰的。对值的访问局限于一次只有一个 goroutine。当互斥锁用于保护一个值时,没有任何指示当前哪个 goroutine 拥有该值的方式,因为所有并发进程共享对值的访问。这使得理解处理顺序变得困难。在 Go 社区中有一句话来描述这种哲学:“通过通信共享内存,不要通过共享内存进行通信。”

有时候,使用互斥锁会更清晰一些,Go 标准库包含了这些情况的互斥锁实现。最常见的情况是当你的 goroutine 读取或写入共享值,但不处理该值。让我们以内存中的多人游戏积分板为例。首先看看如何使用通道来实现这一点。下面是一个函数,你可以启动它作为一个 goroutine 来管理积分板:

func scoreboardManager(ctx context.Context, in <-chan func(map[string]int)) {
    scoreboard := map[string]int{}
    for {
        select {
        case <-ctx.Done():
            return
        case f := <-in:
            f(scoreboard)
        }
    }
}

此函数声明了一个映射,然后监听一个通道以便接收一个读取或修改映射的函数,同时监听上下文的 Done 通道以知道何时关闭。让我们创建一个带有写入值到映射的方法的类型:

type ChannelScoreboardManager chan func(map[string]int)

func NewChannelScoreboardManager(ctx context.Context) ChannelScoreboardManager {
    ch := make(ChannelScoreboardManager)
    go scoreboardManager(ctx, ch)
    return ch
}

func (csm ChannelScoreboardManager) Update(name string, val int) {
    csm <- func(m map[string]int) {
        m[name] = val
    }
}

更新方法非常直接:只需传递一个将值放入映射中的函数即可。但是如何从积分板中读取?你需要返回一个值。这意味着创建一个在传入函数中被写入的通道:

func (csm ChannelScoreboardManager) Read(name string) (int, bool) {
    type Result struct {
        out int
        ok  bool
    }
    resultCh := make(chan Result)
    csm <- func(m map[string]int) {
        out, ok := m[name]
        resultCh <- Result{out, ok}
    }
    result := <-resultCh
    return result.out, result.ok
}

虽然这段代码可以工作,但它很繁琐,并且一次只允许一个读取者。更好的方法是使用互斥锁。标准库中有两种互斥锁实现,都在sync包中。第一种是Mutex,有两个方法,LockUnlock。调用Lock会导致当前 goroutine 暂停,直到另一个 goroutine 当前在临界区内为止。当临界区清除时,锁被当前 goroutine 获取,并执行临界区中的代码。在Mutex上调用Unlock方法标记临界区的结束。

第二种互斥锁实现称为RWMutex,允许同时拥有读锁和写锁。虽然每次只能有一个写锁进入临界区,但读锁是共享的;多个读者可以同时进入临界区。写锁由LockUnlock方法管理,而读锁由RLockRUnlock方法管理。

每当你获取一个互斥锁时,一定要确保释放锁。使用defer语句在调用LockRLock之后立即调用Unlock

type MutexScoreboardManager struct {
    l          sync.RWMutex
    scoreboard map[string]int
}

func NewMutexScoreboardManager() *MutexScoreboardManager {
    return &MutexScoreboardManager{
        scoreboard: map[string]int{},
    }
}

func (msm *MutexScoreboardManager) Update(name string, val int) {
    msm.l.Lock()
    defer msm.l.Unlock()
    msm.scoreboard[name] = val
}

func (msm *MutexScoreboardManager) Read(name string) (int, bool) {
    msm.l.RLock()
    defer msm.l.RUnlock()
    val, ok := msm.scoreboard[name]
    return val, ok
}

第十二章存储库sample_code/mutex目录中可以找到示例。

现在你已经看到了使用互斥锁的实现,再在使用之前慎重考虑你的选择。Katherine Cox-Buday 的优秀著作《Go 语言并发编程》(O’Reilly)包含一个决策树,帮助你决定是使用通道还是互斥锁:

  • 如果你在协调 goroutine 或跟踪值在一系列 goroutine 中的转换过程中,请使用通道。

  • 如果你正在共享结构体中的字段访问,请使用互斥锁。

  • 如果在使用通道时发现了关键性能问题(参见“使用基准测试”了解如何做到这一点),并且找不到其他解决方法来修复问题,请修改你的代码以使用互斥锁。

因为你的记分板是结构体中的一个字段,并且没有记分板的传递,使用互斥锁是合理的。只有在数据存储在内存中时,才是互斥锁的良好使用场景。当数据存储在外部服务(如 HTTP 服务器或数据库)中时,请勿使用互斥锁来保护系统访问。

互斥锁需要你进行更多的簿记。例如,你必须正确配对锁定和解锁,否则你的程序很可能会死锁。示例中在同一方法中同时获取和释放锁。另一个问题是 Go 中的互斥锁不是可重入的。如果一个 goroutine 尝试两次获取同一个锁,它将死锁,等待自己释放锁。这与像 Java 这样的语言不同,那里的锁是可重入的。

非可重入锁使得在调用自身递归的函数中获取锁变得棘手。你必须在递归函数调用之前释放锁。总体而言,在持有锁时调用函数时要小心,因为你不知道这些调用中会获取哪些锁。如果你的函数调用另一个尝试获取相同互斥锁的函数,则 goroutine 将死锁。

sync.WaitGroupsync.Once一样,互斥锁绝不能被复制。如果它们被传递给一个函数或作为结构体上的字段访问,必须通过指针。如果复制互斥锁,则其锁将不会被共享。

警告

永远不要尝试从多个 goroutine 访问一个变量,除非你首先为该变量获取互斥锁。这可能导致难以追踪的奇怪错误。参见“使用数据竞争检测器查找并发问题”了解如何检测这些问题。

原子操作——你可能不需要这些操作。

除了互斥锁,Go 还提供了另一种方式来在多个线程中保持数据一致性。sync/atomic包提供了访问现代 CPU 内置的原子变量操作,包括添加、交换、加载、存储或比较并交换(CAS)适合单个寄存器的值。

如果您需要尽可能提高性能,并且是编写并发代码的专家,您会很高兴知道 Go 包含原子支持。对于其他人,请使用 goroutine 和互斥锁来管理您的并发需求。

关于并发学习更多信息

我在这里介绍了一些简单的并发模式,但实际上还有很多。事实上,您可以撰写一本关于如何在 Go 中正确实现各种并发模式的完整书籍,幸运的是,Katherine Cox-Buday 已经做到了。在讨论互斥锁或通道如何选择时,我已经提到了Concurrency in Go,但它是关于 Go 和并发的优秀资源。如果您想了解更多,请查看她的书籍。

练习

有效地使用并发是 Go 开发人员最重要的技能之一。通过这些练习来检验您是否掌握了它们。解决方案可以在第十二章的存储库中的exercise_solutions目录中找到。

  1. 创建一个函数,启动三个使用通道进行通信的 goroutine。前两个 goroutine 各自向通道写入 10 个数字。第三个 goroutine 从通道中读取所有数字并打印出来。函数在所有值打印完毕后应退出。确保没有 goroutine 泄漏。如有需要,您可以创建额外的 goroutine。

  2. 创建一个函数,启动两个 goroutine。每个 goroutine 向自己的通道写入 10 个数字。使用for-select循环从两个通道读取数据,打印出数字及其写入该值的 goroutine。确保在所有值都被读取后函数退出,并且没有 goroutine 泄漏。

  3. 编写一个函数,构建一个map[int]float64,其中键是从 0(包括)到 100,000(不包括)的数字,而值是这些数字的平方根(使用math.Sqrt函数计算平方根)。使用sync.OnceValue生成一个函数,缓存此函数返回的map,并使用缓存值来查找从 0 到 100,000 每 1000 个数字的平方根。

总结

在本章中,您已经了解了并发性,并学习了为什么 Go 的方法比传统的并发机制更简单。在此过程中,您还学会了何时应该使用并发,以及一些并发规则和模式。在下一章中,您将快速浏览 Go 的标准库,这个库以现代计算的“一揽子”理念为核心。

第十三章:标准库

使用 Go 开发的最大优势之一是能够利用其标准库。像 Python 一样,Go 也有“电池包含”的哲学,提供了构建应用程序所需的许多工具。由于 Go 是一种相对较新的语言,它附带了一个专注于现代编程环境中遇到的问题的库。

我无法覆盖所有标准库包,幸运的是,我也不需要,因为有许多优秀的信息源涵盖了标准库,从文档开始。相反,我将重点放在几个最重要的包上,以及它们的设计和使用如何展示 Go 的成语风格的原则。一些包(errorssynccontexttestingreflectunsafe)在它们自己的章节中进行了介绍。在这一章中,你将看到 Go 对 I/O、时间、JSON 和 HTTP 的内置支持。

io 及其伙伴们

要使程序有用,它需要读取和写入数据。Go 的输入/输出哲学的核心可以在io包中找到。特别是在该包中定义的两个接口可能是 Go 中使用第二和第三最多的接口:io.Readerio.Writer

注意

第一名是什么?那将是error,你已经在第九章中看过了。

io.Readerio.Writer都定义了一个单一方法:

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

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

io.Writer接口上的Write方法接受一个字节切片,将其写入接口实现。它返回写入的字节数以及如果出现错误则返回错误。io.Reader上的Read方法更有趣。与通过返回参数返回数据不同,一个切片被传递到实现中并进行修改。最多会写入len(p)字节到切片中。该方法返回写入的字节数。这可能看起来有点奇怪。你可能期望这样:

type NotHowReaderIsDefined interface {
    Read() (p []byte, err error)
}

io.Reader被定义为它的样子有一个非常好的原因。让我们编写一个代表如何使用io.Reader的函数来说明:

func countLetters(r io.Reader) (map[string]int, error) {
    buf := make([]byte, 2048)
    out := map[string]int{}
    for {
        n, err := r.Read(buf)
        for _, b := range buf[:n] {
            if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') {
                out[string(b)]++
            }
        }
        if err == io.EOF {
            return out, nil
        }
        if err != nil {
            return nil, err
        }
    }
}

有三件事需要注意。首先,你只需创建一次缓冲区,并在每次调用r.Read时重复使用它。这允许你使用单个内存分配从可能很大的数据源中读取。如果Read方法被编写为返回一个[]byte,则每次调用都需要一个新的分配。每个分配都将最终在堆上,这将为垃圾收集器造成相当多的工作。

如果你想进一步减少分配,可以在程序启动时创建一个缓冲池。然后,在函数开始时从池中取出一个缓冲区,在函数结束时归还它。通过将一个切片传递给io.Reader,内存分配就在开发者的控制之下。

第二,你使用从r.Read返回的n值来知道写入缓冲区的字节数,并迭代处理被读取的buf切片的子切片数据。

最后,当从r.Read返回的错误是io.EOF时,你知道已经完成从r的读取。这种错误有点奇怪,因为它并不是真正的错误。它表示从io.Reader中没有剩余可读取的内容。当返回io.EOF时,你完成处理并返回你的结果。

io.Reader中的Read方法有一个不寻常的方面。在大多数情况下,当函数或方法有一个错误返回值时,你在处理非错误返回值之前会先检查错误。但是对于Read来说,你要做相反的操作,因为字节可能已经被复制到缓冲区中,然后才会因数据流结束或意外条件而触发错误。

提示

如果意外地到达了io.Reader的末尾,会返回不同的哨兵错误(io.ErrUnexpectedEOF)。请注意,它以Err开头,表示这是一个意外的状态。

因为io.Readerio.Writer是如此简单的接口,它们可以有很多种实现方式。你可以使用strings.NewReader函数从字符串创建一个io.Reader

s := "The quick brown fox jumped over the lazy dog"
sr := strings.NewReader(s)
counts, err := countLetters(sr)
if err != nil {
    return err
}
fmt.Println(counts)

正如我在“接口是类型安全的鸭子类型”中讨论的那样,io.Readerio.Writer的实现通常在装饰器模式中链接在一起。因为countLetters依赖于io.Reader,你可以使用完全相同的countLetters函数来计算 gzip 压缩文件中的英文字母数。首先,编写一个函数,给定文件名后返回一个*gzip.Reader

func buildGZipReader(fileName string) (*gzip.Reader, func(), error) {
    r, err := os.Open(fileName)
    if err != nil {
        return nil, nil, err
    }
    gr, err := gzip.NewReader(r)
    if err != nil {
        return nil, nil, err
    }
    return gr, func() {
        gr.Close()
        r.Close()
    }, nil
}

此函数演示了正确包装实现io.Reader的类型的方法。你创建一个*os.File(符合io.Reader接口),在确保它有效后,将其传递给gzip.NewReader函数,该函数返回一个*gzip.Reader实例。如果有效,返回*gzip.Reader和一个闭包,当调用时会适当地清理你的资源。

因为*gzip.Reader实现了io.Reader,所以你可以像之前使用*strings.Reader一样使用它来与countLetters配合使用:

r, closer, err := buildGZipReader("my_data.txt.gz")
if err != nil {
    return err
}
defer closer()
counts, err := countLetters(r)
if err != nil {
    return err
}
fmt.Println(counts)

你可以在第十三章存储库sample_code/io_friends目录中找到countLettersbuildGZipReader的代码。

因为有用于读取和写入的标准接口,io包中有一个用于从io.Reader复制到io.Writer的标准函数,io.Copy。还有其他标准函数用于为现有的io.Readerio.Writer实例添加新功能。其中包括以下内容:

io.MultiReader

返回一个从多个io.Reader实例依次读取的io.Reader

io.LimitReader

返回一个从提供的io.Reader读取指定字节数的io.Reader

io.MultiWriter

返回一个 io.Writer,可以同时向多个 io.Writer 实例写入数据。

标准库中的其他包提供了它们自己的类型和函数来处理 io.Readerio.Writer。你已经看到了其中的一些,但还有更多。这些包括压缩算法、存档、加密、缓冲区、字节切片和字符串。

io 包中还定义了其他单方法接口,例如 io.Closerio.Seeker

type Closer interface {
        Close() error
}

type Seeker interface {
        Seek(offset int64, whence int) (int64, error)
}

io.Closer 接口由像 os.File 这样的类型实现,它们在读取或写入完成后需要进行清理。通常情况下,通过 defer 调用 Close

f, err := os.Open(fileName)
if err != nil {
    return nil, err
}
defer f.Close()
// use f
警告

如果在循环中打开资源,请不要使用 defer,因为它不会在函数退出之前运行。相反,应在循环迭代结束前调用 Close。如果存在可能导致退出的错误,则也必须在那里调用 Close

io.Seeker 接口用于对资源进行随机访问。whence 的有效值为常量 io.SeekStartio.SeekCurrentio.SeekEnd。如果使用自定义类型来明确此点会更好,但出人意料的设计疏忽,whence 的类型却是 int

io 包定义了几种方式将这四种接口组合起来的接口。它们包括 io.ReadCloserio.ReadSeekerio.ReadWriteCloserio.ReadWriteSeekerio.ReadWriterio.WriteCloserio.WriteSeeker。使用这些接口来指定你的函数将如何处理数据。例如,不要只将 os.File 作为参数传递,而是使用接口精确地指定你的函数将如何处理参数。这不仅使得你的函数更具通用性,也使你的意图更加清晰。此外,如果你正在编写自己的数据源和接收器,应使你的代码兼容这些接口。总的来说,应力求创建与 io 包定义的接口一样简单和解耦的接口。它们展示了简单抽象的力量。

除了 io 包中的接口外,还有几个用于常见操作的辅助函数。例如,io.ReadAll 函数从 io.Reader 中读取所有数据到一个字节切片中。io 中的一种更聪明的函数展示了一种向 Go 类型添加方法的模式。如果你有一个实现了 io.Reader 但没有实现 io.Closer 的类型(例如 strings.Reader),并且需要将其传递给期望 io.ReadCloser 的函数,则可以将你的 io.Reader 传递给 io.NopCloser,从而得到一个实现了 io.ReadCloser 的类型。如果查看其实现,你会发现它非常简单:

type nopCloser struct {
    io.Reader
}

func (nopCloser) Close() error { return nil }

func NopCloser(r io.Reader) io.ReadCloser {
    return nopCloser{r}
}

如果需要为某种类型添加额外的方法以满足某个接口,可以使用嵌入类型模式。

注意

io.NopCloser 函数违反了不从函数返回接口的一般规则,但它是一个简单的适配器,用于保证接口保持不变,因为它是标准库的一部分。

os包包含与文件交互的函数。函数os.ReadFileos.WriteFile分别将整个文件读入字节片段并将字节片段写入文件。这些函数(以及io.ReadAll)适用于小数据量,但不适合大数据源。处理较大数据源时,请使用os包中的CreateNewFileOpenOpenFile函数。它们返回一个实现了io.Readerio.Writer接口的*os.File实例。您可以将*os.File实例与bufio包中的Scanner类型一起使用。

时间

与大多数语言一样,Go 标准库包含时间支持,通常在time包中。用于表示时间的两个主要类型是time.Durationtime.Time

时间段由time.Duration表示,这是基于int64的类型。Go 能表示的最小时间单位是纳秒,但time包定义了time.Duration类型的常量,表示纳秒、微秒、毫秒、秒、分钟和小时。例如,表示 2 小时 30 分钟的持续时间如下:

d := 2 * time.Hour + 30 * time.Minute // d is of type time.Duration

这些常量使得使用time.Duration既可读性强又类型安全。它们展示了类型化常量的良好使用。

Go 定义了一种合理的字符串格式,一系列数字,可以使用time.ParseDuration函数解析为time.Duration。此格式在标准库文档中有描述:

时间段字符串是一系列可能带有可选小数部分和单位后缀的十进制数字,例如“300ms”、“-1.5h”或“2h45m”。有效的时间单位有“ns”、“us”(或“µs”)、“ms”、“s”、“m”、“h”。

Go 标准库文档

time.Duration上定义了几种方法。它符合fmt.Stringer接口,并通过String方法返回格式化的持续时间字符串。它还具有将值作为小时数、分钟数、秒数、毫秒数、微秒数或纳秒数返回的方法。TruncateRound方法将time.Duration截断或四舍五入到指定的time.Duration单位。

时间的瞬间由time.Time类型表示,带有时区信息。使用time.Now函数获取当前时间的引用,返回一个设置为当前本地时间的time.Time实例。

提示

time.Time实例包含时区信息,因此不应使用==来检查两个time.Time实例是否指向同一时刻。相反,请使用Equal方法,该方法会校正时区。

time.Parse函数将string转换为time.Time,而Format方法将time.Time转换为string。虽然 Go 通常采纳在过去表现良好的想法,但它使用自己的日期和时间格式语言。它依赖于格式化日期和时间为 2006 年 1 月 2 日下午 3:04:05PM MST(山区标准时间)来指定您的格式。

注意

为什么选择这个日期?因为它的每一部分都按照顺序代表从 1 到 7 的数字,也就是说,01/02 03:04:05PM ’06 -0700(MST 比 UTC 提前 7 小时)。

例如,以下代码

t, err := time.Parse("2006-01-02 15:04:05 -0700", "2023-03-13 00:00:00 +0000")
if err != nil {
    return err
}
fmt.Println(t.Format("January 2, 2006 at 3:04:05PM MST"))

打印出以下输出:

March 13, 2023 at 12:00:00AM UTC

尽管用于格式化的日期和时间旨在成为一个聪明的记忆技巧,但我发现很难记住,每次想要使用它时都必须查找。幸运的是,在time包中,最常用的日期和时间格式都已经被赋予了自己的常量。

就像在time.Duration上有提取部分的方法一样,在time.Time上也定义了类似的方法,包括DayMonthYearHourMinuteSecondWeekdayClock(返回time.Time的时间部分作为单独的小时、分钟和秒int值)和Date(返回年、月和日作为单独的int值)。您可以使用AfterBeforeEqual方法比较一个time.Time实例与另一个。

Sub方法返回一个表示两个time.Time实例之间经过的时间的time.Duration,而Add方法返回比当前时间晚time.Durationtime.TimeAddDate方法返回增加指定年、月和日数的新time.Time实例。与time.Duration一样,还定义了TruncateRound方法。所有这些方法都在值接收器上定义,因此它们不会修改time.Time实例。

单调时间

大多数操作系统会跟踪两种类型的时间:挂钟,它对应于当前时间,以及单调时钟,它从计算机启动时开始计数。跟踪两个时钟的原因是挂钟不会均匀地增长。夏令时、闰秒和网络时间协议(NTP)更新可能会使挂钟意外地前进或后退。这在设置定时器或查找经过的时间时可能会导致问题。

为了解决这个潜在问题,Go 语言使用单调时间来跟踪经过的时间,每当设置定时器或使用time.Now创建time.Time实例时,都会用到单调时间。这种支持是透明的;定时器会自动使用它。Sub方法使用单调时钟来计算time.Duration,如果两个time.Time实例都已设置。如果它们没有(因为一个或两个实例没有使用time.Now创建),那么Sub方法会使用实例中指定的时间来计算time.Duration

注意

如果你想了解在未正确处理单调时间时可能发生的问题的类型,请查看 Cloudflare 的博文,详细描述了 Go 早期版本中由于缺乏单调时间支持而导致的错误。

计时器和超时

正如我在“超时代码”中所介绍的,time包包含返回通道的函数,这些通道在指定时间后输出值。time.After函数返回一个仅输出一次的通道,而time.Tick返回的通道在每个指定的time.Duration经过后返回一个新值。这些函数与 Go 的并发支持一起使用,以实现超时或定期任务。

你还可以使用time.AfterFunc函数在指定的time.Duration后触发单个函数运行。不要在非平凡程序之外使用time.Tick,因为底层的time.Ticker无法关闭(因此无法被垃圾回收)。相反,请使用time.NewTicker函数,它返回一个*time.Ticker,该类型具有用于监听的通道以及重置和停止计时器的方法。

encoding/json

REST API 已经将 JSON 确立为服务之间通信的标准方式,而 Go 的标准库包含了将 Go 数据类型与 JSON 相互转换的支持。Marshaling一词表示从 Go 数据类型到编码的转换,unmarshaling则表示从编码到 Go 数据类型的转换。

使用结构体标签添加元数据

假设你正在构建一个订单管理系统,并且必须读取和写入以下 JSON:

{
    "id":"12345",
    "date_ordered":"2020-05-01T13:01:02Z",
    "customer_id":"3",
    "items":[{"id":"xyz123","name":"Thing 1"},{"id":"abc789","name":"Thing 2"}]
}

你定义了类型来映射这些数据:

type Order struct {
    ID            string        `json:"id"`
    DateOrdered   time.Time     `json:"date_ordered"`
    CustomerID    string        `json:"customer_id"`
    Items         []Item        `json:"items"`
}

type Item struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

你可以使用结构体标签来指定处理 JSON 的规则,这些标签写在结构体字段后面。尽管结构体标签是用反引号标记的字符串,但它们不能超过单行。结构体标签由一个或多个标签/值对组成,写成*tagName:"tagValue"*,并用空格分隔。因为它们只是字符串,编译器无法验证它们的格式,但go vet可以。还要注意,所有这些字段都是可导出的。与任何其他包一样,encoding/json包中的代码无法访问另一个包中未导出的结构体字段。

对于 JSON 处理,使用标签json来指定应与结构体字段关联的 JSON 字段的名称。如果未提供json标签,则默认行为是假定 JSON 对象字段的名称与 Go 结构体字段的名称相匹配。尽管存在此默认行为,最好还是使用结构体标签显式指定字段的名称,即使字段名称相同。

注意

当将 JSON 解组到没有json标签的结构体字段时,名称匹配是不区分大小写的。当将没有json标签的结构体字段编组回 JSON 时,JSON 字段的首字母始终大写,因为该字段是可导出的。

如果在编组或解组时应忽略某个字段,请使用破折号(-)作为字段名。如果该字段在为空时应从输出中省略,则在字段名后添加,omitempty。例如,在Order结构中,如果不希望在输出中包含CustomerID,如果其设置为空字符串,则结构标记应为json:"customer_id,omitempty"

警告

不幸的是,“空”这一定义与零值并不完全对齐,正如你所期望的那样。结构体的零值并不算作空,但零长度的切片或映射则属于空。

结构标记允许您使用元数据来控制程序的行为。其他语言,尤其是 Java,鼓励开发者在各种程序元素上放置注解,以描述处理方式,而不显式指定处理内容。虽然声明性编程能够实现更简洁的程序,但元数据的自动处理使得理解程序行为变得困难。在一个大型 Java 项目中,有注解的开发者在出现问题时,往往会陷入一片茫然,不知道哪段代码处理了特定的注解,以及做了哪些改变。Go 更倾向于显式代码而不是短小的代码。结构标记永远不会自动评估;它们在将结构实例传递给函数时进行处理。

解组和编组

encoding/json包中的Unmarshal函数用于将字节切片转换为结构体。如果有一个名为data的字符串,则以下代码将data转换为类型为Order的结构体:

var o Order
err := json.Unmarshal([]byte(data), &o)
if err != nil {
    return err
}

json.Unmarshal函数将数据填充到输入参数中,就像io.Reader接口的实现一样。正如我在“指针是最后的选择”中所讨论的,这样可以有效地重复使用同一个结构体,从而控制内存使用。

使用encoding/json包中的Marshal函数将Order实例写回为 JSON,存储在字节切片中:

out, err := json.Marshal(o)

这引出了一个问题:你如何能够评估结构标记?你可能还想知道json.Marshaljson.Unmarshal如何能够读取和写入任何类型的结构体。毕竟,你编写的其他方法仅与程序编译时已知的类型一起工作(即使在类型开关中列出的类型也是预先枚举的)。对这两个问题的答案是反射。你可以在第十六章了解更多关于反射的信息。

JSON、读者和写者

json.Marshaljson.Unmarshal 函数在字节片上工作。正如你刚才看到的,Go 中的大多数数据源和汇合都实现了 io.Readerio.Writer 接口。虽然你可以使用 io.ReadAllio.Reader 的整个内容复制到字节片中,以便 json.Unmarshal 读取,但这是低效的。类似地,你可以使用 json.Marshal 写入到内存中的字节片缓冲区,然后将该字节片写入网络或磁盘,但最好直接写入到 io.Writer

encoding/json 包含两种类型,允许你处理这些情况。json.Decoderjson.Encoder 类型分别从符合 io.Readerio.Writer 接口的任何地方读取和写入。让我们快速看看它们的工作方式。

从你的数据 toFile 开始,它实现了一个简单的结构体:

type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}
toFile := Person {
    Name: "Fred",
    Age:  40,
}

os.File 类型实现了 io.Readerio.Writer 接口,因此可以用来演示 json.Decoderjson.Encoder。首先,通过将临时文件传递给 json.NewEncodertoFile 写入临时文件,该方法返回一个临时文件的 json.Encoder。然后,将 toFile 传递给 Encode 方法:

tmpFile, err := os.CreateTemp(os.TempDir(), "sample-")
if err != nil {
    panic(err)
}
defer os.Remove(tmpFile.Name())
err = json.NewEncoder(tmpFile).Encode(toFile)
if err != nil {
    panic(err)
}
err = tmpFile.Close()
if err != nil {
    panic(err)
}

一旦 toFile 写入完成,你可以通过将临时文件的引用传递给 json.NewDecoder,然后在返回的 json.Decoder 上调用 Decode 方法,类型为 Person 的变量进行 JSON 读取:

tmpFile2, err := os.Open(tmpFile.Name())
if err != nil {
    panic(err)
}
var fromFile Person
err = json.NewDecoder(tmpFile2).Decode(&fromFile)
if err != nil {
    panic(err)
}
err = tmpFile2.Close()
if err != nil {
    panic(err)
}
fmt.Printf("%+v\n", fromFile)

你可以在 Go Playground 上看到完整的示例,或在 第十三章示例代码/json 目录 中找到。

编码和解码 JSON 流

当你有多个 JSON 结构需要一次性读取或写入时,我们的朋友 json.Decoderjson.Encoder 也可以用于这些情况。

假设你有以下数据:

{"name": "Fred", "age": 40}
{"name": "Mary", "age": 21}
{"name": "Pat", "age": 30}

为了本示例,假设它存储在一个名为 streamData 的字符串中,但它可以在文件中或甚至是传入的 HTTP 请求中(稍后你将看到 HTTP 服务器如何工作)。

你将这些数据逐个 JSON 对象地存储到你的 t 变量中。

就像之前一样,你用数据源初始化你的 json.Decoder,但这次你使用 for 循环并运行直到出现错误。如果错误是 io.EOF,则成功读取所有数据。如果不是,则 JSON 流存在问题。这让你能够逐个 JSON 对象地读取和处理数据:

var t struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

dec := json.NewDecoder(strings.NewReader(streamData))
for {
    err := dec.Decode(&t)
    if err != nil {
        if errors.Is(err, io.EOF) {
            break
        }
        panic(err)
    }
    // process t
}

使用 json.Encoder 写出多个值的方式与使用它写出单个值的方式完全相同。在这个示例中,你将写入到一个 bytes.Buffer,但任何符合 io.Writer 接口的类型都可以工作:

var b bytes.Buffer
enc := json.NewEncoder(&b)
for _, input := range allInputs {
    t := process(input)
    err = enc.Encode(t)
    if err != nil {
        panic(err)
    }
}
out := b.String()

你可以在 Go Playground 上运行这个示例,或在 第十三章示例代码/encode_decode 目录 中找到。

此示例在数据流中有多个未包装在数组中的 JSON 对象,但你也可以使用json.Decoder从数组中读取单个对象,而无需一次性加载整个数组到内存中。这可以极大地提高性能并减少内存使用。在Go 文档中有一个示例。

自定义 JSON 解析

尽管默认功能通常足够使用,但有时你需要进行覆盖。虽然time.Time默认支持 RFC 3339 格式的 JSON 字段,但你可能需要处理其他时间格式。你可以通过创建一个实现json.Marshalerjson.Unmarshaler两个接口的新类型来处理这个问题:

type RFC822ZTime struct {
    time.Time
}

func (rt RFC822ZTime) MarshalJSON() ([]byte, error) {
    out := rt.Time.Format(time.RFC822Z)
    return []byte(`"` + out + `"`), nil
}

func (rt *RFC822ZTime) UnmarshalJSON(b []byte) error {
    if string(b) == "null" {
        return nil
    }
    t, err := time.Parse(`"`+time.RFC822Z+`"`, string(b))
    if err != nil {
        return err
    }
    *rt = RFC822ZTime{t}
    return nil
}

你将一个time.Time实例嵌入到一个称为RFC822ZTime的新结构体中,这样你仍然可以访问time.Time上的其他方法。正如在“指针接收器和值接收器”中讨论的那样,读取时间值的方法声明为值接收器,而修改时间值的方法声明为指针接收器。

然后你改变了DateOrdered字段的类型,并可以使用 RFC 822 格式化时间进行处理:

type Order struct {
    ID          string      `json:"id"`
    DateOrdered RFC822ZTime `json:"date_ordered"`
    CustomerID  string      `json:"customer_id"`
    Items       []Item      `json:"items"`
}

你可以在Go Playground上运行此代码,或在第十三章的代码库sample_code/custom_json目录中找到它。

这种方法存在一个哲学上的缺点:JSON 的日期格式决定了数据结构中字段的类型。这是encoding/json方法的一个缺点。你可以让Order实现json.Marshalerjson.Unmarshaler,但这需要你编写代码来处理所有字段,即使那些不需要定制支持的字段也是如此。结构体标签格式并没有提供一种指定解析特定字段的函数的方式。这使得你不得不为字段创建一个自定义类型。

另一种选择在 Ukiah Smith 的博客文章中有描述。它允许你仅重新定义那些不匹配默认编组行为的字段,通过利用结构体嵌入(在“使用嵌入进行组合”中介绍过)。如果嵌入结构体的字段与包含结构体的同名,那么在 JSON 编组或解组时,该字段将被忽略。

在这个例子中,Order的字段看起来像这样:

type Order struct {
    ID          string    `json:"id"`
    Items       []Item    `json:"items"`
    DateOrdered time.Time `json:"date_ordered"`
    CustomerID  string    `json:"customer_id"`
}

MarshalJSON方法看起来像这样:

func (o Order) MarshalJSON() ([]byte, error) {
    type Dup Order

    tmp := struct {
        DateOrdered string `json:"date_ordered"`
        Dup
    }{
        Dup: (Dup)(o),
    }
    tmp.DateOrdered = o.DateOrdered.Format(time.RFC822Z)
    b, err := json.Marshal(tmp)
    return b, err
}

对于OrderMarshalJSON方法,你定义了一个类型为Dup,其基础类型是Order。创建Dup的原因是,基于另一个类型的类型具有与基础类型相同的字段,但不具有方法。如果没有Dup,在调用json.Marshal时将会导致MarshalJSON的无限循环调用,最终导致堆栈溢出。

你定义了一个具有DateOrdered字段和嵌入式Dup的匿名结构体。然后将Order实例分配给tmp中的嵌入字段,在tmp中为DateOrdered字段分配 RFC822Z 格式的时间,并在tmp上调用json.Marshal。这会产生所需的 JSON 输出。

UnmarshalJSON中也有类似的逻辑:

func (o *Order) UnmarshalJSON(b []byte) error {
    type Dup Order

    tmp := struct {
        DateOrdered string `json:"date_ordered"`
        *Dup
    }{
        Dup: (*Dup)(o),
    }

    err := json.Unmarshal(b, &tmp)
    if err != nil {
        return err
    }

    o.DateOrdered, err = time.Parse(time.RFC822Z, tmp.DateOrdered)
    if err != nil {
        return err
    }
    return nil
}

UnmarshalJSON中,对json.Unmarshal的调用填充了o中的字段(除了DateOrdered),因为它被嵌入到tmp中。然后,你使用time.Parse处理tmp中的DateOrdered字段,并在o中填充DateOrdered

你可以在Go Playground上运行此代码,或在第十三章存储库sample_code/custom_json2目录中找到它。

这样做可以使Order不与 JSON 格式绑定,但Order上的MarshalJSONUnmarshalJSON方法与 JSON 中时间字段的格式耦合在一起。你无法重用Order来支持其他时间格式的 JSON。

为了限制关心 JSON 外观的代码量,定义两个结构体。使用一个结构体进行 JSON 的转换,另一个进行数据处理。将 JSON 读入你的 JSON-aware 类型,然后将其复制到另一个类型。当你想要写出 JSON 时,做相反的操作。这确实会产生一些重复,但它可以使你的业务逻辑不依赖于通信协议。

你可以将map[string]any传递给json.Marshaljson.Unmarshal,在 JSON 和 Go 之间进行双向转换,但在编码探索阶段保存它,并在理解正在处理的数据后用具体类型替换它。Go 使用类型是有原因的;它们文档化了预期数据和预期数据的类型。

尽管 JSON 可能是标准库中最常用的编码器,Go 还包含其他编码器,包括 XML 和 Base64。如果你有一种数据格式需要编码,而标准库或第三方模块中找不到支持,你可以自己编写一个。你将会学习如何在“使用反射编写数据编组器”中实现我们自己的编码器。

警告

标准库包括encoding/gob,这是 Go 特有的二进制表示,有点像 Java 中的序列化。就像 Java 序列化是 Enterprise Java Beans 和 Java RMI 的传输协议一样,gob 协议旨在成为net/rpc包中 Go 特有 RPC(远程过程调用)实现的传输格式。不要使用encoding/gobnet/rpc。如果你想要在 Go 中进行远程方法调用,请使用像GRPC这样的标准协议,这样你就不会被绑定到特定的语言。无论你有多么喜欢 Go,如果你希望你的服务有用,让其他语言的开发者能够调用它们。

net/http

每种语言都附带了一个标准库,但随着时间的推移,标准库应该包含的预期也在变化。作为在 2010 年代推出的语言,Go 的标准库包括一些其他语言分发版本认为应该由第三方负责的内容:一个高质量的 HTTP/2 客户端和服务器。

客户端

net/http包定义了一个Client类型,用于发出 HTTP 请求和接收 HTTP 响应。在net/http包中可以找到一个默认的客户端实例(巧妙地命名为DefaultClient),但是在生产应用程序中应避免使用它,因为它默认没有超时设置。相反,应实例化自己的客户端。对于整个程序,只需要创建一个http.Client,它可以正确处理跨 goroutine 的多个同时请求:

client := &http.Client{
    Timeout: 30 * time.Second,
}

当您想要发出请求时,可以使用http.NewRequestWithContext函数创建一个新的*http.Request实例,传递一个上下文、方法和要连接的 URL。如果要进行PUTPOSTPATCH请求,请将请求体作为最后一个参数作为io.Reader指定。如果没有请求体,请使用nil

req, err := http.NewRequestWithContext(context.Background(),
    http.MethodGet, "https://jsonplaceholder.typicode.com/todos/1", nil)
if err != nil {
    panic(err)
}
注意

在第十四章中,我将讨论上下文的概念。

一旦您有了*http.Request实例,您可以通过实例的Headers字段设置任何标头。使用http.ClientDo方法和您的http.Request,结果将返回为http.Response

req.Header.Add("X-My-Client", "Learning Go")
res, err := client.Do(req)
if err != nil {
    panic(err)
}

响应具有几个字段,其中包含有关请求的信息。响应状态的数值代码在StatusCode字段中,响应代码的文本在Status字段中,响应头在Header字段中,任何返回的内容在Body类型为io.ReadCloser的字段中。这使您可以与json.Decoder一起处理 REST API 响应:

defer res.Body.Close()
if res.StatusCode != http.StatusOK {
    panic(fmt.Sprintf("unexpected status: got %v", res.Status))
}
fmt.Println(res.Header.Get("Content-Type"))
var data struct {
    UserID    int    `json:"userId"`
    ID        int    `json:"id"`
    Title     string `json:"title"`
    Completed bool   `json:"completed"`
}
err = json.NewDecoder(res.Body).Decode(&data)
if err != nil {
    panic(err)
}
fmt.Printf("%+v\n", data)

您可以在第十三章的代码库sample_code/client目录中找到此代码。

警告

net/http包中有用于进行GETHEADPOST调用的函数。应避免使用这些函数,因为它们使用默认客户端,这意味着它们不会设置请求超时。

服务器

HTTP 服务器围绕着http.Serverhttp.Handler接口的概念构建。就像http.Client发送 HTTP 请求一样,http.Server负责监听 HTTP 请求。它是一个性能优越的支持 TLS 的 HTTP/2 服务器。

服务器对请求的处理由分配给Handler字段的http.Handler接口的实现来处理。此接口定义了一个方法:

type Handler interface {
    ServeHTTP(http.ResponseWriter, *http.Request)
}

*http.Request应该看起来很熟悉,因为它正是用于向 HTTP 服务器发送请求的类型。http.ResponseWriter是一个接口,有三个方法:

type ResponseWriter interface {
        Header() http.Header
        Write([]byte) (int, error)
        WriteHeader(statusCode int)
}

这些方法必须按特定顺序调用。首先,调用Header以获取http.Header的实例,并设置任何你需要的响应头。如果不需要设置任何头部,可以跳过此步骤。接下来,使用你的响应的 HTTP 状态码调用WriteHeader。(所有状态码都在net/http包中定义为常量。这里本应是定义自定义类型的好地方,但没有这样做;所有状态码常量都是无类型整数。)如果要发送的响应具有 200 状态码,可以跳过WriteHeader。最后,调用Write方法设置响应体。以下是一个简单处理程序的示例:

type HelloHandler struct{}

func (hh HelloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello!\n"))
}

实例化新的http.Server与实例化其他结构体一样简单:

s := http.Server{
    Addr:         ":8080",
    ReadTimeout:  30 * time.Second,
    WriteTimeout: 90 * time.Second,
    IdleTimeout:  120 * time.Second,
    Handler:      HelloHandler{},
}
err := s.ListenAndServe()
if err != nil {
    if err != http.ErrServerClosed {
        panic(err)
    }
}

Addr字段指定服务器监听的主机和端口。如果不指定,服务器将默认监听所有主机的标准 HTTP 端口 80。你可以使用time.Duration值设置服务器的读取、写入和空闲超时,以正确处理恶意或损坏的 HTTP 客户端,因为默认行为是根本不设置超时。最后,使用Handler字段为服务器指定http.Handler

你可以在第十三章代码库sample_code/server目录中找到这段代码。

一个只处理单个请求的服务器并不是非常有用,因此 Go 标准库包含一个请求路由器*http.ServeMux。你可以使用http.NewServeMux函数创建一个实例。它满足http.Handler接口,因此可以分配给http.ServerHandler字段。它还包含两个方法用于调度请求。第一个方法叫做Handle,接受两个参数,一个路径和一个http.Handler。如果路径匹配,就会调用http.Handler

虽然可以创建http.Handler的实现,但更常见的模式是在*http.ServeMux上使用HandleFunc方法:

mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello!\n"))
})

此方法接受一个函数或闭包,并将其转换为http.HandlerFunc。你在“函数类型是接口的桥梁”中探讨了http.HandlerFunc类型。对于简单的处理程序,闭包足够了。对于依赖于其他业务逻辑的更复杂的处理程序,请使用结构体的方法,如“隐式接口使依赖注入更容易”中所示。

Go 1.22 扩展了路径语法,可选择允许 HTTP 动词和路径通配符变量。通配符变量的值通过http.RequestPathValue方法读取:

mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter,
                                         r *http.Request) {
    name := r.PathValue("name")
    w.Write([]byte(fmt.Sprintf("Hello, %s!\n", name)))
})
警告

包级别的函数http.Handlehttp.HandleFunchttp.ListenAndServehttp.ListenAndServeTLS与名为http.DefaultServeMux的包级别实例一起工作。不要在非常简单的测试程序之外使用它们。http.Server实例是在http.ListenAndServehttp.ListenAndServeTLS函数中创建的,因此你无法配置服务器属性如超时。此外,第三方库可能已经使用http.DefaultServeMux注册了它们自己的处理程序,而没有扫描所有依赖项(直接和间接的)就无法知道这一点。通过避免共享状态,保持你的应用程序受控制。

因为*http.ServeMux分派请求给http.Handler实例,而且*http.ServeMux本身也实现了http.Handler接口,所以你可以创建一个*http.ServeMux实例,并注册它到父*http.ServeMux中:

person := http.NewServeMux()
person.HandleFunc("/greet", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("greetings!\n"))
})
dog := http.NewServeMux()
dog.HandleFunc("/greet", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("good puppy!\n"))
})
mux := http.NewServeMux()
mux.Handle("/person/", http.StripPrefix("/person", person))
mux.Handle("/dog/", http.StripPrefix("/dog", dog))

在这个例子中,请求/person/greet由附加到person的处理程序处理,而/dog/greet由附加到dog的处理程序处理。当你将persondog注册到mux时,使用http.StripPrefix辅助函数来移除已经被mux处理过的路径部分。你可以在第十三章代码库sample_code/server_mux目录中找到这段代码。

中间件

HTTP 服务器最常见的需求之一是在多个处理程序中执行一组操作,如检查用户是否已登录、计时请求或检查请求头。Go 使用中间件模式处理这些横切关注点。

中间件模式不是使用特殊类型,而是使用一个接受http.Handler实例并返回http.Handler实例的函数。通常,返回的http.Handler是一个转换为http.HandlerFunc的闭包。这里有两个中间件生成器,一个提供请求的定时,另一个使用可能是最糟糕的访问控制:

func RequestTimer(h http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        h.ServeHTTP(w, r)
        dur := time.Since(start)
        slog.Info("request time",
            "path", r.URL.Path,
            "duration", dur)
    })
}

var securityMsg = []byte("You didn't give the secret password\n")

func TerribleSecurityProvider(password string) func(http.Handler) http.Handler {
    return func(h http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if r.Header.Get("X-Secret-Password") != password {
                w.WriteHeader(http.StatusUnauthorized)
                w.Write(securityMsg)
                return
            }
            h.ServeHTTP(w, r)
        })
    }
}

这两个中间件实现展示了中间件的作用。首先,进行设置操作或检查。如果检查未通过,则在中间件中编写输出(通常使用错误代码)并返回。如果一切正常,则调用处理程序的ServeHTTP方法。当返回时,运行清理操作。

TerribleSecurityProvider展示了如何创建可配置的中间件。你传递配置信息(在本例中是密码),函数返回使用该配置信息的中间件。这有点令人费解,因为它返回一个返回闭包的闭包。

注意

也许你想知道如何通过中间件层传递值。这通过上下文(context)完成,在第十四章中会详细介绍。

通过将中间件链接起来,你可以将中间件添加到请求处理程序中:

terribleSecurity := TerribleSecurityProvider("GOPHER")

mux.Handle("/hello", terribleSecurity(RequestTimer(
    http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello!\n"))
    }))))

我们从 TerribleSecurityProvider 获取你的中间件,然后通过一系列函数调用包装你的处理程序。首先调用 terribleSecurity 闭包,然后调用 RequestTimer,然后调用你的实际请求处理程序。

因为 *http.ServeMux 实现了 http.Handler 接口,你可以将一组中间件应用于注册到单个请求路由器的所有处理程序:

terribleSecurity := TerribleSecurityProvider("GOPHER")
wrappedMux := terribleSecurity(RequestTimer(mux))
s := http.Server{
    Addr:    ":8080",
    Handler: wrappedMux,
}

你可以在 第十三章仓库sample_code/middleware 目录中找到这段代码。

使用第三方模块增强服务器功能。

仅仅因为服务器是生产质量的,并不意味着你不应该使用第三方模块来提升其功能。如果你不喜欢中间件的函数链,可以使用一个名为 alice 的第三方模块,它允许你使用以下语法:

helloHandler := func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello!\n"))
}
chain := alice.New(terribleSecurity, RequestTimer).ThenFunc(helloHandler)
mux.Handle("/hello", chain)

虽然在 Go 1.22 中 *http.ServeMux 增加了一些广受欢迎的功能,但其路由和变量支持仍然很基础。嵌套 *http.ServeMux 实例也有些笨拙。如果你发现自己需要更高级的功能,比如基于头部值进行路由、使用正则表达式指定路径变量或更好的处理程序嵌套,那么有许多第三方请求路由器可供选择。其中两个最受欢迎的是 gorilla muxchi。它们都被认为是惯用的,因为它们与 http.Handlerhttp.HandlerFunc 实例配合使用,展示了使用与标准库相容的可组合库的 Go 哲学。它们还与惯用的中间件一起工作,这两个项目还提供了常见问题的可选中间件实现。

几个流行的 Web 框架还实现了自己的处理程序和中间件模式。其中两个最受欢迎的是 EchoGin。它们通过包含自动绑定请求或响应数据到 JSON 等功能,简化了 Web 开发。它们还提供了适配器函数,使你可以使用 http.Handler 实现,提供了迁移路径。

ResponseController

在 “接受接口,返回结构体” 中,你学到了修改接口会破坏向后兼容性。你还学到了可以通过定义新接口并使用类型开关和类型断言来检查是否实现了新接口,随时间演变接口。创建这些额外接口的缺点是很难知道它们的存在,使用类型开关来检查它们也很冗长。

你可以在http包中找到此类示例。在设计该包时,选择将http.ResponseWriter设为接口。这意味着不能在未来的发布版本中向其添加额外的方法,否则将破坏 Go 兼容性保证。为了表示http.ResponseWriter实例的新可选功能,http包包含了几个可能由http.ResponseWriter实现的接口:http.Flusherhttp.Hijacker。这些接口上的方法用于控制响应的输出。

在 Go 1.20 中,http包新增了一个具体类型http.ResponseController。它展示了向现有 API 添加方法的另一种方式:

func handler(rw http.ResponseWriter, req *http.Request) {
    rc := http.NewResponseController(rw)
    for i := 0; i < 10; i++ {
        result := doStuff(i)
        _, err := rw.Write([]byte(result))
        if err != nil {
            slog.Error("error writing", "msg", err)
            return
        }
        err = rc.Flush()
        if err != nil && !errors.Is(err, http.ErrNotSupported) {
            slog.Error("error flushing", "msg", err)
            return
        }
    }
}

在这个示例中,如果http.ResponseWriter支持Flush,则需要将计算后的数据即时返回给客户端。否则,在所有数据都计算完毕后再返回。工厂函数http.NewResponseController接收一个http.ResponseWriter并返回一个指向http.ResponseController的指针。这个具体类型具有用于http.ResponseWriter可选功能的方法。通过将返回的错误与http.ErrNotSupported比较,使用errors.Is来检查底层http.ResponseWriter是否实现了可选方法。你可以在第十三章存储库sample_code/response_controller目录中找到这段代码。

因为http.ResponseController是一个具体类型,它包装了对http.ResponseWriter实现的访问,所以可以随着时间的推移向其添加新方法,而不会破坏现有的实现。这使得新功能可以被发现,并提供了一种标准错误检查的方法来检查可选方法的存在或不存在。这种模式是处理接口需要演变的情况的一种有趣方式。事实上,http.ResponseController包含两个没有对应接口的方法:SetReadDeadlineSetWriteDeadline。未来可能会通过这种技术向http.ResponseWriter添加新的可选方法。

结构化日志

自其首次发布以来,Go 标准库包含了一个简单的日志包log。虽然对于小型程序来说很好用,但它不容易生成结构化日志。现代 Web 服务可能有数百万同时在线用户,在这种规模下,您需要软件来处理日志输出以理解发生的情况。结构化日志使用每个日志条目的文档化格式,使得编写处理日志输出并发现模式和异常的程序变得更加容易。

JSON 通常用于结构化日志,但甚至是使用空格分隔的键值对比起不将值分隔为字段的非结构化日志更易处理。虽然您当然可以通过使用log包来编写 JSON,但它不提供任何简化结构化日志创建的支持。log/slog包解决了这个问题。

log/slog添加到标准库展示了几个良好的 Go 库设计实践。第一个良好的决定是在标准库中包含结构化记录。拥有标准化的结构化记录器使得编写协同工作的模块变得更加容易。已发布了几个第三方结构化记录器来解决log的不足,包括zaplogrusgo-kit log等等。碎片化的记录生态系统的问题在于您希望控制日志输出的位置以及记录的消息级别。如果您的代码依赖于使用不同记录器的第三方模块,这将变得不可能。避免记录分片的通常建议是不要在作为库的模块中记录,但这是不可强制执行的,并且使得监视第三方库中发生的情况更加困难。log/slog包在 Go 1.21 中是新推出的,但它解决了这些不一致性的事实使得它可能在未来几年内被广泛应用于大多数 Go 程序中。

第二个良好的决定是将结构化记录作为其自己的包,而不是log包的一部分。尽管这两个包有着类似的目的,但它们有着非常不同的设计哲学。试图将结构化记录添加到非结构化记录包中会混淆 API。通过将它们作为独立的包,您一眼就能知道slog.Info是结构化记录,而log.Print是非结构化记录,即使您记不清Info是用于结构化还是非结构化记录。

下一个良好的决定是使log/slog API 可扩展化。它从简单开始,通过函数提供默认记录器:

func main() {
    slog.Debug("debug log message")
    slog.Info("info log message")
    slog.Warn("warning log message")
    slog.Error("error log message")
}

这些函数允许您以各种记录级别记录简单消息。输出如下所示:

2023/04/20 23:13:31 INFO info log message
2023/04/20 23:13:31 WARN warning log message
2023/04/20 23:13:31 ERROR error log message

有两件事需要注意。首先,默认记录器默认抑制调试消息。稍后在讨论如何创建自己的记录器时,您将看到如何控制记录级别。

第二点则更加微妙。虽然这是纯文本输出,但它利用空白来生成结构化日志。第一列是年/月/日格式的日期。第二列是 24 小时制的时间。第三列是记录级别。最后是消息内容。

结构化记录的强大之处在于能够添加具有自定义值的字段。通过一些自定义字段更新您的日志:

userID := "fred"
loginCount := 20
slog.Info("user login",
    "id", userID,
    "login_count", loginCount)

你可以像之前一样使用相同的函数,但现在可以添加可选参数。可选参数成对出现。第一部分是键,应为字符串。第二部分是值。此日志行输出以下内容:

2023/04/20 23:36:38 INFO user login id=fred login_count=20

在消息之后,你有键-值对,再次以空格分隔。

虽然这种文本格式比非结构化日志更容易解析,但你可能希望使用类似 JSON 的东西。你可能还希望自定义日志的写入位置或日志级别。为此,你可以创建一个结构化日志实例:

options := &slog.HandlerOptions{Level: slog.LevelDebug}
handler := slog.NewJSONHandler(os.Stderr, options)
mySlog := slog.New(handler)
lastLogin := time.Date(2023, 01, 01, 11, 50, 00, 00, time.UTC)
mySlog.Debug("debug message",
    "id", userID,
    "last_login", lastLogin)

你正在使用slog.HandlerOptions结构来定义新日志记录器的最低日志级别。然后,你使用slog.HandlerOptions上的NewJSONHandler方法来创建一个slog.Handler,将日志使用 JSON 写入指定的io.Writer。在这种情况下,你使用标准错误输出。最后,你使用slog.New函数创建一个包装了slog.Handler*slog.Logger。然后,你创建一个lastLogin值来记录,还有一个用户 ID。这将产生以下输出:

{"time":"2023-04-22T23:30:01.170243-04:00","level":"DEBUG",
 "msg":"debug message","id":"fred","last_login":"2023-01-01T11:50:00Z"}

如果 JSON 和文本不能满足你的输出需求,你可以定义自己实现slog.Handler接口的实现,并将其传递给slog.New

最后,log/slog包考虑了性能问题。如果你不小心,你的程序可能会花更多时间写日志,而不是执行它设计进行的工作。你可以选择以多种方式将数据写入log/slog。你已经看到了最简单(但最慢)的方法,即在DebugInfoWarnError方法上交替使用键和值。为了提高性能并减少分配次数,建议使用LogAttrs方法:

mySlog.LogAttrs(ctx, slog.LevelInfo, "faster logging",
                slog.String("id", userID),
                slog.Time("last_login", lastLogin))

第一个参数是context.Context,接下来是日志级别,然后是零个或多个slog.Attr实例。对于最常用的类型,有工厂函数可用,对于没有提供函数的类型,你可以使用slog.Any

由于兼容性承诺,log包不会被移除。使用它的现有程序将继续工作,同样适用于使用第三方结构化日志记录器的程序。如果你的代码使用了log.Loggerslog.NewLogLogger函数提供了一个桥接到原始log包的方法。它创建一个使用slog.Handler来写输出的log.Logger实例:

myLog := slog.NewLogLogger(mySlog.Handler(), slog.LevelDebug)
myLog.Println("using the mySlog Handler")

这会产生以下输出:

{"time":"2023-04-22T23:30:01.170269-04:00","level":"DEBUG",
 "msg":"using the mySlog Handler"}

你可以在第十三章代码库sample_code/structured_logging目录中找到所有log/slog的编码示例。

log/slog API 包括更多功能,包括动态日志级别支持、上下文支持(上下文在第十四章中讨论),值分组和创建共同的值头。你可以通过查看其API 文档来了解更多信息。最重要的是,看看log/slog是如何组合的,以便学习如何构建自己的 API。

练习

现在你已经更多地了解了标准库,通过这些练习来巩固你所学的知识。解决方案在第十三章代码库exercise_solutions目录中。

  1. 编写一个小的 Web 服务器,当你发送GET命令时返回当前时间的 RFC 3339 格式。如果愿意,可以使用第三方模块。

  2. 编写一个小的中间件组件,使用 JSON 结构化日志记录每个传入请求到你的 Web 服务器的 IP 地址。

  3. 添加以 JSON 格式返回时间的功能。使用Accept头来控制返回 JSON 还是文本(默认为文本)。JSON 应按以下结构进行组织:

    {
        "day_of_week": "Monday",
        "day_of_month": 10,
        "month": "April",
        "year": 2023,
        "hour": 20,
        "minute": 15,
        "second": 20
    }
    

结语

在本章中,你看到了标准库中一些最常用的包,并了解了它们如何体现应在你的代码中效仿的最佳实践。你还看到了其他合理的软件工程原则:在经验丰富的情况下可能会做出不同的决策,以及如何尊重向后兼容性,从而可以在坚实的基础上构建应用程序。

在下一章中,你将看到上下文、通过 Go 代码传递状态和计时器的包和模式。

第十四章:上下文

服务器需要一种处理个别请求元数据的方式。这些元数据可以大致分为两类:一是正确处理请求所需的元数据,二是控制何时停止处理请求的元数据。例如,一个 HTTP 服务器可能希望使用跟踪 ID 标识一系列通过一组微服务的请求。它还可能希望设置一个计时器,以便在其他微服务请求时间过长时结束请求。

许多语言使用线程本地变量来存储这类信息,将数据关联到特定的操作系统线程执行上。这在 Go 语言中行不通,因为 goroutine 没有可以用来查找值的唯一标识。更重要的是,线程本地变量感觉像是魔术;值放在一个地方,却在其他地方出现。

Go 语言通过一种称为context的构造解决请求元数据问题。让我们看看如何正确使用它。

什么是上下文?

不是为语言添加新功能,上下文仅仅是符合context包中定义的Context接口的实例。正如你所知,Go 语言鼓励通过函数参数显式传递数据。对于上下文而言也是如此,它只是作为你的函数的另一个参数。

就像 Go 语言约定的最后一个返回值是error一样,Go 还约定上下文作为函数的第一个参数显式传递到你的程序中。上下文参数的通常名称是ctx

func logic(ctx context.Context, info string) (string, error) {
    // do some interesting stuff here
    return "", nil
}

除了定义Context接口外,context包还包含几个工厂函数,用于创建和包装上下文。当你没有现成的上下文,比如在命令行程序的入口点时,可以使用函数context.Background创建一个空的初始上下文。这将返回一个context.Context类型的变量。(是的,这与通常从函数调用中返回具体类型的模式不同。)

空上下文是一个起点;每次向上下文添加元数据时,都可以通过context包中的工厂函数包装现有的上下文。

注意

另一个函数context.TODO也创建一个空的context.Context。它用于开发过程中的临时使用。如果你不确定上下文将来自何处或将如何使用它,请使用context.TODO在代码中放置一个占位符。生产代码不应包含context.TODO

在编写 HTTP 服务器时,你使用稍微不同的模式来通过中间件层传递并获取上下文,最终传递给顶级的http.Handler。不幸的是,上下文是在net/http包创建之后很长时间才添加到 Go API 中的。由于兼容性承诺,无法修改http.Handler接口以添加context.Context参数。

兼容性承诺确实允许在现有类型中添加新方法,这也是 Go 团队所做的。http.Request 上有两个与上下文相关的方法:

  • Context 返回与请求关联的 context.Context

  • WithContext 接收一个 context.Context 并返回一个新的 http.Request,其中包含旧请求的状态和提供的 context.Context 的组合。

这是一般的模式:

func Middleware(handler http.Handler) http.Handler {
    return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
        ctx := req.Context()
        // wrap the context with stuff -- you'll see how soon!
        req = req.WithContext(ctx)
        handler.ServeHTTP(rw, req)
    })
}

在你的中间件中,第一件事情是通过使用 Context 方法从请求中提取现有的上下文。(如果你想要跳过,可以看看如何将值放入上下文中的“值”部分。)在将值放入上下文后,你可以使用 WithContext 方法基于旧请求和现在填充的上下文创建一个新请求。最后,调用 handler 并传递你的新请求和现有的 http.ResponseWriter

当你实现处理程序时,使用 Context 方法从请求中提取上下文,并将上下文作为第一个参数调用你的业务逻辑,就像之前看到的那样:

func handler(rw http.ResponseWriter, req *http.Request) {
    ctx := req.Context()
    err := req.ParseForm()
    if err != nil {
        rw.WriteHeader(http.StatusInternalServerError)
        rw.Write([]byte(err.Error()))
        return
    }
    data := req.FormValue("data")
    result, err := logic(ctx, data)
    if err != nil {
        rw.WriteHeader(http.StatusInternalServerError)
        rw.Write([]byte(err.Error()))
        return
    }
    rw.Write([]byte(result))
}

当你的应用程序从另一个 HTTP 服务进行 HTTP 调用时,请使用 net/http 包中的 NewRequestWithContext 函数来构造一个请求,其中包含现有的上下文信息:

type ServiceCaller struct {
    client *http.Client
}

func (sc ServiceCaller) callAnotherService(ctx context.Context, data string)
                                          (string, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
                "http://example.com?data="+data, nil)
    if err != nil {
        return "", err
    }
    resp, err := sc.client.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return "", fmt.Errorf("Unexpected status code %d",
                              resp.StatusCode)
    }
    // do the rest of the stuff to process the response
    id, err := processResponse(resp.Body)
    return id, err
}

你可以在第十四章的仓库中的 sample_code/context_patterns 目录中找到这些代码示例。

现在你知道如何获取和传递上下文了,让我们开始让它们有用起来。你将从传递值开始。

默认情况下,你应该优先通过显式参数传递数据。正如之前提到的,习惯上 Go 更偏向于显式而非隐式,包括显式数据传递。如果一个函数依赖于某些数据,它应该清楚地指出它需要什么数据以及数据来自何处。

然而,在某些情况下,你不能显式地传递数据。最常见的情况是 HTTP 请求处理程序及其关联的中间件。正如你所见,所有的 HTTP 请求处理程序都有两个参数,一个用于请求,一个用于响应。如果你想要在中间件中使一个值对处理程序可用,你需要将它存储在上下文中。可能的情况包括从 JWT(JSON Web Token)中提取用户或创建一个通过多层中间件传递到处理程序和业务逻辑的每个请求的 GUID。

有一个用于将值放入上下文的工厂方法,context.WithValue。它接收三个值:一个上下文,一个键来查找值以及值本身。键和值参数声明为 any 类型。context.WithValue 函数返回一个上下文,但它不是传入函数的同一个上下文。相反,它是一个包含键-值对并包裹传入的父 context.Context上下文。

注意

你会多次看到这种包装模式。上下文被视为一个不可变实例。每当向上下文添加信息时,都是通过将现有的父上下文包装为子上下文来实现的。这使你能够使用上下文将信息传递到代码的更深层。上下文永远不用于将信息从更深的层传递到更高的层。

context.Context上的Value方法检查一个值是否在上下文或其任何父上下文中。此方法接受一个键,并返回与该键关联的值。同样,键参数和值结果都声明为any类型。如果未找到提供的键的值,则返回nil。使用逗号-ok 惯用法将返回的值断言为正确的类型:

ctx := context.Background()
if myVal, ok := ctx.Value(myKey).(int); !ok {
    fmt.Println("no value")
} else {
    fmt.Println("value:", myVal)
}
注意

如果您熟悉数据结构,您可能会意识到在上下文链中搜索存储的值是线性搜索。当只有少量值时,这不会对性能造成严重影响,但如果在请求期间将几十个值存储在上下文中,性能会表现不佳。也就是说,如果您的程序正在创建具有几十个值的上下文链,那么您的程序可能需要进行一些重构。

上下文中存储的值可以是任何类型,但选择正确的键很重要。就像map的键一样,上下文值的键必须是可比较的。不要仅仅使用像"id"这样的string。如果你使用string或者另一个预定义或导出的类型作为键的类型,不同的包可能会创建相同的键,导致冲突。这会引起难以调试的问题,例如一个包向上下文写入数据,掩盖了另一个包写入的数据,或者从上下文读取由另一个包写入的数据。

有两种模式用于保证键是唯一且可比较的。第一种是基于int创建一个新的、未导出的键类型:

type userKey int

在声明您的未导出键类型后,然后声明该类型的未导出常量:

const (
    _ userKey = iota
    key
)

由于类型和键的类型常量都是未导出的,因此来自包外部的代码无法将数据放入上下文以引起冲突。如果您的包需要将多个值放入上下文,请为每个值定义相同类型的不同键,使用您在“iota 用于枚举——有时”中查看过的iota模式。由于您只关心常量的值作为区分多个键的一种方式,这是iota的一个完美用例。

接下来,构建一个 API 来将值放入上下文并从上下文中读取该值。仅在包外的代码应该能够读取和写入您的上下文值时,使这些函数公开。创建具有该值的上下文的函数的名称应以ContextWith开头。从上下文返回值的函数的名称应以FromContext结尾。以下是设置和从上下文中读取用户的函数实现:

func ContextWithUser(ctx context.Context, user string) context.Context {
    return context.WithValue(ctx, key, user)
}

func UserFromContext(ctx context.Context) (string, bool) {
    user, ok := ctx.Value(key).(string)
    return user, ok
}

另一个选项是通过使用空结构体定义未导出的键类型:

type userKey struct{}

然后更改用于管理上下文值访问的函数:

func ContextWithUser(ctx context.Context, user string) context.Context {
    return context.WithValue(ctx, userKey{}, user)
}

func UserFromContext(ctx context.Context) (string, bool) {
    user, ok := ctx.Value(userKey{}).(string)
    return user, ok
}

如何知道使用哪种键样式?如果您有一组用于在上下文中存储不同值的相关键,则使用intiota技术。如果只有一个单一的键,则任何一种都可以。重要的是,您希望使上下文键不可能发生冲突。

现在您已编写了用户管理代码,让我们看看如何使用它。您将编写中间件,从 cookie 中提取用户 ID:

// a real implementation would be signed to make sure
// the user didn't spoof their identity
func extractUser(req *http.Request) (string, error) {
    userCookie, err := req.Cookie("identity")
    if err != nil {
        return "", err
    }
    return userCookie.Value, nil
}

func Middleware(h http.Handler) http.Handler {
    return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
        user, err := extractUser(req)
        if err != nil {
            rw.WriteHeader(http.StatusUnauthorized)
            rw.Write([]byte("unauthorized"))
            return
        }
        ctx := req.Context()
        ctx = ContextWithUser(ctx, user)
        req = req.WithContext(ctx)
        h.ServeHTTP(rw, req)
    })
}

在中间件中,您首先获取用户值。接下来,使用Context方法从请求中提取上下文,并使用ContextWithUser函数创建一个包含用户的新上下文。当您包装上下文时,重用ctx变量名是惯用的。然后,您通过使用WithContext方法从旧请求和新上下文创建一个新请求。最后,您使用我们提供的http.ResponseWriter调用处理程序链中的下一个函数。

在大多数情况下,您希望在请求处理程序中从上下文中提取值,并将其显式传递给业务逻辑。Go 函数具有显式参数,不应将上下文用作通过 API 绕过的方式:

func (c Controller) DoLogic(rw http.ResponseWriter, req *http.Request) {
    ctx := req.Context()
    user, ok := identity.UserFromContext(ctx)
    if !ok {
        rw.WriteHeader(http.StatusInternalServerError)
        return
    }
    data := req.URL.Query().Get("data")
    result, err := c.Logic.BusinessLogic(ctx, user, data)
    if err != nil {
        rw.WriteHeader(http.StatusInternalServerError)
        rw.Write([]byte(err.Error()))
        return
    }
    rw.Write([]byte(result))
}

通过在请求上使用Context方法获取上下文,使用UserFromContext函数从上下文中提取用户,然后调用业务逻辑,您的处理程序会得到上下文。这段代码展示了关注点分离的价值;如何加载用户对Controller来说是未知的。一个真实的用户管理系统可以在中间件中实现,并且可以在不更改任何控制器代码的情况下进行交换。

此示例的完整代码位于第十四章代码库sample_code/context_user目录中。

在某些情况下,最好将值保留在上下文中。前面提到的跟踪 GUID 就是一个例子。此信息用于管理您的应用程序;它不是业务状态的一部分。通过将跟踪 GUID 明确地通过您的代码传递,可以添加额外的参数,并防止与不知道您元信息的第三方库集成。通过在上下文中留下跟踪 GUID,它将在不需要了解跟踪的业务逻辑中隐式传递,并在您的程序写日志消息或连接到另一个服务器时可用。

这是一个简单的上下文感知的 GUID 实现,用于跟踪服务之间的流动,并在日志中包含 GUID:

package tracker

import (
    "context"
    "fmt"
    "net/http"

    "github.com/google/uuid"
)

type guidKey int

const key guidKey = 1

func contextWithGUID(ctx context.Context, guid string) context.Context {
    return context.WithValue(ctx, key, guid)
}

func guidFromContext(ctx context.Context) (string, bool) {
    g, ok := ctx.Value(key).(string)
    return g, ok
}

func Middleware(h http.Handler) http.Handler {
    return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
        ctx := req.Context()
        if guid := req.Header.Get("X-GUID"); guid != "" {
            ctx = contextWithGUID(ctx, guid)
        } else {
            ctx = contextWithGUID(ctx, uuid.New().String())
        }
        req = req.WithContext(ctx)
        h.ServeHTTP(rw, req)
    })
}

type Logger struct{}

func (Logger) Log(ctx context.Context, message string) {
    if guid, ok := guidFromContext(ctx); ok {
        message = fmt.Sprintf("GUID: %s - %s", guid, message)
    }
    // do logging
    fmt.Println(message)
}

func Request(req *http.Request) *http.Request {
    ctx := req.Context()
    if guid, ok := guidFromContext(ctx); ok {
        req.Header.Add("X-GUID", guid)
    }
    return req
}

Middleware 函数从传入的请求中提取或生成一个新的 GUID。在这两种情况下,它都将 GUID 放入上下文中,创建一个带有更新后的上下文的新请求,并继续调用链。

接下来您可以看到如何使用这个 GUID。Logger 结构提供了一个通用的日志记录方法,接受上下文和字符串作为参数。如果上下文中有 GUID,则将其附加到日志消息的开头并输出。当此服务调用另一个服务时,会使用 Request 函数。它接受一个 *http.Request,如果上下文中存在 GUID,则添加一个带有 GUID 的头部,并返回更新后的 *http.Request

一旦您获得了这个包,您可以使用我在 “隐式接口使依赖注入更容易” 中讨论的依赖注入技术来创建完全不知道任何跟踪信息的业务逻辑。首先,声明一个接口来表示您的日志记录器,一个函数类型来表示请求装饰器,以及一个依赖于它们的业务逻辑结构:

type Logger interface {
    Log(context.Context, string)
}

type RequestDecorator func(*http.Request) *http.Request

type LogicImpl struct {
    RequestDecorator RequestDecorator
    Logger           Logger
    Remote           string
}

接下来,实现您的业务逻辑:

func (l LogicImpl) Process(ctx context.Context, data string) (string, error) {
    l.Logger.Log(ctx, "starting Process with "+data)
    req, err := http.NewRequestWithContext(ctx,
        http.MethodGet, l.Remote+"/second?query="+data, nil)
    if err != nil {
        l.Logger.Log(ctx, "error building remote request:"+err.Error())
        return "", err
    }
    req = l.RequestDecorator(req)
    resp, err := http.DefaultClient.Do(req)
    // process the response...
}

GUID 通过日志记录器和请求装饰器传递,而业务逻辑不知道它,从而将程序逻辑所需的数据与程序管理所需的数据分离。唯一知道关联的地方是在 main 中将您的依赖项连接起来的代码。

controller := Controller{
    Logic: LogicImpl{
        RequestDecorator: tracker.Request,
        Logger:           tracker.Logger{},
        Remote:           "http://localhost:4000",
    },
}

您可以在 第十四章存储库sample_code/context_guid 目录中找到 GUID 追踪器的完整代码。

小贴士

使用上下文通过标准 API 传递值。在处理业务逻辑时,将上下文中的值复制到显式参数中。系统维护信息可以直接从上下文中访问。

取消

虽然上下文值对于传递元数据并解决 Go 的 HTTP API 限制非常有用,但上下文还有第二个用途。上下文还允许您控制应用程序的响应性并协调并发的 goroutine。让我们看看如何实现。

我在 “使用上下文终止 Goroutines” 中简要讨论了这个问题。想象一下,您有一个请求启动了几个 goroutine,每个 goroutine 调用不同的 HTTP 服务。如果一个服务返回错误,导致您无法返回有效结果,则继续处理其他 goroutine 没有意义。在 Go 中,这被称为 取消,上下文提供了其实现的机制。

要创建一个可取消的上下文,使用context.WithCancel函数。它以context.Context作为参数,并返回一个context.Context和一个context.CancelFunc。就像context.WithValue一样,返回的context.Context是传入函数的上下文的子上下文。context.CancelFunc是一个不带参数的函数,用于取消上下文,告诉所有监听潜在取消事件的代码停止处理。

每当你创建一个带有关联取消函数的上下文时,无论处理是否以错误结束,必须调用该取消函数。如果不这样做,你的程序将泄露资源(内存和 goroutine),最终会变慢或崩溃。多次调用取消函数不会引发错误;第一次之后的每次调用都不会产生任何效果。

确保调用取消函数的最简单方法是使用defer在取消函数返回后立即调用它:

ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()

这带来一个问题,你如何检测取消?context.Context接口有一个叫做Done的方法。它返回一个类型为struct{}的通道。(选择这种返回类型的原因是空结构体不使用内存。)当调用cancel函数时,这个通道会关闭。记住,关闭的通道在尝试读取时会立即返回其零值。

警告

如果在一个不可取消的上下文中调用Done,它将返回nil。正如在“在select语句中关闭一个case”中所述,从nil通道读取永远不会返回。如果这不是在select语句的一个case内完成,你的程序将挂起。

让我们看看这是如何工作的。假设你有一个程序从多个 HTTP 端点收集数据。如果其中任何一个失败,你希望结束所有的处理。上下文取消允许你这样做。

注意

在这个例子中,你将利用一个名为httpbin.org的优秀服务。你可以向它发送 HTTP 或 HTTPS 请求,以测试你的应用程序对各种情况的响应方式。你将使用它的两个端点:一个是延迟指定秒数后返回响应的端点,另一个会返回你发送的状态码之一。

首先,创建你的可取消上下文、一个用于从 goroutine 获取数据的通道,以及一个sync.WaitGroup以允许等待直到所有 goroutine 完成:

ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
ch := make(chan string)
var wg sync.WaitGroup
wg.Add(2)

接下来,启动两个 goroutine,一个调用 URL,随机返回一个错误的状态,另一个在延迟后发送一个预设的 JSON 响应。首先是随机状态的 goroutine:

    go func() {
        defer wg.Done()
        for {
            // return one of these status code at random
            resp, err := makeRequest(ctx,
                "http://httpbin.org/status/200,200,200,500")
            if err != nil {
                fmt.Println("error in status goroutine:", err)
                cancelFunc()
                return
            }
            if resp.StatusCode == http.StatusInternalServerError {
                fmt.Println("bad status, exiting")
                cancelFunc()
                return
            }
            select {
            case ch <- "success from status":
            case <-ctx.Done():
            }
            time.Sleep(1 * time.Second)
        }
    }()

makeRequest函数是一个辅助函数,用于使用提供的上下文和 URL 进行 HTTP 请求。如果返回 OK 状态,你将向通道写入一条消息并休眠一秒钟。当出现错误或者返回了一个错误的状态码时,你调用cancelFunc并退出 goroutine。

延迟 goroutine 类似:

    go func() {
        defer wg.Done()
        for {
            // return after a 1 second delay
            resp, err := makeRequest(ctx, "http://httpbin.org/delay/1")
            if err != nil {
                fmt.Println("error in delay goroutine:", err)
                cancelFunc()
                return
            }
            select {
            case ch <- "success from delay: " + resp.Header.Get("date"):
            case <-ctx.Done():
            }
        }
    }()

最后,您使用 for/select 模式从 goroutine 写入的通道中读取数据,并等待取消的发生:

loop:
    for {
        select {
        case s := <-ch:
            fmt.Println("in main:", s)
        case <-ctx.Done():
            fmt.Println("in main: cancelled!")
            break loop
        }
    }
    wg.Wait()

在你的select语句中,有两种情况。一种从消息通道读取,另一种等待完成通道关闭。当它关闭时,你退出循环并等待 goroutine 退出。你可以在第十四章仓库sample_code/cancel_http目录中找到此程序。

这是您运行代码时发生的情况(结果是随机的,所以请继续运行几次以查看不同的结果):

in main: success from status
in main: success from delay: Thu, 16 Feb 2023 03:53:57 GMT
in main: success from status
in main: success from delay: Thu, 16 Feb 2023 03:53:58 GMT
bad status, exiting
in main: cancelled!
error in delay goroutine: Get "http://httpbin.org/delay/1": context canceled

有一些有趣的事情需要注意。首先,您多次调用cancelFunc。正如前面提到的,这是完全可以的,不会引起问题。接下来,请注意,在触发取消后,您从延迟 goroutine 获取了一个错误。这是因为 Go 标准库中内置的 HTTP 客户端尊重取消。您使用可取消的上下文创建了请求,当取消时,请求结束。这会触发 goroutine 中的错误路径,并确保它不会泄漏。

你可能想知道导致取消的错误以及如何报告它。名为WithCancelCauseWithCancel的替代版本返回一个接受错误作为参数的取消函数。context包中的Cause函数返回传递给取消函数首次调用的错误。

注意

Causecontext包中的一个函数,而不是context.Context上的方法,因为在 Go 1.20 中将通过取消返回错误的功能添加到了context包中,这是在最初引入context之后很久的事情。如果在context.Context接口上添加了一个新方法,这将破坏任何实现它的第三方代码。另一个选项是定义一个包含此方法的新接口,但现有代码已经到处传递context.Context,并将其转换为具有Cause方法的新接口将需要类型断言或类型切换。添加一个函数是最简单的方法。随着时间的推移,有几种方式可以演化你的 API。你应该选择对用户影响最小的方式。

让我们重写程序以捕获错误。首先,您更改了上下文的创建:

ctx, cancelFunc := context.WithCancelCause(context.Background())
defer cancelFunc(nil)

接下来,您将对两个 goroutine 进行轻微修改。状态 goroutine 中for循环的主体现在如下所示:

resp, err := makeRequest(ctx, "http://httpbin.org/status/200,200,200,500")
if err != nil {
    cancelFunc(fmt.Errorf("in status goroutine: %w", err))
    return
}
if resp.StatusCode == http.StatusInternalServerError {
    cancelFunc(errors.New("bad status"))
    return
}
ch <- "success from status"
time.Sleep(1 * time.Second)

您已删除fmt.Println语句,并将错误传递给cancelFunc。延迟 goroutine 中for循环的主体现在如下所示:

resp, err := makeRequest(ctx, "http://httpbin.org/delay/1")
if err != nil {
    fmt.Println("in delay goroutine:", err)
    cancelFunc(fmt.Errorf("in delay goroutine: %w", err))
    return
}
ch <- "success from delay: " + resp.Header.Get("date")

fmt.Println仍然存在,因此您可以显示仍然生成并传递给cancelFunc的错误。

最后,您使用context.Cause在初始取消和等待 goroutine 完成后打印错误:

loop:
    for {
        select {
        case s := <-ch:
            fmt.Println("in main:", s)
        case <-ctx.Done():
            fmt.Println("in main: cancelled with error", context.Cause(ctx))
            break loop
        }
    }
    wg.Wait()
    fmt.Println("context cause:", context.Cause(ctx))

你可以在第十四章存储库sample_code/cancel_error_http目录中找到此代码。

运行新程序会生成以下输出:

in main: success from status
in main: success from delay: Thu, 16 Feb 2023 04:11:49 GMT
in main: cancelled with error bad status
in delay goroutine: Get "http://httpbin.org/delay/1": context canceled
context cause: bad status

当最初在switch语句中检测到取消时,你会看到来自状态 goroutine 的错误输出,以及在等待延迟 goroutine 完成后。请注意,延迟 goroutine 使用错误调用了cancelFunc,但该错误并未覆盖最初的取消错误。

当你的代码达到结束处理的逻辑状态时,手动取消非常有用。有时候你想取消是因为任务花费的时间太长。在这种情况下,你可以使用定时器。

带有截止时间的上下文。

服务器的最重要工作之一是管理请求。初学者程序员通常认为服务器应该尽可能多地接受请求,并在可能的情况下处理它们,直到为每个客户端返回结果。

问题在于这种方法不具备可扩展性。服务器是共享资源。像所有共享资源一样,每个用户都希望从中获取尽可能多的资源,并且并不十分关心其他用户的需求。共享资源的责任是自我管理,以便为所有用户提供公平的时间。

通常,服务器可以执行四项操作来管理其负载:

  • 限制同时请求。

  • 限制等待运行的排队请求数量。

  • 限制请求可以运行的时间量。

  • 限制请求可以使用的资源(如内存或磁盘空间)。

Go 提供了处理前三个问题的工具。在学习并发性时,你了解到如何处理前两个问题(参见第十二章)。通过限制 goroutine 的数量,服务器可以管理同时负载。通过缓冲通道处理等待队列的大小。

上下文提供了一种控制请求运行时间的方法。在构建应用程序时,你应该有一个性能范围的概念:在用户体验不佳之前,请求完成的最长时间。如果你知道请求可以运行的最长时间,可以使用上下文进行强制执行。

注意

虽然GOMEMLIMIT提供了限制 Go 程序使用的内存量的软性方法,但如果你想强制约束单个请求使用的内存或磁盘空间,你必须编写管理此类限制的代码。本书讨论此主题超出了范围。

你可以使用两个函数之一创建有时间限制的上下文。第一个是context.WithTimeout。它接受两个参数:一个现有的上下文和指定持续时间的time.Duration,在此持续时间后上下文将自动取消。它返回一个上下文,在指定持续时间后自动触发取消,并返回一个可立即调用以取消上下文的取消函数。

第二个函数是context.WithDeadline。此函数接收一个现有的上下文和一个指定上下文何时自动取消的time.Time。像context.WithTimeout一样,它返回一个上下文,在指定时间后自动触发取消以及一个取消函数。

提示

如果你将过去的时间传递给context.WithDeadline,上下文已经被创建取消。

就像从context.WithCancelcontext.WithCancelCause返回的取消函数一样,你必须确保context.WithTimeoutcontext.WithDeadline返回的取消函数至少被调用一次。

如果你想知道上下文何时会自动取消,请使用context.ContextDeadline方法。它返回一个time.Time,指示时间和一个bool,指示是否设置了超时。这与读取映射或通道时使用的 comma ok 习语类似。

当你为请求的总持续时间设置时间限制时,你可能希望将这段时间细分。如果你从你的服务调用另一个服务,你可能希望限制允许网络调用运行的时间,为其余处理或其他网络调用预留一些时间。通过使用context.WithTimeoutcontext.WithDeadline创建包装父上下文的子上下文,你可以控制每个单独调用所花费的时间。

你在子上下文设置的任何超时都受父上下文设置的超时的限制;如果父上下文在 2 秒钟后超时,你可以声明子上下文在 3 秒钟后超时,但当父上下文在 2 秒钟后超时时,子上下文也将超时。

你可以通过一个简单的程序看到这一点:

ctx := context.Background()
parent, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
child, cancel2 := context.WithTimeout(parent, 3*time.Second)
defer cancel2()
start := time.Now()
<-child.Done()
end := time.Now()
fmt.Println(end.Sub(start).Truncate(time.Second))

在这个示例中,你在父上下文上指定了 2 秒钟的超时,在子上下文上指定了 3 秒钟的超时。然后,你通过等待从子context.ContextDone方法返回的通道来等待子上下文完成。我将在下一节更多地讨论Done方法。

你可以在第十四章存储库sample_code/nested_timers目录中找到此代码,或在Go Playground上运行此代码。你将看到以下结果:

2s

由于带有计时器的上下文可以因超时或显式调用取消函数而取消,上下文 API 提供了一种告知取消原因的方法。Err方法返回nil,如果上下文仍处于活动状态,或者如果上下文已被取消,则返回两种哨兵错误之一:context.Canceledcontext.DeadlineExceeded。当显式取消时返回第一个错误,当超时触发取消时返回第二个错误。

让我们看看它们的用法。你将对你的 httpbin 程序进行一次修改。这一次,你要为控制 goroutine 运行时间的上下文添加一个超时:

ctx, cancelFuncParent := context.WithTimeout(context.Background(), 3*time.Second)
defer cancelFuncParent()
ctx, cancelFunc := context.WithCancelCause(ctx)
defer cancelFunc(nil)
注意

如果您希望返回取消原因的错误选项,则需要将由WithTimeoutWithDeadline创建的上下文包装在由WithCancelCause创建的上下文中。您必须推迟两个取消函数,以防止资源泄漏。如果希望在上下文超时时返回自定义哨兵错误,请改用context.WithTimeoutCausecontext.WithDeadlineCause函数。

现在,如果返回 500 状态代码或者在 3 秒内未获取 500 状态代码,您的程序将退出。您对程序的唯一其他更改是在取消发生时打印Err返回的值:

fmt.Println("in main: cancelled with cause:", context.Cause(ctx),
    "err:", ctx.Err())

您可以在第十四章存储库sample_code/timeout_error_http目录中找到该代码。

结果是随机的,因此运行程序多次以查看不同的结果。如果运行程序并达到超时,您将获得如下输出:

in main: success from status
in main: success from delay: Sun, 19 Feb 2023 04:36:44 GMT
in main: success from status
in main: success from status
in main: success from delay: Sun, 19 Feb 2023 04:36:45 GMT
in main: cancelled with cause: context deadline exceeded
    err: context deadline exceeded
in delay goroutine: Get "http://httpbin.org/delay/1":
    context deadline exceeded
context cause: context deadline exceeded

注意,context.Cause返回的错误与Err方法返回的错误相同:context.DeadlineExceeded

如果状态错误发生在 3 秒内,您将获得如下输出:

in main: success from status
in main: success from status
in main: success from delay: Sun, 19 Feb 2023 04:37:14 GMT
in main: cancelled with cause: bad status err: context canceled
in delay goroutine: Get "http://httpbin.org/delay/1": context canceled
context cause: bad status

现在context.Cause返回的错误是bad status,但Err返回context.Canceled错误。

您自己代码中的上下文取消

大多数情况下,您无需担心自己的代码超时或取消;它根本不会运行足够长时间。每当调用另一个 HTTP 服务或数据库时,应传递上下文;这些库通过上下文正确处理取消。

您应考虑处理两种情况的取消。第一种情况是当您的函数使用select语句读取或写入通道时。如“取消”中所示,包括检查上下文上的Done方法返回的通道的case。这允许您的函数在上下文取消时退出,即使 goroutine 未正确处理取消。

第二种情况是当您编写的代码运行时间足够长,应该被上下文取消中断时。在这种情况下,定期使用context.Cause检查上下文的状态。context.Cause函数如果上下文已被取消,则返回错误。

这是支持您代码中上下文取消的模式:

func longRunningComputation(ctx context.Context, data string) (string, error) {
    for {
        // do some processing
        // insert this if statement periodically
        // to check if the context has been cancelled
        if err := context.Cause(ctx); err != nil {
            // return a partial value if it makes sense,
            // or a default one if it doesn't
            return "", err
        }
        // do some more processing and loop again
    }
}

这是一个示例循环,通过使用低效的 Leibniz 算法计算π的函数。使用上下文取消允许您控制其运行时间:

i := 0
for {
    if err := context.Cause(ctx); err != nil {
        fmt.Println("cancelled after", i, "iterations")
        return sum.Text('g', 100), err
    }
    var diff big.Float
    diff.SetInt64(4)
    diff.Quo(&diff, &d)
    if i%2 == 0 {
        sum.Add(&sum, &diff)
    } else {
        sum.Sub(&sum, &diff)
    }
    d.Add(&d, two)
    i++
}

您可以查看完整的示例程序,演示了sample_code/own_cancellation目录中的这种模式,在第十四章存储库中找到。

练习

现在您已经看到如何使用上下文,请尝试实现这些练习。所有答案都可以在第十四章存储库中找到。

  1. 创建一个生成中间件的函数,它创建一个带有超时的上下文。该函数应该有一个参数,即请求允许运行的毫秒数。它应返回一个func(http.Handler) http.Handler

  2. 编写一个程序,将在生成的随机数介于 0(含)和 100,000,000(不含)之间的范围内随机生成,直到两件事中的一件发生为止:生成数字 1234 或经过 2 秒。打印出总和、迭代次数以及结束原因(超时或达到数字)。

  3. 假设你有一个简单的日志记录函数,看起来像这样:

    func Log(ctx context.Context, level Level, message string) {
        var inLevel Level
        // TODO get a logging level out of the context and assign it to inLevel
        if level == Debug && inLevel == Debug {
            fmt.Println(message)
        }
        if level == Info && (inLevel == Debug || inLevel == Info) {
            fmt.Println(message)
        }
    }
    

    定义一个名为Level的类型,其底层类型为string。定义该类型的两个常量,DebugInfo,分别设置为"debug""info"

    在上下文中创建函数来存储日志级别并提取它。

    创建一个中间件函数,从名为log_level的查询参数中获取日志级别。log_level 的有效值为 debuginfo

    最后,在Log中填写TODO,从上下文中正确提取日志级别。如果未分配日志级别或不是有效值,则不应打印任何内容。

总结

在本章中,您学习了如何使用上下文管理请求元数据。现在,您可以设置超时时间,执行显式取消操作,通过上下文传递值,并知道应该在何时执行这些操作。在下一章中,您将了解 Go 的内置测试框架,并学习如何使用它来查找程序中的错误并诊断性能问题。