如果我们将条带锁彼此非常靠近地放在内存中以用于并发哈希映射,则缓存行大小会影响性能,因为我们将不得不不必要地使缓存无效。如果将填充添加到条带锁数组,它将提高性能。
有人可以解释一下吗?
从非并发hashmap开始,基本原理是这样的:
123439281 % 31which is9并将其用作我们的索引)。可以使用相同的方法来查找给定键的值(或查找不存在的值)。
当然,如果同一个槽中的键与您关心的键不相等,则上述方法不起作用,并且有不同的方法来处理这个问题,主要是继续查看不同的槽直到一个是免费的,或者让插槽实际上充当等索引对的链表。我不会深入讨论这件事的细节。
如果您正在寻找其他插槽,一旦您填充了数组,它将无法工作(并且在那之前会很慢)并且如果您使用链表来处理冲突,如果您有很多键,您将非常慢相同的索引(随着情况变得更糟,您想要的 O(1) 变得越来越接近 O(n))。无论哪种方式,当存储量太大时,您都需要一种机制来调整内部存储的大小。
好的。这是哈希图的一个非常高级的描述。如果我们想让它线程安全怎么办?
默认情况下它不会是线程安全的,例如,两个不同的线程将不同的键写入不同的键,其散列模下为相同的值,然后一个线程可能会踩到另一个。
使 hashmap 线程安全的最简单方法是简单地拥有一个我们在所有操作上使用的锁。这意味着每个线程都会等待每个其他线程,所以它不会有很好的并发行为。通过一些巧妙的结构,我们可以拥有多个读取线程或单个写入线程(但不能同时拥有)并不太难,但这仍然不是很好。
可以创建完全不使用锁的安全并发哈希图(请参阅http://www.communicraft.com/blog/details/a-lock-free-dictionary以了解我用 C# 或http: //www.azulsystems.com/blog/cliff/2007-03-26-non-blocking-hashtable for Java 中的一个,由 Cliff Click 提供,我在 C# 版本中使用了他的基本方法)。
但另一种方法是条纹锁。
因为映射的基础是键值对的数组(可能是哈希码的缓存副本)或它们的一组数组,并且因为让两个线程向不同的线程写入和/或读取通常是安全的一次数组的一部分(有警告,但我现在将忽略它们)唯一的问题是当两个线程需要相同的插槽时,或者需要调整大小时。
因此,不同的插槽可以有不同的锁,然后只有在同一插槽上运行的线程才需要相互等待。
仍然存在调整大小的问题,但这并非不可克服;如果您需要调整大小,请获取每个锁(按固定顺序,以便您可以防止发生死锁),然后进行调整大小,首先检查没有其他线程在此期间进行相同的调整大小。
但是,如果您有一个包含 10,000 个插槽的哈希图,这将意味着 10,000 个锁定对象。这是使用了大量内存,而且调整大小意味着获取这 10,000 个锁中的每一个。
条带锁介于单锁方法和每槽锁方法之间。有一个包含一定数量锁的数组,比如 16 作为一个不错的(二进制)整数。当您需要对某个插槽进行slotIndex % 16操作时,请获取锁号,然后进行操作。现在,虽然线程最终可能仍会阻塞在完全不同的插槽上执行操作的线程(插槽 5 和插槽 21 具有相同的锁),但它们仍然可以并发执行许多其他操作,因此它是两个极端之间的中间地带。
这就是条带锁定的工作原理,在高层次上。
现在,现代内存访问并不统一,因为在 CPU 中存在一定级别的缓存(通常至少为 2 级),因此访问任意内存块不会花费相同的时间。这种缓存有好有坏。
显然,好的效果通常大于坏处,否则芯片制造商不会使用它。如果您访问一块内存,然后访问一块非常接近它的内存,则第二次访问可能会非常快,因为它会在第一次读取时加载到缓存中。写入也得到改进。
一段给定的代码很可能想要对彼此靠近的内存块进行多次操作(例如读取同一对象中的两个字段,或一个方法中的两个局部变量),这已经很自然了,这就是为什么这种缓存首先起作用。程序员进一步努力在他们设计代码的方式中尽可能多地利用这一事实,哈希映射等集合就是一个典型的例子。例如,我们可能将键和散列存储在同一个数组中,以便读取一个会将另一个带入缓存以快速读取,依此类推。
虽然有时这种缓存会产生负面影响。特别是如果两个线程要同时处理彼此接近的内存位。
这并不经常出现,因为线程最经常处理自己的堆栈或由自己的堆栈指向的堆内存位,并且只是偶尔处理其他线程可见的堆内存。这本身就是为什么 CPU 缓存通常是性能的一大胜利的重要原因。
然而,并发散列图的使用本质上是多个线程命中相邻内存块的情况。
CPU 缓存基于“缓存线”工作。这些是从 RAM 加载到缓存中或作为一个单元从缓存写入 RAM 的代码块。(同样,虽然我们即将讨论这是一件坏事的情况,但在大多数情况下这是一个有效的模型)。
现在,考虑具有 64 字节高速缓存线的 64 位处理器。每个指向对象的指针或引用都将占用 8 个字节。如果一段代码试图访问这样的引用,则意味着有 64 个字节被加载到缓存中,然后有 8 个字节由 CPU 处理。如果 CPU 写入该内存,那么这 8 个字节会在缓存上发生更改,并将缓存写回 RAM。如上所述,这通常是好的,因为我们也希望对附近的其他 RAM 位执行相同操作的可能性很高,因此在同一缓存行中。
但是如果另一个线程想要访问同一个内存块呢?
如果 CPU0 从 CPU1 刚刚写入的同一高速缓存行中读取值,它将有一个已失效的陈旧高速缓存行,必须再次读取它。如果 CPU0 试图写入它,它可能不仅必须再次读取它,而且还要重做赋予它写入结果的操作。
现在,如果另一个线程想要访问完全相同的内存位,即使没有缓存也会发生冲突,所以事情并没有比他们本来的那么糟糕(但他们更糟)。但是如果另一个线程要访问附近的内存,它仍然会受到影响。
这显然对我们的并发映射的插槽不利,但对其条带锁更糟。我们说过我们可能有 16 把锁。使用 64 字节缓存行和 64 位引用,所有锁有 2 个缓存行。一个锁与另一个线程想要的在同一个缓存行中的几率是 50%。对于 128 字节缓存行(安腾有这些)或 32 位引用(所有 32 位代码都使用这些),它是 100%。有很多线程,它实际上 100% 你会等待。如果还有另一个打击,请再次等待。和等待。
我们试图阻止线程等待同一个锁,结果变成了它们等待同一个缓存行。
更糟糕的是,使用锁的内核越多,情况就越糟。每个额外的核心都会以指数方式降低总吞吐量。8 核的执行时间可能是 1 核的 200 多倍!
然而,如果我们用空格填充条纹锁,使每个锁之间有 56 字节的间隙,那么这不会发生;锁都在不同的缓存行上,对相邻锁的操作不再影响它。这会消耗内存,并使正常的读取和写入速度变慢(缓存的重点是它在大多数情况下使事情变得更快),但适用于预期特别频繁的并发访问的情况,并且我们不太可能想要点击下一个锁(我们不是,除了调整大小操作)。(另一个例子是条带计数器;让不同的线程递增不同的整数,并在你想要得到计数时对它们求和)。
线程击中相邻内存块的问题(称为“错误共享”,因为它具有共享访问同一内存导致的性能影响,即使它们实际上访问的是相邻内存而不是同一内存)也会影响内部存储哈希映射本身,但没有那么多,因为映射本身可能更大,因此两次访问命中同一缓存行的几率更小。出于同样的原因,在这里使用填充也会更昂贵;更大的填充量可能会很大。