65.9K
CodeProject 正在变化。 阅读更多。
Home

GetOrCreateValueDictionary

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (33投票s)

2014年2月11日

CPOL

25分钟阅读

viewsIcon

45742

downloadIcon

439

一个针对缓存和GetOrCreateValue方法优化的字典实现,支持真正的并行读取,并避免了ConcurrentDictionary中遇到的一些问题。

代码更新

在与朋友讨论并发值生成(无重复)后,我创建了一个新的ConcurrentGetOrCreateValueDictionary

如果你对此感兴趣,请下载新的示例,或者阅读文章末尾对更改的简要解释。

背景

去年我发表了文章字典+锁定 vs ConcurrentDictionary,其中我解释了为什么使用普通字典+一些锁定技术可能比使用ConcurrentDictionary<TKey, TValue>更好。最后我介绍了ThreadSafeDictionary<TKey, TValue>,它使用了与ConcurrentDictionary<TKey, TValue>相同的无锁读取技术,同时避免了该字典的问题,甚至还拥有更多方法。

好吧,此刻我正专注于编写小型解决方案,以便人们可以简单地复制一个文件并使用该解决方案。而我所介绍的ThreadSafeDictionary<TKey, TValue>并不符合这一类别。事实上,当我将这种字典用于缓存时,我总是使用GetOrCreateValue(),有时也会使用setter、Remove()Clear(),但我不会使用任何其他方法。那么,为什么不编写一个专门只做这些的类,使其非常小巧并针对这种用例进行优化呢?

好吧,这就是本文的全部内容。

目的

GetOrCreateValue()是任何类型缓存的理想方法。你传入一个键(例如,记录的Id),如果该值(记录)存在,你将直接获取它。如果不存在,则会调用一个能够创建该值(从数据库加载)的委托,然后将其缓存,以便将来调用时无需再次加载,最后将其返回给用户。

请注意,即使我以数据库记录为例,这仍然适用于任何“函数”,只要提供相同的键,它总是生成相同的值。因此,任何耗时的计算都可能从此类解决方案中受益(事实上,我一直期待函数式语言能有某种自动缓存)。

可能的实现

如果您需要一个GetOrCreateValue()方法,您可以实际寻找一个现有解决方案。

Dictionary<TKey, TValue>类实际上是“默认”解决方案。它没有GetOrCreateValue()方法,但可以通过使用TryGetValue()Add()方法编写一个扩展方法。实际上,将此方法作为字典的一部分可以帮助提高性能,因为Add()方法在添加项目之前会进行一次新的搜索,但由于这种搜索非常快,而且我们不会重复添加项目,所以这不是一个真正的问题。这个解决方案的真正问题是它不是线程安全的,而在许多情况下,这正是我们所需要的。

正如我在另一篇文章中提出的,在搜索过程中进行完全锁定(无论我们是否添加值)可能比使用ReaderWriterLockSlim更快,因为这种锁并不像其名称所暗示的那样纤细。但是完全锁定确实会阻止两个(或更多)线程同时访问字典。一个线程必须等待另一个线程完成。此外,锁定会清除CPU缓存,因此接下来的调用(即使与字典无关)可能需要再次访问主内存,从而变慢(这是非常难以衡量的)。

这就是ConcurrentDictionary<TKey, TValue>的优势所在。实际上,它的方法名为GetOrAdd(),但我们可以说它是名为GetOrCreateValue的不同名称。它通过使用无锁技术真正允许两个或更多线程并行访问其内容,因此当内容已在字典中时,无需锁定,两个或更多线程可以真正并行访问同一个项目。

它的实现有两个缺点。第一个是它使用了Thread.MemoryBarrier(),它会完全清除CPU缓存,考虑到.NET强大的内存模型,这并不是真正需要的(如果需要,它的位置也是错误的,所以有bug)。第二个缺点,也是一个大缺点,是当值不在缓存中时,它们是在没有锁的情况下生成的,这意味着两个或更多线程可以为同一个键生成一个值。即使最终每个线程都收到相同的实例,并行生成的其他值也会丢失。如果这些值必须被释放,或者它们实际上不能并行创建,这可能会非常成问题。

