C# 中线程安全都有哪些

文章目录

    • 为什么会出现线程不安全?
    • [C# 实现线程安全的常用手段](# 实现线程安全的常用手段)
      • [1. 排它锁/互斥锁(Lock)](#1. 排它锁/互斥锁(Lock))
      • [2. 原子操作(Interlocked)](#2. 原子操作(Interlocked))
      • [3. 读写锁 (ReaderWriterLockSlim)](#3. 读写锁 (ReaderWriterLockSlim))
      • [4. 线程安全集合](#4. 线程安全集合)
        • [4.1. 线程安全集合的类型与特点](#4.1. 线程安全集合的类型与特点)
          • [4.1.1. `System.Collections.Concurrent` 命名空间](#4.1.1. System.Collections.Concurrent 命名空间)
          • [4.1.2. 其他线程安全容器](#4.1.2. 其他线程安全容器)
        • [4.2. **线程安全集合的实现原理**](#4.2. 线程安全集合的实现原理)
          • [1. 无锁算法(Lock-Free)](#1. 无锁算法(Lock-Free))
          • [2. 分片设计(Partitioning)](#2. 分片设计(Partitioning))
          • [3. 细粒度锁(Fine-Grained Locking)](#3. 细粒度锁(Fine-Grained Locking))
          • [4. 阻塞同步](#4. 阻塞同步)
        • [4.3. **示例与性能对比**](#4.3. 示例与性能对比)
          • [1. `ConcurrentQueue` 生产者-消费者模型](#1. ConcurrentQueue 生产者-消费者模型)
          • [2. `ConcurrentDictionary` 高频计数](#2. ConcurrentDictionary 高频计数)
            • [1. TryAdd()](#1. TryAdd())
            • [2. TryUpdate()](#2. TryUpdate())
            • [3. AddOrUpdate()](#3. AddOrUpdate())
            • [4. GetOrAdd()](#4. GetOrAdd())
            • [5. TryRemove()](#5. TryRemove())
            • 使用场景
            • 总结
          • [3. 性能对比(普通集合 vs 线程安全集合)](#3. 性能对比(普通集合 vs 线程安全集合))
      • [4.4. 线程安全集合的局限性](#4.4. 线程安全集合的局限性)
      • 4.5.如何选择线程安全集合
      • [4.6. 总结](#4.6. 总结)
    • 易混淆
      • [async / await 本身不保证线程安全](#async / await 本身不保证线程安全)
      • volatile
        • [为什么需要 volatile?](#为什么需要 volatile?)
        • 核心作用
        • [典型场景:单标记停止(Stop Flag)](#典型场景:单标记停止(Stop Flag))
        • [关键区别:volatile vs lock](#关键区别:volatile vs lock)
        • [volatile 的局限性](#volatile 的局限性)

在软件工程中,处理并发就像管理一个繁忙的十字路口。如果没有任何规则,必然发生碰撞(数据损坏)。

在多线程环境下,多个线程同时访问同一块内存区域(比如一个变量或对象),如果最终的结果符合预期且程序没有崩溃或数据错乱,这就是线程安全

通俗点说,线程安全就是给共享资源加了"红绿灯"或"排队机制",防止大家一哄而上把数据写乱。

为什么会出现线程不安全?

最经典的情况是 读-改-写 操作。假设一个变量 count = 0,两个线程同时执行 count++

  1. 线程 A 读取 count 为 0。
  2. 线程 B 读取 count 为 0。
  3. 线程 A 计算 0 + 1 = 1,写入内存。
  4. 线程 B 计算 0 + 1 = 1,写入内存。结果本该是 2,现在变成了 1。这就是典型的"竞态条件"。

C# 实现线程安全的常用手段

1. 排它锁/互斥锁(Lock)

这是最常用、最简单的办法。它确保同一时刻只有一个线程能进入代码块。

lock 关键字是 Monitor 类的语法糖。它会在对象头中设置一个标记,强制其他线程排队。

  • 注意 :永远不要 lock 一个 stringthis。必须声明一个私有的 readonly object
csharp 复制代码
private readonly object _locker = new object();
private int _count = 0;

public void Increment()
{
    // 只有拿到 _locker 的线程才能进去
    lock (_locker)
    {
        _count++;
    }
}

2. 原子操作(Interlocked)

如果你只是想做简单的加减法或替换lock太重了。底层 CPU 指令可以直接保证这些操作的原子性。

确保对共享变量的读写操作是线程安全的。

使用 Interlocked 类 避免竞争条件,可以在不阻塞线程(lock、Monitor)的情况下,对目标对象做修改。

方法 作用
CompareExchange() 比较两个数是否相等,如果相等,则替换第一个值。
Decrement() 以原子操作的形式递减指定变量的值并存储结果。
Exchange() 以原子操作的形式,设置为指定的值并返回原始值。
Increment() 以原子操作的形式递增指定变量的值并存储结果。
Add() 对两个数进行求和并用和替换第一个整数,上述操作作为一个原子操作完成。
Read() 返回一个以原子操作形式加载的值。
csharp 复制代码
using System.Threading;

private int _count = 0;

public void Increment()
{
    // 性能极高,直接在硬件层面保证一次性完成
    Interlocked.Increment(ref _count);
}

这是性能最高的方案。它不涉及内核对象的上下文切换,而是利用 CPU 的特殊指令(如 Compare-Exchange)直接完成操作。

适用场景:简单的计数器、状态标记。

csharp 复制代码
private int _isRunning = 0; // 0: 停止, 1: 运行中

public void Start()
{
    // 如果原值是 0,则改为 1。整个过程是原子的。
    if (Interlocked.CompareExchange(ref _isRunning, 1, 0) == 0)
    {
        // 执行启动逻辑
    }
}
ref _isStopping 目标位置,你想要修改的那个变量。必须用 ref,因为要直接修改内存。
1 (value) 期望写入的值 如果条件满足,你想把变量改成什么。
0 (comparand) 对比值 你认为这个变量现在应该是多少。
// 如果返回 0:说明在你修改之前,它的确是 0,你成功把它改成了 1。你是第一个抢到执行权的线程。
// 如果返回 1:说明在你尝试修改时,已经有别的线程把它改成 1 了。你"抢占"失败。

//或者比如这样
if (Interlocked.Exchange(ref _isExecuting, 1) == 1) return;
try
{
    if (sender != null && sender is ListViewItem)
    {
           if (_studyHistoryItem.ExamCommand.CanExecute(null))
           {
                _logger.Debug($"HandleDoubleClick Source = {e.Source}");
                _studyHistoryItem.ExamCommand.Execute(null);
           }
     }
}
finally
{
      Interlocked.Exchange(ref _isExecuting, 0);
}

3. 读写锁 (ReaderWriterLockSlim)

当你的场景是"多读少写"时,lock 会限制性能。可以使用 ReaderWriterLockSlim,它允许多个线程同时读,但在写的时候会排斥所有读写请求。

当你的数据被频繁读取,但很少修改时,用 lock 会导致读取操作也必须排队,效率极低。读写锁允许多个"读者"同时进入,但"写者"进入时会独占。

csharp 复制代码
private ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim();
private List<string> _data = new List<string>();

public void ReadData()
{
    _rwLock.EnterReadLock();
    try { /* 执行读取 */ }
    finally { _rwLock.ExitReadLock(); }
}

4. 线程安全集合

不要在多线程里直接用 ListDictionary<K, V> 。请使用 System.Collections.Concurrent 命名空间下的类。标准集合(如 List)在多线程下执行 Add 或 Foreach 极易抛出异常。并发集合内部通过分段锁或无锁算法(Lock-Free)解决了这个问题

  • ConcurrentDictionary: 线程安全字典。
  • ConcurrentQueue: 线程安全队列(无锁编程实现)。
4.1. 线程安全集合的类型与特点
4.1.1. System.Collections.Concurrent 命名空间
集合类型 数据结构 适用场景 核心特性
ConcurrentQueue 先进先出队列 生产者-消费者模型 无锁算法(CAS 操作)
ConcurrentStack 后进先出栈 临时任务缓存 无锁(Treiber 栈)
ConcurrentBag 无序集合 线程本地存储 + 全局共享 基于线程本地存储的分片设计
ConcurrentDictionary<K,V> 哈希表 高频键值读写 细粒度锁(桶级别锁)
BlockingCollection 阻塞式集合 有界/无界队列 + 阻塞操作 封装 ConcurrentQueue 并提供阻塞语义
4.1.2. 其他线程安全容器
  • ImmutableCollections(不可变集合):每次修改返回新实例,天然线程安全(适合读多写极少场景)。
  • Channel(.NET Core+):基于异步消息传递的高性能生产者-消费者模型。

4.2. 线程安全集合的实现原理
1. 无锁算法(Lock-Free)
  • ConcurrentQueue ConcurrentStack
    • 使用 Interlocked 原子操作(如 CompareExchange)更新头尾指针。
    • 优点:高并发下性能优异(减少上下文切换)。
    • 缺点:复杂的内存管理(ABA 问题需处理)。
2. 分片设计(Partitioning)
  • ConcurrentBag
    • 每个线程维护自己的本地队列,减少竞争。
    • 当其他线程窃取任务时,访问其他线程的队列。
    • 适用场景:任务分配不均匀的高并发环境。
3. 细粒度锁(Fine-Grained Locking)
  • ConcurrentDictionary
    • 哈希表按桶(Bucket)划分,每个桶独立加锁。
    • 读操作无锁(volatile 读),写操作桶锁。
    • 优化点:桶数量与并发度的平衡(默认桶数 = CPU 核数 × 2)。
4. 阻塞同步
  • BlockingCollection
    • 封装底层集合(如 ConcurrentQueue)并提供阻塞方法(TakeAdd)。
    • 使用 ManualResetEventSlimSemaphoreSlim 实现等待通知机制。

4.3. 示例与性能对比
1. ConcurrentQueue 生产者-消费者模型
csharp 复制代码
ConcurrentQueue<int> queue = new ConcurrentQueue<int>();

// 生产者
Task.Run(() => {
    for (int i = 0; i < 1000; i++) {
        queue.Enqueue(i);
    }
});

// 消费者
Task.Run(() => {
    while (queue.TryDequeue(out int item)) {
        Console.WriteLine($"处理: {item}");
    }
});
2. ConcurrentDictionary 高频计数
csharp 复制代码
ConcurrentDictionary<string, int> wordCounts = new ConcurrentDictionary<string, int>();

Parallel.ForEach(GetTextLines(), line => {
    foreach (string word in line.Split()) {
        wordCounts.AddOrUpdate(word, 1, (key, oldValue) => oldValue + 1);
    }
});
特性 Dictionary ConcurrentDictionary
线程安全 ❌ 需要手动加锁 ✅ 内置线程安全
读性能 非常高(无锁读取)
写性能 较高(细粒度锁)
并发支持 需要同步机制 原生支持并发
内存开销 较低 稍高(维护内部结构)
1. TryAdd()
csharp 复制代码
bool success = concurrentDict.TryAdd("task3", 25);
// 成功添加返回 true,键已存在返回 false
2. TryUpdate()
csharp 复制代码
int currentValue;
do {
    currentValue = concurrentDict["task1"];
} while (!concurrentDict.TryUpdate("task1", currentValue + 10, currentValue));
// 原子性更新:只有当前值等于 expectedValue 时才更新
3. AddOrUpdate()
csharp 复制代码
// 添加或更新(原子操作)
int newValue = concurrentDict.AddOrUpdate(
    "task1",
    key => 50,                // 添加时的工厂函数
    (key, oldValue) => oldValue + 10 // 更新时的函数
);
4. GetOrAdd()
csharp 复制代码
// 获取或添加(原子操作)
int value = concurrentDict.GetOrAdd("task4", key => 0);
// 如果 task4 不存在,初始化为 0
5. TryRemove()
csharp 复制代码
if (concurrentDict.TryRemove("task2", out int removedValue))
{
    Console.WriteLine($"已删除 task2,原值: {removedValue}");
}

优先使用原子方法

csharp 复制代码
// ✅ 推荐
dict.AddOrUpdate(key, 1, (k, v) => v + 1);

// ❌ 避免(非原子操作)
if (dict.ContainsKey(key)) {
    dict[key] = dict[key] + 1;
}

处理工厂函数副作用

csharp 复制代码
// 工厂函数应简单无副作用
var value = dict.GetOrAdd(key, k => {
    // 避免耗时操作
    return CalculateInitialValue(k);
});

迭代时处理并发修改

csharp 复制代码
foreach (var pair in concurrentDict)
{
    // 注意:迭代期间字典可能被修改
    Process(pair.Value);
}

值类型注意事项

csharp 复制代码
// 值类型更新需特殊处理
var dict = new ConcurrentDictionary<string, (int, DateTime)>();

dict.AddOrUpdate("task",
    k => (0, DateTime.UtcNow),
    (k, v) => (v.Item1 + 1, DateTime.UtcNow)
);

性能考量

  1. 读取密集型场景:性能接近无锁读取
  2. 写入密集型场景:比锁+Dictionary 性能高 2-3 倍
  3. 混合工作负载:在高并发下表现最佳
使用场景
  1. 缓存系统
  2. 实时监控/统计
  3. 并行计算中间结果
  4. 高并发计数器
  5. 任务调度系统(如所述进度监控)
总结

ConcurrentDictionary 是 .NET 中处理并发字典操作的首选方案,它:

  • 提供线程安全的原子操作
  • 比手动锁实现更高效
  • 简化多线程编程模型
  • 特别适合高频读写的场景

在任务进度监控系统中,使用 ConcurrentDictionary 可以安全高效地管理多个任务的进度状态,确保在高并发环境下数据的一致性和实时性。

CAS (Compare-And-Swap): 比较并交换。一种乐观锁技术,检查内存值是否没变,没变才更新。

自旋 (Spin): 线程不进入阻塞挂起状态,而是循环检查锁是否释放,减少上下文切换开销。

3. 性能对比(普通集合 vs 线程安全集合)
操作 List + lock ConcurrentBag 性能差异(百万次操作)
添加元素 ~1200 ms ~450 ms 2.6 倍更快
遍历元素 ~200 ms ~180 ms 接近

4.4. 线程安全集合的局限性

1.复合操作非原子性

复制代码
// 非原子操作:先检查是否存在,再添加
if (!concurrentDict.ContainsKey(key)) {
    concurrentDict.TryAdd(key, value); // 可能已被其他线程插入
}
  1. 解决方案 :使用原子方法(如 GetOrAdd)。
  2. 内存开销
    1. 无锁结构和分片设计会增加内存占用(如 ConcurrentBag 的线程本地存储)。
  3. 顺序性牺牲
    1. ConcurrentQueue 保证先进先出,但并行消费者可能乱序处理。

4.5.如何选择线程安全集合

场景 推荐集合 理由
高频生产者-消费者(无阻塞) ConcurrentQueue 无锁设计,吞吐量高
线程本地任务缓存 ConcurrentBag 分片减少竞争,适合线程本地存储 + 全局窃取
高频键值读写 ConcurrentDictionary 细粒度锁,避免全局锁竞争
异步消息管道 Channel 零拷贝、异步友好,适合高性能 IPC
读多写少(数据几乎不变) ImmutableDictionary 无锁读,写操作返回新实例

4.6. 总结

  • 优先使用标准库集合ConcurrentCollectionsImmutableCollections 覆盖绝大多数场景。
  • 性能关键路径:根据读写模式选择无锁或细粒度锁结构。
  • 避免重复造轮子:自定义实现需谨慎处理内存可见性、ABA 问题等底层细节。
  • 复合操作:即使集合本身线程安全,多个操作的组合仍需额外同步(如事务性操作)。

易混淆

async / await 本身不保证线程安全

这是一个非常普遍的误区。async / await 解决的是异步 问题(不阻塞当前线程),而不是同步问题(保护共享资源)。

  1. 线程切换await 之后的代码可能会在不同的线程上运行(取决于 SynchronizationContext)。
  2. 并发依然存在 :如果你在一个 async 方法里修改全局变量,而这个方法被同时调用了两次,依然会发生数据错乱。
  3. 不能在 lock 块内使用 await :编译器不允许在 lock 块里使用 await,因为 lock 是基于线程标识的,而异步方法在 await 之后可能换了线程,导致无法释放锁。

解决方案 :如果需要在异步环境加锁,请使用 SemaphoreSlim(1, 1)

csharp 复制代码
private SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);

public async Task AccessResourceAsync()
{
    await _semaphore.WaitAsync(); // 异步等待进入
    try
    {
        // 这里可以安全地执行异步操作
        await Task.Delay(100);
    }
    finally
    {
        _semaphore.Release();
    }
}

volatile

在 C# 中,volatile 是一个非常底层且容易被误导的关键字。它解决的不是"多个线程同时修改"的问题,而是解决**"内存可见性""指令重排"**的问题。

为什么需要 volatile?

在现代计算机架构中,为了提升性能,CPU 和编译器会对代码做两件事:

  1. 缓存变量:CPU 每一核都有自己的 L1/L2 缓存。线程 A 修改了一个变量,可能只写到了自己的缓存里,而线程 B 从主内存读到的还是旧值。
  2. 指令重排:编译器或 CPU 为了优化速度,可能会调整代码的执行顺序。

volatile 告诉编译器:不要对我做任何优化,每次读写都必须直接操作主内存(Main Memory)。


核心作用
  1. 保证可见性

当一个线程修改了 volatile 变量,其他线程能立即看到最新值。

  1. 禁止指令重排

它会在读写操作前后插入"内存屏障"(Memory Barrier),防止编译器把原本应该在它之后的指令挪到它前面。


典型场景:单标记停止(Stop Flag)

这是 volatile 最常见的用法。如果不用 volatile,线程 B 可能会因为编译器优化(认为 _stop 在循环中没变过)而永远死循环。

csharp 复制代码
public class Worker
{
    // 必须加 volatile,确保线程 B 能看到线程 A 的修改
    private volatile bool _stop = false;

    public void Run()
    {
        // 线程 B 执行
        while (!_stop)
        {
            // 执行任务
        }
        Console.WriteLine("线程已停止");
    }

    public void RequestStop()
    {
        // 线程 A 执行
        _stop = true;
    }
}

关键区别:volatile vs lock

很多初学者会混淆两者,看下表对比:

特性 volatile lock / Interlocked
原子性 不保证(不能保证 i++ 安全) 保证原子操作
阻塞 非阻塞(无锁) 会导致线程阻塞(挂起)
开销 极低(仅禁止缓存优化) 较高(涉及线程上下文切换)
适用场景 单个简单变量的读写、状态标记 复杂的逻辑块、复合操作(如读-改-写)

volatile 的局限性

它不能替代锁。

如果你执行 volatileVariable++,这实际上包含三步:取值、加一、写回。volatile 只能保证取值和写回时内存是同步的,但在加一的过程中,如果有其他线程介入,数据依然会错乱。这种场景必须用 Interlocked.Incrementlock

内存可见性 (Memory Visibility): 指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

指令重排 (Instruction Reordering): 编译器或处理器为了提高性能,在不影响单线程执行结果的前提下,对指令执行顺序进行调整。

内存屏障 (Memory Barrier): 一种 CPU 指令,用于同步内存访问,确保屏障前后的指令不会被乱序执行。

相关推荐
金士镧(厦门)新材料有限公司2 小时前
稀土抑烟剂:提升家居安全,环保又高效
科技·安全·全文检索·生活·能源
wuguan_2 小时前
C#递推算法
算法·c#·递推算法
+VX:Fegn08952 小时前
计算机毕业设计|基于springboot + vue英语学习系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
步步为营DotNet2 小时前
深度剖析ASP.NET Core Middleware:构建高效请求处理管道的关键
后端·asp.net
米羊1212 小时前
TCP/IP 协议 (上)
网络·安全
无心水2 小时前
【Stable Diffusion 3.5 FP8】8、生产级保障:Stable Diffusion 3.5 FP8 伦理安全与问题排查
人工智能·python·安全·docker·stable diffusion·ai镜像开发·镜像实战开发
唐叔在学习2 小时前
Pyinstaller进阶之构建管理大杀器-SPEC文件
后端·python·程序员
伯明翰java2 小时前
【无标题】springboot项目yml中使用中文注释报错的解决方法
java·spring boot·后端
de之梦-御风2 小时前
【WebAPI 模拟器】.NET 8/9 + Minimal API + Swagger + DI + WPF Host
.net·wpf·web