多线程实务最佳实践 .NET 篇 ── 在增加线程之前应先确定的事

· · Windows, 多线程, C#, .NET, 业务应用, 故障调查, 设计

「因为处理太慢,立了线程做并行处理,结果汇总结果偶尔会出现偏差」「加了后台处理之后,应用每个月都会固定卡死一次」「明明说调试运行时无法重现,但在客户那边却确实发生了」── 多线程编程的可怕之处在于,代码写完之后看起来运行得完全正确。竞争产生的 bug 依赖于时序,会悄悄躲过测试,只在生产环境中露面。

另一方面,如今多核已是理所当然,业务应用中「不冻结 UI 的同时执行繁重处理」「并行处理多台设备或多个文件」之类的需求,也确实让人无法回避多线程。重要的是,在增加线程之前先确定设计原则。多线程的 bug 不是靠调试来消灭的,而是要靠设计从根本上消除其滋生的空间。

本文是多线程实务系列的 .NET 篇。面向在 Windows 上开发业务应用、需要引入多线程化的开发者,基于截至 2026 年 8 月的一手信息,整理与语言、操作系统无关的通用设计原则,以及在 C#/.NET 中的具体工具。原则本身在 Linux 或 C++ 中同样成立。若使用原生代码编写,可参考将同一原则落实到各语言工具上的「C++ 篇」「C 语言篇」;若使用 Java 编写,请参考「Java 篇」。

1. 先说结论

  • 不自行创建线程,是第一条最佳实践。不使用 new Thread,而是依托 Task、线程池、Parallel 类等更高层的 API,把线程数量的管理交给运行时。12
  • 并行化时首先应该削减的是「共享的可变状态」。多个线程写入同一变量的地方正是竞争产生的源头,与其先用锁来保护,不如先通过数据的拆分、不可变化、传递来减少共享本身。3
  • 让锁具备纪律。明确「哪把锁保护哪份数据」的一一对应关系,锁对象要使用不对外暴露的专用对象。禁止使用 lock(this)lock(typeof(X))。.NET 9 以后使用专用的 System.Threading.Lock 类型。4
  • 线程间的数据传递交给队列。使用 System.Threading.Channels 或并发集合构建生产者/消费者结构,比起到处布设锁,设计更简单,边界也更清晰。56
  • 停止方式要在一开始就设计好。停止唯一正确的做法是通过 CancellationToken 实现协作式取消,Thread.Abort 在 .NET(Core 系列)中会在运行时抛出异常。78
  • UI 是 UI 线程的专属物。无论是 WinForms 的控件还是 WPF 的元素,都不能从创建它的线程以外的地方触碰。要从其他线程操作,必须通过 Control.Invoke / Dispatcher 来委托。910
  • 「并行了就一定快」并不成立。单次工作量很小的循环,反而会因并行化的开销而变慢。必须先实测再采用。3

2. 为什么多线程很难 ── 竞争状态与死锁

多线程带来的问题,归根结底只有两种。4

竞争状态(race condition)是指多个线程到达某段特定代码的先后顺序不同,会导致结果不同的一类 bug。最典型的例子就是共享计数器的自增:count++ 这一行代码实际上分为「读取 → 加一 → 写回」三个步骤。当两个线程同时执行这三个步骤时,其中一方的加一结果会被另一方的写回覆盖,导致一次自增丢失。每次执行结果都会变化,而且无法预测最终会得到哪种结果。4

线程B共享变量 count线程A线程B共享变量 count线程Acount = 10明明加了两次,count 却仍是 11线程A的加一丢失了读取(10)读取(10)本地加一(11)本地加一(11)写回(11)写回(11)

图1:共享计数器中加法丢失的典型竞争状态。在 count++ 的三个步骤之间,只要有另一个线程插入,后写回的一方就会覆盖前者

死锁是指两个线程互相等待对方持有的锁,谁都无法继续前进的状态。线程A持有锁1、等待锁2,线程B持有锁2、等待锁1 ── 仅凭这一点,两者就会永远停滞。4

等待锁2释放等待锁1释放线程A持有锁1线程B持有锁2

图2:死锁的循环等待。等待箭头一旦形成环,环中的所有线程就会永远停止

麻烦的是,这两者都依赖时序。在开发机上几万次才会中一次的交错执行(执行顺序的组合),在核心数和时序都不同的客户机器上每天都发生,这种情况并不罕见。「接上调试器就不再重现」「加了日志就消失了」也是因为观测本身改变了时序,这正是竞争 bug 的典型表现。

正因如此,接下来的所有原则都指向同一个方向。比起「正确地同步」,更优先的是「减少需要同步的地方」 ── 这就是多线程设计的脊梁。

3. 原则1:不自行创建线程

3.1. 依托 Task 与线程池

