下面我将从核心理念、常用工具、最佳实践和一个完整的示例四个方面,详细讲解如何在 .NET 中进行多线程同步。

核心理念:为什么需要同步?
想象一下,一个银行账户(共享资源)有两个窗口(两个线程),两个人分别要从两个窗口存入100元。
-
没有同步的情况(线程不安全):
- 线程A读取账户余额:1000元。
- 线程B读取账户余额:1000元。
- 线程A计算新余额:1000 + 100 = 1100元。
- 线程B计算新余额:1000 + 100 = 1100元。
- 线程A将新余额1100写回账户。
- 线程B将新余额1100写回账户。
结果:账户余额变成了1100元,而不是正确的1200元,这就是数据竞争,因为多个线程同时读写了同一个数据,导致结果不可预测。
-
有同步的情况(线程安全): 我们需要一种机制,确保在任何一个线程修改余额时,其他线程必须等待,这个过程就像一个“锁”。
(图片来源网络,侵删)- 线程A请求“锁”,成功获得。
- 线程A读取账户余额:1000元。
- 线程A计算新余额:1100元。
- 线程A将新余额1100写回账户。
- 线程A释放“锁”。
- 线程B现在才能请求“锁”,成功获得。
- 线程B读取账户余额(此时已经是1100元)。
- 线程B计算新余额:1200元。
- 线程B将新余额1200写回账户。
- 线程B释放“锁”。
结果:账户余额正确地变成了1200元。
常用的同步工具(从简单到强大)
.NET 提供了丰富的同步工具,适用于不同的场景。
lock 语句 - 最常用、最基础的锁
lock 是 C# 提供的一个语法糖,它基于 Monitor 类实现,是最简单、最常用的线程同步方式。
工作原理:

