Skip to content

USE LOCK

未竟 edited this page Sep 24, 2026 · 1 revision

C# async/await 的死锁成因与并发实践

在存在单线程 SynchronizationContext 的环境(WinUI 3、WPF、Windows Forms 等桌面应用)中,async/await 本身不会死锁,死锁来自「在被捕获上下文的线程上同步阻塞等待异步任务」。本文说明该死锁的成因、可用的规避手段及其代价,并给出 ConfigureAwait 的分层用法、lock 与 SemaphoreSlim 的选择依据,以及取消、并发与分配方面的常用做法。内容基于 .NET;涉及 System.Threading.Lock 的部分要求 .NET 9 与 C# 13 及以上版本。

术语与前提

术语 含义
SynchronizationContext 把委托排队到特定执行环境的抽象。UI 框架提供单线程实现,队列由 UI 线程的消息循环处理;线程池线程上 SynchronizationContext.Current 为 null
continuation(后续) await 之后的剩余代码。编译器将其编译进状态机,在被等待的任务完成后调度执行
同步等异步(sync-over-async) 在同步代码中用 .Result、.Wait() 或 GetAwaiter().GetResult() 阻塞等待异步任务完成

两个前提:

  • await 默认捕获当前的 SynchronizationContext;若其为 null,则捕获当前的 TaskScheduler。捕获的是上下文,不是线程 ID。
  • ASP.NET Core 没有 UI 式的单线程 SynchronizationContext,因此不会出现下文描述的上下文死锁;但阻塞等待仍会占用线程池线程。旧版 ASP.NET(.NET Framework)存在单线程请求上下文,会死锁。

死锁:在捕获上下文的线程上阻塞等待

成因

以 UI 线程的点击事件中调用 .Result 为例:

private void BtnRead_Click(object sender, EventArgs e)
{
    var data = ReadDeviceAsync().Result;  // UI 线程在此阻塞
    UpdateGrid(data);
}

执行顺序:

  1. UI 线程调用 ReadDeviceAsync(),该方法同步执行到第一个 await。
  2. 被等待的任务尚未完成,await 捕获 UI 线程的 SynchronizationContext 并挂起,方法返回一个未完成的 Task。
  3. UI 线程在 .Result 上阻塞,停止处理消息队列。
  4. 任务完成后,continuation 被 Post 回捕获的上下文,即排队到 UI 线程的消息队列。
  5. UI 线程正被 .Result 阻塞,无法取出该消息;任务因此永远不会完成,.Result 也永远不会返回。

双方互相等待,形成死锁。Task<TResult>.Result 是阻塞属性,在任务完成前访问会阻塞当前线程。

触发条件

同时满足以下两点才会发生:

  • 阻塞发生在具有单线程 SynchronizationContext 的线程上(UI 线程、STA 线程、旧版 ASP.NET 请求线程、自建消息泵线程)。
  • 被等待的异步方法在 await 处捕获了该上下文,即未使用 ConfigureAwait(false)。

同一段代码在控制台应用中可能正常运行,在 UI 线程上却死锁,因此不能以「在某个环境跑通了」作为验证结论。

规避手段

按优先级:

  1. 异步到底。 把调用链上的每一层都改为 async,用 await 代替 .Result / .Wait()。这是唯一没有副作用的做法。
  2. 库代码使用 ConfigureAwait(false)。 continuation 不再回到调用方上下文,死锁链条被切断,同时省去一次派发开销。详见下一节。
  3. 把调用卸载到线程池。 当无法修改被调用方、且必须同步返回时,使用 Task.Run:
public int Sync()
{
    // Task.Run 的委托在线程池线程上开始执行,此处 SynchronizationContext.Current 为 null,
    // 其内部的 await 不会捕获 UI 上下文,continuation 直接在线程池线程恢复
    return Task.Run(() => Library.FooAsync()).GetAwaiter().GetResult();
}

GetAwaiter().GetResult() 与 .Result 的阻塞行为相同,区别在于异常:.Result 和 .Wait() 把异常包装为 AggregateException,GetAwaiter().GetResult() 直接抛出原始异常。

卸载到线程池的代价