因此,解决方案是创建一个像ConcurrentDictionary<TKey, TValue>一样的无锁字典(用于读取),但它在创建值时进行锁定。这就是我在另一篇文章中介绍的,但如果我们只需要该缓存功能,它是一个很大的字典。事实上,由于其他功能,它并未真正针对这种用例进行优化,它进行了一个可升级锁(只影响其他方法),然后升级它。这意味着一个没有这些其他功能的类可以避免该UpgradeableLock(因为它实际上作为一个完全锁在工作),并直接进入完全/写入锁。

ThreadSafeGetOrCreateValueDictionary

ThreadSafeGetOrCreateValueDictionary<TKey, TValue>是一个新实现,它针对真正的并行性、CPU缓存利用率进行了优化,并避免为同一个键创建两个值。它进行无锁读取,当需要添加值时,它使用完全锁(普通的Monitor锁)来创建和添加值,因此不可能为同一个键创建两个值并丢失一个。而且,通过使用普通锁,它还能够被递归使用(如果用于创建值的委托需要读取字典的另一个值,例如在递归斐波那契实现中,可能会发生这种情况)。

它还具有Set()Remove()Clear()字典的方法,但没有其他。它不打算被视为“集合”,它旨在用作缓存。

此优化字典的代码如下

public sealed class ThreadSafeGetOrCreateValueDictionary<TKey, TValue>
{
  private sealed class _Node
  {
    internal _Node(int hashCode, _Node nextNode, TKey key, TValue value)
    {
      _hashCode = hashCode;
      _nextNode = nextNode;
      _key = key;
      _value = value;
    }

    internal readonly int _hashCode;
    internal _Node _nextNode;
    internal readonly TKey _key;
    internal readonly TValue _value;
  }

  private readonly object _lock = new object();
  private readonly Func<TKey, TValue> _creator;
  private readonly IEqualityComparer<TKey> _comparer;
  private _Node[] _baseNodes;
  private int _count;

  public ThreadSafeGetOrCreateValueDictionary(Func<TKey, TValue> creator):
    this(creator, 31, null)
  {
  }
  public ThreadSafeGetOrCreateValueDictionary(Func<TKey, TValue> creator, int capacity, IEqualityComparer<TKey> comparer)
  {
    if (creator == null)
      throw new ArgumentNullException("creator");

    capacity = DictionaryHelper.AdaptSize(capacity);
      
    if (comparer == null)
      comparer = EqualityComparer<TKey>.Default;

    _creator = creator;
    _comparer = comparer;
    _baseNodes = new _Node[capacity];
  }

  public TValue GetOrCreateValue(TKey key)
  {
    if (key == null)
      throw new ArgumentNullException("key");

    int hashCode = _comparer.GetHashCode(key) & int.MaxValue;
    var baseNodes = _baseNodes;
    int bucketIndex = hashCode % baseNodes.Length;

    var node = baseNodes[bucketIndex];
    while(node != null)
    {
      if (hashCode == node._hashCode)
        if (_comparer.Equals(key, node._key))
          return node._value;

      node = node._nextNode;
    }

    lock(_lock)
    {
      bucketIndex = hashCode % _baseNodes.Length;
      node = _baseNodes[bucketIndex];
      while(node != null)
      {
        if (hashCode == node._hashCode)
          if (_comparer.Equals(key, node._key))
            return node._value;

        node = node._nextNode;
      }

      if (_count >= _baseNodes.Length)
      {
        _Resize();
        bucketIndex = hashCode % _baseNodes.Length;
      }

      TValue value = _creator(key);
      var newNode = new _Node(hashCode, _baseNodes[bucketIndex], key, value);
      _baseNodes[bucketIndex] = newNode;
      _count++;
      return value;
    }
  }

  private void _Resize()
  {
      
    int newSize;
    checked
    {
      newSize = DictionaryHelper.AdaptSize(_baseNodes.Length * 2);
    }

    var newNodes = new _Node[newSize];

    foreach(var baseNode in _baseNodes)
    {
      var oldNode = baseNode;
      while(oldNode != null)
      {
        int hashCode = oldNode._hashCode;
        int bucketIndex = hashCode % newSize;
        var newNode = new _Node(hashCode, newNodes[bucketIndex], oldNode._key, oldNode._value);
        newNodes[bucketIndex] = newNode;

        oldNode = oldNode._nextNode;
      }
    }

    _baseNodes = newNodes;
  }