- 它确保只有一个线程可以执行被
lock保护的代码块。 - 当一个线程进入
lock代码块时,它会获取一个“互斥锁”,其他试图进入该lock的线程将被阻塞,直到第一个线程释放锁。 - 锁的释放是自动的,即使代码块内发生异常。
语法:
private readonly object _lockObject = new object();
// ...
lock (_lockObject)
{
// 只有一个线程能同时执行这里的代码
// 访问或修改共享资源
}
重要提示:
- 锁对象必须是
private和readonly的,以防止被外部代码意外修改或锁定,这可能导致死锁。 - 永远不要锁定
this、字符串类型或任何可被公开访问的对象,因为它们可能在代码的其他地方被锁定,增加死锁风险。
Monitor 类 - lock 的底层实现
lock 语句实际上是对 Monitor 类的简化封装。Monitor 提供了更灵活的功能,但使用起来也更复杂。
常用方法:
Monitor.Enter(obj): 尝试获取指定对象上的锁,如果已被锁定,则阻塞。Monitor.Exit(obj): 释放锁。Monitor.TryEnter(obj, millisecondsTimeout): 尝试获取锁,但可以设置超时时间,避免无限等待。
示例(不推荐直接使用,除非有特殊需求):
private readonly object _lockObject = new object();
// ...
try
{
Monitor.Enter(_lockObject);
// 受保护的代码区域
}
finally
{
// 确保锁一定会被释放
Monitor.Exit(_lockObject);
}
lock 语句会自动生成这个 try-finally 结构,所以更安全、更推荐。
Mutex (互斥量) - 跨进程同步
Mutex 和 lock 非常相似,但它是一个内核对象,因此可以用于跨进程同步,这意味着不同的应用程序实例可以通过 Mutex 来协调它们的行为。
由于涉及到操作系统内核,Mutex 的开销比 lock 大得多,仅在需要跨进程同步时才使用。
示例:
// 全局命名互斥量,确保同一时间只有一个实例在运行
using (var mutex = new Mutex(false, "Global\\MyAppSingleInstance"))
{
if (!mutex.WaitOne(0, false)) // 尝试立即获取锁
{
Console.WriteLine("另一个实例已在运行。");
return;
}
// ... 程序主逻辑 ...
// 当 `using` 块结束时,mutex 会被自动释放
}
Semaphore (信号量) - 限制资源并发数
Semaphore 允许指定数量的线程同时访问一个资源,它像一个夜总会的保镖,只允许固定数量的人进入。
SemaphoreSlim是轻量级的,适用于进程内同步。System.Threading.Semaphore是内核对象,可用于跨进程同步。
示例 (SemaphoreSlim):
假设我们有一个只能处理3个并发请求的服务。
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(3, 3); // 初始3个,最大3个
public async Task ProcessRequestAsync()
{
await _semaphore.WaitAsync(); // 尝试获取一个“入场券”
try
{
Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 开始处理,当前并发数: {_semaphore.CurrentCount}");
// 模拟耗时操作
await Task.Delay(1000);
Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 处理完成。");
}
finally
{
_semaphore.Release(); // 释放“入场券”
}
}
ReaderWriterLockSlim - 读写分离优化
当共享资源的读操作远多于写操作时,使用 lock 会很低效,因为它会把所有读操作都串行化。ReaderWriterLockSlim 允许多个线程同时读取资源,但在写入时会独占访问。
- 进入读锁 (
EnterReadLock):多个线程可以同时进入。 - 进入写锁 (
EnterWriteLock):只有一个线程可以进入,此时所有其他读线程和写线程都会被阻塞。
示例:
private readonly ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim();
private int _sharedValue = 0;
public void ReadValue()
{
_rwLock.EnterReadLock();
try
{
Console.WriteLine($"读取值: {_sharedValue}");
}
finally
{
_rwLock.ExitReadLock();
}
}
public void WriteValue(int newValue)
{
_rwLock.EnterWriteLock();
try
{
_sharedValue = newValue;
Console.WriteLine($"写入值: {_sharedValue}");
}
finally
{
_rwLock.ExitWriteLock();
}
}
Interlocked 类 - 原子操作
对于简单的单一变量的原子操作(如递增、递减、交换),Interlocked 类提供了最高性能的解决方案,它不使用锁,而是通过CPU的原子指令来保证操作的不可分割性。
常用方法:
Interlocked.Increment(ref value): 原子递增。Interlocked.Decrement(ref value): 原子递减。Interlocked.Exchange(ref location, value): 原子交换。Interlocked.CompareExchange(ref location, value, comparand): 原子比较并交换,是实现无锁算法的关键。
示例:
private int _counter = 0; // 在多个线程中调用 Interlocked.Increment(ref _counter); // 比 lock 更快
同步的最佳实践和注意事项
-
锁定范围要尽可能小:只锁定访问共享资源所必需的最小代码块,锁得越久,其他线程等待的时间就越长,性能越差。
-
避免死锁:死锁是指两个或多个线程互相等待对方释放锁,导致所有线程都卡住。
- 场景:线程A锁住了对象1并等待对象2;线程B锁住了对象2并等待对象1。
- 预防:
- 按固定顺序获取锁:如果需要锁住多个对象,总是以相同的顺序获取它们。
- 使用
try-finally:确保锁一定会被释放。 - 避免在持有锁时调用外部代码:因为你不知道外部代码是否会尝试获取同一个锁。
-
优先使用
async和await:- 当一个线程在等待锁时,它会被阻塞,无法做任何事。
- 对于
lock和Monitor,这是无法避免的。 - 但对于
SemaphoreSlim.WaitAsync(),线程在等待时不会阻塞,而是会“让出”CPU,释放线程池资源,当锁可用时,它会自动恢复执行,这能极大地提高高并发场景下的应用程序吞吐量。
-
考虑无锁编程:对于高性能场景,如果可能,尽量使用
Interlocked或更高级的无锁数据结构(如ConcurrentQueue<T>,ConcurrentDictionary<TKey, TValue>),它们通过原子操作避免了锁的开销,但编程模型更复杂。
完整示例:生产者-消费者模型
这个例子综合了 lock、Monitor、while 循环和线程协作,是多线程同步的经典案例。
场景:一个线程(生产者)不断向队列中添加产品,另一个线程(消费者)不断从队列中取出产品,队列是共享资源,需要同步。
using System;
using System.Collections.Generic;
using System.Threading;
public class Program
{
// 共享队列
private static readonly Queue<int> _productQueue = new Queue<int>();
// 用于同步队列访问的锁对象
private static readonly object _queueLock = new object();
// 用于在生产者停止时通知消费者的信号量
private static readonly SemaphoreSlim _consumerSignal = new SemaphoreSlim(0);
public static void Main()
{
var producer = new Thread(ProducerThread);
var consumer = new Thread(ConsumerThread);
producer.Start();
consumer.Start();
// 让生产者运行5秒
Thread.Sleep(5000);
producer.Interrupt(); // 中断生产者线程(或设置一个停止标志)
producer.Join();
consumer.Join(); // 等待消费者处理完所有剩余产品后退出
Console.WriteLine("程序结束。");
}
// 生产者线程
public static void ProducerThread()
{
int productId = 0;
try
{
while (true)
{
productId++;
// 模拟生产耗时
Thread.Sleep(100);
lock (_queueLock)
{
_productQueue.Enqueue(productId);
Console.WriteLine($"生产了产品 {productId},队列长度: {_productQueue.Count}");
}
// 通知消费者有新产品了
_consumerSignal.Release();
}
}
catch (ThreadInterruptedException)
{
Console.WriteLine("生产者线程被中断,停止生产。");
}
}
// 消费者线程
public static void ConsumerThread()
{
while (true)
{
// 等待生产者发出信号
_consumerSignal.Wait();
int product = -1;
lock (_queueLock)
{
if (_productQueue.Count > 0)
{
product = _productQueue.Dequeue();
}
}
if (product != -1)
{
// 模拟消费耗时
Thread.Sleep(200);
Console.WriteLine($"消费了产品 {product},队列长度: {_productQueue.Count}");
}
}
}
}
| 工具 | 类型 | 用途 | 特点 |
|---|---|---|---|
lock |
语法糖 | 基本互斥锁 | 最简单、最常用,封装了 Monitor |
Monitor |
类 | 互斥锁 | lock 的底层实现,功能更灵活 |
Mutex |
类 | 跨进程互斥锁 | 内核对象,开销大,可用于进程间同步 |
Semaphore |
类 | 限制并发数 | 允许N个线程同时访问资源 |
ReaderWriterLockSlim |
类 | 读写锁 | 读多写少场景的性能优化 |
Interlocked |
类 | 原子操作 | 对单一变量的无锁、高性能操作 |
选择哪种同步工具,完全取决于你的具体场景:是简单的互斥,还是复杂的跨进程协作,或是读写性能的优化,理解它们的工作原理和适用场景,是编写健壮、高效多线程程序的关键。