直接用 new Thread(...) 创建线程的写法,在今天的 .NET 中已属例外性的最后手段。自 .NET Framework 4 起,多线程・并行代码推荐使用的手段就是 TPL(Task Parallel Library),即以 Task 为核心的一系列 API。TPL 会根据可用处理器动态调整并行度,把工作拆分、调度到线程池、取消处理、状态管理等底层的麻烦事全部承担下来。1

线程池是 .NET 自身广泛用于 Task 的执行、异步 I/O 的完成、定时器回调等各方面的基础设施,只要投递的是短小的工作,开发者就不需要管理线程的生命周期。2

// 把耗费 CPU 的繁重处理放到后台执行
var result = await Task.Run(() => HeavyCalculation(input));

// 并行运行多个相互独立的处理,并全部等待完成(数量较少时)
// ※ 这种写法适用于 ProcessAsync 是以 I/O 为主的异步方法的场景。
//   WhenAll 只是「等待已经在运行的 Task」,如果想并行执行 CPU 计算,
//   需要用 Task.Run(() => Calc(x)) 把各处理包起来,投递到线程池
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));

// 数量较多时,给同时执行数设置上限后再流转
await Parallel.ForEachAsync(items,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
    async (x, ct) => await ProcessAsync(x, ct));
// 要点有两个:把调用方的令牌接到 ParallelOptions 上
// (忘记这一步的话,主体里的 ct 就永远是 None),以及把这个 ct 也传给主体(不要丢弃)

有一点需要注意。Task.WhenAll(items.Select(...)) 会在被枚举的那一刻同时启动全部元素的处理。对于数件到数十件规模、数量固定的工作而言没有问题,但如果用于数量较多的集合,就会一次性耗尽套接字、数据库连接、内存。数量不可预知的处理,应像上面的 Parallel.ForEachAsync 那样给同时执行数设置上限,或用后文介绍的有界(bounded)通道来控制流量。

自行创建线程只有在「拥有专用的消息循环」「需要指定线程单元模型(STA)」「在应用的整个生命周期内持续运行」等场景中,因线程本身的特性成为需求的一部分时,才具有正当性。

3.2. 数据并行用 Parallel.For / ForEach

对于「对集合的每个元素执行相同处理,想让整体变快」这类数据并行需求,不要自己把循环拆分到各线程,而应使用 Parallel.For / Parallel.ForEach。数据源的拆分(分区)和负载的再分配由 TPL 负责,基本形式的循环甚至不需要锁。11

不过,官方文档明确指出了两个陷阱。3

  • 不要以为并行总是更快。迭代次数较少,或者单次处理很轻量的循环,并行化的开销会超过主体本身,反而变慢。性能受多种因素影响,必须实测后再判断。
  • 不要让各次迭代互相等待。Parallel.For 的各次迭代并不保证真正并行执行。如果代码让某次迭代去等待另一次迭代设置事件,就有可能因调度方式而发生死锁。

3.3. 「等待」类处理不用线程,而用异步 I/O

文件、网络、数据库等以等待 I/O 为主的处理,不是应该增加线程的对象。在等待期间占用一整个线程只是浪费,而使用 async/await 的异步 I/O 在等待期间不会消耗线程。这种取舍(CPU 密集型走并行化、I/O 密集型走异步化)是多线程设计入口处最先应该划清的界线。

以等待 I/O 为主文件・网络・数据库消耗 CPU 的计算对集合的全部元素应用相同处理独立的一整块后台处理消息循环或指定STA等线程本身的特性是需求有想要并行处理的工作处理的主体是?async/await 的异步I/O不增加线程工作的形态?Parallel.For / ForEachTask.Run / Task.WhenAllnew Thread(例外性的最后手段)

图3:「立线程」之前的分支。绝大多数业务处理会落在上面三个出口之一,只有例外情况才会走到 new Thread

async/await 的实务判断可参见「C# async/await 实战判断表」,而线程池与异步 I/O 在底层如何衔接,则在「IOCP 与 .NET 线程池」中有详细说明。

4. 原则2:将共享可变状态降到最低

竞争只会在「多个线程」与「共享的可变数据」同时具备时发生。线程的数量由需求决定,因此设计上能削减的是共享这一侧。手段有三种。

4.1. 拆分 ── 让每个线程只碰自己的数据

最简单也最有效的方法,是把数据按线程拆开。以并行循环的汇总为例,与其每次都写入共享的合计变量,不如使用 Parallel.For 中接收线程本地状态的重载,让每个线程在本地累积小计,最后只合并一次。共享写入的次数会从「每次迭代都写」减少到「每个线程一次」,同步成本和竞争窗口都会随之大幅缩小。3

long total = 0;
Parallel.For(0, items.Length,
    () => 0L,                                  // 线程本地的初始值
    (i, state, local) => local + Weigh(items[i]), // 每次迭代只累加到自己的local
    local => Interlocked.Add(ref total, local));  // 合并每个线程只发生一次
数据数组(处理对象)线程1处理自己负责的部分只累加到本地小计线程2处理自己负责的部分只累加到本地小计线程3处理自己负责的部分只累加到本地小计合并: 用 Interlocked.Add每个线程只反映到总计一次