  // Note: If you download the sample you will see that it has
  // the methods Clear(), Set() and Remove(), which I am not presenting
  // here to make the code smaller.
}

您可以这样使用它

var dictionary = new ThreadSafeGetOrCreateValueDictionary<int, int>(DelegateToGenerateTheValue);

// And when needed:
int value = dictionary.GetOrCreateValue(57);

为什么ThreadSafeGetOrCreateValueDictionary<TKey, TValue>在其构造函数中接收委托?

有两个原因

  1. 我们期望总是使用相同的逻辑来生成值,因此在构造函数中给出它保证了它始终是相同的逻辑;
  2. 当我们编写匿名 lambda 表达式时,编译器会在每次调用时生成一个新的委托实例,而不是创建一个单一的委托实例,但由于我们将其传递给构造函数,它将只被创建一次,而不是在每次调用GetOrCreateValue()方法时都被创建。

事实上,第一项才是最重要的。第二项只是一个好的结果,但这些分配速度如此之快,以至于我认为它不重要。

多“列”键

我经常看到的一个问题与多列键有关。普通字典只支持单个键,我提出的解决方案也处于相同的情况。我经常看到人们编写专门的字典来处理这些多列,很多时候使用一个.NET字典,其中键是第一列,值是另一个字典,使用第二列作为该内部字典的键,但最简单和性能更好的解决方案是使用KeyValuePair<TKey, TValue>Tuple<AnyNumberOfArguments>作为键。所以,不用担心,即使您只能使用单个对象作为键,该对象也可以由其他对象组成,从而允许您拥有所需任意多的“列”作为键。

KeyValuePair<TKey, TValue>问题

多年来,当我需要一个两列键时,我一直使用KeyValuePair<TKey, TValue>作为字典键。最糟糕的是,我做了性能测试,而且它表现出色,但,好吧,我多年来一直在做一件错事。

我一直认为KeyValuePair<TKey, TValue>实现了GetHashCode()Equals()方法,但它没有!

这意味着只有第一个字段用于生成哈希码(因此,只有该对的Key用于生成哈希码)。在我的案例中,这通常不是性能问题,因为我通常没有太多第一列的重复,但这实际上意味着所有具有相同第一列值的项目都将最终位于同一个节点中。

因此,为了解决这个问题,我创建了Pair<TKey, TValue>结构体。它与KeyValuePair<TKey, TValue>非常相似,但它实现了GetHashCode()和相等性方法(来自System.Object类型的方法以及来自IEquatable<T>接口的方法)。因此,这是当您想要两列键时使用的理想类型。

结构体的代码如下

public struct Pair<TKey, TValue>:
  IEquatable<Pair<TKey, TValue>>
{
  public Pair(TKey key, TValue value)
  {
    _key = key;
    _value = value;
  }

  private readonly TKey _key;
  public TKey Key
  {
    get
    {
      return _key;
    }
  }

  private readonly TValue _value;
  public TValue Value
  {
    get
    {
      return _value;
    }
  }

  public override int GetHashCode()
  {
    int hashCode = 0;

    if (_key != null)
    {
      hashCode = _key.GetHashCode();
      hashCode = (hashCode << 16) | (hashCode >> 16);
    }

    if (_value != null)
      hashCode ^= _value.GetHashCode();

    return hashCode;
  }

  public override bool Equals(object obj)
  {
    if (obj is Pair<TKey, TValue>)
      return Equals((Pair<TKey, TValue>)obj);

    return false;
  }
  public bool Equals(Pair<TKey, TValue> other)
  {
    return
      EqualityComparer<TKey>.Default.Equals(_key, other._key) &&
      EqualityComparer<TValue>.Default.Equals(_value, other._value);
  }

  public override string ToString()
  {
    return string.Concat(_key, ", ", _value);
  }
}

您可以使用此示例测试性能差异

var stopwatch = new Stopwatch();
stopwatch.Start();
var hashsetOfPair = new HashSet<Pair<int, int>>();
for(int i=0; i<1000; i++)
  for(int j=0; j<1000; j++)
    hashsetOfPair.Add(new Pair<int,int>(i, j));

stopwatch.Stop();
Console.WriteLine(stopwatch.Elapsed);

