到目前为止,我们已经讨论了 CPU 缓存的基本概念。同时,我们已经看到一些特定的缓存不是在所有逻辑内核之间共享,而是特定于物理内核(通常是 L1 和 L2)。这种特殊性有一些具体的影响,例如并发性和可能导致性能显着下降的错误共享的概念。首先,让我们通过一个具体的例子来了解什么是虚假共享,然后了解如何防止它。
在本例中,我们将使用两个结构体 Input
和 Result
:
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 都会增加自己的变量。但是,此示例说明了错误共享概念,降低了预期性能。
让我们看一下主存储器。由于 sumA
和 sumB
是连续分配的,因此在大多数情况下(8 个变量中的 7 个),两个变量都将分配到同一个内存块:
现在,让我们假设机器包含两个内核。在大多数情况下,最终我们应该将两个线程安排在不同的内核上。因此,如果 CPU 决定将此内存块复制到缓存行,它将被复制两次:
两个缓存行都将被复制,因为 L1D(L1 数据)是每个内核的。现在,我们回想一下,在我们的示例中,两个 goroutine 都将更新自己的变量,一侧是 sumA
,另一侧是 sumB
:
随着这些高速缓存行的复制,CPU 的目标之一是保证高速缓存的一致性。例如,如果一个 goroutine 更新 sumA
而另一个读取 sumA
(经过一些同步),我们希望我们的应用程序获得最新的值。
但是,我们的示例并非完全如此。两个 goroutine 都在访问自己的变量,而不是共享变量。在这种情况下,我们可能希望 CPU 知道它并理解这不是冲突。然而,事实并非如此。
当我们写入缓存中存在的变量时,CPU 跟踪的粒度不是变量;这是缓存线。
当一个缓存线在多个内核之间共享并且至少有一个 goroutine 作为写入器时,它将强制使整个缓存线无效。同样,即使更新在逻辑上彼此独立(例如 sumA
和 sumB
)。这就是错误共享的问题,它会导致性能下降.
Note 在内部,CPU 使用 MESI 协议来保证缓存的一致性。它允许跟踪每个缓存行并将其标记为已修改、独占、共享、无效 (MESI)。
了解内存和缓存的最重要方面之一是:不存在跨内核共享内存;这是一种错觉。同样,这种理解源于我们不将机器视为黑匣子这一事实,相反,我们试图对底层层次产生机械共鸣。
那么我们如何解决虚假分享呢?有两个主要的解决方案。
第一个解决方案是保持相同的方法,但确保 sumA
和 sumB
不会是同一缓存行的一部分。例如,我们可以更新 Result
结构以在两个字段之间添加填充。
填充是一种分配额外内存的技术。例如,由于 int64
需要 8 字节分配并且高速缓存行是 64 字节的长度,我们需要 64 ‑ 8 = 56 字节的填充:
type Result struct {
sumA int64
_ [56]byte
sumB int64
}
这将是一个可能的内存分配:
使用填充,sumA
和 sumB
将始终是不同内存块的一部分;因此,不同的缓存行。
如果我们对两种解决方案(有和没有填充)进行基准测试,我们会注意到填充解决方案明显更快(在我的机器上大约 40%)。因此,这是一项重要的改进,因为我们在两个字段之间添加了填充以防止错误共享。
第二种解决方案是重新设计算法的完整结构。例如,不是让两个 goroutines 共享相同的结构,我们可以让它们通过通道传递它们的本地结果。无论是填充还是通信,基准测试的结果大致相同。
总之,我们必须记住,跨 goroutine 共享内存是最低内存级别的错觉。我们已经看到,当一个缓存行在两个内核之间共享时会发生错误共享,其中至少有一个 goroutine 是写入器。如果我们需要优化 依赖于并发的应用程序,我们应该检查是否存在虚假共享,因为它是一种已知的应用程序性能下降模式。同时,让我们记住,我们可以通过填充或通信来防止错误共享。
以下部分将讨论 CPU 如何并行执行指令以及如何利用它。