Go中的并发机制

Go 的内置并发机制是其最大的优势之一,通常也是开发者选择使用 Go 构建服务的主要原因之一。由于 Go 的 goroutinechannel,在 Go 中实现并发非常容易(且无痛!)。在本节中,我们将探讨每种机制并回顾其行为,以便更好地理解如何使用和测试它们。

并发 是指程序同时处理多个任务的能力。这种关键能力使我们能够充分利用 CPU 的处理能力,从而优化资源的使用。这对于所有系统都很重要,因为它能够在尽可能处理更多请求的同时,不中断程序中的其他流程,并保持较低的计算成本。

图9.1 描绘了两个并发任务:

image 2025 01 04 20 38 08 072
Figure 1. Figure 9.1 – Concurrent execution flow of two tasks

任务被划分为组成调用堆栈的函数:

  1. 在此示例中,每个任务被划分为三个函数,这些函数构成了调用堆栈。任务在接收到输入时开始,并在计算出结果或输出时结束。任务 A 被划分为三个函数:函数 A1、函数 A2 和函数 A3。任务 B 的划分方式相同。

  2. 任务 A 和任务 B 彼此独立。每个任务接收自己的输入并计算自己的结果。由于任务之间没有关联,它们可以以任何顺序计算。这使得它们适合作为并发执行流的一部分执行。

  3. 当并发执行任务时,子任务会被调度和中断,以实现最高效的执行。中断调用堆栈中函数的能力是这两个任务并发执行的关键要求。我们将在后续章节中学习如何防止这些中断。

  4. 每个任务在接收到输入时开始。在此示例中,输入 A 先于输入 B 被接收,因此任务 A 首先开始执行。

  5. 子任务(或函数)以交错的方式执行,CPU 以组合的方式执行任务 A 和任务 B 中的函数。我们注意到,子任务在任务内部是按顺序执行的。这意味着函数 A1 在函数 A2 之前执行,但对于任务 B 的子任务,没有时间顺序的保证。

  6. 一旦任务成功完成,输出将被返回,CPU 可以自由执行其他任务。我们注意到,尽管输入 B 是第二个到达的,任务 B 也是第二个开始的,但它首先完成并返回结果 B。函数的调度取决于资源的可用性和其他因素。我们将在后面的章节中探讨 Go 中的调度工作原理。

由于并发运行的任务之间没有顺序保证,我们应该小心确保允许并发运行的任务是独立的,并且不相互依赖。否则,任务的并发执行可能会导致任务运行缓慢或出现错误。

避免顺序假设

并发可能在库的内部使用,有时并不总是能够直接看出其使用位置。因此,我们应该避免对顺序或执行时间做出假设。我们将学习如何使用同步机制和检查来确保在执行开始之前满足条件。

并行性 经常与并发混淆,但它是指程序同时执行任务的能力。与并发不同(并发不保证任务顺序),我们知道在这种模式下任务执行将是并行的。任务也应该是相互独立的,因为它们不能相互等待。

图9.2 描绘了两个并行任务:

image 2025 01 04 20 40 02 167
Figure 2. Figure 9.2 – Parallel execution flow of two tasks

两个任务的并行执行流程是同时进行的:

  1. 任务在接收到输入 A 和输入 B 后开始执行。

  2. 任务同时且独立地执行,没有中断或交错。

  3. 任务在误差范围内同时完成。无论我们如何尝试使它们完全相同,资源使用和性能方面总会存在偏差。

为了实现真正的并行性,需要单独的计算资源。这会增加系统基础设施的成本,这对于某些工程团队来说是不可取的,甚至可能是不可接受的。因此,并发 通常是程序中实现多任务的首选方式。随着系统的成功,正确实现的并发可以促进系统在能够承受增加成本时顺利过渡到并行性。

在 Go 中,函数或子任务的并发处理是通过 goroutine 执行的。我们将在接下来的章节中探讨它们是什么、如何调度它们以及如何同步它们。

Goroutines

现在我们理解了并发和并行之间的区别,在本章的剩余部分,我们将重点关注 Go 中并发的实现。

Goroutine 是可以与其他函数或方法并发运行的函数或方法。它们通常被称为轻量级线程,因为它们占用的内存较少,并且在更少的操作系统线程上运行。

通过使用 go 关键字,可以轻松地指示 Go 运行时在一个单独的 goroutine 中运行一个函数:

func greet(gr string) {
    fmt.Println("Hello, friend!")
}

func main() {
    go greet()
    fmt.Println("Goodbye, friend!")
}

这段代码创建了一个 main 函数和一个 greet 函数,greet 函数接收一个字符串作为参数并将其打印到终端。我们通过在函数调用前添加 go 关键字来指示运行时在一个单独的 goroutine 中运行该函数。最后,我们打印了 "Goodbye, friend!" 以表示 main 函数已完成。

我们使用以下命令运行这个小程序:

