一、多线程并发冲突的那些事儿
咱们程序员在写C#程序时,经常会遇到多线程并发的问题。比如,你有一个在线购物系统,100个人同时抢购最后一件商品,如果不处理好线程冲突,可能就会超卖——这件商品被卖了100次!这显然不合理。
多线程并发冲突的核心问题是:多个线程同时读写共享资源。比如全局变量、静态字段、文件、数据库记录等。举个简单例子:
// 技术栈:C# (.NET 6)
// 一个典型的线程冲突示例
public class Counter
{
private int _count = 0;
// 多个线程同时调用这个方法会导致计数错误
public void Increment()
{
_count++; // 这里不是原子操作!
}
public int GetCount() => _count;
}
你可能觉得_count++只是一行代码,不会有问题。但实际上,它会被编译成多条CPU指令:读取当前值、加1、写回新值。如果两个线程同时执行这行代码,可能会出现两个线程都读到相同的初始值(比如10),各自加1后写回11,最终结果应该是12,但实际上却是11。
二、解决并发冲突的四大法宝
1. 锁(lock)
锁是最简单粗暴的解决方案,就像厕所门上的"有人"标志,一次只允许一个线程进入。
// 使用lock的线程安全计数器
public class SafeCounter
{
private int _count = 0;
private readonly object _lock = new object(); // 专门用于锁的对象
public void Increment()
{
lock (_lock) // 关键代码加锁
{
_count++;
}
}
public int GetCount()
{
lock (_lock)
{
return _count;
}
}
}
注意事项:
- 锁对象应该是私有的,避免外部代码也能锁它
- 锁的范围要尽量小,否则会影响性能
- 不要锁字符串(因为字符串可能被驻留)
2. 互斥体(Mutex)
Mutex比lock更重量级,可以跨进程使用。比如防止程序多开:
// 使用Mutex确保程序单实例运行
static void Main()
{
using var mutex = new Mutex(true, "MyAppUniqueMutex", out bool createdNew);
if (!createdNew)
{
Console.WriteLine("程序已经在运行了!");
return;
}
// 正常程序逻辑...
}
3. 信号量(Semaphore)
信号量就像游乐园的旋转门,一次允许N个线程通过:
// 限制最多5个线程同时访问资源
private static Semaphore _pool = new Semaphore(5, 5);
void AccessResource()
{
_pool.WaitOne(); // 等待许可
try
{
// 访问共享资源...
}
finally
{
_pool.Release(); // 释放许可
}
}
4. 原子操作(Interlocked)
对于简单的数值操作,使用原子操作性能最好:
// 使用Interlocked实现线程安全计数
public class AtomicCounter
{
private int _count = 0;
public void Increment() => Interlocked.Increment(ref _count);
public int GetCount() => Interlocked.CompareExchange(ref _count, 0, 0);
}
三、高级并发控制技术
1. 读写锁(ReaderWriterLockSlim)
当读多写少时,读写锁可以大幅提升性能:
private ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim();
// 读取数据(允许多个线程同时读)
public string ReadData()
{
_rwLock.EnterReadLock();
try
{
return _data; // 假设_data是共享数据
}
finally
{
_rwLock.ExitReadLock();
}
}
// 修改数据(一次只允许一个线程写)
public void WriteData(string newData)
{
_rwLock.EnterWriteLock();
try
{
_data = newData;
}
finally
{
_rwLock.ExitWriteLock();
}
}
2. 并发集合(Concurrent Collections)
.NET提供了现成的线程安全集合:
// 使用ConcurrentDictionary实现线程安全的缓存
var cache = new ConcurrentDictionary<string, object>();
// 添加或更新缓存项(线程安全)
cache.AddOrUpdate("key",
key => GetExpensiveValue(key), // 如果key不存在,调用这个函数获取值
(key, oldValue) => GetUpdatedValue(key, oldValue)); // 如果key存在,调用这个函数更新值
// 安全读取
if (cache.TryGetValue("key", out var value))
{
// 使用value...
}
四、实战:实现一个线程安全的日志系统
让我们用所学知识实现一个实用的线程安全日志系统:
public class ThreadSafeLogger
{
private readonly ConcurrentQueue<string> _logQueue = new ConcurrentQueue<string>();
private readonly SemaphoreSlim _logSemaphore = new SemaphoreSlim(1);
private readonly string _logFilePath;
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
public ThreadSafeLogger(string logFilePath)
{
_logFilePath = logFilePath;
// 启动后台写入线程
Task.Run(ProcessLogQueue);
}
// 添加日志(线程安全)
public void Log(string message)
{
_logQueue.Enqueue($"{DateTime.Now:yyyy-MM-dd HH:mm:ss} - {message}");
_logSemaphore.Release(); // 通知有新的日志
}
// 后台处理日志队列
private async Task ProcessLogQueue()
{
while (!_cts.IsCancellationRequested)
{
await _logSemaphore.WaitAsync(_cts.Token);
// 批量处理队列中的日志
var batch = new List<string>();
while (_logQueue.TryDequeue(out var logEntry))
{
batch.Add(logEntry);
}
if (batch.Count > 0)
{
await File.AppendAllLinesAsync(_logFilePath, batch);
}
}
}
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}
这个日志系统有三大特点:
- 使用
ConcurrentQueue实现线程安全的日志入队 - 使用
SemaphoreSlim实现高效的通知机制 - 后台线程批量写入磁盘,减少IO操作
五、技术选型与性能考量
选择并发控制技术时,要考虑:
- 锁粒度:锁的范围越小越好
- 访问模式:读多写少用读写锁,频繁小操作用原子操作
- 性能影响:锁会阻塞线程,影响吞吐量
- 死锁风险:避免嵌套锁,按固定顺序获取多个锁
六、常见陷阱与最佳实践
死锁:A等B,B等A,程序卡死
- 解决方案:使用
Monitor.TryEnter设置超时
- 解决方案:使用
锁竞争:太多线程争抢同一个锁
- 解决方案:减小锁粒度,使用无锁数据结构
线程饥饿:某些线程一直得不到执行机会
- 解决方案:使用公平锁,限制最大并发数
记住:多线程调试比单线程难10倍,所以一定要:
- 编写线程安全的单元测试
- 使用
Debug.WriteLine输出线程信息 - 在关键位置添加断言检查
七、总结
处理多线程并发就像管理一个繁忙的餐厅:
- 锁就像包间门锁,一次只允许一桌客人
- 信号量就像大厅座位,控制同时用餐人数
- 原子操作就像自动售货机,无需服务员
在C#中,我们有丰富的工具应对各种并发场景。关键是要理解每种技术的适用场景,避免过度设计。记住:最简单的解决方案往往是最好的。
评论