该做法消除的是「continuation 回发到被阻塞线程」这一类死锁,其余问题依旧存在:

  • 界面仍然冻结。 阻塞时长等于后台操作耗时,期间窗口不响应拖拽与重绘。它把「永久无响应」换成了「有限时长无响应」。
  • 占用两个线程。 真正的 async/await 在等待期间不占用线程;此方案下 UI 线程阻塞等待,同时还占用一个线程池线程。
  • 仍可能挂起。 线程池可用线程被耗尽(线程饥饿)时,Task.Run 的委托可能长时间得不到调度;在静态构造器中阻塞等待也会因 CLR 持有的类型初始化锁而死锁。
  • 掩盖设计问题。 多数情况下,正确做法是让调用方也变为异步。

因此该方案适用于「实现同步接口且只有异步实现可用」等确实无法异步化的场合,并应在 UI 线程、负载中的线程池、最大线程数较低的线程池等多种环境下验证。

注意:Task.Run 本身没有问题,问题在于其后的阻塞等待。把 CPU 密集型工作从 UI 线程卸载到线程池并 await 其结果,是 Task.Run 的正常用法。

无法直接异步化的两个场景

场景 1:属性访问器。 C# 不允许属性的 get 访问器带 async 修饰符,因此无法在其中 await。不要在 getter 里同步阻塞获取数据,而应改为异步方法加载并通过变更通知刷新绑定:

public class UserViewModel : INotifyPropertyChanged
{
    private string? _avatarUrl;

    public string? AvatarUrl
    {
        get => _avatarUrl;
        private set { _avatarUrl = value; OnPropertyChanged(); }
    }