图4:线程本地汇总。处理过程中每个线程只碰自己的数据,因此没有竞争的余地,共享写入只在合并时按线程各发生一次

4.2. 不可变化 ── 不会被改写的东西可以共享

只被读取的数据,无论多少线程同时读取都是安全的。配置值、主数据、计算的输入等,只要在构建完成后不再改写(做成不可变),就可以在无需同步的情况下自由共享。在 C# 中,record 类型和 init 属性能够支持这种设计。只要定下「需要变更时不改写,而是创建新实例来替换」这一规则,就能少一份需要守护的可变状态。

不过「看起来是只读的」和「真正不可变」是两回事。像 IReadOnlyList<T> 这样的只读接口,只是「通过该接口无法改写」,并不能阻止背后的 List<T> 被其他引用改写。record / init 的保证同样是浅层的,无法守护属性所引用的对象本身。要在线程之间真正安全地共享数据,应使用 System.Collections.Immutable 中的不可变集合(如 ImmutableArray<T>),或者在共享时传递一份副本,从根本上切断改写的路径。此时要素类型 T 本身也必须是不可变的这一条件不可缺少。不可变集合所守护的只是「排列」,可变的元素对象仍然按原样共享其引用,一旦从其他路径改写了元素内部的内容,竞争依然存在。要么把对象图的末端也做成不可变,要么传递深拷贝。

4.3. 传递 ── 不共享,而用队列发送

即便如此,线程之间仍然需要传递数据。这时不应「让两侧都去碰同一个共享变量」,而应采用一方写、一方读的队列居中的生产者/消费者结构。

在 .NET 中的首选是 System.Threading.Channels。它是一个由生产者异步写入、消费者异步读出的 FIFO,同步方面的一切麻烦都由通道自行管理。5

var channel = Channel.CreateBounded<WorkItem>(100); // 容量100,形成背压

// 生产者侧
await channel.Writer.WriteAsync(item, ct); // 满了就等到有空位为止

// …所有生产者都写完之后:
channel.Writer.Complete();   // 声明「不会再有数据了」。没有这一步,读取端的循环无法结束

// 消费者侧
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
    Process(item);
}
满了就让写入方等待(背压)空了就让读取方等待生产者1WriteAsync有界通道(容量100)FIFO队列同步由通道管理生产者2WriteAsync消费者1ReadAllAsync消费者2ReadAllAsync

图5:夹着通道的生产者/消费者结构。两侧都不直接触碰共享变量,等待与容量控制全都交给通道

实务中重要的是要选择容量有上限(bounded)的通道。达到上限时的默认行为是「写入方等待空位」,这自然形成了背压(back pressure)。如果生产速度快于消费速度的结构使用了无上限的队列,那就会变成表面上在正常运行、但内存会不断增长的定时炸弹。5

在同步的世界里扮演与 bounded 通道相同角色的,是指定了容量的 BlockingCollection<T>。它兼具容量限制(防止生产方过度超前于消费方)与阻塞机制(为空时让消费方阻塞等待)。12ConcurrentQueue<T> / ConcurrentStack<T> 则是不使用锁、仅靠 Interlocked 操作实现线程安全的高速集合6,但它们既没有容量限制,也没有「空了就等待」的机制,只是纯粹的线程安全队列。应把它们当作零件而非工作传递的主角。另外,BlockingCollection<T> 的设计并不面向异步访问,如果要与 async/await 组合使用,应选择 Channel<T>12

还需注意一个误区:「把字典换成 ConcurrentDictionary 就等于线程安全了」这种想法并不成立。即使各个操作本身是线程安全的,「先检查是否存在再添加」这类复合操作仍然会发生竞争(应使用 GetOrAdd 等专为复合操作设计的方法)。而 GetOrAdd 本身也有附加说明:虽然最终存入的值只会有一个,但用于创建值的工厂函数在发生竞争时可能会被调用多次。如果在工厂函数中加入副作用(打开连接、创建文件等),重复执行就会造成泄漏,因此应使工厂函数保持无副作用,或者对于必须确保只执行一次的初始化,改为把 Lazy<T> 存入值中。更换集合的类型,并不能替代减少共享可变状态本身。

5. 原则3:让锁具备纪律

即使减少了共享可变状态,多数情况下也无法归零。对于剩下的共享部分需要使用排他控制(锁),但锁并不是「把可疑的地方姑且用 lock 圈起来」的工具。纪律有四条。

5.1. 先确定「要保护什么」,再用专用对象加锁

锁的单位应以「数据」而非「代码区间」来考量。为想要保护的每一组可变数据对应一个锁对象,在触碰该数据的所有地方都取同一把锁 ── 竞争 bug 的实质,往往就是这张对照表崩坏了。

