IEnumerable<T> 线程安全吗?

16

我有一个主线程,用于填充一个List<T>。然后我创建了一系列对象在不同的线程上执行,需要访问该列表。原始列表生成后将不会对其进行写入。我的想法是将该列表作为IEnumerable<T>传递给在其他线程上执行的对象,主要是为了防止那些实现这些对象的人错误地向列表中写入数据。换句话说,如果保证原始列表不被写入,那么多个线程在IEnumerable上使用.Whereforeach是否安全?

我不确定迭代器本身是否在原始集合未更改的情况下是线程安全的。

6个回答

13

IEnumerable<T>不能被修改。 那么它有哪些非线程安全的地方呢?(如果您不修改实际的List<T>)。

对于非线程安全,您需要写入和读取操作。

“迭代器本身”为每个foreach实例化。

编辑:我简化了我的答案,但@Eric Lippert添加了有价值的评论。IEnumerable<T>并没有定义修改方法,但这并不意味着访问操作是线程安全的(GetEnumeratorMoveNext等)。最简单的例子:将GetEnumerator实现为此:

  • 每次返回相同的IEnumerator实例
  • 重置其位置

更复杂的示例是缓存。

这是一个有趣的观点,但幸运的是我不知道任何标准类具有不线程安全的IEnumerable实现。


1
你需要为每个线程使用不同的迭代器,但它们都可以由同一个列表支持。 - Joseph Earl
1
是的,在这种情况下是这样的。我在考虑一个显式的情况,即您调用Iterator i = list.iterator(),然后使用该迭代器 - 在这种情况下,您需要为每个线程创建一个新的迭代器。 - Joseph Earl
24
仅因为一个 IE<T> 不能被 调用方 修改并不保证其 内部运作 对于多个读者是线程安全的!IE<T> 的实现可能包含缓存或其他性能增强算法,这些算法涉及写入共享数据结构,并且对于多个读者可能不是线程安全的。仅因为集合在 逻辑上 没有被改变并不要求在其枚举期间 没有任何 被改变。 - Eric Lippert
5
@Andrey: 我不知道有没有这样的例子,但这并不意味着不存在。例如,考虑一个用哈希表实现的字典,一如既往地,但有一个小技巧:当您访问位于桶列表末尾的键时,它会将该桶移动到桶列表的前面。这样的字典在编译器中很常见,因为编译器经常遇到多次出现相同标识符的情况。将表内部动态重写为读取时尝试使未来的读取更快速,可以提高性能。 - Eric Lippert
针对 .NET 4.5 的并发集合,例如堆栈和队列。 来自 MSDN: “System.Collections.Concurrent.ConcurrentQueue<T> 和 System.Collections.Concurrent.ConcurrentStack<T> 类在枚举元素之前会对其进行快照,以防止另一个线程对集合进行变异。System.Collections.Concurrent.ConcurrentDictionary<TKey, TValue> 类不会拍摄快照。” - thewpfguy
显示剩余3条评论

9

每个调用 Where 或 foreach 的线程都有自己的枚举器 - 它们不会为同一列表共享一个枚举器对象。因此,由于 List 没有被修改,每个线程都使用自己的枚举器副本,所以不应该出现线程安全问题。

你可以在一个线程中看到这个过程 - 只需创建一个包含 10 个对象的 List,并从该 List 获取两个枚举器。使用一个枚举器枚举前 5 个项,并使用另一个枚举器枚举后 5 个项。你会发现两个枚举器都只枚举了前 5 个项,并且第二个枚举器没有从第一个枚举器离开的地方开始。


我认为这就是让我困惑的原因,每个请求都提供了一个新的迭代器,因此它不是共享的。 - e36M3
+1 表示“每个调用 Where 或 foreach 的线程都会获得自己的枚举器”,我无法在任何地方找到这些信息。从我能找到的文档中并不清楚这是否是事实。谢谢。 - theyetiman
任何人都可以在此处找到 “每个调用Where或foreach的线程都会获得自己的枚举器” 的证明。 - Sergei Iashin
这完全取决于类如何实现 GetEnumerator 方法。不能保证返回一个单独的实例。 - Rufus L
@RufusL,你说得完全正确,这取决于类如何实现GetEnumerator()。然而,List确实是以这种方式实现的,如果有一个类以任何不同的方式实现它,那将会非常奇怪。事实上,我认为这将是一个bug。 - Phil
显示剩余2条评论

6
只要您确定List不会被修改,那么从多个线程中读取它是安全的。这还包括使用它所提供的IEnumerator实例。
对于大多数集合来说,这都是正确的。事实上,BCL中的所有集合在枚举期间都应该是稳定的。换句话说,枚举器不会修改数据结构。我可以想到一些模糊的情况,比如Splay-Tree,它可能会在枚举时修改结构。但是,BCL中的所有集合都不会这样做。

3
如果您确定列表在创建后不会被修改,那么您应该将其转换为ReadOnlyCollection<T>来保证它的只读性。当然,如果您保留了只读集合使用的原始列表,则可以对其进行修改,但是如果您丢弃了原始列表,则相当于永久地将其设置为只读。
从集合的线程安全性部分可以看出:

只读集合可以同时支持多个读取器,只要不修改集合即可。

因此,如果您不再触碰原始列表并停止引用它,就可以确保多个线程可以无忧地读取它(只要您不尝试通过奇怪的方式再次修改它)。

3
换句话说,如果保证原始列表不会被写入,那么多个线程在IEnumerable上使用.Where或foreach是安全的吗?
是的,只有当列表发生改变时才会出现问题。
但请注意,IEnumerable可以转换回列表,然后进行修改。
但还有另一种选择:将您的列表包装到ReadOnlyCollection中并传递。如果您现在放弃原始列表,您基本上创建了一个新的不可变列表。

你是正确的。然而,使用ReadOnlyCollection的缺点是,例如在myList.Add()中没有编译时警告,而IEnumerable则不会出现这种问题。现在我想知道如果我拿我的原始IList并做.AsReadOnly().AsEnumerable(),会怎样? - e36M3
1
据我所知,ReadOnlyCollection 显式实现了 Add 方法。因此 myList.Add 是无法使用的,只有当 ((IList<T>)myList).Add 才能使用。对我来说,这已经足够安全了。 - CodesInChaos

0

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接