    // 由页面加载、命令等异步入口调用;
    // 从 UI 线程调用且未使用 ConfigureAwait(false) 时,赋值发生在 UI 线程上
    public async Task LoadAsync()
    {
        AvatarUrl = await _service.GetAvatarUrlAsync();
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    private void OnPropertyChanged([CallerMemberName] string? name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

场景 2:实现只有同步签名的接口。 此时只能采用上一节的卸载方案,并承担其代价。

ConfigureAwait 的分层使用

ConfigureAwait(false) 表示 continuation 不需要回到捕获的上下文,可直接在任务完成的线程(通常是线程池线程)上恢复。它只影响 continuation 的调度,不影响 ExecutionContext 的流动。

代码层级 做法 原因
类库、Core、API Client、后台服务 每个 await 都加 ConfigureAwait(false) 纯数据处理不需要 UI 上下文;省去一次 Post 派发;同时切断调用方误用 .Result 时的死锁链条
页面、ViewModel 中需要回到界面的那段 不加 否则 continuation 会在线程池线程上执行,此时访问 UI 元素或绑定属性会抛跨线程异常

跨线程访问的表现因框架而异:

  • WinUI 3 / UWP:抛 COMException,HRESULT 为 0x8001010E(RPC_E_WRONG_THREAD)。绑定到 x:Bind 的属性即使不直接操作控件,在非 UI 线程赋值同样会触发。
  • WPF / Windows Forms:抛 InvalidOperationException。

async void

async void 方法没有可返回的 Task 来承载异常。方法体内未捕获的异常不会传给调用方,而是被抛到当前的 SynchronizationContext 上,绕过调用处的 try-catch,通常表现为进程崩溃。

使用约束:

  • 仅用于事件处理程序这类签名必须返回 void 的场合,其余一律返回 Task 或 Task<TResult>。
  • 方法体内自行 try-catch 兜底,不要把异常交给上层。
// SerialPort.DataReceived 的处理程序;该事件在辅助线程上触发,不是 UI 线程,
// 需要更新界面时应通过 Dispatcher / Invoke 派发回 UI 线程
private async void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
    try
    {
        var frame = await ReadFrameAsync();
        Process(frame);
    }
    catch (Exception ex)
    {
        Log(ex);
    }
}

互斥与限流:lock 与 SemaphoreSlim

对比

特性 lock SemaphoreSlim
跨 await 使用 不支持。lock 体内使用 await 报 CS1996;锁对象为 System.Threading.Lock 时,在 async 方法或 async lambda 中使用 lock 报 CS9217 支持,await WaitAsync()
等待时的行为 阻塞当前线程 WaitAsync 挂起而不阻塞,线程返回线程池
并发容量 固定为 1 可配置,为 1 时等价于互斥锁
超时与取消 lock 语句不支持;Monitor.TryEnter 支持超时,不支持 CancellationToken Wait / WaitAsync 支持超时与 CancellationToken
线程亲和性 有。加锁与释放必须是同一线程,否则抛 SynchronizationLockException 无。线程 A 获取、线程 B 释放是允许的
可重入 可重入。同一线程可多次进入,需对应次数退出 不可重入。同一执行流重复获取会导致自身永久等待
无竞争时开销 更低,不涉及状态机与队列节点分配 更高,异步等待路径涉及状态机与排队

差异源于实现方式:lock 语句编译为 Monitor.Enter / Monitor.Exit(锁对象为 System.Threading.Lock 时编译为 Lock.EnterScope()),两者都记录持有者线程;而 SemaphoreSlim 只维护一个原子计数,WaitAsync 减一、Release 加一,不记录持有者。await 前后可能是不同线程,因此线程亲和的锁无法跨 await 保护临界区——即使用 Monitor.Enter / Monitor.Exit 手动绕开编译器检查,退出时也会因线程不匹配而抛异常。

lock 的适用场景

适用于纯内存操作、不含 I/O 与 await、执行时间极短的临界区。

保护非线程安全的集合:

public class SafeDataCache
{
    private readonly Lock _lockObj = new();   // .NET 9 / C# 13 起可用 System.Threading.Lock
    private readonly List<string> _cache = new();

    public void Add(string item)
    {
        lock (_lockObj)
        {
            _cache.Add(item);
        }
    }

    public List<string> GetAll()
    {
        lock (_lockObj)
        {
            return new List<string>(_cache);   // 返回快照,避免调用方在锁外遍历原集合
        }
    }
}

注意:lock 目标表达式的类型必须精确为 System.Threading.Lock 才会使用新实现。若先赋给 object 或泛型参数再 lock,编译器会退回 Monitor 实现并给出警告 CS9216。较早的 .NET 版本继续使用 private readonly object _lockObj = new();。

延迟初始化不建议手写双重检查锁定:该模式需要对实例字段做 volatile 访问才能在弱内存模型(如 ARM64)上正确发布,容易写错。使用 Lazy<T>:

public class Singleton
{
    private static readonly Lazy<Singleton> _instance = new(() => new Singleton());

    public static Singleton Instance => _instance.Value;
}

无参构造函数或 Lazy<T>(Func<T>) 构造的实例默认线程安全(LazyThreadSafetyMode.ExecutionAndPublication),只会初始化一次。需要避免包装对象开销时可改用 LazyInitializer.EnsureInitialized。

SemaphoreSlim 的适用场景

异步互斥锁。 临界区内必须执行异步 I/O 时,用容量为 1 的 SemaphoreSlim 代替 lock:

public class FileLogger
{
    // 初始计数 1,最大计数 1:同一时刻只允许一个执行流进入
    private readonly SemaphoreSlim _mutex = new(1, 1);

    public async Task AppendLogAsync(string message)
    {
        await _mutex.WaitAsync();
        try
        {
            await File.AppendAllTextAsync("app.log", message + "\n");
        }
        finally
        {
            _mutex.Release();   // 必须在 finally 中释放,否则异常路径会永久占用名额
        }
    }
}

并发限流。 计数大于 1 时用于限制同时进行的操作数量,例如避免同时打开过多连接:

public class Downloader
{
    private readonly SemaphoreSlim _throttler = new(3, 3);   // 最多 3 个并发

    public async Task DownloadAllAsync(List<string> urls, CancellationToken ct)
    {
        var tasks = urls.Select(async url =>
        {
            // 名额已满时在此异步排队,不阻塞线程
            await _throttler.WaitAsync(ct);
            try
            {
                await DownloadSingleFileAsync(url, ct);
            }
            finally
            {
                _throttler.Release();
            }
        }).ToList();

        await Task.WhenAll(tasks);
    }
}

防止重复触发。 超时设为 0 时 WaitAsync 立即返回结果,可用于在上一次操作完成前忽略新的触发:

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

public async Task OnRefreshButtonClickAsync()
{
    // 拿不到名额直接返回,不排队
    if (!await _gate.WaitAsync(0))
    {
        return;
    }

    try
    {
        await LoadDataFromServerAsync();
    }
    finally
    {
        _gate.Release();
    }
}

使用要点:

  • Release 必须放在 finally 中;WaitAsync 自身抛出(例如被取消)时不应释放,因此获取操作要写在 try 之外。
  • 不可重入。同一调用链中再次获取同一个容量为 1 的信号量会永久等待。
  • SemaphoreSlim 实现 IDisposable,作为字段长期持有时随宿主一并释放。

取消:让 CancellationToken 贯穿调用链

取消令牌应从入口一直传到最底层的 I/O 与延迟调用,否则取消只能在两次操作的间隙生效:

async Task PollLoopAsync(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        await ReadOnceAsync(ct);        // 底层读取同样接收 ct
        await Task.Delay(1000, ct);     // 延迟可取消,取消时立即结束等待
    }
}

调用 CancellationTokenSource.Cancel() 后,Task.Delay(delay, ct) 会抛出 TaskCanceledException(派生自 OperationCanceledException),调用方需捕获或让其向上传播,不应作为错误记录。

并发与分配

Task.WhenAll:并行执行互不依赖的 I/O

多个互不依赖的异步操作应并发发起,总耗时取决于最慢的一个而非累加:

var tasks = deviceIds.Select(id => ReadDeviceAsync(id, ct)).ToList();
await Task.WhenAll(tasks);

Select 返回的是延迟执行的 IEnumerable<Task>,只有在枚举时才会真正调用异步方法。用 ToList() 或 ToArray() 立即物化有两个作用:明确任务的启动时机(在物化处全部启动,而不是等到被枚举);避免同一个查询被枚举多次——每次枚举都会重新发起一整组操作。

需要澄清的是,把未物化的序列直接传给 Task.WhenAll 并不会导致串行执行:Task.WhenAll 会先完整枚举序列(此时所有任务均已启动)再等待其全部完成。真正的风险是重复枚举,例如先 await Task.WhenAll(query),随后又对同一个 query 调用 Sum(t => t.Result)。

ValueTask:减少同步完成路径上的分配

被高频调用且大多数情况下同步完成的方法,返回 ValueTask / ValueTask<TResult> 可以避免每次调用都分配 Task 对象。

使用限制(违反后果未定义):