作为锁对象的实例应使用不对外公开的专用实例。lock(this) 会与能够引用自身实例的外部代码共享锁,lock(typeof(X)) 则会与整个应用程序域共享锁,两者都是滋生死锁的温床。在 .NET 9 / C# 13 及以后版本中,推荐用专用类型 System.Threading.Lock 的实例作为锁对象。4

public class OrderBook
{
    private readonly Lock _gate = new();          // .NET 9+(更早版本用 readonly object)
    private readonly List<Order> _orders = [];    // _gate 所保护的数据

    public void Add(Order order)
    {
        lock (_gate) { _orders.Add(order); }
    }
}

C# 的 lock 语句能保证即使发生异常也一定会释放锁。展开方式会因锁对象的类型而不同:普通对象会展开为在 finally 中调用 Monitor.Exit 的形式,而 Lock 类型则会展开为使用 EnterScope() 及其释放的形式。413 也就是说,Lock 类型的字段和 Monitor 是不同的机制,如果只有部分代码手写 Monitor.Enter(_gate),就无法与 lock (_gate) 形成互斥。无论使用哪种类型,都不要手写 Monitor.Enter / Exit,而应统一使用 lock 语法,这样才安全。4

5.2. 持锁期间不做「耗时的事」「涉及外部的事」

持有锁的时间越短越好,锁内应只做被保护数据的读写。持锁期间执行 I/O,或通过事件/回调调用外部代码,这类写法不仅会延长持锁时间,还会为「被调用方试图取另一把锁而导致死锁」开辟路径。基本形式是:在锁外做好准备,在锁内只做替换。

另外,lock 内部不能使用 await(会编译报错)。这不是限制,而是一种保护 ── Monitor 具有「持锁线程必须自己释放」的线程亲和性,这与 await 前后可能切换执行线程的异步代码无法共存。异步代码中的互斥应使用初始计数为 1 的 SemaphoreSlim14

private readonly SemaphoreSlim _asyncGate = new(1, 1);

public async Task SaveAsync(Data data, CancellationToken ct)
{
    await _asyncGate.WaitAsync(ct);
    try   { await WriteToFileAsync(data, ct); }
    finally { _asyncGate.Release(); }
}

5.3. 多把锁始终按同一顺序获取

当存在两把以上的锁时,获取顺序在不同线程之间互相颠倒,是死锁的经典模式。对策很简单:规定所有线程都按相同顺序获取锁。对于无法保证顺序的地方,可以使用带超时的 Monitor.TryEnter 重载,取不到就放弃并重试(或记录异常),从而把永远的挂起转化为可检测的失败。4

5.4. 单纯更新用 Interlocked,读多写少用 ReaderWriterLockSlim

对于计数器的增减、标志的切换这类单一变量的原子更新,Interlocked 类(Increment / Add / CompareExchange)比 lock 更快。在没有竞争的情况下,只需一条 CPU 指令前缀即可完成。4 反过来,Interlocked 能做的也仅止于此,无法用于需要同时保持多个变量一致性的场景。结合 volatile 自行搭建的无锁(lock-free)结构,是需要深入理解内存模型的高阶工具,不应该出现在业务应用中。

对于「读取频繁、写入罕见」的共享数据,还有一个选项是只对写入进行排他、读取可以同时通过的 ReaderWriterLockSlim13

6. 原则4:先设计好停止方式

多线程设计评审时最先应该问的问题是「这个东西要怎么停下来」。能启动的代码写起来不难,但能安全停止的代码,不设计就不会自己出现。

6.1. 协作式取消(CancellationToken)是唯一正确答案

.NET 的停止模型统一为协作式取消。想要停止的一方创建 CancellationTokenSource,把它的 Token 传给各处理;想停止时调用 Cancel();处理方监视令牌,在自己认为合适的时间点收尾并结束 ── 因为不是强制而是协作,处理方可以在保持一致状态的前提下结束。7

private CancellationTokenSource? _cts;
private Task? _worker;

public void Start()
{
    if (_worker is { IsCompleted: false })    // 拒绝在运行期间的重复Start
        throw new InvalidOperationException("工作线程已在运行中。");
    if (_worker is { IsFaulted: true })       // 不要把上一次的失败悄悄吞掉后就重建
        throw new InvalidOperationException("上一次的工作线程已失败。", _worker.Exception);
    _cts = new CancellationTokenSource();
    var token = _cts.Token;   // 先捕获到局部变量,避免和停止后的再次Start产生竞争
    _worker = Task.Run(() => WorkLoop(token), token);
}

private void WorkLoop(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)   // 用轮询来监视
    {
        ProcessNextItem(ct);              // 对会阻塞的调用传入ct以便立即中断
    }
}

