CodeGym /课程 /C# SELF /用于诊断的最佳实践和工具

用于诊断的最佳实践和工具

C# SELF
第 57 级 , 课程 4
可用

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 提供了针对多线程场景的 专用集合ConcurrentDictionaryConcurrentQueueConcurrentBagBlockingCollection 等。它们内部已经做了保护。

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)

对象在创建后不变的话,从任意线程读取都是安全的。例子:stringTupleDateTime、只读的 DTO 等。

2. 用来诊断多线程问题的工具

同步错误很狡猾:出现得少且不可预测。用工具和方法去捕捉并分析它们。

1. 记录事件和线程日志

记录当前 Thread.ManagedThreadId 和关键操作 —— 是了解“谁什么时候”进入/离开临界区的简单方法。

Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 进入临界区");
// ...
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 离开临界区");

实际应用中使用 Microsoft.Extensions.LoggingNLogSerilog

2. Thread Sanitizer & Race Detector

在 .NET 里没有“完美”的内置 ThreadSanitizer,但有很多有用的工具:

3. Visual Studio Diagnostics Tools

Visual Studio 的分析器能帮你看到:

  • 应用里有哪些线程存在;
  • 线程在哪些地方闲着(waiting);
  • 哪里发生了锁竞争和争用;
  • 什么时候出现 deadlock 和 contention。

抓取跟踪(trace),查看锁使用的详细图。

4. dump 分析和 WinDbg

如果服务器“挂住”了,抓取进程的 dump 并用 WinDbgdotnet-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[只有一个写入,其他等待]

同步原语及其用途

原语 用途 允许多少线程同时通过 跨进程 性能 使用场景
lock (Monitor)
简单的临界区 1 非常高 99% 情况
Mutex
同样的临界区,但跨进程 1 中等 Files, IPC
Semaphore
最多 N 个线程 N 中等 资源池
SemaphoreSlim
同样,但更快,进程内 N 代码中的池
ReaderWriterLockSlim
多读/单写 多个/1 缓存、配置
1
调查/小测验
互斥死锁第 57 级,课程 4
不可用
互斥死锁
多线程常见问题
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION