-
Notifications
You must be signed in to change notification settings - Fork 0
USE LOCK
在存在单线程 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);
}执行顺序:
- UI 线程调用
ReadDeviceAsync(),该方法同步执行到第一个await。 - 被等待的任务尚未完成,
await捕获 UI 线程的 SynchronizationContext 并挂起,方法返回一个未完成的Task。 - UI 线程在
.Result上阻塞,停止处理消息队列。 - 任务完成后,continuation 被
Post回捕获的上下文,即排队到 UI 线程的消息队列。 - UI 线程正被
.Result阻塞,无法取出该消息;任务因此永远不会完成,.Result也永远不会返回。
双方互相等待,形成死锁。Task<TResult>.Result 是阻塞属性,在任务完成前访问会阻塞当前线程。
同时满足以下两点才会发生:
- 阻塞发生在具有单线程 SynchronizationContext 的线程上(UI 线程、STA 线程、旧版 ASP.NET 请求线程、自建消息泵线程)。
- 被等待的异步方法在
await处捕获了该上下文,即未使用ConfigureAwait(false)。
同一段代码在控制台应用中可能正常运行,在 UI 线程上却死锁,因此不能以「在某个环境跑通了」作为验证结论。
按优先级:
-
异步到底。 把调用链上的每一层都改为
async,用await代替.Result/.Wait()。这是唯一没有副作用的做法。 -
库代码使用
ConfigureAwait(false)。 continuation 不再回到调用方上下文,死锁链条被切断,同时省去一次派发开销。详见下一节。 -
把调用卸载到线程池。 当无法修改被调用方、且必须同步返回时,使用
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(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 方法没有可返回的 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 |
|---|---|---|
跨 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 手动绕开编译器检查,退出时也会因线程不匹配而抛异常。
适用于纯内存操作、不含 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。
异步互斥锁。 临界区内必须执行异步 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,作为字段长期持有时随宿主一并释放。
取消令牌应从入口一直传到最底层的 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),调用方需捕获或让其向上传播,不应作为错误记录。
多个互不依赖的异步操作应并发发起,总耗时取决于最慢的一个而非累加:
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<TResult> 可以避免每次调用都分配 Task 对象。
使用限制(违反后果未定义):
- 只能被
await一次,AsTask()也只能调用一次,且两种消费方式不可混用。 - 未完成前不得读取
.Result或调用GetAwaiter().GetResult()。 - 需要多次消费时,先用
AsTask()或Preserve()转换。
代码分析规则 CA2012(在 .NET 10 中默认以建议级别启用)会标记这些误用。ValueTask 是包含多个字段的结构,返回与存储的复制成本高于单个引用,异步方法的状态机也会随之变大;默认仍应返回 Task / Task<TResult>,仅在性能分析确认收益后改用。同步且成功完成的无返回值方法可直接返回 Task.CompletedTask。
单次返回体积很大的数据时,一次性读入内存会造成内存峰值。改为逐块产出、边收边处理:
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() 物化任务序列 |
- Common async/await bugs
- Synchronous wrappers for asynchronous methods
- ExecutionContext and SynchronizationContext
- ConfigureAwait FAQ
- Async semaphores, locks, and reader/writer coordination
- The lock statement 与 lock 相关的错误与警告
- Lazy Initialization
- ValueTask<TResult> 结构 与 CA2012: Use ValueTasks correctly
- Asynchronous programming scenarios
- SerialPort.DataReceived 事件
- 有所不为《C# 中大量使用 async/await,如何避免死锁和优化性能?》,https://www.zhihu.com/question/1965011882793472536/answer/2061436427859243685