  • 只能被 await 一次,AsTask() 也只能调用一次,且两种消费方式不可混用。
  • 未完成前不得读取 .Result 或调用 GetAwaiter().GetResult()。
  • 需要多次消费时,先用 AsTask() 或 Preserve() 转换。

代码分析规则 CA2012(在 .NET 10 中默认以建议级别启用)会标记这些误用。ValueTask 是包含多个字段的结构,返回与存储的复制成本高于单个引用,异步方法的状态机也会随之变大;默认仍应返回 Task / Task<TResult>,仅在性能分析确认收益后改用。同步且成功完成的无返回值方法可直接返回 Task.CompletedTask。

IAsyncEnumerable:流式处理大数据

单次返回体积很大的数据时,一次性读入内存会造成内存峰值。改为逐块产出、边收边处理:

async IAsyncEnumerable<byte[]> ReadBigFrameAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    while (HasMore())
        yield return await ReadChunkAsync(ct);
}

调用方用 await foreach 消费,通过 WithCancellation(ct) 传入取消令牌,令牌由标注了 [EnumeratorCancellation] 的参数接收。

要点速查

问题 结论
死锁 根因是「同步等异步 + 单线程 SynchronizationContext 排队」。UI 线程上的 .Result / .Wait() 是主要来源
上下文 ConfigureAwait(false) 分层使用:库代码关闭上下文捕获,需要回到界面的代码保留
async void 仅用于事件处理程序,且必须在方法体内 try-catch
取消 CancellationToken 贯穿整条调用链,包括 Task.Delay 与底层 I/O
互斥与限流 临界区含 await 时用 SemaphoreSlim.WaitAsync;纯内存短临界区用 lock
延迟初始化 用 Lazy<T>,不手写双重检查锁定
高频热路径 ValueTask 可减少分配,但受消费次数限制;默认仍用 Task
大数据 IAsyncEnumerable 流式处理
并行 I/O Task.WhenAll 并发执行独立操作,并用 ToList() / ToArray() 物化任务序列

参考资料

Clone this wiki locally