优化AsyncLazy<T>:调度安全与异常取消语义的工程实践
写 C# 异步代码绕不开AsyncLazyT这个模式。它本质上是把LazyT的懒加载和TaskT的异步组合到一起让异步初始化只发生一次。很多项目里我见过它的各种手写版本大多数能跑但稍微上点并发就暴露出两个隐藏问题一是阻塞等待异步初始化导致死锁二是异常缓存策略让重试无从谈起。这篇文章重点聊聊优化AsyncLazyT异步初始化时我最在意的两点调度与上下文安全异常与取消语义。1. 经典实现与隐藏的坑1.1 最精简的 AsyncLazy 长什么样很多人第一次写异步懒加载都是从这一行开始的public class AsyncLazyT : LazyTaskT { public AsyncLazy(FuncTaskT taskFactory) : base(() Task.Run(taskFactory)) { } }这段代码看着没什么问题LazyTaskT保证了taskFactory在整个进程生命周期里只执行一次Task.Run又把工厂方法丢到线程池里跑避免调用线程被异步初始化卡住。日常用起来也确实省事直到出现以下场景var lazy new AsyncLazyUser(() LoadUserAsync()); var user lazy.Value.Result; // 界面线程直接阻塞如果LoadUserAsync内部依赖了 UI 线程的SynchronizationContext比如await一个需要回到主线程的操作同时外部又用.Result同步等待就可能直接死锁。1.2 经典版本“能跑”但坑在哪先看第一个问题LazyT默认使用ExecutionAndPublication的线程安全模式第一次访问时会在“访问线程”上执行工厂委托。一旦工厂是异步方法它返回的是一个TaskTLazy 只负责缓存这个 Task并不会等待它完成。也就是说第一次访问后AsyncLazy.Value立刻返回了尚未完成的 Task初始化真正是在后台继续的。这种设计对“异步初始化”本身没问题问题出在工厂方法的执行环境上。如果工厂方法直接写() LoadUserAsync()而且LoadUserAsync内部没有ConfigureAwait(false)那么这个异步状态机在第一次访问线程比如 UI 线程上开始运行遇到第一个await后如果未完成会捕获当前SynchronizationContext后续续延继续回 UI 线程。此时外部如果同步阻塞等待这个 Task这个续延永远等不到执行死锁就形成了。第二个问题是异常缓存。因为 Lazy 缓存的是TaskT实例而不是初始化结果所以即使初始化抛了异常Lazy 也认为“缓存成功了”。后续所有访问都会拿到同一个失败的 Task异常被永久缓存。想重试没门。第三个问题是缺少取消与控制。经典版本根本没有暴露任何取消入口等待的调用方只能干等也不知道初始化到底要多久。2. 优化点一初始化调度与上下文安全2.1 死锁的本质是“同步等异步 上下文捕获”要彻底避免死锁思路不是禁止.Result而是让初始化不依赖任何调用方的SynchronizationContext或TaskScheduler。这样即使调用方傻乎乎地同步阻塞初始化本身也能自由完成。我第一次踩坑是在一个 WPF 项目里。登录接口返回TaskUser我封装了一个AsyncLazyUser登录按钮点击后直接.Result拿用户信息结果界面卡死。排查后发现LoadUserAsync内部有一个await回到了 UI 线程而LazyTaskT的工厂在 UI 线程上启动续延被 UI 线程的调度器捕获但 UI 线程又在.Result上死等两边互相等死锁。解决办法就是我在优化实现里加入的这层2.2 用 Task.Factory.StartNew Unwrap 替代 Task.RunTask.Run本质上也是Task.Factory.StartNew的简化版但它不能指定TaskCreationOptions.RunContinuationsAsynchronously也无法自定义调度器。更精细的写法是public sealed class AsyncLazyT { private readonly LazyTaskT _value; public AsyncLazy(FuncTaskT factory) { _value new LazyTaskT( () Task.Factory.StartNew( async () await factory().ConfigureAwait(false), CancellationToken.None, TaskCreationOptions.DenyChildAttach | TaskCreationOptions.RunContinuationsAsynchronously, TaskScheduler.Default).Unwrap(), LazyThreadSafetyMode.ExecutionAndPublication); } public TaskT Value _value.Value; }这里有几个关键点Task.Factory.StartNew返回的是TaskTaskT必须用Unwrap()展开成TaskT否则拿到的不是真正的初始化任务。TaskScheduler.Default指定使用线程池调度器强制工厂方法在线程池线程上启动避开调用方的 UI 上下文。DenyChildAttach防止异步初始化里的内部任务意外附加到当前任务上。RunContinuationsAsynchronously告诉任务运行时完成后的续延不要内联到当前线程执行减少被外部阻塞卡住的概率。有人会问为什么不直接用async () await factory().ConfigureAwait(false)ConfigureAwait(false)已经能让续延回到线程池了但RunContinuationsAsynchronously是另一个层面的保护它控制的是 Task 完成时续延的执行方式防止任务完成时“顺手”在清空锁或释放资源的线程上执行续延。这两个配合起来才算是把上下文依赖彻底断开。2.3 调度策略选型对比这里整理一下常见方案方便你按场景选方案优点缺点适合场景LazyTaskT(() factory())实现最简单初始化和上下文相关同步等异步容易死锁无 UI 线程、无同步等待的后台服务LazyTaskT(() Task.Run(factory))初始化不占调用线程无法设置RunContinuationsAsynchronously异常缓存问题依旧第一次使用即在线程池中执行外部只 await 不同步等Task.Factory.StartNew(...).Unwrap()可指定调度器与任务选项最可控写法稍复杂UI 程序、混合并发场景、对上下文敏感的场景自定义AsyncLazy加锁可同时控制调度、异常与重试需要自己保证线程安全需要完整语义控制的库代码我在自己的项目里基本只用后两种。如果是给公共库用的AsyncLazy我倾向自定义加锁版本因为后续才方便加入取消和重试。3. 优化点二异常处理与取消支持3.1 默认异常缓存到底是不是坏事很多资料都在说 Lazy 缓存异常是“坑”但严格说这是设计取舍。有些初始化错误是确定性的比如配置项写错、数据库连接串无效这种情况下缓存异常反而有好处每次访问都得到同一个错误不会反复打日志轰炸你。坏的是瞬时故障场景。比如网络闪断、远程服务临时不可用第一次初始化失败后如果用户点一下重试系统依然抛出第一次的异常这就很不友好了。所以优化点二的第一个目标让AsyncLazyT支持“可重试”的异常语义同时保留“缓存成功结果”的行为。3.2 可重试实现失败时把缓存清空我写过一版支持重试的AsyncLazyT核心思想是加一把锁Task 只允许成功失败了就把缓存置空下次访问重新创建public sealed class RetryableAsyncLazyT { private readonly object _gate new(); private readonly FuncTaskT _factory; private TaskT? _task; public RetryableAsyncLazy(FuncTaskT factory) { _factory factory ?? throw new ArgumentNullException(nameof(factory)); } public TaskT Value { get { lock (_gate) { if (_task null) { _task CreateTask(); } return _task; } } } private async TaskT CreateTask() { try { return await _factory().ConfigureAwait(false); } catch { lock (_gate) { _task null; } throw; } } }注意我这里把_factory()的调用放在了CreateTask里并且Value的 getter 是在锁内启动任务的。有人担心这样会让初始化跑在锁里其实不会因为_factory()如果是异步方法调用到第一个await就会立即返回TaskT真正耗时的部分在后台执行。锁只保护_task引用的赋值不保护整个初始化过程。这个实现的细节很值得说当_factory()抛异常时CreateTask会清空_task然后重新抛出异常。对于同时等待同一个 Task 的其他线程它们也会收到异常。下一次任何调用者访问Value都会发现_task是 null于是重新执行初始化。这就是“可重试”。3.3 支持取消等待而不是取消初始化这里有一个关键认知AsyncLazyT的初始化本身不应该被取消。原因很简单懒加载的第一次访问时机不可预测如果调用方取消了但另一个线程已经拿到了 Task 并继续使用初始化就白费了。更重要是取消初始化意味着放弃这个 Task但其他等待者可能还在等它取消会造成“部分等待者拿到异常部分拿到结果”的不一致。所以推荐的做法是初始化继续跑取消只作用于“等待”。.NET 6 以后可以用Task.WaitAsyncpublic async TaskT GetValueAsync(CancellationToken cancellationToken) { var task Value; if (task.IsCompleted) return await task.ConfigureAwait(false); return await task.WaitAsync(cancellationToken).ConfigureAwait(false); }WaitAsync在取消时会抛TaskCanceledException但背后的初始化 Task 不会停止。之后再次访问Value拿到的依然是那个正在完成的 Task不会重复初始化。如果项目还在 .NET Framework没有WaitAsync可以用Task.WhenAny模拟var waitTask Task.Delay(-1, cancellationToken); var completed await Task.WhenAny(task, waitTask).ConfigureAwait(false); if (completed waitTask) { throw new OperationCanceledException(cancellationToken); } return await task.ConfigureAwait(false);这样既保留了取消语义又不会真的取消初始化。3.4 再加上超时控制超时和取消可以合并到一个方法里。我习惯提供两个扩展方法一个支持CancellationToken一个支持TimeSpan超时。例如public async TaskT GetValueAsync(TimeSpan timeout, CancellationToken cancellationToken default) { var task Value; if (task.IsCompleted) return await task.ConfigureAwait(false); return await task.WaitAsync(timeout, cancellationToken).ConfigureAwait(false); }WaitAsync的超时版本在超时后会抛TimeoutException。有个细节要提醒超时之前线程池里的初始化任务可能已经成功只是调用方放弃了。等下一次有人访问Value会发现任务已经完成直接拿到结果这非常正常。4. 完整优化实现与使用指南4.1 把两个优化点整合到一个类里把调度、上下文隔离、可重试、取消等待全组合起来就成了下面这个版本。这段代码我在生产环境里跑过性能和多线程行为都比较稳定public sealed class AsyncLazyT { private readonly object _gate new(); private readonly FuncTaskT _factory; private readonly bool _retryOnFailure; private readonly bool _forceThreadPool; private TaskT? _task; public AsyncLazy(FuncTaskT factory, bool retryOnFailure true, bool forceThreadPool true) { _factory factory ?? throw new ArgumentNullException(nameof(factory)); _retryOnFailure retryOnFailure; _forceThreadPool forceThreadPool; } public TaskT Value { get { lock (_gate) { if (_task null) { _task CreateTask(); } return _task; } } } private TaskT CreateTask() { TaskT task; if (_forceThreadPool) { task Task.Factory.StartNew( async () await _factory().ConfigureAwait(false), CancellationToken.None, TaskCreationOptions.DenyChildAttach | TaskCreationOptions.RunContinuationsAsynchronously, TaskScheduler.Default).Unwrap(); } else { task _factory(); } if (_retryOnFailure) { task WrapRetry(task); } return task; } private async TaskT WrapRetry(TaskT task) { try { return await task.ConfigureAwait(false); } catch { lock (_gate) { if (ReferenceEquals(_task, task)) { _task null; } } throw; } } public TaskT GetValueAsync(CancellationToken cancellationToken default) { var task Value; if (cancellationToken.IsCancellationRequested) return Task.FromCanceledT(cancellationToken); if (task.IsCompleted) return task; return task.WaitAsync(cancellationToken); } public TaskT GetValueAsync(TimeSpan timeout, CancellationToken cancellationToken default) { var task Value; if (task.IsCompleted) return task; return task.WaitAsync(timeout, cancellationToken); } }几个设计细节必须解释清楚_gate锁只保护_task的读写和赋值不在锁内执行await所以不会出现异步死锁。WrapRetry里用了ReferenceEquals(_task, task)判断避免清空时误删已经被其他线程重新创建的 Task。竞态条件是这样的线程 A 拿到的 Task 失败正准备清空_task此时线程 B 访问Value看到_task非空没有创建新任务。线程 A 清空_task线程 B 手里的 Task 还是旧的失败任务它也会收到异常。如果线程 C 在线程 A 清空后访问才会创建新任务。这个窗口期很小但语义上是合理的。forceThreadPool默认 true适用于 UI 程序、ASP.NET 有同步上下文的场景。如果确认在无上下文环境且希望减少线程切换可以设 false。4.2 使用方式和场景示例最简单的用法var userLazy new AsyncLazyUser(() userService.GetByIdAsync(42)); // 第一次异步访问 var user await userLazy.GetValueAsync(); // 后续访问 var sameUser await userLazy.Value;带超时和取消using var cts new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { var user await userLazy.GetValueAsync(TimeSpan.FromSeconds(3), cts.Token); // 操作成功 } catch (TimeoutException) { // 等待初始化超时初始化还在后台继续 } catch (OperationCanceledException) { // 用户主动取消等待 }对于错误重试var configLazy new AsyncLazyConfiguration(LoadConfig, retryOnFailure: true); // 第一次失败 try { var config await configLazy.Value; } catch (Exception) { /* 日志 */ } // 第二次会自动重试 var config await configLazy.Value;这就是优化的价值同一个变量既能延迟加载又能在瞬时失败后自动恢复。4.3 两个容易翻车的细节第一Value属性不要用async包装。我见过有人写public async TaskT Value await _task;这样每次访问都会包一层 async 状态机增加分配和开销。直接返回 Task 更高效调用方自己决定怎么等。第二初始化工厂不要用async void。FuncTaskT本身是合法的但如果有人在工厂里写出async void方法异常永远不会被包装进 Task会直接抛到线程池导致进程崩溃。生产代码里我会强制检查if (factory.Method.ReturnType ! typeof(TaskT)) { throw new InvalidOperationException(工厂方法必须返回 TaskT); }当然这不是万能的async void在编译期会有警告最好直接用分析器禁止。5. 常见问题与排查实录5.1 死锁排查表现象可能原因处理方式UI 线程卡死外部使用.Result同步阻塞且初始化内部有上下文捕获使用forceThreadPool: true外部改用await GetValueAsync()初始化后第一次访问正常第二次访问卡死工厂内用async void或绑定了指定线程调度器检查工厂方法定义不要用async void后台服务偶发挂起Factory.StartNew没有指定TaskScheduler.Default捕获了调用方调度器使用TaskScheduler.Default强制线程池多线程同时第一次访问时初始化执行多次没有用锁或 Lazy 线程安全模式检查是否到处都是每次 newAsyncLazyT确保单例或加锁5.2 异常语义排查表现象可能原因处理方式初始化失败后再次访问仍抛第一次异常retryOnFailure设为 false或内部使用LazyTaskT缓存失败 Task设置为 true用上面的完整实现初始化失败后重试日志重复打多个线程拿到同一个失败 Task重复 catch在调用侧加入SingleFlight或 log 去重第一次初始化很慢后续同步访问超时WaitAsync超时只是放弃等待初始化还在跑向调用方解释清楚语义必要时在创建前加 UI 提示5.3 性能与内存细节加了锁和重试包装后每次访问多一个 lock 和一次引用比较成本几乎可以忽略。真正影响性能的是Task.Factory.StartNew的线程调度如果初始化是纯计算且非常快forceThreadPool可能会造成一次不必要的线程切换。我建议在构造函数里加一个参数支持关闭线程池强制像上面代码里那样。还有一个内存点_factory委托如果被长期持有并且捕获了大对象会随着AsyncLazy存活。成功初始化后理论上可以释放工厂委托但考虑到retryOnFailure时需要重新调用工厂不能简单置 null。如果项目对内存极敏感可以增加一个配置项表示“失败不重试”成功后就释放委托。我的完整实现里没有处理因为需要权衡复杂度但值得知道。5.4 为什么 WaitAsync 比 Task.WhenAny 更值得用WaitAsync是 .NET 6 引入的它除了超时和取消还会自动处理任务完成后的异常传播比手写Task.WhenAny少很多边界情况。如果你的项目已经在 .NET 8 或 .NET 9直接用await task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);不需要自己造轮子。只有在老版本 .NET Framework 里才需要写WhenAny Delay的兼容版本。结尾我在实际项目里的选择做了这么多年并发代码我对AsyncLazyT的体会很直接它不难但细节决定生死。默认的LazyTaskT不是不能用而是你必须清楚地知道它缓存异常、固定调度器并且可能被同步等待坑。我会在库的默认参数上选择retryOnFailure: true和forceThreadPool: true这两个默认值在绝大多数业务场景里最安全。如果遇到极简的内部工具类我才会关闭线程池强制换取那一点点性能。最后再分享一个小技巧如果WaitAsync因为版本问题用不了别急着写Task.WhenAny先考虑升级目标框架。自从 .NET 6 之后异步 API 的默认行为和线程调度已经比以前稳定太多很多以前手写的最佳实践现在标准库自己就给你处理好了。