澄清 C# 字典上的读写

Posted

技术标签:

【中文标题】澄清 C# 字典上的读写【英文标题】:Clarification of Read and Write on a C# Dictionary 【发布时间】:2011-06-09 09:35:06 【问题描述】:

在本声明的上下文中,

字典可以支持 多个阅读器同时进行,只要 因为集合没有被修改。 即便如此,通过枚举 集合本质上不是 线程安全的过程。在罕见的 枚举争辩的情况 具有写访问权限的集合 必须在整个过程中锁定 枚举。允许集合 被多个线程访问 阅读和写作,你必须 实现您自己的同步。

读写是什么意思?我的理解是,读取是一种查找键并提供对其值的引用的操作,而写入是一种从字典中添加或删除键值对的操作。但是,我找不到任何关于此的结论。

所以最大的问题是,在实现线程安全字典时,更新字典中现有键的值的操作会被视为读取器还是写入器?我计划让多个线程访问字典中的唯一键并修改它们的值,但线程不会添加/删除新键。

假设修改现有值不是对字典的写操作,显而易见的含义是我的线程安全字典的实现可以更有效,因为我不需要每次都获得排他锁尝试将值更新为现有键。

.Net 4.0 中的 ConcurrentDictionary 的使用不是一个选项。

【问题讨论】:

【参考方案1】:

还没有提到的一个重点是,如果TValue 是一个类类型,那么Dictionary<TKey,TValue> 所持有的东西将是TValue 对象的身份。如果一个人从字典中接收到一个引用,那么字典既不知道也不关心人们可能对所引用的对象所做的任何事情。

如果与字典关联的所有键在需要使用它的代码之前就已知,那么一个有用的小实用程序类是:

class MutableValueHolder<T>

   public T Value;

如果想要让多线程代码计算各种字符串在一堆文件中出现的次数,并且事先知道所有感兴趣的字符串,那么可以为此目的使用Dictionary&lt;string, MutableValueHolder&lt;int&gt;&gt; 之类的东西。一旦字典加载了所有正确的字符串和每个字符串的MutableValueHolder&lt;int&gt; 实例,那么任意数量的线程都可以检索对MutableValueHolder&lt;int&gt; 对象的引用,并使用Threading.Interlocked.Increment 或其他此类方法来修改关联的Value与每一个,根本不必写到字典。

【讨论】:

【参考方案2】:

覆盖现有值应视为写入操作

【讨论】:

这真的是评论,而不是问题的答案。请使用“添加评论”为作者留下反馈。 @SliverNinja,在我看来,这个答案解决了核心问题“更新字典中现有键值的操作会被认为是读取器还是写入器?”。【参考方案3】:

任何可能影响另一次读取结果的事件都应被视为写入。

更改键绝对是一种写入,因为它会导致项目在内部哈希或索引中移动,或者字典会执行 O(log(n)) 的操作...

您可能想要做的是查看 ReaderWriterLock

http://msdn.microsoft.com/en-us/library/system.threading.readerwriterlock.aspx

【讨论】:

我读过的很多文档都显示 Reader/Writer 锁的性能非常差,比只使用单个锁还要糟糕。看起来 ReaderWriterLockSlim 应该是您使用的,如果它可用或使用其他东西。 msdn.microsoft.com/en-us/library/… @Erik - 我同意,但要补充:这始终是一种平衡。它必须取决于组合中的其他成本。如果读取和写入的数量大致相等,那么我建议只使用 lock() - 但如果写入很少 - 通常是这样,那么读写器锁可能就可以了。当他们升级时,很容易移动到 Slim 锁。遇到问题时进行分析是获得性能的最佳方式。人们通常会花费数天时间进行编码以节省每次操作的毫秒数。 同意,有时开发人员会花费太多时间来进行预优化。我使用“令人震惊的性能差”这个短语来表示这似乎不是一个微妙的性能问题,而是一个相当激烈的问题。通常认为避免它而不是使用它更好。至少,这似乎是我在各种性能讨论中看到的很多结论。【参考方案4】:

更新值在概念上是一种写​​操作。在写入完成之前执行读取的并发访问更新值时,您会读出旧值。当两次写入冲突时,可能会存储错误的值。

