c# 多线程并发执行的注意事项

接入与开发

给 C# 开发者梳理多线程并发里最容易踩的锁、共享状态与死锁坑,附排查顺序和落地清单。

写 C# 的时候,单线程跑得好好的代码,一上多线程就开始偶发丢数据、界面卡死、日志对不上,甚至过几天才崩一次。多数不是语言问题,是锁没加对、共享状态没护住、锁的嵌套顺序乱了。这篇不铺理论,按「先定位再改」的顺序,把常见坑、判断标准和排查动作列成清单,你可以对着代码一条条过。

写 C# 的时候,单线程跑得好好的代码,一上多线程就开始偶发丢数据、界面卡死、日志对不上,甚至过几天才崩一次。多数不是语言问题,是锁没加对、共享状态没护住、锁的嵌套顺序乱了。这篇不铺理论,按「先定位再改」的顺序,把常见坑、判断标准和排查动作列成清单,你可以对着代码一条条过。

下面的内容不追求覆盖所有并发模型,只盯三个最容易出事的点:共享状态、锁的边界、死锁的排查。每节后面都给了「什么情况下不用这么干」,避免把简单代码改复杂。

先分清哪些状态真的被共享了

很多人一看到多线程就条件反射加锁,结果把不共享的东西也锁了,代码又慢又难改。正确的第一步是分类:把类里的字段按「只读 / 每次调用新建 / 被多线程改写」标出来,只有第三类才需要保护。局部变量和参数天然不共享,别给它们加锁。

只读字段
初始化后不再变,读它不需要锁,注意别在构造后又去写
每次调用新建
方法内部 new 出来的对象,天然线程私有
被多线程改写
多个线程都可能读或写,这才是需要保护的对象
  • 实例方法里的 this 字段不一定安全:如果同一个对象被注册成单例或塞进静态缓存,它就等同于共享状态
  • 集合类型是重灾区:List、Dictionary 在多线程只读时通常没事,只要有一个线程在写,就必须换成并发集合或整体加锁
  • 静态字段、缓存、连接池、事件订阅列表,是最常见的隐藏共享点,改代码前先搜一遍 static
  • 什么情况下不适用:如果对象生命周期只在一个线程内(比如每次请求 new 出来、不往静态容器里塞),就不用按共享状态处理

锁的粒度与边界:锁住什么、锁多久

锁的问题一半出在锁对象选错,一半出在锁里干了太多事。锁对象要私有且稳定,用专门的 readonly object 字段;别锁 this、别锁 Type、别锁字符串字面量,外部代码可能也在锁同一个东西,你根本控制不了。

写法风险建议
锁 this外部代码锁同一个对象就互相干扰换成私有 object 字段
锁 typeof(X)全局唯一,别的类也锁它换成私有 object 字段
锁字符串字面量字符串驻留,可能被别处锁住换成私有 object 字段
锁里做 IO / 网络请求持锁时间长,吞吐骤降挪到锁外面
锁里 await普通互斥锁不能跨 await 持有用专门的异步锁

锁里只放必须原子的读写。判断标准很简单:如果这段逻辑的中间状态被别人看到也不会出错,就不必进锁;只有「读到一半被改」会出错时才需要。异步场景里普通互斥锁不能跨 await 持有,应该用专门的异步锁,而不是把 .Result 或 .Wait 塞进去硬等。

  1. 找锁对象搜代码里所有 lock 语句,确认锁的是不是私有 object 字段,不是就改掉
  2. 砍锁内工作把 IO、日志写盘、网络请求、事件触发从锁里挪出去,只在锁内改内存状态
  3. 验证边界问自己「这段逻辑中间状态被看到会错吗」,不会错就移出锁;会错才保留

死锁的典型形态与排查顺序

死锁最常见的样子是锁顺序反转:A 方法先锁 X 再锁 Y,B 方法反过来,两个线程各拿一把等另一把,就永远卡住。另一类是持锁调用外部代码,事件回调、虚方法重写、第三方库内部可能回头调你的方法再抢同一把锁,形成隐式嵌套。

  • 约定全局统一的加锁顺序,所有代码都按同一个顺序拿锁,或者干脆合并成一把锁
  • 持锁时不要调用外部代码:事件、回调、可被重写的虚方法,都可能绕回来再抢锁
  • 异步里混用 .Result / .Wait 容易把线程池占满,表现出来像卡死,先看线程数和调用堆栈再判断是不是真死锁
  • 排查顺序:先看是不是单点卡住 → 看堆栈里几把锁的持有关系 → 再回代码找两处加锁顺序不一致的地方

不确定是不是并发问题,最省事的对照实验是把任务改成串行跑一遍:串行不出错、并发出错,基本就是共享状态或锁的问题;串行也出错,那就是普通逻辑 bug,别再往并发方向查了。

改完之后怎么验证,别靠「感觉好像好了」

并发 bug 的特点是偶发,单次跑通不算数。把并发度调高、循环次数加大,让问题更容易复现,跑够轮次再看。给关键路径加上带线程标识的日志,出问题时能还原谁先谁后;日志本身要线程安全,别让日志写坏掩盖真实问题。

验证手段怎么看结果注意点
提高并发度与循环次数多轮运行是否稳定跑一轮通过不能下结论
带线程标识的日志能否还原加锁先后顺序日志写入本身要线程安全
并行压同一段逻辑多次运行结果是否一致结果不一致就还有共享状态没护住
改回串行做对照问题是否消失消失说明方向对,没消失查普通逻辑
  1. 放大复现把线程数和循环次数拉高,连续跑多轮,记录失败出现在第几轮
  2. 加观测日志在加锁前后打带线程 ID 的日志,确认拿锁顺序是否符合预期
  3. 对照实验把并发改成串行再跑一遍,用结果差异判断问题是不是出在并发上
  4. 收尾确认改完后重复上面三步,直到多轮运行结果一致再合并代码

工具和参考数据这类会随时间变的信息,不用在这篇里记死。想了解常见并发库、诊断工具的实际表现,可以到站内的工具目录和评测页面核对当时的数字,别拿旧印象做决策。

下一步如果你正在选并发相关的库或诊断工具,先到工具目录看分类和说明,再到评测页核对当时的实际表现,比凭印象选稳妥。

常见问题

锁住整个方法是不是最省事最安全?

能跑通,但代价是吞吐掉得厉害,而且容易把外部调用也圈进锁里,反而更容易死锁。更稳的做法是先找出真正需要原子的那几行读写,只给这几行加锁,把 IO、日志、网络调用挪到锁外。如果一时分不清,先粗后细地收窄,比一上来就整方法加锁更好调。

偶发、复现不了的问题,怎么判断是不是并发引起的?

几个信号:只在有并发压力的环境出现、日志顺序和时间戳对不上、结果偶尔少一条或重复一条。可以先让同一段逻辑串行跑一轮,如果问题消失,基本就锁定在共享状态或加锁边界上。再配合线程标识日志还原时序,比盯着代码猜快得多。

异步方法里还能用普通锁吗?

能编译通过,但锁里面一旦有 await 就会出问题,锁的持有者可能跨线程切换。异步路径上应该用专门的异步同步原语,或者把需要原子的部分抽成一个不含 await 的同步小段。混着写最容易出现「看着锁了但没锁住」的假象,排查成本很高。

本文依据各平台公开文档与官方公告整理,只做中文归纳;具体做法、参数与限制以官方文档为准。