多线程实战最佳实践 .NET 篇——增加线程之前必须先定好的事

· 更新日期: · · Windows, 多线程, C#, .NET, 业务应用, 故障排查, 设计

更新记录(仅首版,2026年08月02日 发布)
首次发布
引用本文(DOI(已登记存档): 10.5281/zenodo.22175842)

以下 DOI 指向先前登记的存档,内容可能与当前正文不同。引用当前正文时,请使用本页网址。

Go Komura(2026)。《多线程实战最佳实践 .NET 篇——增加线程之前必须先定好的事》。小村软件有限公司。 https://comcomponent.com/zh-CN/blog/multithreading-best-practices-dotnet/

DOI(已登记存档)
10.5281/zenodo.22175842
DOI(上次登记版本)
10.5281/zenodo.22175843

“处理太慢,于是开线程做并行,结果汇总数字偶尔会对不上”“加了后台处理之后,应用每个月都要卡死一次”“都说调试运行时重现不了,可客户现场确实在发生”——多线程编程可怕的地方在于,刚写完的时候看上去是正确的。竞态引起的 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

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 28 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

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 的保证也很浅,管不到属性所引用的那个对象内部。真正想在线程之间安全共享的数据,要么使用 ImmutableArray<T>System.Collections.Immutable 的不可变集合,要么在共享的时候交出一份副本,直接切断改写的路径。此时的条件是元素类型 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);
}
满了就让写入等待(背压)空了就让读出等待生产者 1WriteAsyncbounded 通道(容量 100)FIFO 队列同步由通道管理生产者 2WriteAsync消费者 1ReadAllAsync消费者 2ReadAllAsync

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

实务中重要的是选择容量有上限的(bounded)通道。达到上限时的默认行为是“写入方等待空位”,这就是天然的背压(backpressure)。在生产快于消费的结构里使用没有上限的队列,就会变成一颗定时炸弹:程序照跑,内存却一直往上涨。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 组合的自制无锁结构,是需要深入理解内存模型的高手工具,不该出现在业务应用里。

对“读取频繁但写入很少”的共享数据,还有 ReaderWriterLockSlim 这个选项,它只对写入做互斥,读取可以同时通过。13

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();           // 汇合完毕的源要释放掉(释放 WaitHandle 等操作系统资源)。
        if (ReferenceEquals(_cts, cts))
        {
            _cts = null;         // 不让后续的 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()。后者会抛出 OperationCanceledException,而 Task 会把它当作“取消完成”而不是“失败”来对待。既想用外部传入的令牌停止、又想按内部的需要(比如超时)停止时,用链接令牌把它们合成起来。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 个的应用,需要怀疑的地方数量差 10 倍。评审时用表格确认“共享的可变数据有哪些”“各自由哪把锁保护”“锁的获取顺序是否唯一”“停止路径在哪里”。写不出这张表的设计,即使跑得起来也还没有完成。

第二,不要把异常藏起来,要让它可观测。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 保证的是对该字段的访问不会与前后的内存操作重新排序,也就是获取/释放语义,而不是“读取、计算、写回”这类复合操作的原子性。例如多个线程对一个 volatile int 计数器执行 ++,加法照样会丢失。计数器的增减、比较并交换这类操作要用 Interlocked 类;需要把多个变量一起保护起来时要用 lock。volatile 值得考虑的场合,几乎只剩“一个线程写、其他线程只读”这种简单情形,比如停止标志,而这种标志现在的标准做法也是用 CancellationToken 来表达。
偶尔才出现的故障,怎么判断是不是多线程引起的?
值得怀疑的迹象有三个:同样的操作有时能重现、有时不能;接上调试器或者加了日志之后就不再重现;只在负载高的时候或刚启动之后才发生。依赖时序的 bug 的特点就是每次执行结果都不一样,这正是竞态条件的定义本身。排查时先把共享的可变数据全部列出来,逐项整理成“各自由哪把锁保护”的对照表,只要有一处访问没被保护,它就是嫌疑对象。如果是卡死,就抓取所有线程的调用栈,确认它们是否因为互相等待对方的锁而形成了环。可以在 Visual Studio 的调试器中暂停后查看“并行堆栈”,如果是生产环境则采集转储之后再分析。

作者简介

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

Go Komura

小村软件有限公司 代表

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

返回博客列表