public async Task StopAsync()
{
    var cts = _cts;          // 即使等待期间字段被替换,
    var worker = _worker;    // 也要固定在局部变量中,避免搞错停止对象
    if (cts is null || worker is null) return;

    Exception? cancelFailure = null;
    try { cts.Cancel(); }    // 注册在令牌上的回调有可能抛出异常
    catch (Exception ex) { cancelFailure = ex; }   // 先捕获,等完成合并之后再报告

    try
    {
        try { await worker; }    // 不论Cancel是否成功,都必须完成合并,并观测过程中的失败
        catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
        { }                      // 只把自己请求的停止当作「正常」处理
        catch (Exception ex) when (cancelFailure is not null)
        {
            throw new AggregateException(cancelFailure, ex);  // 两边的失败都不能丢失
        }
    }
    finally
    {
        cts.Dispose();           // 合并结束后的Source要释放(释放WaitHandle等OS资源)。
        if (ReferenceEquals(_cts, cts))
        {
            _cts = null;         // 不让已释放的Source被后续的StopAsync使用
            _worker = null;
        }
    }
    if (cancelFailure is not null)
        throw new AggregateException(cancelFailure);
}

需要说明的是,这里的 Start / StopAsync假定由同一个线程(如UI线程)按顺序调用的最小化实现。如果有多个线程可能同时操作生命周期,请用 SemaphoreSlim 等把 Start / StopAsync 本身也串行化 ── 如果连管理工作线程的操作本身都会发生竞争,那保护工作线程也就没有意义了。

这个小示例中还包含了一些在实务中真正管用的技巧。首先 Start 会拒绝运行期间的重复调用。如果无条件覆盖 _cts_worker,之前那个工作线程的引用就会丢失,导致既无法停止也无法合并的「失控线程」并行运行。生命周期 API(Start/Stop)的基本要求是自己保证「同一时刻只有一个」。此外还有三点。第一,停止 API 会等待完成Cancel() 只是「请求」取消,返回的那一刻工作线程也许还在 ProcessNextItem 执行途中。如果做成只请求就返回的 Stop(),就会在调用方开始收尾时工作线程还在运行,从而制造出新的竞争。第二,不要丢弃 Task,要保留它。像 _ = Task.Run(...) 这样抛出去,一旦工作线程因异常而死掉,没有人能察觉。第三,令牌不要在 lambda 内部引用 _cts.Token,而应先捕获到局部变量再传入。在 lambda 内部引用的话,求值发生在运行时,如果在停止后立刻重新 Start,就会出现旧工作线程抓到新令牌的错乱。同时把这个令牌也传给 Task.Run 的第二个参数,这样当处理方以 ThrowIfCancellationRequested 或支持取消的 API 抛出的 OperationCanceledException 结束时,Task 会被归类为「取消(Canceled)」而非「失败(Faulted)」(像本例这样通过循环条件正常退出的情况,则算作正常完成)。另外,StopAsync 中的 catch 通过 when 过滤器只捕获来自自身令牌的取消。如果无条件吞掉 OperationCanceledException,那么处理内部由其他令牌(比如按元素设置的超时)抛出的真正失败,也会被误当成「因停止而正常」看待。还需要注意,这种基于令牌一致性的识别方式,在 WorkLoop 内部使用了链接令牌(6.1节的链接合成)时会失效,因为抛出的异常携带的是链接侧的令牌。在那种结构下,要么在 WorkLoop 的出口调用 ct.ThrowIfCancellationRequested(),把异常「翻译」为外层令牌之后再退出,要么把过滤条件放宽为 when (cts.IsCancellationRequested),把「停止请求中发生的取消」直接判定为正常,二者选一,并且要作为设计决策明确写下来

调用一次 Cancel()传递 Token传递 Token传递 Token确认 IsCancellationRequested收尾后自行结束ThrowIfCancellationRequested即使在等待中也立即中断停止方CancellationTokenSource工作处理1工作处理2库的取消支持API正常结束OperationCanceledException= 视为取消完成取消完成

图6:协作式取消的结构。停止方只需调用一次 Cancel(),「何时・如何结束」由各处理自行决定,因此能在保持一致状态的前提下停止

库一侧的写法也已有定式。可取消的操作应提供接受 CancellationToken 的公开方法,计算循环中应定期确认 IsCancellationRequested,或调用 ThrowIfCancellationRequested()。后者会抛出 OperationCanceledExceptionTask 会将其视为「取消完成」而非「失败」。如果既要响应外部传入的令牌,又要响应内部自身的原因(如超时)来停止,可以用链接令牌进行合成。7

6.2. 把 Thread.Abort 当作不存在

「从外部强杀不听话的线程」的 Thread.Abort,在 .NET Core / .NET 5 及以后版本中只会抛出 PlatformNotSupportedException,已经无法使用。这是因为在不清楚对方正在执行到哪里的情况下向线程抛入异常,会导致资源释放被中断、状态被破坏。如果确实需要强制终止不响应协作式取消(或无法改写成响应形式)的第三方代码,官方指引是在单独的进程中运行,用 Process.Kill 来终止。8

6.3. 等待时不要轮询,而用等待句柄

