【问题标题】:How to call the GetOrAdd method on a .NET ConcurrentDictionary in a thread-safe way?如何以线程安全的方式调用 .NET ConcurrentDictionary 上的 GetOrAdd 方法?
【发布时间】:2017-02-27 02:51:01
【问题描述】:

我正在尝试拥有一个 keys 的集合,如果密钥不存在,那么我需要 DoSomeMethod() 然后将密钥添加到集合中。

问题是,这需要能够同时处理多个线程尝试添加相同的键。

如果两个线程有​​相同的键,那么只有一个会做DoSomeMethod(),而另一个需要等待。

我已经研究过使用ConcurrentDictionaryGetOrAdd (with the Func(..) param option) 方法,但如果两个线程具有相同的密钥,这似乎会同时“启动”。我以为GetOrAdd 的实现会是

  • 锁定“钥匙”
  • key 获取value
  • 如果没有value 然后做任何事情.. 现在设置值。
  • 返回value.
    ... 和任何其他 key 命中将等到锁定完成。

感觉就像GetOrAdd 方法调用的我的自定义方法不是线程安全的。

MSDN 文档也建议这样做?

备注
如果在不同线程上同时调用GetOrAddaddValueFactory 可能会被多次调用,但它的键/值对可能不会每次调用都添加到字典中


悔过的例子:我正在将文件从source 复制到destination

  • 复制source 文件时,如果我们尝试检查并创建目标文件夹,请检查集合。
  • 如果集合没有key,则检查目标文件夹是否不存在...如果不存在则创建它。
  • 创建目标文件夹后,将此文件夹名称/路径存储在集合中。
  • 对所有文件重复。

所以实际上,如果我们还没有创建目标文件夹,我们只会创建它。

这就像我想锁定一个集合 KEY ....

【问题讨论】:

  • 您必须在同一个锁中结合“检查”和“执行”。如果一个线程检查密钥并决定做某事,那么线程可以中断“做”并尝试做同样的事情。我是正确理解问题还是有其他问题?
  • 不——第二个线程不能中断,但必须等待。
  • 我还是不明白问题出在哪里。为什么并发字典不适合你?
  • 是的,这就是 GetOrAdd 的工作方式。它不保证值工厂不会被调用两次,只是保证您在两个线程上获得相同的值。您建议的实现的问题是它需要一个相当高级别的锁(每次检查一个键)并且它需要在调用外部代码时保持一个锁。并发集合是“低锁”以在高争用场景中最大化吞吐量。在持有锁的同时调用外部代码是不好的,因为它会为死锁创建路径。
  • 你可以选择使用 SemaphoreSlim 和超时吗?你也可以检查一下。

标签: c# .net multithreading collections thread-safety


【解决方案1】:

不需要GetOrAdd。简单检查一下路径和密钥是否存在就足够了:

class FileWorker
{
    private object _sync;
    private IDictionary<string, Task> _destTasks;

    public FileWorker()
    {
        _sync = new object();
        _destTasks = new Dictionary<string, Task>();
    }

    public async Task Copy(IEnumerable<FileInfo> files, string destinationFolder)
    {
        await Task.WhenAll(files.Select(f => Copy(f, destinationFolder)));
    }

    private async Task CreateDestination(string path)
    {
        await Task.Run(() =>
        {
            if (!Directory.Exists(path))
            {
                Directory.CreateDirectory(path);
            }
        });
    }

    private Task Destination(string path)
    {
        lock(_sync)
        {
            if (!_destTasks.ContainsKey(path))
            {
                _destTasks[path] = CreateDestination(path);
            }
        }
        return _destTasks[path];
    }

    private async Task Copy(FileInfo file, string destinationFolder)
    {
        await Destination(destinationFolder).ContinueWith(task => file.CopyTo(Path.Combine(destinationFolder, file.Name), true));
    }
}

class Program
{
    static void Main(string[] args)
    {
        var file1 = new FileInfo("file1.tmp");
        using(var writer = file1.CreateText())
        {
            writer.WriteLine("file 1");
        }
        var file2 = new FileInfo("file2.tmp");
        using(var writer = file2.CreateText())
        {
            writer.WriteLine("file 2");
        }
        var worker = new FileWorker();
        worker.Copy(new[] { file1, file2 }, @"C:\temp").Wait();
        Console.ReadLine();
    }
}

【讨论】:

  • 如果Destination(string path)同时在两个线程上被调用,你将调用CreateDestination(string path)两次,这是OP试图避免的
  • @ScottChamberlain,你的意思是 ConcurrentDictionary.ContainsKey 不是线程安全的吗?
  • 不,ConcurrentDictionary.ContainsKey 是线程安全的,但 if(!_destTasks.ContainsKey(path)) { _destTasks[path] = CreateDestination(path); } 的组合不是。因为这两个操作不是原子的,所以有可能同时发生两个 ContainsKey(path) 检查并且都返回 true,从而导致对 CreateDestination(path) 的两次调用发生。 (这是 OP 不想要的现有 GetOrAdd 的确切逻辑)
  • @Scott 这正是我建议将两个调用都封装到任何“Slim”储物柜的原因。在这种情况下,您甚至不需要并发集合。
  • @ScottChamberlain,我同意 2 个 ContainsKey 检查可能同时发生,因为这就是我的目标。 2 个检查可能同时发生,但只返回 1 个任务。这导致只执行 1 个 CreateDestination。
【解决方案2】:
public class OnceOnlyConcurrent<TKey, TValue>
{
    private readonly ConcurrentDictionary<TKey, Lazy<TValue>> _dictionary = new ConcurrentDictionary<TKey, Lazy<TValue>>();

    public TValue GetOrAdd(TKey key, Func<TValue> computation)
    {
        var result = _dictionary.AddOrUpdate(key, _ => new Lazy<TValue>(computation, LazyThreadSafetyMode.ExecutionAndPublication), (_, v) => v);
        return result.Value;
    }
}

我想我应该稍微描述一下。基本上这里发生的情况是,虽然AddOrUpdate 将始终调用addValueFactory 委托两次,如果两个调用者同时发生在AddOrUpdate 上,这两个调用实际上并没有做任何事情,只是返回一个Lazy&lt;T&gt; 引用包装计算。

AddOrUpdate 内部,两个结果都将被捕获,但一个会被丢弃。只有Lazy&lt;T&gt; 的单个实例将返回给AddOrUpdate 的两个调用者,因此单个Lazy&lt;T&gt; 将控制被调用的计算。

然后,在下一行,当我们请求 .Value 时,这实际上会触发此自定义 GetOrAdd 的调用者之一的计算,而另一个将在第一个计算时阻塞 - 这是Lazy&lt;T&gt; (LazyThreadSafteMode.ExecutionAndPublication) 的第二个参数。顺便说一句,这是 Lazy&lt;T&gt; 的默认行为,所以你并不需要第二个参数 - 我只是在这篇文章中使用它来更清楚。

当然,这段代码也可以写成扩展方法,但不幸的是,你必须知道创建一个包含Lazy&lt;T&gt;对象的字典,所以我认为最好作为ConcurrentDictionary&lt;TKey, TValue&gt;的包装类.

【讨论】:

  • 这是我要写的解决方案,唯一不同的是var result = _dictionary.GetOrAdd(key, _ =&gt; new Lazy&lt;TValue&gt;(computation)); 让它更短。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-06-04
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多