添加新值可能会触发底层存储的增长。在这种情况下,分配了新内存,将所有元素复制到新内存中,添加新元素,更新字典对象以引用新内存位置进行存储,并释放旧内存并用于垃圾回收。在此期间,更多的写入可能会导致大问题。同时两次写入可能会触发此内存复制的两个实例。如果您遵循逻辑,您会看到一个元素会丢失,因为只有最后一个更新引用的线程会知道现有项目,而不是尝试添加的其他项目。

ICollection provides a member to synchronize access 并且该引用在增长/收缩操作中仍然有效。

【讨论】:

这是一个有趣的点,如果我声明一个 的字典并插入 ("Key1","Hello") 然后做 directory["Key1"] = "This is a really真的很长的字符串”,它是否必须为值重新分配内存,或者由于该值是一个对象,并且已经为该对象分配了内存,它可以重新使用该内存?我想很大程度上取决于 Dictionary 对象的内部结构。 对于 Dictionary 的底层存储是 ICollection>。您已经插入了一个值这一事实意味着一条记录已添加到 ICollection(扩展其内容,如果需要,增加内存)。内容只是一个包含对两个字符串对象的引用的结构。将字典值字符串更改为其他值只会在内存中创建一个新字符串并更新 KeyValuePair 引用。 IDictionary 底层存储大小没有变化。 另外,如果你尝试两次插入同一个键,Insert 会抛出异常。这在未正确实现锁定的多线程环境中很容易发生。如果您想更安全,只需访问一个不存在的密钥就会导致它被创建。如果它确实存在,它将被更新。没有异常抛出。如果添加相同的键是您想要捕获的实际错误条件,请使用 Insert。 directory.Insert("Key1", "Hello");与目录["Key1"] = "Hello";【参考方案5】:

读取操作是从Dictionary 获取键或值的任何操作,写入操作是更新或添加键或值的任何操作。因此,更新密钥的进程将被视为写入者。

创建线程安全字典的一种简单方法是创建您自己的IDictionary 实现,该实现简单地锁定互斥体,然后将调用转发给实现:

public class MyThreadSafeDictionary<T, J> : IDictionary<T, J>

      private object mutex = new object();
      private IDictionary<T, J> impl;

      public MyThreadSafeDictionary(IDictionary<T, J> impl)
      
          this.impl = impl;
      

      public void Add(T key, J value) 
      
         lock(mutex) 
             impl.Add(key, value);
         
      

      // implement the other methods as for Add

如果你有一些线程只读取字典,你可以用读写锁替换互斥锁。

还要注意Dictionary 对象不支持更改键;实现您想要的唯一安全方法是删除现有的键/值对并使用更新的键添加一个新的。

【讨论】:

有什么理由这样做而不是使用 ConcurrentDictionary?【参考方案6】:

修改值是一种写操作,会引入竞争条件。

假设 mydict[5] 的原始值 = 42。 一个线程将 mydict[5] 更新为 112。 另一个线程将 mydict[5] 更新为 837。

最后 mydict[5] 的值应该是多少?在这种情况下,线程的顺序很重要,这意味着您需要确保顺序是明确的,或者它们不写。

【讨论】:

您所描述的并不是真正的竞争条件 - 因为无论哪种方式最后一次获胜 - 如果您锁定访问权限,那将是相同的。竞争条件类似于:if(!dict.ContainsKey(5)) dict(5,new Value()) 现在可能会产生不一致。 @Neil 我认为这是一个竞争条件,而不是字典可以处理的竞争条件。我已经编写好代码以保证答案中提到的竞争条件类型不会发生。 @Joshua - 实现“mydict[5] = X”的字典代码中可能出现竞争条件,但 Serinus 的 3 个集合语句之间不存在竞争条件。最后设置值的人获胜 - 这只是对正常编程的描述。在您根据先前的状态检查设置状态的情况下会出现竞争条件。 x=1 本身不能生成竞争条件,x++ 可以。

以上是关于澄清 C# 字典上的读写的主要内容,如果未能解决你的问题,请参考以下文章

关于字典编辑及文件读写

Python基础之字典元祖常用字符串方法文件读写

字典读写训练

python字符串字典操作,文件读写

C# 字典 vs 二维数组

文件读写切片字典的操作