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