「用 Sleep(100) 的循环等待某个标志被置位」这种写法,会同时浪费 CPU 和响应性。线程之间的信号传递有 ManualResetEventSlimSemaphoreSlim 等同步原语,能让线程在进入信号状态之前正确地休眠。13 关于 Windows 上定时器精度与事件等待的取舍,「为什么在 Windows 上应优先选择事件等待而非 Sleep(1)」有详细讨论。

7. UI 线程这一特殊情况 ── Windows 桌面应用的规矩

除了通用原则之外,Windows 桌面应用还有另一条强约束。UI 只能由创建它的那个线程(UI线程)触碰,这是铁律。

WinForms 的控件并非线程安全,从多个线程操作会让控件陷入不一致的状态,成为竞争、死锁、卡死的原因。Windows 要求应用准备一个专门接收系统消息的线程,UI 的创建与操作必须集中在这个线程上。9 WPF 的结构完全相同,能够变更 UI 元素的也只有 UI 线程。10

想从其他线程更新 UI 时,不应直接触碰,而应转换为「向 UI 线程发出委托请求」。

通过 Control.Invoke /Dispatcher.InvokeAsync 委托直接触碰控件Windows鼠标・键盘・重绘UI线程的消息队列后台线程(繁重处理・通信)UI线程唯一能触碰控件的线程禁止竞争・死锁・卡死的原因

图7:UI 更新要转换成「委托」。后台线程的工作只做到把请求放进消息队列为止,触碰控件的永远是 UI 线程自己

框架 委托手段
WinForms Control.Invoke(同步)/ Control.BeginInvoke(异步)/ .NET 9以后为 Control.InvokeAsync9
WPF Dispatcher.Invoke(同步)/ Dispatcher.InvokeAsync / Dispatcher.BeginInvoke(异步)10

其中同步形式(Control.Invoke / Dispatcher.Invoke)需要格外小心。如果 UI 线程正在同步等待某个工作线程完成,而该工作线程又调用了 Invoke,就会形成互相等待的死锁(正是第2章那种循环等待)。来自后台的通知、进度报告应以异步形式(BeginInvoke / InvokeAsync)为默认选择,同步形式只应限于能够断定 UI 线程并未在等待自己的场景。

在实务中还有更进一步的答案。将在 UI 线程上启动的处理用 async/await 编写,await 会捕获 UI 线程的 SynchronizationContext,并自动让后续处理在 UI 线程上恢复执行,因此手写 Invoke 的场景本身会大幅减少。不过这并非无条件成立的性质:从后台回调进入的代码,以及经过 ConfigureAwait(false) 之后的延续处理都不会回到 UI 线程,如果要在这些路径上触碰 UI,仍然需要显式的调度。「繁重处理交给 Task.Run 或异步 I/O,结果反映到画面则放在 await 之后的延续中」,这已是现代 Windows 应用的基本形态。UI 线程与 async/await 的关系,在「用一张图整理 WPF/WinForms 的 async 与 UI 线程」中有一张图的整理。

另外,当涉及 Office 联动或旧版组件等 COM 相关内容时,还会加入 COM 独有的线程模型(STA/MTA)这一层。「明明是在 UI 线程创建的 COM 对象,却被别的线程调用而卡死了」这类事故就属于这一层,「COM STA/MTA 基础」中有相关说明。

8. 如果用原生代码(C++/C)编写

到目前为止的原则 ── 不直接创建线程、减少共享可变状态、锁的纪律、停止方式的设计 ── 在原生代码中同样完全成立。变化的只是工具。C++ 中对应的是 RAII 与 std::jthread / std::mutex / std::atomic,C 中对应的则是 Win32 API 的 _beginthreadex、SRW 锁、条件变量、停止事件模式。本系列的「C++ 篇」「C 语言篇」分别涵盖了这些内容,也包括语言特有的陷阱(std::thread 的析构函数、TerminateThread 的危险性、DllMain 与加载器锁等)。

9. 验证与调试 ── 以「无法重现」为前提做好准备

多线程的 bug 不能指望靠测试发现。普通的单元测试会把「碰巧没有发生竞争」的执行也算作成功。准备工作分三层来考虑。

第一道防线,就是到目前为止的设计原则本身。共享可变状态有 5 个的应用和有 50 个的应用,需要怀疑的地方数量相差十倍。评审时应通过表格确认「哪些是共享的可变数据」「各自由哪把锁保护」「锁的获取顺序是否唯一」「停止路径在哪里」。写不出这张表的设计,即使能运行也还没有完成。

第二,让异常变得可被观测,而不是被隐藏。Monitor.TryEnter 的超时检测锁等待的异常并记录到日志4,不吞掉投递给线程池的处理中未被观测的异常而是记录下来,卡死时确保可以采集完整的内存转储并查看所有线程的调用栈 ── 与「偶尔才发生」的 bug 的战斗,胜负取决于从那仅有的一次发生中能获取到多少信息。转储与日志的整备在「Windows 应用崩溃时保留日志与转储的设计」中有讨论。