stopwatch.Reset();
stopwatch.Start();
var hashsetOfKeyValuePair = new HashSet<KeyValuePair<int, int>>();
for(int i=0; i<1000; i++)
  for(int j=0; j<1000; j++)
    hashsetOfKeyValuePair.Add(new KeyValuePair<int,int>(i, j));

stopwatch.Stop();
Console.WriteLine(stopwatch.Elapsed);

实际上,在我的电脑上,使用Pair<int, int>的版本不到一秒钟就完成了,而另一个版本则需要40多秒。

经验教训: 始终检查结构体是否实现了GetHashCode()方法。这只会提醒我,我一直认为GetHashCode()System.Object类中的一个错误,如果它只存在于一个接口中(在IEquatable<T>中是一个好地方),并且应该由可以用作哈希表键的类型实现,那会更好。如果那样的话,KeyValuePair<TKey, TValue>就不是字典键的有效类型,我也不会犯这样的错误(我想很多人也不会)。

静态缓存?

我通常使用这种解决方案来创建静态缓存。

我知道,有些人立刻就看到了问题。所有放入缓存的东西都会在应用程序的整个生命周期内保持活动状态。那么,这是正确的吗?

我可以说,我通常使用这些缓存来将访问字段和属性的委托保存在内存中,而且由于我将使用的类型和属性是有限的,所以我认为它们在应用程序的整个生命周期内保持活动状态没有问题。我们可以说应用程序的代码本身几乎符合这一类别。

但对于其他缓存来说,这可能是一个问题。这就是为什么我也创建了一个它的弱版本。WeakGetOrCreateValueDictionary<TKey, TValue>

原理是相同的。一个只包含Clear()GetOrCreateValue()Set()Remove()方法的类。但生成的值存储在WeakReference中,因此可以被回收。

这种解决方案的复杂之处在于,当项目被回收时,字典不会收到通知。我曾考虑使用我以前的解决方案之一,以便它能收到通知,但我决定在_Resize()方法中进行此类分析。每次字典需要调整大小时,它实际上会计算有多少项目仍然存活(重新计算_count),并根据情况,它会简单地删除已回收的节点。因此,如果您在字典中放入大量项目(最终会被回收),然后从不再次使用您的字典,则存在在内存中保留一个大型WeakReference数组的小风险,但我真的认为它适用于需要使用它的情况,而且WeakReference可能比仍然可以被回收的项目小得多。

我不会在文章中放入其代码,但您可以下载示例并查看WeakGetOrCreateValueDictionary<TKey, TValue>

GCKeepAliver

还有一个额外的类,你可能会喜欢和WeakGetOrCreateValueDictionary<TKey, TValue>一起使用。GCKeepAliver的目的是在下次垃圾回收(通常是下次完全回收)期间保持项目存活。原因很简单:你使用缓存项目的频率并不重要,如果在垃圾回收时它没有在使用,它就会被回收。

现在让我们极端一点。想象一下,你有一个重要的对象,需要1分钟才能生成。生成后,你每秒使用它一次,但你只对它保持强引用一毫秒。如果在一年中任何其他999毫秒发生垃圾回收,它将被回收。我相信你不想那样。

即使我使用极端情况,但这在使用弱引用时确实会发生,因此,如果您想保证最近使用的对象能存活一段时间,请调用GCKeepAliver.KeepAlive()并传入该对象,它将在下一次回收中幸存。在下一次回收中幸存后,如果它再次被使用,您会说它将再次幸存一次回收。这意味着这些对象没有特定的生存时间,但如果它们被频繁使用,它们将保留在内存中,同时如果它们在两次垃圾回收之间没有被使用,它们将被回收。最好的是,由于它与回收相关而不是与时间相关,如果计算机内存严重不足并频繁进行垃圾回收,它们也可以被回收。

WeakGetOrCreateValueDictionary默认情况下不会调用GCKeepAliver.KeepAlive(),这是有意的,因为您可能希望使用不同的技术来保持项目存活,如果您确实想要这样做的话。

示例

示例应用程序仅用于演示如何使用字典方法并显示它确实很快,但它没有做任何有用的事情。

如果您需要一个弱缓存,或者一个可以并行访问而没有ConcurrentDictionary<TKey, TValue>问题的缓存(如果您认为有问题,甚至不确定),则由您决定在应用程序中使用它。

理解代码 - 高级

如果您在这里停止阅读文章,您已经可以使用字典缓存数据。本主题比文章的其余部分更高级,但如果您想了解其工作原理,可能会有所帮助。

