死锁是必然发生的资源循环等待问题,只要多锁获取顺序不一致就会触发;应使用Monitor.TryEnter设超时避免永久阻塞,锁对象须可控唯一,临界区须剔除I/O等耗时操作。

死锁不是“能不能避免”的问题,而是“不按规范写就一定会踩中”的坑。 你用 lock 或 Monitor 时只要出现多把锁、且获取顺序不一致,上线后早晚触发——它不会报错,只会让线程卡死在某个 lock 语句里,CPU 占用低、日志无异常、接口超时,排查起来像在黑盒里摸钥匙。
为什么 lock(lockA) { lock(lockB) } 和 lock(lockB) { lock(lockA) } 必然死锁
这不是 C# 特性,是资源循环等待的必然结果。线程 A 拿着 lockA 等 lockB,线程 B 拿着 lockB 等 lockA,双方都不释放已持锁,也不推进,系统就僵住。
- 四个死锁必要条件中,“循环等待”在这里被直接满足,其他三个(互斥、持有并等待、不可剥夺)
lock默认全符合 - Visual Studio 的“并行堆栈”窗口能一眼看出两个线程都停在
Monitor.Enter调用上,调用栈末尾分别是lock(lockA)和lock(lockB) - 用
dotnet-dump analyze查看线程状态,会看到多个线程处于WaitSleepJoin状态,且!syncblk显示它们正等待同一个对象的 Monitor
Monitor.TryEnter 是唯一能“及时止损”的方案
lock 是阻塞式获取,一旦卡住就无限等;而 Monitor.TryEnter 允许你设超时,失败就放弃,避免线程永久挂起。
- 必须显式传入
TimeSpan,例如Monitor.TryEnter(lockObj, TimeSpan.FromMilliseconds(50)),不带超时参数的重载等同于无限等待 - 返回
false时,一定要做兜底处理(如重试、降级、记录告警),不能直接吞掉或抛异常中断流程 - 不要把它当“锁优化”用:在高频路径上频繁
TryEnter失败再重试,可能比老老实实排队还伤性能 - 编译器不会把
lock自动转成TryEnter,这是纯手动防御手段
锁对象选 object 还是 this?哪些绝对不能当锁
锁对象的核心要求是:**可控、唯一、生命周期匹配业务范围**。
- 推荐用
private static readonly object _locker = new object():静态 + readonly 保证全局单例,且不会被意外赋值覆盖 - 严禁用字符串字面量(如
lock("mykey")):.NET 字符串驻留机制会让不同类里的相同字符串指向同一内存地址,导致跨模块锁冲突 - 慎用
this:如果类实例可能被外部传参、序列化或作为 public 属性暴露,别人也能对它加锁,等于把锁钥匙交出去 - 避免用值类型(如
int、struct):装箱后每次生成新对象,锁失效;也不要用typeof(MyClass),反射对象在热重载下可能不稳定
临界区代码越短越好,但“短”不等于“快”
很多人以为只要把耗时操作移出 lock 块就安全了,其实关键在于“是否依赖锁内状态”。比如库存扣减,读取当前值、判断是否足够、执行扣减、更新 DB —— 这四步中,只要前两步不在锁里,就仍可能超卖。
- 真正要缩的是“持有锁的时间”,不是“逻辑行数”:DB 查询、HTTP 调用、文件 IO 绝对不能放在
lock里 - 如果必须做 I/O,先在锁内完成状态判断和本地变更(如
if (stock > 0) stock--;),再解锁后异步提交 DB - 曾有项目把日志写入放锁内,结果磁盘慢导致锁平均等待 800ms,吞吐暴跌 70%——日志该走队列或异步批写
- 用
Stopwatch监控锁内耗时,超过 10ms 就要警惕,20ms 以上基本说明设计有问题
最危险的不是不知道怎么加锁,而是加了锁却没意识到自己正在制造一个定时死锁。线上环境线程数、请求节奏、数据分布都会放大竞态,测试很难覆盖。所以规范不是为了“看起来专业”,而是把不确定的等待,变成确定的失败或确定的顺序。


