第三,施加负载去摇晃它。用超过核心数的并行度长时间运行、把处理顺序随机化、人为插入延迟等手段,让开发机更容易命中交错执行的时机,这类压力测试是在出货前把竞争揪出来的现实手段。有些调试运行时消失的 bug,换成发布版本加高负载,往往就能重现。

10. 总结 ── 增加线程之前的检查清单

多线程编程的最佳实践,归根结底不是「正确编写同步代码的技术」,而是「设计到不需要编写同步代码」。只要在动手之前能回答下面这 8 个问题,就基本可以避免重大事故。

  1. 该处理是 CPU 密集型还是 I/O 密集型(如果是后者,答案不是线程而是 async/await)
  2. 是否正打算写 new Thread(能否用 Task・Parallel・线程池来表达)
  3. 线程之间共享的可变数据有哪些,能否一一列出
  4. 这些共享能否通过「拆分」「不可变化」「用队列传递」来消除
  5. 剩下的每份共享数据,是否都确定了对应的唯一一把锁
  6. 锁的获取顺序在所有线程中是否唯一,锁内是否存在外部调用
  7. CancellationToken 是否传递给了所有长时间运行的处理,停止路径是否能说清楚
  8. 触碰 UI 的代码是否都集中在 UI 线程上

多线程的 bug 不会在写下代码的当天出现,而是在被人遗忘之后才在客户那里露出獠牙。反过来说,如果在设计阶段就走完这份检查清单,就能在动笔写代码之前,把「偶尔崩溃」「每月固定卡死一次」这类代价最高的故障提前拔除。

相关文章

相关咨询领域

合同会社小村软件承接多线程化在内的业务应用设计评审、「偶尔崩溃・卡死」这类难以重现的故障原因调查(转储分析・竞争位置定位)、既有应用并行化・异步化的技术咨询。从「想请你看看这个设计会不会出现竞争」这种阶段开始咨询也完全没问题。

参考链接

  1. Microsoft Learn, Task Parallel Library (TPL)。关于 TPL 是 .NET Framework 4 以后多线程・并行代码的推荐手段、会根据可用处理器动态调整并行度、承担工作拆分・线程池调度・取消支持・状态管理、单次迭代工作量很小的循环可能因并行化开销而变慢、即使使用 TPL 也建议对锁・死锁・竞争状态有基本理解。  2

  2. Microsoft Learn, The managed thread pool。关于 ThreadPool 类提供由系统管理的工作线程池、使开发者能专注于应用任务而非线程管理、.NET 在 TPL 的操作・异步I/O完成・定时器回调・已注册的等待・套接字连接等广泛场景中使用线程池。  2

  3. Microsoft Learn, Potential Pitfalls in Data and Task Parallelism。关于并行循环有可能比顺序执行更慢、必须实测、应避免在并行循环内写入共享内存并推荐使用带线程本地状态的重载、For/ForEach 的各次迭代不保证并行执行,迭代间互相等待的代码可能死锁。  2 3 4

  4. Microsoft Learn, Managed threading best practices。关于竞争状态(计数器自增被分解为读取・加一・写回、因覆盖而丢失的例子)与死锁的定义、应使用协作式取消而非 Thread.Abort、不应以类型或 this 作为锁对象且 .NET 9/C# 13 以后应使用专用的 System.Threading.Lock 实例、C# 的 lock 语句在 finally 中保证 Monitor.Exit、通过 Monitor.TryEnter 的超时检测死锁、简单的状态变更使用 Interlocked 类速度更快、静态数据默认应线程安全・实例数据默认应非线程安全等设计方针。  2 3 4 5 6 7 8 9 10

  5. Microsoft Learn, System.Threading.Channels library。关于通道是生产者/消费者模型的 FIFO 并在内部管理同步、可用 CreateBounded 创建带容量上限的通道、达到上限时的默认行为是写入方等待(Wait),也可选择 DropOldest 等 FullMode、写入快于读取时会产生背压。  2 3

  6. Microsoft Learn, Thread-safe collections。关于 System.Collections.Concurrent 下的集合通过细粒度锁或无锁机制实现线程安全、ConcurrentQueue 与 ConcurrentStack 不使用锁而基于 Interlocked 操作实现,能够承受多线程高频率的添加与删除。  2

  7. Microsoft Learn, Cancellation in Managed Threads。关于 CancellationTokenSource 与 CancellationToken 的协作式取消模型步骤、取消并非强制而是协作,如何停止由监听方决定、轮询・回调注册・等待句柄这三种监视手段、ThrowIfCancellationRequested 抛出的 OperationCanceledException 会被 Task 视为取消完成、通过链接令牌合成多个令牌、库应提供接受 CancellationToken 的公开方法。  2 3

  8. Microsoft Learn, Using threads and threading。关于停止线程应使用 CancellationToken、在 .NET Core 及 .NET 5 以后 Thread.Abort 只会抛出 PlatformNotSupportedException,且 .NET 5 以后在编译期也会给出弃用警告(SYSLIB0006)、要强制终止不响应协作式取消的第三方代码,应在单独进程中运行并用 Process.Kill 终止。  2

  9. Microsoft Learn, How to handle cross-thread operations with controls。关于 WinForms 控件的访问并非线程安全,多线程操作会导致不一致状态・竞争・死锁・卡死、所有控件必须在同一线程创建与访问,Windows 要求有专用的 UI 线程来分发系统消息、从其他线程应通过 Control.Invoke、.NET 9 以后的 Control.InvokeAsync,或 BackgroundWorker 安全调用。  2 3

  10. Microsoft Learn, Threading model (WPF)。关于 WPF 中能够变更 UI 的线程仅限一个,后台线程需要向 UI 线程的 Dispatcher 注册工作项来委托、Dispatcher.Invoke 为同步,InvokeAsync 与 BeginInvoke 为异步、Dispatcher 以带优先级的队列处理工作。  2 3

  11. Microsoft Learn, Data Parallelism (Task Parallel Library)。关于 Parallel.For / Parallel.ForEach 以接近 for 循环的写法提供数据并行、无需创建线程或排队工作项、基本循环甚至不需要锁、TPL 会拆分数据源并在多个线程上处理,负载偏斜时会重新分配。 

  12. Microsoft Learn, BlockingCollection<T> Class。关于 BlockingCollection 是兼具阻塞与容量限制的生产者/消费者实现、容量限制能防止生产方过度超前于消费方、其设计并不面向异步访问,异步的生产者/消费者应考虑使用 Channel<T>。  2

  13. Microsoft Learn, Overview of synchronization primitives。关于 Monitor 通过锁对象提供互斥并具有线程亲和性、C# 中应使用 lock 语句而非直接使用 Monitor、ReaderWriterLockSlim 使写入排他・读取可并发访问、SemaphoreSlim 是仅限进程内使用的轻量级信号量,而 Semaphore 可命名并用于跨进程同步。  2 3

  14. Microsoft Learn, Async semaphores, locks, and reader/writer coordination。关于 C# 的 lock 语句与 Lock 类型具有线程亲和性,因此无法跨 await 使用(因为 await 前后执行继续的线程可能不同)、异步代码中的互斥应使用计数为 1 的 SemaphoreSlim,配合 WaitAsync 与 try/finally 中的 Release、节流场景可用 bounded 的 Channel 替代。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