要理解这段代码,我们必须了解什么是哈希码,如何使用它来索引值,以及无锁编程是如何工作的。所以...

索引

想象一下,你有一个电话簿的索引

A 第57页
B 第50页
C 第20页
D 第15页
...
P 第57页
...

我知道它没有排序,也不是电话簿的组织方式。但它符合我的目的。另外请考虑,在一页之内,名字也没有排序,但所有以给定字母开头的名字总是放在一页中。

那么,如果你想查找我的名字(Paulo),你会去第2页还是第30页查找呢?我很确定你会直接翻到第57页。如果第57页名字太多,你可能会浪费时间;如果我的名字不在那里,你可能需要通读整页以确保它确实不在。你还会看到,这一页也有以A开头的名字,但你不需要浪费时间去读第50、20、15页等等。

所以我们可以说这与哈希码的工作方式非常相似。哈希码不能保证数据有序,如果有很多项目具有相同的哈希码(或者最终在同一个页面[称为桶]),我们可能会花费太多时间寻找正确的项目,如果键不在那里,就必须查找该“页面”上的所有项目。

但与字母(如果我们使用英文字母,有26个)不同,我们有32位哈希码,这给我们提供了超过40亿个值,因此冲突预计会不那么常见。但这是使哈希码非常重要的原因之一:如果只用第一个字母来生成字符串的哈希码,我们将只为所有可能的名称生成26个值,从而导致糟糕的索引(别担心,.NET字符串有一个很好的哈希码)。

哈希码和桶

我将字母A和字母P放在同一页是有原因的。当我们使用字典时,这些页面(桶)是预先分配的,但我们不期望为这些32位值预先分配40亿个桶。在已给出的实现中,我从31个桶开始。这就是为什么bucketIndex被计算为hashCode % _baseNodes.Length。那个_baseNodes.Length就是我们拥有的桶/页的数量。因此,值范围从0到30的哈希码将有一个等于其哈希码的bucketIndex。但是31 % 31的值为0,所以哈希码为31的项目也将位于页面索引0(哈希码为62或31的任何倍数的项目也将位于桶/页面0)。

例如,如果我们的键是intint将自身作为哈希码返回),并且以10递增,我们将得到以下结果

0 0
10 10
20 20
30 30
40 9
50 19
60 29
70 8

我想现在已经可以看到,即使我们以10为步长跳跃值,趋势也是使用所有桶(您可以看到我们还没有发生过一次冲突)。最终我们将不得不增加桶的数量(毕竟,如果我们有31个桶,并且有100个项目分布良好,我们期望每个桶有3个项目,但如果项目数量增加到10000个,我们期望每个桶有300个项目)。

增加桶的数量

实际上,每次_count大于桶的数量时,我们都会调整桶数组的大小。这个过程实际上需要访问所有节点,以便通过新的桶数量重新计算节点索引。

_count大于桶的长度时,重新计算桶的数量并不是强制性的。我这样做是因为对于分布非常均匀的哈希码(例如,当我们使用每次递增1的整数值时),这意味着我们永远不会有桶冲突。但事实上,在某些情况下,使用不同的调整大小规则可能会更好,例如只在计数大于桶数量的2倍时才调整大小,但这是我的代码尚未准备好处理的问题。

素数

在调整节点大小时,我尝试使用素数。这实际上有助于在哈希码是某个值的倍数时创建良好的值分布。例如,假设我们有30个节点而不是31个节点。

如果哈希码以10递增,那么值0、30、60等将最终进入同一个桶(值10、40、70也适用),而所有不是10倍数的桶将为空。这很糟糕。

当然,如果所有值都是31的倍数,我们就会遇到有31个桶的问题,但更常见的情况是,值是较小数字的倍数(通常是2、10或16)。此外,即使哈希码是31的倍数,我们也会在调整大小时解决问题,因为下一个素数不会是31的倍数。

但如果你查看代码,你会发现我并没有将结果限制在素数,因为这会花费太多时间,并且与拥有快速字典的目的相悖。所以我只尝试搜索一个不能被2到31的素数整除的值。

为什么有那个hashCode & int.MaxValue?

