【问题标题】:Can ConcurrentDictionary.GetOrAdd() be called recursively?可以递归调用 ConcurrentDictionary.GetOrAdd() 吗?
【发布时间】:2016-11-14 13:33:46
【问题描述】:

ConcurrentDictionary.GetOrAdd(TKey, Func<TKey, TValue>) 接受一个工厂函数以允许将项目的延迟实例化放入字典中。

定义一个本身调用 GetOrAdd() 的工厂函数是否安全,即 GetOrAdd 在“父级”GetOrAdd() 的上下文中被调用。

以下代码演示了该模式;它似乎确实有效,但它安全吗?

class Program
{
    static ConcurrentDictionary<string,object> __dict = new ConcurrentDictionary<string, object>();

    static void Main(string[] args)
    {
        Foo foo = GetOrAddFoo();
        Console.WriteLine(foo._name);
        Console.WriteLine(foo._bar._name);
        Console.ReadKey();
    }

    static Bar GetOrAddBar()
    {
        Console.WriteLine("GetOrAddBar: enter");
        Func<string,Bar> factoryFn = (x) => LoadBar(x);
        Bar bar = __dict.GetOrAdd("bar", factoryFn) as Bar;
        Console.WriteLine("GetOrAddBar: exit");
        return bar;
    }

    static Foo GetOrAddFoo()
    {
        Console.WriteLine("GetOrAddFoo: enter");
        Func<string,Foo> factoryFn = (x) => LoadFoo(x);
        Foo foo = __dict.GetOrAdd("foo", factoryFn) as Foo;
        Console.WriteLine("GetOrAddFoo: exit");
        return foo;
    }

    static Bar LoadBar(string name)
    {
        Bar bar =  new Bar();
        bar._name = name;
        return bar;
    }

    static Foo LoadFoo(string name)
    {
        Foo foo = new Foo();
        foo._name = name;
        foo._bar = GetOrAddBar();
        return foo;
    }

    public class Foo
    {
       public string _name;
       public Bar _bar;
    }

    public class Bar
    {
        public string _name;
    }
}

