Skip to content

Latest commit

 

History

History
104 lines (68 loc) · 5.45 KB

12-2-Writing-concurrent-code-leading-to-false-sharing.md

File metadata and controls

104 lines (68 loc) · 5.45 KB

12.2 并发代码导致 CPU 缓存的虚假共享

到目前为止,我们已经讨论了 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

为了这个例子,我们将实现一个并发解决方案,一个计算 sumA 的 goroutine 和另一个计算 sumB 的 goroutin:

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
}

我们启动了两个 goroutine,一个遍历 a 字段,而另一个遍历 b 字段。从并发的角度来看,这个例子非常好。例如,它不会导致任何数据竞争,因为每个 goroutine 都会增加自己的变量。但是,此示例说明了错误共享概念,降低了预期性能。

让我们看一下主存储器。由于 sumAsumB 是连续分配的,因此在大多数情况下(8 个变量中的 7 个),两个变量都将分配到同一个内存块:

现在,让我们假设机器包含两个内核。在大多数情况下,最终我们应该将两个线程安排在不同的内核上。因此,如果 CPU 决定将此内存块复制到缓存行,它将被复制两次:

两个缓存行都将被复制,因为 L1D(L1 数据)是每个内核的。现在,我们回想一下,在我们的示例中,两个 goroutine 都将更新自己的变量,一侧是 sumA,另一侧是 sumB

随着这些高速缓存行的复制,CPU 的目标之一是保证高速缓存的一致性。例如,如果一个 goroutine 更新 sumA 而另一个读取 sumA(经过一些同步),我们希望我们的应用程序获得最新的值。

但是,我们的示例并非完全如此。两个 goroutine 都在访问自己的变量,而不是共享变量。在这种情况下,我们可能希望 CPU 知道它并理解这不是冲突。然而,事实并非如此。

当我们写入缓存中存在的变量时,CPU 跟踪的粒度不是变量;这是缓存线。

当一个缓存线在多个内核之间共享并且至少有一个 goroutine 作为写入器时,它将强制使整个缓存线无效。同样,即使更新在逻辑上彼此独立(例如 sumAsumB)。这就是错误共享的问题,它会导致性能下降.

Note 在内部,CPU 使用 MESI 协议来保证缓存的一致性。它允许跟踪每个缓存行并将其标记为已修改、独占、共享、无效 (MESI)。

了解内存和缓存的最重要方面之一是:不存在跨内核共享内存;这是一种错觉。同样,这种理解源于我们不将机器视为黑匣子这一事实,相反,我们试图对底层层次产生机械共鸣。

那么我们如何解决虚假分享呢?有两个主要的解决方案。

第一个解决方案是保持相同的方法,但确保 sumAsumB 不会是同一缓存行的一部分。例如,我们可以更新 Result 结构以在两个字段之间添加填充。

填充是一种分配额外内存的技术。例如,由于 int64 需要 8 字节分配并且高速缓存行是 64 字节的长度,我们需要 64 ‑ 8 = 56 字节的填充:

type Result struct {
    sumA int64
    _    [56]byte
    sumB int64
}

这将是一个可能的内存分配:

使用填充,sumAsumB 将始终是不同内存块的一部分;因此,不同的缓存行。

如果我们对两种解决方案(有和没有填充)进行基准测试,我们会注意到填充解决方案明显更快(在我的机器上大约 40%)。因此,这是一项重要的改进,因为我们在两个字段之间添加了填充以防止错误共享。

第二种解决方案是重新设计算法的完整结构。例如,不是让两个 goroutines 共享相同的结构,我们可以让它们通过通道传递它们的本地结果。无论是填充还是通信,基准测试的结果大致相同。

总之,我们必须记住,跨 goroutine 共享内存是最低内存级别的错觉。我们已经看到,当一个缓存行在两个内核之间共享时会发生错误共享,其中至少有一个 goroutine 是写入器。如果我们需要优化 依赖于并发的应用程序,我们应该检查是否存在虚假共享,因为它是一种已知的应用程序性能下降模式。同时,让我们记住,我们可以通过填充或通信来防止错误共享。

以下部分将讨论 CPU 如何并行执行指令以及如何利用它。