如果hashCode % someValue是负数,我们最终会得到一个负数,这对于我们的桶索引是无效的。我们可以使用Math.Abs(),或者我们可以使用& int.MaxValue来消除负号。我没有再次测试,但上次我检查时,使用该&运算符更快。

在比较项目之前比较哈希码

如果您查看代码,您会发现我在比较键本身之前,会比较节点的哈希码与我正在搜索的键的哈希码。我还将哈希码存储在节点本身中。

这样做是出于性能原因。我们不知道键需要多少时间来计算哈希码,因此通过将其存储在节点中,我们可以避免潜在的性能损失。即使计算哈希码很快,我们也可以避免一次虚拟调用。

我们也不知道Equals()方法实际上需要多少时间来比较对象。对于一个int,我们可以说,先检查哈希码再检查项目比直接比较值慢,但那是最快的情况。如果equals方法很慢,我们不想浪费时间比较那些实际上具有不同哈希码的对象,因为它们应该不同。

无锁读取

如果你读到这里,你可能已经理解了为什么哈希码很重要,以及为什么我进行了那些“奇怪的计算”来获取bucketIndex和调整大小时的桶数量。但现在我们有了问题的另一部分,即无锁读取。

例如,如果您查看_Resize()方法,您会看到创建了新节点。如果我们没有无锁读取器,我们可以简单地更改现有节点的_nextNode,而不是创建新节点(垃圾回收器会喜欢这样)。事实上,我们可以做一些更类似于普通Dictionary<TKey, TValue>的事情,即将节点用作结构体而不是类,这样所有节点都存储在一个数组中,并作为一个单一对象计算,这对垃圾回收器再次更好。

但是,我们实际上不能通过使用“非原子”操作来更改我们的对象,即使更改_nextNode引用是一个原子操作,我们也必须确保所有进入新项目的项目都一起更改,而且,由于我们无法做到这一点,我们创建了新对象,这些对象在执行以下操作之前将不会被看到

_baseNodes = newNodes;

许多人认为我们必须将对象标记为volatile才能进行无锁读取,或者我们应该使用那些Volatile方法,但我们不需要。.NET有一个强大的内存模型,这意味着不同的线程不可能在没有看到在其之前放入_baseNodes = newNodes;中的正确内容的情况下看到_baseNodes引用新数组。

此外,有些人担心,如果不进行volatile读取,我们可能无法看到另一个线程刚刚添加的项目。这是真的,但这就是我们首先进行读取的原因,如果找不到项目,我们会进行锁定并再次搜索该项目。如果该项目刚刚添加,我们的锁会保证我们将看到内存中项目的最新版本,并且没有人能够同时添加项目。

那么,我们需要做什么才能实现无锁读取呢?

  • 我们必须只访问_baseNodes一次。这就是为什么我将_baseNodes放入一个局部变量,然后获取它的长度并访问其内容的原因;
  • 我们必须保证项目不会以非原子方式更改。实际上,Set()方法可以直接更改引用类型和像int或更小(long仅在64位处理器上安全)的基元类型的节点的_value,但对于其他值类型则不能,所以我们简单地认为我们没有这样的保证;
  • 我们必须保证对_baseNodes内容的更改是原子性的。这就是为什么我们的_Node类型是类(引用类型)而不是结构体。.NET永远不会分配一个引用数组,其对齐方式不正确(这会导致读写非原子性);
  • 我们还必须保证,在调整节点大小时,只有在新节点数组完全填充后才发布它。这就是为什么我将新数组保存在局部变量中,直到调整大小完成,然后才将_baseNodes设置为newNodes
  • 所有可以通过无锁读取看到的变化也必须是原子性的。这会影响所有实际可以改变字典内容的方法,无论这些方法是否使用锁(因为锁只保护其他使用相同锁的代码)。考虑到我们的方法,我们有这样的保证,因为

    • Clear()方法只用null替换节点(原子操作),并且无锁读取永远不会看到_count
    • Remove()方法只改变基节点或前一个节点的_nextNode字段,以引用被移除节点的_nextNode,并且替换引用的值是一个原子操作;
    • Set()方法保证新项目在可访问之前完全填充;
    • 由于这些更改彼此之间可能会出现问题(例如,在搜索和添加新节点之间,可能已经添加了另一个节点),它们都使用lock来相互保护。

Thread.MemoryBarrier()

在文章开头我说ConcurrentDictionary<TKey, TValue>使用Thread.MemoryBarrier()有一个缺点,而且它放错了位置。