$ go run chapter09/concurrency/goroutines/main.go
Goodbye, friend!

程序没有打印问候语,而是只打印了告别行。这是由于程序和 goroutine 的行为所致。图9.3 展示了这些属性的可视化:

image 2025 01 04 20 41 53 093
Figure 3. Figure 9.3 – Goroutine execution of the greeting program

程序没有将问候语打印到终端,这是因为 goroutine 的创建具有非阻塞的特性:

  1. 当我们运行程序时,main 函数开始执行。该函数在自己的 goroutine 中运行,我们将其称为 主 goroutinemain 函数的执行时间取决于其函数体内的语句。

  2. main 函数执行期间,主 goroutine 指示 Go 运行时创建一个 goroutine,并在该 goroutine 中运行 greet 函数。这个 goroutine 与主 goroutine 具有父子关系。我们将其称为 greet goroutine

  3. 创建子 goroutine(用于运行 greet 函数)是一个非阻塞操作。这使我们能够实现之前讨论的并发多任务特性。

  4. 由于主 goroutine 没有被阻塞,它完成了自己的工作并结束了执行时间。一旦主 goroutine 完成,Go 运行时将清理其所有资源。由于主 goroutine 与 greet goroutine 具有父子关系,运行时也会终止 greet goroutine。

  5. greet goroutine 立即停止执行并关闭。根据它从 CPU 获得的执行时间,greet goroutine 可能能够将其打印内容输出到终端,也可能不能。

由于这些特性,程序无法可靠地将问候语打印到终端。我们需要阻止主 goroutine 关闭,以便为子 goroutine 提供完成执行的时间。

一种解决方案是通过调用 time.Sleep 函数阻塞主 goroutine 一段时间(例如 1 秒)。另一种更有趣的解决方案是通过向共享变量写入值来通知 greet goroutine 已完成工作:

var finished bool

func greet() {
    fmt.Println("Hello, friend!")
    finished = true
}

func main() {
    go greet()
    for !finished {
        fmt.Println("Child goroutine not finished.")
        time.Sleep(10 * time.Millisecond)
    }
    fmt.Println("Child goroutine finished.")
    fmt.Println("Goodbye, friend!")
}

这两个函数共享内存空间,因此它们可以读写共享变量。代码片段展示了这一点:

  1. 我们在顶部创建了一个名为 finished 的布尔类型变量。该变量的目的是向 main 函数发出信号,表明 greet 函数已完成。

  2. 一旦 greet 函数将其问候语写入终端,它将 finished 变量的值设置为 true

  3. main 函数体内,我们创建了一个 for 循环,该循环将一直执行,直到 finished 变量的值为 true。通过使用 time.Sleep 函数,我们每 10 毫秒轮询一次变量的值。

  4. 一旦 for 循环完成,主 goroutine 完成其执行,两个 goroutine 的所有资源都将被清理。

运行此程序将打印以下内容:

$ go run chapter09/concurrency/goroutines/main.go
Child goroutine not finished.
Hello, friend!
Child goroutine finished.
Goodbye, friend!

最后,通过这种简单的写入共享变量的方法,我们成功阻塞了主 goroutine,直到其子 goroutine 完成。我们终于能够在终端中看到问候语的打印,并且程序正确执行。

这种在 goroutine 之间共享信息的方式被称为 通过共享内存进行通信,这是其他编程语言中处理并发的传统方式。然而,这种方法并非没有缺点。Go 提供了另一种方法,我们将在下一节中探讨。

Channels

Channel(通道)goroutine 提供了另一种相互通信的方式。我们可以将内置的通道类型视为一种管道,通过它可以在 goroutine 之间安全地发送信息,而无需使用共享变量或内存。在 Go 中,这一原则被称为 通过通信共享内存

图9.4 描绘了通道的主要操作和语法:

image 2025 01 04 20 43 44 407
Figure 4. Figure 9.4 – Operations and syntax of Go channels

与通道的交互展示了其两个主要操作的语法: 1. ch chan bool:通道是 Go 中的内置类型,因此不需要导入任何库。通道使用 chan 关键字声明,后跟数据类型(例如 bool),该类型是通道能够传输的数据类型。只有这种类型的变量才能通过通道传输,这是由编译器强制执行的。 2. ch <- true:通道支持的第一个操作是 发送操作。通道操作符看起来像一个箭头 <-,指示数据通过通道流动的方向。在这种情况下,箭头指向通道,表示我们将 true 值发送到通道中。 3. f := <-ch:与发送操作对应的是 接收操作。该操作通过将通道操作符指向通道外部并将接收到的值分配给名为 f 的局部变量来完成。

这是通道的基本用法,尽管我们将在后面的章节中探讨更多细节。发送和接收操作是阻塞和同步的,因此交易的双方都需要可用才能完成操作。

通道是一种很好的同步和通信机制。我们可以使用它们以更简洁的代码同步主 goroutinegreet goroutine

