1. 稳健多线程代码的秘诀
把多线程想象成几个人一起修车——只要有人去拿别人的工具,修车就停了。同样地,对共享数据的不小心会导致隐藏的错误,往往只在“实战”中暴露。
本讲说明如何写不会像纸牌屋一样塌掉的多线程代码。还会讲,如果真出问题,哪些工具能帮你查原因。
1. 把临界区 (lock) 最小化
lock 里面的代码越少越好。一个线程拿着锁时,其他线程都在等。
示例:
// 不好:所有业务逻辑都在 lock 里 — 所有线程都在等
lock(_locker)
{
// 耗时操作(与共享资源无关)
Thread.Sleep(500);
counter++;
}
// 好:只在 lock 里做必要的操作
// 把耗时工作放到 lock 外面
Thread.Sleep(500);
lock(_locker)
{
counter++;
}
现实情况:如果锁里有网络调用或耗时计算,性能会瞬间掉下来。
2. 别用通用对象当锁
写 lock(this) 或 lock(typeof(MyClass)) 是个坏主意。
为啥? 如果别的库或代码也用同一个对象做锁,会导致死锁或怪异 bug。总是用单独的私有对象:
private readonly object _locker = new object();
lock(_locker)
{
// 你的操作
}
禁止:string 字符串、public 字段、值类型对象。
3. 抓了资源就用 try...finally 释放
任何抓取 Mutex、信号量、ReaderWriterLockSlim 的地方都要在 finally 里释放。
_mutex.WaitOne();
try
{
// 临界区
}
finally
{
_mutex.ReleaseMutex();
}
4. 不要滥用同步
只对真正的共享资源(比如集合)做同步,不要对“每个小动作”都加锁。过多的锁会把代码变成等待队列。
5. 使用线程安全的集合和类型
.NET 提供了针对多线程场景的 专用集合:ConcurrentDictionary、ConcurrentQueue、ConcurrentBag、BlockingCollection 等。它们内部已经做了保护。
using System.Collections.Concurrent;
ConcurrentDictionary<int, string> users = new ConcurrentDictionary<int, string>();
users.TryAdd(1, "瓦夏");
users[2] = "彼佳";
6. 小心 deadlock(互相等待)
常见陷阱是按不同顺序抓多个锁。
// 线程 1
lock(obj1)
{
lock(obj2)
{
// 干点事
}
}
// 线程 2
lock(obj2)
{
lock(obj1)
{
// 干点事
}
}
建议:在所有线程中按相同顺序获取锁。
7. 有可能就用不可变状态 (immutable state)
对象在创建后不变的话,从任意线程读取都是安全的。例子:string、Tuple、DateTime、只读的 DTO 等。
2. 用来诊断多线程问题的工具
同步错误很狡猾:出现得少且不可预测。用工具和方法去捕捉并分析它们。
1. 记录事件和线程日志
记录当前 Thread.ManagedThreadId 和关键操作 —— 是了解“谁什么时候”进入/离开临界区的简单方法。
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 进入临界区");
// ...
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 离开临界区");
实际应用中使用 Microsoft.Extensions.Logging、NLog、Serilog。
2. Thread Sanitizer & Race Detector
在 .NET 里没有“完美”的内置 ThreadSanitizer,但有很多有用的工具:
- JetBrains ReSharper — 检查可以抓到一部分危险模式。
- Roslyn Analyzers — 静态代码分析。
- Concurrency Visualizer — 分析等待/锁竞争和线程负载。
3. Visual Studio Diagnostics Tools
Visual Studio 的分析器能帮你看到:
- 应用里有哪些线程存在;
- 线程在哪些地方闲着(waiting);
- 哪里发生了锁竞争和争用;
- 什么时候出现 deadlock 和 contention。
抓取跟踪(trace),查看锁使用的详细图。
4. dump 分析和 WinDbg
如果服务器“挂住”了,抓取进程的 dump 并用 WinDbg 或 dotnet-dump 打开。通过调用栈可以看到线程卡在哪里,谁持有哪些锁。
栈分析示例:
0:000> !syncblk
Index SyncBlock MonitorHeld Recursion Owning Thread Info SyncBlock Owner
1 000001d4b6f90e08 1 1 000001d4b5c941c0 000001d4b6f03458
(通常只有“有经验的部署高手”会去看 dump —— 别怕,这是个很强力的工具。)
5. 压力测试(stress testing)和单元测试
并行运行多线程代码数百/数千次迭代 —— 可以更容易地发现稀有的竞态。
[Test]
public void Counter_IsThreadSafe()
{
var counter = 0;
var locker = new object();
var tasks = new List<Task>();
for (int i = 0; i < 100; i++)
{
tasks.Add(Task.Run(() =>
{
for (int j = 0; j < 10000; j++)
{
lock (locker)
{
counter++;
}
}
}));
}
Task.WaitAll(tasks.ToArray());
Assert.AreEqual(100 * 10000, counter);
}
6. 使用断言和专门检查
在调试时加一些检查来保证状态正确。例如,尝试重复获取同一资源时用 Debug.Assert。
3. 结论与建议
可视化示意:危险区和安全区
graph TD
A[共享资源] -- 无同步 --> B(竞态条件)
A -- 加锁 (lock/Mutex) --> C[安全访问:临界区]
C -- "锁太多" --> D(性能下降)
A -- ReaderWriterLockSlim --> E{多读 / 单写}
E -- "读取" --> F[多个线程同时读取]
E -- "写入" --> G[只有一个写入,其他等待]
同步原语及其用途
| 原语 | 用途 | 允许多少线程同时通过 | 跨进程 | 性能 | 使用场景 |
|---|---|---|---|---|---|
|
简单的临界区 | 1 | 否 | 非常高 | 99% 情况 |
|
同样的临界区,但跨进程 | 1 | 是 | 中等 | Files, IPC |
|
最多 N 个线程 | N | 是 | 中等 | 资源池 |
|
同样,但更快,进程内 | N | 否 | 高 | 代码中的池 |
|
多读/单写 | 多个/1 | 否 | 高 | 缓存、配置 |
GO TO FULL VERSION