Skip to content

Latest commit

 

History

History
121 lines (82 loc) · 7.96 KB

8-5-Not-understanding-the-concurrency.md

File metadata and controls

121 lines (82 loc) · 7.96 KB

8.5 goroutines 数量取决于工作负载类型

本节将讨论工作负载类型在并发实现中的影响。事实上,如果工作负载受 CPU 或 IO 限制,我们可能不必以相同的方式处理它。让我们首先定义这些概念,然后深入研究其影响。

在编程中,工作负载的执行时间受以下限制:

  • CPU的速度。例如,运行归并排序算法。工作负载称为 CPU 绑定。
  • I/O 的速度。例如,进行 REST 调用或数据库查询。工作负载称为 I/O 绑定。
  • 可用内存量。工作负载称为内存绑定。

Note 鉴于过去几十年内存变得非常便宜,后者可能是当今最不稀缺的。因此,本节将重点介绍前两种工作负载类型: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
}

此函数创建一个 count 变量,从 io.Reader 输入中读取,调用 task 并递增计数。现在,如果我们想以并行方式运行所有任务函数怎么办?

一种选择是使用所谓的工作池模式。它涉及创建固定大小的工作程序(goroutines),这些工作程序将从一个公共通道轮询任务工作:

首先,我们启动一个固定的 goroutines 池(我们稍后会讨论有多少)。然后,我们创建一个共享通道,在每次读取 io.Reader 后,我们将在其中发布任务。池中的每个 goroutine 从这个通道接收,执行它们的工作,然后自动更新共享计数器。

让我们看看用 Go 编写它的一种可能方式,池大小为 10 个 goroutine。每个 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 中的潜在争用。然后,我们迭代 n 次以创建一个将从共享通道接收的新 goroutine。接收到的每条消息都将通过执行任务并自动增加共享计数器来处理。从通道读取后,每个 goroutine 递减等待组。

在父 goroutine 中,我们不断从 io.Reader 读取并将每个任务发布到通道。最后但同样重要的是,我们关闭通道并等待等待组完成(意味着所有子 goroutine 都完成了它们的工作),然后再返回。

具有固定数量的 goroutine 限制了所讨论的缺点;它缩小了资源的影响并防止外部系统被淹没。现在,黄金问题:池大小值应该是多少?答案取决于工作负载类型。

如果工作负载是 IO 密集型的,答案主要取决于外部系统。如果我们想最大化吞吐量,系统将处理多少并发访问?

如果工作负载受 CPU 限制,最佳实践是依赖 GOMAXPROCSGOMAXPROCS 是一个变量,它设置分配给运行的 goroutines 的操作系统线程数。默认情况下,此值设置为逻辑 CPU 的数量。

Note 我们可以使用 runtime.GOMAXPROCS(int) 函数来更新 GOMAXPROCS 的值。但是,用零作为参数调用它不会改变值;它只返回当前的值:

n := runtime.GOMAXPROCS(0)

那么,将池的大小映射到 GOMAXPROCS 的基本原理是什么?

让我们举一个具体的例子,假设我们将在 4 核机器上运行我们的应用程序;因此 Go 将实例化四个 OS 线程,在那里执行 goroutine。起初,事情可能并不理想,我们可能会遇到这样的场景,有四个 CPU 内核和四个 goroutine,但只有一个被执行:

M0 当前正在运行工作池的 goroutine。因此,这些 goroutine 开始从通道接收消息并执行它们的工作。但是,池中的其他三个 goroutine 尚未分配给 M。因此它们处于可运行状态。M1、M2 和 M3 没有任何 goroutines 可以运行,因此它们保持在核心之外。因此,只有一个 goroutine 正在运行。

最终,根据已经描述的工作窃取概念,P1 可能会从本地 P0 队列中窃取 goroutine:

这里 P1 从 P0 窃取了三个 goroutine。此外,在这种情况下,最终,Go 调度程序可能会将所有 goroutine 分配给不同的 OS 线程。然而,不能保证这应该在什么时候准确发生。然而,由于 Go 调度程序的主要目标之一是优化资源(这里是 goroutines 的分布),考虑到工作负载的性质,我们最终应该处于这样的场景中。

但是,这种情况仍然不是最优的,因为最多有两个 goroutine 正在运行。假设这台机器只运行我们的应用程序(除了操作系统进程),所以 P2 和 P3 将是闲置的。因此,最终,操作系统可能会以这种方式移动 M2 和 M3:

在这里,操作系统调度程序决定将 M2 移动到 P2 并将 M3 移动到 P3。同样,不能保证这种情况何时会发生。然而,假设一台机器只执行我们的 4 线程应用程序,这应该是最后的画面。

现在情况发生了变化;它已成为最佳状态。四个 goroutine 在单独的线程中运行,每个线程都在单独的核心上运行。因此,它将减少 goroutine 和线程级别的上下文切换量。

我们这些 Go 开发人员无法设计和请求这张全局图片。然而,正如我们所见,我们可以在 CPU 密集型工作负载的情况下以有利的条件启用它:拥有一个基于 GOMAXPROCS 的工作池。

Note 如果在特定情况下,我们希望将 goroutine 的数量绑定到 CPU 核心的数量,那么为什么不依赖 runtime.NumCPU(),它返回逻辑 CPU 核心的数量?正如我们提到的,GOMAXPROCS 可以更改并且可能低于 CPU 内核的数量。在 CPU 密集型工作负载的情况下,如果核心数量为四个但我们只有三个线程,我们不应该启动四个 goroutine,而是三个。否则,线程将在两个 goroutine 之间共享其执行时间,从而增加上下文切换的次数。

在实施工作池模式时,我们已经看到池中 goroutine 的最佳数量取决于工作负载类型。如果 worker 执行的工作类型是 IO-bound,那么这个值主要取决于外部系统。相反,如果工作负载受 CPU 限制,则最佳 goroutine 数量接近可用线程数。这就是为什么在设计并发应用程序时了解工作负载类型(I/O 或 CPU)至关重要。

最后但同样重要的是,让我们记住,在大多数情况下,我们应该通过基准来验证我们的假设。事实上,并发性并不简单,很容易草率地做出最终可能无效的假设。

对于本章的最后一部分,现在让我们深入了解精通 Go 的一个关键方面:上下文。