func greet(ch chan bool) {
    fmt.Println("Hello, friend!")
    ch <- true
}

func main() {
    ch := make(chan bool)
    go greet(ch)
    <-ch
    fmt.Println("Child goroutine finished.")
    fmt.Println("Goodbye, friend!")
}

这个简化版本的解决方案使用通道来同步两个 goroutine

  1. greet 函数被修改为接收一个通道参数。与映射和切片类似,通道类型具有内置的指针引用,因此我们不需要使用 & 操作符显式传递指针。

  2. 一旦问候语被打印,greet 函数将 true 值发送到通道。这将向主 goroutine 发出信号,表明 greet 函数已成功完成。

  3. main 函数内部,我们使用 make 函数初始化一个通道。通道类型的零值是 nil,因此我们使用 make 函数创建一个准备就绪的通道。在底层,make 函数将分配所有所需的资源。

  4. 一旦 greet 函数在其自己的 goroutine 中启动,main 函数在通道上调用接收操作。由于通道上的发送和接收操作是阻塞的,这将阻塞主 goroutine,直到 greet goroutine 完成并能够通过通道发送值。

通过使用通道,我们简化了实现,不再需要轮询 finished 变量的值。我们还注意到,通道变量 ch 已在 main 函数中初始化并作为参数传递。由于现在没有全局变量,我们消除了通过共享内存在两个 goroutine 之间进行通信的需要。

通道还支持最后一个操作,即 关闭操作。与发送和接收不同,关闭通道会改变通道的状态,并向通道的接收者发出工作完成的信号。这是一种可以用于同步目的的操作,而不是支持 goroutine 之间的信息交换和通信。关闭的通道将立即返回通道类型的零值给所有接收操作,并在将来尝试在通道上发送操作时引发 panic

由于我们通道的目的是同步 greetmain goroutine,我们可以使用关闭操作进一步简化代码:

func greet(ch chan struct{}) {
    fmt.Println("Hello, friend!")
    close(ch)
}

func main() {
    ch := make(chan struct{})
    go greet(ch)
    <-ch
    fmt.Println("Child goroutine finished.")
    fmt.Println("Goodbye, friend!")
}

我们对解决方案进行了一些调整。通道的数据类型现在是空结构体 struct{},这减少了通道的内存占用。在 greet 函数内部,我们在函数打印问候语后立即关闭通道。虽然这些更改看起来并不重要,但这个解决方案可以用于向多个接收者发出工作完成的信号,而不必在通道上写入多个值。这是一个强大的机制,我们可以利用它来解决各种问题。

图9.5 总结了我们迄今为止研究的通道行为:

image 2025 01 04 20 45 01 612
Figure 5. Figure 9.5 – Summary of channel operations and states

该图是理解通道在代码中行为的实用参考:

  1. Nil 通道 是未使用 make 函数正确初始化的通道。它们不能用于发送信息,但在启动 goroutine 时传递给 goroutine 是有用的。nil 通道将在未来某个时间初始化以供使用:

    • 发送操作 将阻塞,直到通道被初始化,之后适用于初始化通道的规则。

    • 接收操作 的行为与发送操作相同。

    • 关闭操作 在 nil 通道上会引发 panic。由于 nil 通道尚未准备好发送信息,关闭它们没有意义。因此,如果我们尝试关闭 nil 通道,则被视为致命错误。

  2. 初始化通道 是使用 make 函数创建的,并已准备好使用。它们可以用于发送信息:

    • 发送操作 将阻塞,直到接收者到达。发送 goroutine 将无法继续执行,直到操作完成。

    • 接收操作 将阻塞,直到从发送者接收到值。由于发送和接收是同步操作,两个 goroutine 都必须准备好完成操作,才能完成交易的两个部分。因此,如果发送者启动但接收者尚未准备好,这将导致发送者停止,直到接收者准备好,这可能是一个有用的特性。

    • 关闭操作 立即完成。一旦第一个操作完成,通道将进入 关闭通道 状态。

  3. 关闭通道 是已成功关闭的初始化通道。处于此状态的通道表示它们将不再能够传输信息:

    • 发送操作 将引发 panic。没有简单的方法知道通道是否已关闭,因此 panic 让发送者知道他们应该停止向其发送值,但您应该小心编码以避免遇到 panic。

    • 接收操作 将立即完成并返回通道数据类型的零值。正如我们在 greeter 示例中看到的,我们可以使用关闭通道上的接收操作作为同步机制。

    • 关闭操作 将引发 panic,因为通道只能进入关闭状态一次。同样,防御性编码(例如,单一责任原则,其中只有代码的一部分负责关闭通道)可以帮助控制这一点。

最后需要注意的是,一旦通道关闭,它就不能再次打开。这在解决更复杂的问题时可能会带来一些复杂性。现在我们已经了解了 goroutine 和通道的基本行为,我们可以在下一节中探讨一些常见的并发示例。