1. 当异步遇上多线程:危险的共舞
在咖啡馆等位时点单的场景可以帮助理解异步编程:服务员(主线程)接收订单后交给后厨(后台线程)制作,自己继续接待下一位顾客。C#的async/await就像这个高效的协作系统,但当我们多个后厨同时操作同一个调料瓶时(共享资源),就可能发生调料洒落(数据竞争)。
下面这个咖啡订单计数器暴露了典型的线程安全问题:
// 技术栈:C# 10.0 / .NET 6
class UnsafeCoffeeShop {
private int _completedOrders = 0;
// 模拟100个并发订单处理
public async Task ProcessOrders() {
var tasks = Enumerable.Range(1, 100).Select(async _ => {
await Task.Delay(Random.Shared.Next(10)); // 模拟制作耗时
_completedOrders++; // 危险的自增操作
});
await Task.WhenAll(tasks);
Console.WriteLine($"理论值100,实际完成:{_completedOrders}");
}
}
// 调用示例:
await new UnsafeCoffeeShop().ProcessOrders(); // 输出可能为97、95等错误值
这段代码在多次运行中会出现不同结果,因为_completedOrders++
实际上包含"读取-修改-写入"三个步骤,多个线程可能同时读取到相同的初始值。
2. 线程安全防御工事手册
2.1 同步基石:lock关键字
给共享资源加上"使用中"的挂牌是最直观的解决方案:
class SafeCoffeeShopWithLock {
private int _completedOrders = 0;
private readonly object _locker = new object();
public async Task ProcessOrders() {
var tasks = Enumerable.Range(1, 100).Select(async _ => {
await Task.Delay(Random.Shared.Next(10));
lock(_locker) { // 获得专属操作权
_completedOrders++;
}
});
await Task.WhenAll(tasks);
Console.WriteLine($"最终计数:{_completedOrders}"); // 稳定输出100
}
}
需要注意锁对象的选取:
- 避免使用值类型(会触发装箱导致锁失效)
- 推荐专用object实例(不要锁this或字符串)
- 保持最小作用域(只包裹必要代码)
2.2 无锁编程:原子操作
对于简单数值类型,原子操作就像自动上锁的保险箱:
class AtomicCoffeeShop {
private int _completedOrders = 0;
public async Task ProcessOrders() {
var tasks = Enumerable.Range(1, 100).Select(async _ => {
await Task.Delay(Random.Shared.Next(10));
Interlocked.Increment(ref _completedOrders); // 原子自增
});
await Task.WhenAll(tasks);
Console.WriteLine($"原子计数:{_completedOrders}"); // 保证100
}
}
Interlocked类支持Add、Exchange、CompareExchange等操作,适用于计数器、标志位等场景。
2.3 线程安全集合:ConcurrentBag
当需要处理订单集合时,并发集合就像带分拣功能的传送带:
class ConcurrentCoffeeShop {
private ConcurrentBag<Order> _orders = new ConcurrentBag<Order>();
public async Task ProcessOrders(IEnumerable<Order> orders) {
var tasks = orders.Select(async order => {
await Task.Delay(order.PreparationTime);
_orders.Add(order); // 线程安全添加
});
await Task.WhenAll(tasks);
Console.WriteLine($"已处理订单数:{_orders.Count}");
}
}
public record Order(int Id, int PreparationTime);
ConcurrentBag、ConcurrentQueue等集合内部实现了细粒度锁,比手动加锁更高效安全。
3. 高阶防御技巧
3.1 不可变对象:冻结的盾牌
通过冻结对象状态避免修改冲突:
class ImmutableCoffeeShop {
private ImmutableList<Order> _orders = ImmutableList<Order>.Empty;
public async Task ProcessOrder(Order order) {
await Task.Delay(order.PreparationTime);
ImmutableInterlocked.Update(ref _orders, list => list.Add(order));
}
}
每次修改都创建新实例,适合配置信息等读多写少的场景。
3.2 线程局部存储:专属储物柜
使用ThreadLocal为每个线程创建独立存储空间:
class ThreadLocalCoffeeShop {
private ThreadLocal<int> _threadOrders = new ThreadLocal<int>(() => 0);
private int _totalOrders = 0;
public async Task ProcessOrders() {
var tasks = Enumerable.Range(1, 100).Select(async _ => {
await Task.Delay(10);
_threadOrders.Value++; // 线程内独立计数
Interlocked.Add(ref _totalOrders, _threadOrders.Value);
_threadOrders.Value = 0;
});
await Task.WhenAll(tasks);
Console.WriteLine($"总订单数:{_totalOrders}"); // 100
}
}
适用于统计各线程处理量的场景,避免全局锁竞争。
4. 技术选型指南
应用场景矩阵
场景特征 | 推荐方案 | 典型应用 |
---|---|---|
简单数值操作 | Interlocked | 访问计数器、状态标志 |
复杂对象操作 | lock关键字 | 订单处理、库存管理 |
高频写入集合 | ConcurrentQueue | 消息队列、日志缓冲 |
配置类数据 | 不可变对象 | 运行参数、全局设置 |
线程独立数据 | ThreadLocal | 请求上下文、临时缓存 |
技术方案优缺点对比
lock关键字
- 👍 直观简单,适用性广
- 👎 过度使用会导致性能下降,可能引发死锁
并发集合
- 👍 内置优化,开发效率高
- 👎 内存开销较大,API有学习成本
不可变对象
- 👍 天然线程安全,易于推理
- 👎 频繁修改时性能较差
5. 避坑指南与最佳实践
- 锁粒度控制:就像不要为了保管一支笔而锁住整个办公室,尽量缩小锁的作用域
- 死锁预防:确保多个锁的获取顺序一致,使用Monitor.TryEnter设置超时
- 上下文意识:在UI线程中避免阻塞调用,ConfigureAwait(false)可减少上下文切换
- 性能监控:使用ConcurrentExclusiveSchedulerPair优化任务调度
- 调试工具:Parallel Watch窗口和Thread可视化工具是调试利器
6. 总结与展望
在异步编程的世界中,线程安全就像交通信号灯,虽然增加了些许约束,但保障了整个系统的有序运行。随着C#语言的发展,诸如Channels(System.Threading.Channels)等新特性为生产者-消费者模式提供了更优雅的解决方案,Records的不可变性也为线程安全设计注入新活力。开发者应当根据具体场景选择合适的武器,在安全与性能之间找到最佳平衡点。