导读:本期聚焦于零壳创作的《C#怎么创建线程安全List?SynchronizedCollection并发读写核心指南》,敬请观看详情。多线程并发向List添加元素时偶发索引越界、数据丢失,这类问题在高压场景才暴露,排查困难。C#标准库提供的List并非线程安全结构,直接并发读写会破坏内部数组状态。SynchronizedCollection是System.Collections.Generic命名空间下的一个线程安全包装集合,所有修改和读取操作都在内部锁保护下执行,可以降低同步代码的编写成本。本文将围绕SynchronizedCollection的创建方式、内部锁机制、并发读写行为以及枚举场景的额外同步要求展开,给出可运行的多线程示例,并说明它与lock、ConcurrentQueue、ConcurrentBag等方案的差异。重点提醒开发者在遍历集合时必须手动锁定SyncRoot,否则即使单个Add和Count操作安全,枚举过程仍可能抛异常或读到不一致数据。通过场景化对比,帮助读者判断何时选择SynchronizedCollection、何时改用无锁并发集合。

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

C#怎么创建线程安全List?SynchronizedCollection并发读写核心指南

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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/1004/65455.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。