【问题讨论】:

    标签: .net concurrentdictionary


    【解决方案1】:

    答案是肯定的,绝对安全。仅当给定键 存在时才调用值函数。以下是引擎盖下发生的事情的演练。

    演练

    首先我们假设字典完全是空的,为了简单起见,我只会以数组格式显示键:

    dictionary = []
    

    在首次执行GetOrAddFoo 方法时,“Foo”键 存在,因此字典调用值函数,在此调用中是LoadFoo 方法。这里的字典还是空的。

    dictionary = []
    

    LoadFoo 内部调用GetOrAddBar,它检查并发现“Bar”键不存在,因此调用LoadBar 值函数并返回创建的“Bar”条目。此时的字典如下所示:

    dictionary = ["Bar"]
    

    此时的字典中包含项目“Bar”。我们还没有完成LoadFoo 值函数,但现在可以了。

    dictionary = ["Bar"]
    

    LoadFoo 方法重新获得控制权并返回要存储在字典中的Foo 对象。 LoadFoo 完成后,GetOrAddFoo 也可以完成。我们现在的字典看起来像这样:

    dictionary = ["Bar", "Foo"]
    

    GetOrAddFoo 的未来电话

    在随后对GetOrAddFoo 的调用中,字典已经有了Foo 的条目,因此不会调用它的值函数,甚至也不会调用Bar 值函数。它立即返回。

    dictionary = ["Bar", "Foo"]
    

    但是如果我们从字典中删除Bar 然后调用GetOrAddFoo 会发生什么?假设我们确实删除了它,让我们的字典像这样:

    dictionary = ["Foo"]
    

    现在我们再次致电GetOrAddFooFoo 仍然存在,因此字典不会调用LoadFoo 值函数。因此,Bar没有重新添加到字典中。我们的字典保持不变:

    dictionary = ["Foo"]
    

    如果我们直接调用GetOrAddBar,那么我们会在后面添加“Bar”。

    dictionary = ["Foo", "Bar"]
    

    在并发字典的 Get-Or-Add 方法的底层

    在后台,每当调用GetOrAdd 方法时,都会首先检查给定键的存在。

    如果此时不存在,则调用 value 函数。在 value 函数返回后,会在字典上放置一个锁以允许添加新条目。

    在多线程世界中,我们可以有 2 个线程尝试将相同的确切键添加到字典中。字典会运行 value 函数,然后尝试获取锁,获取锁后,再次检查 key 是否存在。

    原因是在锁检索时,另一个线程可能会加入并添加相同的密钥。原始线程在收到锁后需要检查这种情况,以免最终导致键冲突。

    【讨论】:

    • 这是一个有趣的答案,但它没有解释为什么从工厂访问字典是安全的。当调用工厂时,字典正在自我更新,可能会也可能不会准备好处理重入。
    • 在工厂真正完成之前字典不会锁定。如果工厂 A 链接到工厂 B,则字典将在 B 返回时锁定,以便将 B 的值添加到字典中。然后 A 将被允许恢复并且字典获得一个新的锁,因此可以添加 A 的值。
    • 唯一需要注意的是,在这种情况下,值工厂必须考虑以“反向”顺序添加的值,因为它使用的概念就像“堆栈”数据类型的工作方式......在这种情况下,它是一组值工厂委托。
    【解决方案2】:

    当你反编译 ConcurrentDictionary.GetOrAdd(TKey, Func) 你会看到一行:

    TryAddInternal(key, valueFactory(key), updateIfExists: false, acquireLock: true, out value);
    

    这意味着您在一个进程/线程中的调用时间线将是:

    enter __dict.GetOrAdd("foo", factoryFn)
    enter LoadFoo (from valueFactory(key) in line above)
    enter GetOrAddBar
    enter __dict.GetOrAdd("bar", factoryFn)
    enter LoadBar
    leave LoadBar
    enter TryAddInternal with key "bar" 
    acquire lock ( Monitor.Enter(tables.m_locks[lockNo], ref lockTaken); )
    add key "bar" with appropriate value
    release lock ( Monitor.Exit(tables.m_locks[lockNo]); )
    leave TryAddInternal with key "bar" 
    leave __dict.GetOrAdd("bar", factoryFn)
    leave GetOrAddBar
    leave LoadFoo
    enter TryAddInternal with key "foo" 
    acquire lock ( Monitor.Enter(tables.m_locks[lockNo], ref lockTaken); )
    add key "foo" with appropriate value
    release lock ( Monitor.Exit(tables.m_locks[lockNo]); )
    leave __dict.GetOrAdd("foo", factoryFn)
    

    您可以看到它会锁定和释放两次,并且在另一个进程已经创建“bar”时,另一个进程可以跳入并创建“foo”。是否安全取决于您。

    当您使用相同的键递归调用时,最深入的值将“获胜”,因为 TryAddInternal 中有 updateIfExists: false 参数,因此任何后续调用一旦存在就不会更改它。还有out value参数,所以它会返回第一个插入的值并且不会失败。

    也很有趣:TryAddInternal 不会锁定整个字典,而只会锁定一个基于密钥的存储桶(字典的一部分)。这是性能的提升。

    【讨论】:

    • 我喜欢您使用反编译代码作为参考的事实。尽管实现可能会发生变化,但对于这样的核心类来说不太可能发生,除非需要修复一些错误。很难按照您编写的时间线进行操作。也许一些缩进会有所帮助,而且还不清楚某些方法名称的含义。
    【解决方案3】:

    valueFactory 委托被调用并到达内部锁。 (基于 MSDN)

    因此,只要您的委托很简单并且不访问任何非线程安全的东西,它就非常安全。我的意思是,如果您的方法只是创建某个类,则没有危险。如果您的方法会尝试读取类的某些字段或进行某些锁定,您可能需要考虑如何解决死锁问题。

    我假设这个内部锁是 __dict[key] 上的锁

    【讨论】:

    • 我认为这不能正确回答这个问题,因为文档指出“...... valueFactory 委托在锁外调用以避免在执行未知代码时可能出现的问题锁。”。
    猜你喜欢
    • 2022-06-25
    • 1970-01-01
    • 2011-12-28
    • 2019-04-18
    • 1970-01-01
    • 2016-11-10
    • 2011-09-13
    • 1970-01-01
    相关资源
    最近更新 更多