为什么应该避免 lock(this) 或 lock(typeof(MyClass))?
因为作为锁对象的实例可以被自己代码之外的地方看到。this 就是自己的实例本身,因此任何能够引用该实例的外部代码都可以锁住同一个对象,从而引发意料之外的竞争或死锁。typeof(MyClass) 则更加危险,因为在同一个应用程序域内 Type 对象只有一份,会导致与完全无关的代码共享同一把锁。锁对象应使用不对外公开的专用对象。在 .NET 9 / C# 13 及以后版本中,推荐将专用的 System.Threading.Lock 类型的实例用作锁对象。
线程最多可以创建多少个?最佳线程数是多少?
「不要自己决定线程数」才是现代的答案。只要使用 Task 或 Parallel 类,线程池就会根据 CPU 核心数与负载自动调整并行度。自行反复 new Thread 的设计,在核心数不同的客户机器上很容易出现过多或过少的问题。应该关注的不是数量,而是任务的种类:用尽 CPU 的计算即使并行度超过核心数也不会更快,而以等待 I/O 为主的处理,正确做法根本不是增加线程,而是改用 async/await 的异步 I/O。
加上 volatile 是否就能保证线程安全?
不能。volatile 所保证的,是对该字段的访问不会与前后的内存操作重新排序,即获取/释放语义(acquire/release semantics),而不是「读取、计算、写回」这种复合操作的原子性。举例来说,多个线程对一个 volatile int 计数器执行 ++ 操作,加法仍然会丢失。对于计数器的增减或比较后交换这类操作,应使用 Interlocked 类;需要同时保护多个变量时,则应使用 lock。volatile 真正适用的场景,几乎只限于「一个线程写、其他线程只读」这种单纯情形,比如停止标志,而这类标志现在也已经标准化为用 CancellationToken 来表达。
怎样判断偶发的故障是否源于多线程?
应当怀疑的迹象有三个:「同样的操作有时能重现、有时不能」「接上调试器或加了日志之后就不再重现」「只在高负载或刚启动之后才会发生」。依赖时序的 bug 的特征就是每次执行结果都不同,这正是竞争状态(race condition)的定义本身。排查时,首先要列出所有共享的可变数据,并逐一整理「各自由哪把锁保护」的对照表,只要有一处访问没有被保护,就是嫌疑对象。如果是卡死(hang),则需要获取所有线程的调用栈,确认它们是否在相互等待对方持有的锁而形成循环。可以在 Visual Studio 调试器中暂停后查看「并行堆栈」,若是生产环境则采集内存转储(dump)后再分析。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表