详细解释一下,GetOrAdd()方法首先使用TryGetValue方法。该方法获取桶中的第一个项目,然后调用Thread.MemoryBarrier(),然后处理整个桶。

问题在于

  • Thread.MemoryBarrier()会执行一个完全屏障。这意味着所有被实际线程(以及因此CPU)修改的数据都必须发送到主内存,并且读取的本地缓存也必须被清除。在.NET 4.5中,我们有Volatile类,允许我们使用半屏障(在这种情况下,Volatile.Read()会做得更好);
  • 即使使用Volatile.Read().NET强内存模型下也不是必需的。我们不是以必须强制执行的特定顺序读取两个或更多字段或访问两个或更多数组项,因为这些字段以相同的特定顺序被其他线程更改。我们正在访问一个项,然后访问该项的内容,如果该项第一次对该线程/CPU可用,则.NET有责任保证其内容全部正常。如果.NET不保证这一点,那么我们实际上可能会从内存中读取真正的垃圾,而不仅仅是“未初始化”的对象,这与.NET的安全性相悖。因此,这可能是一个实现细节,但这是Microsoft .NET实现的一个保证,我真的相信所有其他实现都应该提供相同的保证;
  • 如果您仍然认为我的代码因为没有使用屏障或Volatile.Read()而存在bug,那么您应该知道ConcurrentDictionary<TKey, TValue>也有bug。它实际上只在开始处理桶之前使用一个Thread.MemoryBarrier(),而不是在项目之间使用,而且,更改现有项目的值是可能的。这实际上可以更改中间节点,因此,如果需要这样的屏障,它应该在循环内部,而不是循环外部。

最后,如果您愿意,可以查看此链接http://joeduffyblog.com/2007/11/10/clr-20-memory-model/。对我来说,最重要的是

  • 规则1:加载和存储之间的数据依赖性永远不会被违反。

这条规则实际上是我在实现中所需的一切,因为我没有尝试以任何特定顺序更改多个字段。唯一重要的是发布一个新对象,并使其内容正确地对其他线程可见。

如果您仍然不相信我,那么请更改代码,在使用.NET 4.5中可用的Volatile.Read()Volatile.Write()方法(或早期版本中可用的Thread.VolatileRead()/Thread.VolatileWrite())来读取桶数组、其某个项目或_nextNode。请注意,仅仅将数组设为volatile并不能保证项目会以volatile方式访问。

ConcurrentGetOrCreateValueDictionary

ConcurrentDictionary有一个问题。它允许多个线程执行值生成,然后丢弃那些后完成的结果。这种双重(三重或更多)执行不仅对性能不利,还可能对资源利用造成非常糟糕的后果。

ThreadSafeGetOrCreateValueDictionary没有这样的问题,但它不允许并行生成值。它们是无锁读取的,但如果你真的需要并行生成值,你就束手无策了。

ConcurrentGetOrCreateValueDictionary有效地解决了这两个问题。它基于允许生成可能并行且可丢弃的延迟对象的想法。但这并不重要。丢弃一个Lazy<T>对象并不会真正损害性能或资源消耗。

然后,我们立即获取这个延迟对象的值,这保证了只有一个线程会生成该值。也就是说,不同的键会生成不同的延迟对象,并允许它们并行运行,但相同的键总是会使用相同的延迟对象。我对此方法的担忧是,我们一直保持一个延迟对象存活,并且总是需要额外的间接引用。

因此,为了解决这个问题,一旦获取到值,它就会被设置到字典中,替换掉惰性对象。如果多个线程尝试并行执行此操作,则没有问题,因为它们都会为同一个键设置相同的值。唯一剩下的问题是,我们在读取时应该检查我们是已经有值还是只有一个惰性对象。但是,猜猜看,即使Lazy<T>也有这样的转换,所以我们只是避免了通过惰性对象的间接性。

最终,我们只在添加值时多了一个类型转换,以及在并行执行时创建/执行惰性对象,而不会出现同一键的重复昂贵执行……并且当值已在字典中时,读取性能仍然惊人地好。

请查看ConcurrentGetOrCreateValueDictionary(用于引用类型值)和ConcurrentGetOrCreateStructValueDictionary(用于值类型值,即结构体)。

版本历史

© . All rights reserved.