在C#中,List<T>是最常用的动态数组集合,但它并不是线程安全容器。多个线程同时执行 Add、Remove 或索引赋值时,内部数组的扩容、游标移动和元素复制可能会交错执行,导致数据被覆盖、Count 值异常或抛出 ArgumentOutOfRangeException。这类问题通常与线程调度相关,开发环境和低并发测试很难复现,一旦上线就可能造成偶发崩溃。SynchronizedCollection<T> 提供了一个基于锁的线程安全 List 替代方案,它把每个公共方法都串行化执行,使集合在并发读写下保持一致。

SynchronizedCollection与List在线程安全上的差异
List<T> 的内部结构主要是一块连续数组和一个表示元素数量的 _size 字段。当容量不足时,Add 方法会创建更大的数组并把旧元素复制过去。假设两个线程同时执行 Add,线程 A 完成扩容但还没有更新 _size,线程 B 可能直接向旧数组写入元素,随后线程 A 又更新了 _size,最终导致新加入的元素丢失或者数组状态错乱。读取操作也不是绝对安全的,如果一个线程正在扩容,另一个线程访问索引时可能看到空引用或旧数组边界。
SynchronizedCollection<T> 实现了 IList<T>、ICollection<T> 等接口,它在内部同样使用 List<T> 作为存储,但每个修改和读取公共方法都会先获取一个同步对象上的锁,再委托给内部 List<T> 执行。因此单个 Add、Remove、Count、索引读写操作不会被并发撕裂。需要注意的是,这种安全是方法级的,不是复合操作级的。如果在两个调用之间需要维持一致性,例如先判断 Count > 0 再读取索引 0,仍然需要调用方自己加锁。
在 .NET Framework 中,SynchronizedCollection<T> 位于 System.Collections.Generic 命名空间下。在 .NET Core 以及更高版本中,如果使用基于 WCF 的包结构,通常需要引用 System.ServiceModel.Primitives 包才能找到这个类型。创建方式非常简单,可以直接使用无参构造函数,也可以传入自定义同步根对象。
SynchronizedCollection的并发读写实现原理
打开 SynchronizedCollection<T> 的源码可以看到,它内部维护了一个 List<T> items 和一个 object syncRoot。以 Add 方法为例,它的核心逻辑大致是先 lock (syncRoot),再调用 items.Add(item),最后释放锁。Count 属性返回前也会锁定,Remove、Insert、Clear 以及索引器 get/set 都遵循同样的模式。这样设计的好处是调用者不需要在每个操作前手动写 lock,降低了遗漏同步的概率。
不过,SynchronizedCollection<T> 并不能解决所有并发场景。它的锁粒度覆盖单个方法,而不是跨多个方法形成事务。举例来说,下面的复合操作在没有外部锁时仍然存在竞态:线程 A 读取 Count 得到 10,线程 B 随即清空集合,线程 A 再访问索引 5 时就会抛出越界异常。因此,需要把多个方法调用组合成一个逻辑单元时,应该使用 SyncRoot 属性手动锁定整个代码块。
另一个关键点是枚举器。虽然 SynchronizedCollection<T> 的 GetEnumerator 返回枚举器,但它并不会锁定整个枚举过程。底层 List<T> 的枚举器通过版本号检测集合是否在枚举期间被修改,一旦检测到变化,下一次 MoveNext 会抛出 InvalidOperationException。因此即使单个 Add 和 foreach 都各自看起来安全,二者并发执行仍然会触发异常,必须由调用方在遍历期间持有 SyncRoot 锁。
多线程并发读写代码示例
下面的控制台示例创建了一个 SynchronizedCollection<int>,并用 Parallel.For 同时添加 10000 个元素。由于每个 Add 操作内部自动加锁,最终 Count 会稳定为 10000。
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
class Program
{
static void Main()
{
var syncList = new SynchronizedCollection<int>();
Parallel.For(0, 10000, i =>
{
syncList.Add(i);
});
Console.WriteLine($"Count = {syncList.Count}");
}
}
如果只是执行单个方法,上述代码已经满足线程安全需求。但在实际业务中,经常需要先判断某个元素是否存在,再决定是否插入;或者先读取索引再删除。这类操作不能依赖集合内部的单方法锁。正确的做法是使用 syncList.SyncRoot 作为锁对象,把相关操作包在一起。下面示例演示了在锁内完成检查和插入,避免重复添加。
var syncList = new SynchronizedCollection<string>();
object syncRoot = syncList.SyncRoot;
lock (syncRoot)
{
if (!syncList.Contains("admin"))
{
syncList.Add("admin");
}
}
这样锁对象和集合内部使用的锁完全一致,能够保证在锁持有期间其他线程不会修改集合。锁块内可以包含多次读取、判断和写入,不必担心状态在中间被改变。但也要注意不要在锁内调用外部代码、执行远程请求或触发事件回调,否则容易造成长时间阻塞甚至死锁。
枚举遍历为什么必须额外锁定
foreach 遍历是并发场景中最容易踩坑的地方。SynchronizedCollection<T> 的枚举器直接代理内部 List<T>,并不提供数据快照。这意味着如果在遍历过程中有另一个线程执行 Add,内部版本号会发生变化,枚举器下一次访问时就会抛出 InvalidOperationException。这个异常并不是集合本身损坏,而是 List<T> 设计的一种快速失败机制,用于提示调用方遍历已经被并发修改。
推荐做法是在遍历前锁定 SyncRoot,并在同一个锁块内完成整个 foreach。示例如下:
lock (syncList.SyncRoot)
{
foreach (var item in syncList)
{
Console.WriteLine(item);
}
}
需要注意的是,锁住遍历意味着在遍历期间所有其他线程的 Add、Remove 等操作都会被阻塞。如果集合很大,或者遍历内部的逻辑较重,就会形成性能瓶颈。此时可以考虑先把元素复制到数组或新的 List<T> 中,再在锁外遍历副本。例如在锁内执行 var snapshot = syncList.ToArray();,锁释放后再遍历 snapshot。这样能够缩短锁持有时间,但读取的是某一时刻的快照数据。
与lock、ConcurrentQueue、ConcurrentBag的选择对比
如果项目只是偶尔需要一个线程安全的列表,使用 lock + List<T> 也能实现。但手动加锁要求所有访问点都遵守同一个锁对象,多人协作或代码迭代后很容易漏掉某个读取路径。相比之下,SynchronizedCollection<T> 将锁封装在集合内部,对单个操作更安全,使用成本更低。但它牺牲了灵活性和部分性能,因为每次读写都要进入 Monitor,高并发下锁竞争会比较明显。
如果业务不需要索引访问,而只关注先进先出或后进先出,可以选择 ConcurrentQueue<T>、ConcurrentStack<T> 或 ConcurrentBag<T>。这些集合使用更细粒度的同步或无锁算法,在多线程吞吐量方面通常优于简单加锁。但它们不提供 IList<T> 接口,不能通过索引随机访问,也不能按位置插入。
如果读多写少,还可以考虑 ReaderWriterLockSlim 配合 List<T>,允许多个线程同时读取,写入时独占访问。不过这种方案仍然需要手动管理锁的获取和释放,复杂度更高。综合来看,SynchronizedCollection<T> 适合对代码简洁性要求高、集合规模不大、并且需要保留 List 风格索引操作的场景。对于高吞吐、大规模数据或读写比例极端不均的情况,应优先评估专门的并发集合。
线程安全集合的选择本质上是锁粒度、接口便利性和性能之间的权衡。理解 SynchronizedCollection<T> 的单方法锁机制以及枚举器快速失败行为,可以帮助开发者在多线程环境中避免数据竞争和偶发异常,同时清楚什么时候必须手动介入锁控制。
C#线程安全ListSynchronizedCollection并发读写修改时间:2026-10-04 07:30:22