一、为什么需要关心线程同步?
想象一下,你正在管理一个超市的收银台。如果所有顾客都挤在同一个收银台前,没有排队规则,那会是什么场景?这就是多线程程序中没有同步机制时的样子——混乱、错误,甚至崩溃。
在C#中,当我们使用多个线程同时操作同一个数据时,就会出现类似的资源竞争问题。比如,多个线程同时往一个银行账户里存钱,如果没有正确的同步机制,最后的余额很可能是错误的。
// 技术栈:C# .NET 6
// 一个典型的资源竞争示例
class UnsafeAccount
{
private int balance = 1000;
public void Withdraw(int amount)
{
// 这里存在竞态条件
if (balance >= amount)
{
Thread.Sleep(10); // 模拟处理延迟
balance -= amount;
Console.WriteLine($"取款{amount}成功,余额:{balance}");
}
}
}
// 使用时可能出现问题:
var account = new UnsafeAccount();
var threads = new List<Thread>();
for (int i = 0; i < 10; i++)
{
threads.Add(new Thread(() => account.Withdraw(100)));
}
threads.ForEach(t => t.Start());
// 可能输出多个"取款成功",导致余额变为负数
二、C#中的同步工具包
C#为我们提供了多种线程同步的工具,就像超市里的排队栏杆、叫号系统一样,每种工具适合不同的场景。
1. lock语句 - 最简单的选择
// 使用lock解决资源竞争
class SafeAccount
{
private readonly object balanceLock = new object();
private int balance = 1000;
public void Withdraw(int amount)
{
lock (balanceLock) // 关键代码加锁
{
if (balance >= amount)
{
Thread.Sleep(10);
balance -= amount;
Console.WriteLine($"取款{amount}成功,余额:{balance}");
}
}
}
}
lock是最简单直接的同步方式,它确保同一时间只有一个线程能进入被锁定的代码块。但要注意:
- 锁对象应该是私有的,避免外部代码也能锁定它
- 锁的范围要尽可能小,避免不必要的性能损失
- 避免嵌套锁,容易导致死锁
2. Monitor类 - lock的底层实现
// Monitor的等效实现
class MonitorAccount
{
private readonly object balanceLock = new object();
private int balance = 1000;
public void Withdraw(int amount)
{
Monitor.Enter(balanceLock); // 获取锁
try
{
if (balance >= amount)
{
Thread.Sleep(10);
balance -= amount;
Console.WriteLine($"取款{amount}成功,余额:{balance}");
}
}
finally
{
Monitor.Exit(balanceLock); // 确保锁被释放
}
}
}
Monitor提供了比lock更灵活的控制,比如TryEnter可以设置超时时间,避免无限等待。
3. Mutex - 跨进程的锁
当你的程序需要在多个进程间同步时,Mutex就派上用场了。
// 使用Mutex进行跨进程同步
class MutexExample
{
private static Mutex mutex = new Mutex(false, "Global\\MyAppMutex");
public void ProcessData()
{
if (mutex.WaitOne(1000)) // 等待最多1秒
{
try
{
// 临界区代码
Console.WriteLine("处理数据中...");
Thread.Sleep(2000);
}
finally
{
mutex.ReleaseMutex();
}
}
else
{
Console.WriteLine("获取锁超时");
}
}
}
三、更高级的同步机制
1. Semaphore - 控制并发数量
想象一个停车场,只有有限的车位。Semaphore就是这样的机制。
// 使用Semaphore限制并发数
class ParkingLot
{
private static Semaphore semaphore = new Semaphore(3, 3); // 3个车位
public void ParkCar(string carId)
{
Console.WriteLine($"{carId} 等待进入停车场...");
semaphore.WaitOne();
try
{
Console.WriteLine($"{carId} 已进入停车场");
Thread.Sleep(2000); // 模拟停车时间
}
finally
{
semaphore.Release();
Console.WriteLine($"{carId} 已离开停车场");
}
}
}
// 使用示例:
var parkingLot = new ParkingLot();
for (int i = 1; i <= 5; i++)
{
new Thread(() => parkingLot.ParkCar($"车{i}")).Start();
}
2. ReaderWriterLockSlim - 读写分离的锁
对于读多写少的场景,这种锁能显著提高性能。
// 使用ReaderWriterLockSlim优化读写操作
class CacheService
{
private readonly ReaderWriterLockSlim cacheLock = new ReaderWriterLockSlim();
private Dictionary<string, string> cache = new Dictionary<string, string>();
public string Read(string key)
{
cacheLock.EnterReadLock();
try
{
if (cache.TryGetValue(key, out var value))
return value;
return null;
}
finally
{
cacheLock.ExitReadLock();
}
}
public void Write(string key, string value)
{
cacheLock.EnterWriteLock();
try
{
cache[key] = value;
}
finally
{
cacheLock.ExitWriteLock();
}
}
}
四、异步编程中的同步问题
现代C#开发中,async/await模式非常普遍,但同步问题依然存在。
// 异步方法中的同步问题
class AsyncAccount
{
private int balance = 1000;
private readonly object balanceLock = new object();
public async Task WithdrawAsync(int amount)
{
await Task.Delay(100); // 模拟异步操作
lock (balanceLock) // 仍然需要同步
{
if (balance >= amount)
{
balance -= amount;
Console.WriteLine($"取款{amount}成功,余额:{balance}");
}
}
}
}
注意:在异步方法中使用lock时要小心,因为lock会阻塞线程。对于高频的异步同步场景,可以考虑使用SemaphoreSlim:
class AsyncAccountImproved
{
private int balance = 1000;
private readonly SemaphoreSlim semaphore = new SemaphoreSlim(1, 1);
public async Task WithdrawAsync(int amount)
{
await semaphore.WaitAsync(); // 异步等待
try
{
if (balance >= amount)
{
await Task.Delay(100); // 模拟异步操作
balance -= amount;
Console.WriteLine($"取款{amount}成功,余额:{balance}");
}
}
finally
{
semaphore.Release();
}
}
}
五、常见陷阱与最佳实践
死锁:当两个或多个线程互相等待对方释放锁时发生
- 避免方法:按固定顺序获取多个锁,或使用Monitor.TryEnter设置超时
锁粒度过大:锁住太多代码会降低并发性能
- 最佳实践:只锁住真正需要同步的最小代码块
忘记释放锁:这会导致其他线程永远等待
- 最佳实践:使用using语句或try-finally确保锁被释放
过度同步:不是所有共享数据都需要同步
- 最佳实践:识别真正的竞态条件,避免不必要的同步
// 死锁示例(不要在实际代码中这样做!)
class DeadlockExample
{
private readonly object lock1 = new object();
private readonly object lock2 = new object();
public void Method1()
{
lock (lock1)
{
Thread.Sleep(100);
lock (lock2) // 可能在这里死锁
{
Console.WriteLine("Method1完成");
}
}
}
public void Method2()
{
lock (lock2)
{
Thread.Sleep(100);
lock (lock1) // 可能在这里死锁
{
Console.WriteLine("Method2完成");
}
}
}
}
六、如何选择合适的同步机制
选择同步机制时,考虑以下因素:
- 性能需求:读写锁在读多写少时性能更好
- 并发程度:Semaphore适合限制并发数量
- 跨进程需求:Mutex适用于进程间同步
- 异步环境:SemaphoreSlim更适合async/await
- 复杂性:简单的lock通常是最易维护的选择
记住:最简单的解决方案往往是最好的。只有在确实需要时才使用更复杂的同步机制。
七、实际应用场景分析
- 缓存系统:使用ReaderWriterLockSlim优化读写性能
- 资源池:使用Semaphore管理有限资源(如数据库连接)
- 全局配置:使用lock保护共享配置数据
- 订单处理:使用Monitor.TryEnter避免死锁
- 日志系统:使用简单的lock确保日志写入顺序
// 实际示例:线程安全的日志系统
public static class ThreadSafeLogger
{
private static readonly object lockObj = new object();
private static StreamWriter logFile;
static ThreadSafeLogger()
{
logFile = new StreamWriter("app.log", append: true);
}
public static void Log(string message)
{
lock (lockObj)
{
logFile.WriteLine($"{DateTime.Now}: {message}");
logFile.Flush();
}
}
public static void Close()
{
lock (lockObj)
{
logFile?.Close();
}
}
}
八、总结与建议
多线程编程就像指挥一个交响乐团,每个线程就是一个乐手,而同步机制就是指挥棒。没有好的指挥,再优秀的乐手也会奏出不和谐的音符。
关键要点:
- 识别真正的共享资源和竞态条件
- 从最简单的lock开始,只在需要时使用更复杂的机制
- 始终确保锁被正确释放
- 在异步环境中考虑使用SemaphoreSlim
- 避免常见的陷阱,如死锁和过度同步
记住,线程同步不是可有可无的装饰品,而是保证程序正确性的必需品。花时间正确实现同步机制,可以避免许多难以调试的问题。
评论