YAOTU INSIGHTS

C# AsyncLazy实现指南:解决Lazy<T>异步初始化的线程安全与异常重试

C# AsyncLazy实现指南:解决Lazy<T>异步初始化的线程安全与异常重试
在C#的日常开发里延迟初始化和异步操作是两个经常被放在一起聊的话题。前者靠LazyT解决后者靠async/await解决。可一旦把两者合在一起——比如“首次访问时才去异步加载数据”——就会立刻发现LazyT不够用而直接写同步锁又会让代码变得很难看。这个坑我踩过很多次后来用上了AsyncLazyT这个思路才彻底理顺。简单说AsyncLazyT就是给LazyT补上异步能力的工具。它的核心应用场景是对象初始化成本高、必须异步执行比如读文件、查数据库、拉远端配置同时又要保证线程安全、结果只需要算一次。它能替代大量手写的“锁 flag”代码让延迟初始化在异步世界里变得干净又可控。这篇文章适合正在做服务端开发、写过或想写异步单例逻辑的.NET开发者尤其是那些对线程安全和初始化时序比较敏感的人。1. 为什么要折腾AsyncLazyLazy 到底差在哪1.1 Lazy 的老套路同步世界里的好东西在很多C#项目里LazyT几乎是“懒加载的默认答案”。它解决的痛点是某些对象的构造过程很费资源但又不是每次启动程序都必须马上用到那就拖到第一次访问时再创建。private readonly LazyExpensiveService _service new LazyExpensiveService(() new ExpensiveService()); public ExpensiveService Service _service.Value;这段代码使用了一个默认构造函数背后涉及到的机制值得展开说。LazyT有几种线程安全模式ExecutionAndPublication是默认设置它保证工厂委托只会执行一次且多个线程同时访问Value时只有一个线程真正跑构造函数其余线程会等待它完成。这种设计的好处是代码很直观读起来和直接写一个属性几乎一样。但问题也恰恰出在这里一旦构造函数里藏了异步调用这套机制就崩了。因为LazyT的工厂委托是FuncT它只能同步返回结果不能返回TaskT后让调用方自己等待。1.2 异步时代的尴尬把Task塞进Lazy的诱人陷阱很多人第一次尝试的改法就是直接把异步方法包一层让工厂返回TaskTprivate LazyTaskExpensiveService _service new LazyTaskExpensiveService(async () await CreateAsync()); public TaskExpensiveService Service _service.Value;这段代码能运行但埋了几个明显的雷。第一个雷是调用方的体验很差。原本访问同步属性就是一个取值动作现在成了拿Task再await所有依赖方都要跟着改异步。第二个雷更隐蔽如果CreateAsync()在第一次执行时抛异常这个异常会被捕获并放进Task里但LazyTaskT的缓存机制不知道这个Task是失败的它会认为“反正我已经执行过了以后直接返回这个Task”。于是异常被永远缓存下来后续每次调用都拿同一个失败的Task重试逻辑完全无从谈起。这背后其实是“同步语义”和“异步语义”的错位。同步世界里LazyT缓存的是“值”异步世界里你希望缓存的是“异步操作的结果”同时还要保留对异常的处理能力。直接嵌套等于把两个不同层次的语义强行绑在一起自然会出问题。1.3 线程安全再思考不是“锁了”就万事大吉有关线程安全我想多写几句。AtomicInteger这类类型常被拿出来问“是否线程安全”标准答案是单个读改写操作是原子的但多个操作的组合不是。AsyncLazy面临的问题类似甚至更复杂。我见过不少团队把“线程安全”简单理解为“用锁把所有操作包起来”。放到延迟初始化场景如果你的代码是private ExpensiveService? _service; private readonly object _lock new object(); public ExpensiveService Service { get { lock (_lock) { if (_service null) { _service CreateSync(); } return _service; } } }这段代码是线程安全的但它只解决了“多个线程抢着初始化”的问题。一旦CreateSync()换成await CreateAsync()整个写法就作废了。因为lock不能跨越await继续持有——你在异步方法里await之前拿到锁await之后代码执行的线程可能已经变了锁的归属关系就乱了。这也是为什么AsyncLazy不能简单地在LazyT外面包一层async而必须从底层重新设计“异步初始化锁”的机制。2. 设计AsyncLazy的核心思路锁、重入和异常2.1 拦路虎一异步操作需要真正意义上的“异步锁”既然lock跨不过await那就需要一种能“跨异步等待”的互斥机制。常见的做法是使用SemaphoreSlim(1, 1)。它能await获取、Release释放而且等待期间不会阻塞线程。但SemaphoreSlim也有自己的问题它需要手动管理获取和释放一旦忘记释放就会死锁。更麻烦的是它和LazyT的“工厂最多执行一次”语义并不完全匹配。你可以用信号量保证只有一个线程执行初始化但你怎么知道其他线程应该等多久如果正在初始化的线程抛异常了等待的线程是继续等还是也抛异常这些都需要额外设计。2.2 拦路虎二重入问题比想象中更容易踩到“重入”是指初始化过程中又触发了对同一个初始化结果的访问。听起来像是死循环但在真实代码里并不罕见。举个例子初始化一个数据库连接池时连接池内部需要读取一个配置文件而这个配置文件恰好也是用AsyncLazy懒加载的。如果这两个初始化过程互相引用或者同一个AsyncLazy在工厂方法内部又访问了自己的Value就会发生重入。LazyT的ExecutionAndPublication模式会直接抛出InvalidOperationException提示“值正在初始化过程中被递归访问了”。AsyncLazy也需要处理这个问题最合理的方式是让重入访问立刻抛异常而不是让它死锁或无限等待。因为一旦死锁排查难度要比一个明确异常大得多。2.3 拦路虎三异常缓存策略决定了系统的自愈能力关于异常缓存前面已经提到了LazyTaskT的坑这里我展开讲讲什么才是合理的策略。LazyT默认其实不缓存初始化异常ExecutionAndPublication模式下工厂抛异常后下次访问会重新执行工厂。这个设计对同步场景是合理的因为异常通常是临时性的比如网络抖动、文件被占用。但到了异步场景很多自研方案会把异常直接缓存在Task里导致初始化永不重试。我的建议是模仿LazyT的默认行为初始化失败清空内部状态下次访问重新执行。这个策略尤其适合服务端的场景。试想一下一个配置服务启动时依赖远程配置中心恰好启动那一刻网络不通。如果异常被永久缓存这个配置服务就废了必须重启进程才能恢复。如果异常不缓存下一次请求进来时重新拉取配置可能网络已经恢复服务就自动痊愈了。2.4 市面上的成熟实现可以借鉴什么在写自己的AsyncLazy之前有必要看看别人是怎么做的。我最常参考两个实现。一个是Microsoft.VisualStudio.Threading包里的AsyncLazyT。它的设计思路非常紧凑内部持有LazyTaskT但通过自定义的IDisposable机制来管理重入检测。它提供了GetValueAsync()方法和Value属性还支持传入JoinableTaskFactory来避免UI线程死锁。这个实现适合WPF、WinForms等有UI线程的环境。另一个是Nito.AsyncEx库的AsyncLazyT。它的API更像标准库直接提供TaskT AsTask()方法甚至支持await直接作用于AsyncLazy实例。它的内部实现用的是LazyTaskT但特别处理了“异步重入”和“异常缓存”两个问题。这个库和平台无关适合通用服务端开发。我给一个建议如果只是想在项目里快速用上可靠方案优先考虑Nito.AsyncEx如果你有UI线程死锁的顾虑看看Microsoft.VisualStudio.Threading如果不想引第三方依赖那就参考这两个库的设计自己写一个。我自己的项目里有一版自研的原因很简单公司代码审核不喜欢随意引第三方包而且自研的那版可以完全控制性能细节。3. 手写一个可用的AsyncLazy 来着实操3.1 完整代码一个最小但够用的实现先贴代码这段代码综合考虑了线程安全、重入检测和异常缓存是“可用级”的标准版本。public class AsyncLazyT { private readonly object _syncRoot new object(); private readonly FuncTaskT _factory; private LazyTaskT _lazy; private bool _isInitializing; public AsyncLazy(FuncTaskT factory) { _factory factory ?? throw new ArgumentNullException(nameof(factory)); _lazy new LazyTaskT(CreateTask); } private TaskT CreateTask() { lock (_syncRoot) { if (_isInitializing) { throw new InvalidOperationException( AsyncLazy重入检测初始化过程中再次访问了Value。); } _isInitializing true; } return _factory().ContinueWith(t { lock (_syncRoot) { _isInitializing false; } if (t.IsFaulted) { // 关键异常不缓存下次访问重新初始化 lock (_syncRoot) { _lazy new LazyTaskT(CreateTask); } throw t.Exception.InnerException ?? t.Exception; } return t.Result; }, TaskContinuationOptions.ExecuteSynchronously); } public TaskT Value { get { lock (_syncRoot) { return _lazy.Value; } } } public TaskT GetValueAsync() Value; }3.2 核心逻辑解读每个锁都是为了什么上面这段代码可能看起来很紧凑但每一行都有它的来由我来拆解一下。_syncRoot是一个普通对象锁它负责两类保护。第一类是保护_lazy字段的读写防止多个线程同时替换它。第二类是保护_isInitializing标志这个标志用于重入检测。和“简单的lock null检查”不同这里用LazyTaskT作为内部容器本身就已经承担了“多线程同时访问时只执行一次工厂”的责任。也就是说外层的锁不需要处理“并发初始化”的问题只需要处理“异常后重置”和“重入检测”的问题。这个设计极大简化了锁的粒度。CreateTask里的流程值得细看。当LazyTaskT触发工厂时说明这是第一次初始化或者上一次失败后重置。这里先拿到_syncRoot锁设置_isInitializing true然后立刻释放锁开始执行真正的异步工厂。为什么不等异步执行完再释放锁因为异步操作执行期间不能持有锁否则任何想访问状态的线程都会卡住。这里的技巧是只用一个标志位表明“我正在初始化”真正的等待是靠Task完成的。ContinueWith里的ExecuteSynchronously是关键性能细节。它确保当异步操作完成时回调和初始化逻辑在同一个线程上执行减少线程切换。在服务端场景这能省下不少上下文切换开销。当然如果你的工厂涉及UI线程调度可能需要换掉这个选项但服务端无脑用没关系。3.3 异常重试的细节多写两行代码解决大问题这段实现里最容易被忽略但最值得借鉴的是异常后的重置逻辑。lock (_syncRoot) { _lazy new LazyTaskT(CreateTask); } throw t.Exception.InnerException ?? t.Exception;当任务失败时我把_lazy重置成一个新的LazyTaskT实例。这样下一次调用Value时会重新走一遍完整的初始化流程。锁在这里是必要的因为可能有多个线程同时观察到失败它们不能各自重置否则会生成多个新的_lazy实例破坏“只初始化一次”的语义。这里还有个细节处理抛出异常时选择t.Exception.InnerException ?? t.Exception而不是直接抛t.Exception。原因是ContinueWith里拿到的t.Exception是AggregateException如果直接抛出去调用方的catch捕获到的类型会是AggregateException这可能和工厂方法直接抛出的异常类型不一致。把内部异常剥出来抛能让调用方的异常处理逻辑更符合直觉。3.4 使用示例缓存远端配置来看一个真实使用场景。假设系统需要一个全局的远端配置服务初始化时要调用HTTP接口且这个配置要全进程共享。public class ConfigService { private static readonly AsyncLazyAppConfig _config new AsyncLazyAppConfig(LoadConfigFromRemoteAsync); public static TaskAppConfig GetConfigAsync() _config.Value; private static async TaskAppConfig LoadConfigFromRemoteAsync() { using var httpClient new HttpClient(); var json await httpClient.GetStringAsync(https://config.example.com/app); return JsonSerializer.DeserializeAppConfig(json); } }使用起来调用方只需要var config await ConfigService.GetConfigAsync();所有调用方共享同一个TaskAppConfig第一次真正访问时并发请求也只会打一次HTTP接口。如果第一次请求网络失败下一次调用会自动重新发起请求不会拿到一个缓存下来的坏结果。4. 实战中的坑我把排查经验整理成速查表4.1 最常见的四个坑死锁、重入、异常、线程切换我把自己在实际项目里踩过的坑整理成一个表格方便快速对照。症状 可能原因 解决方案 await Value时UI卡死 工厂内部等待UI线程 使用JoinableTaskFactory或避免在工厂中访问UI 初始化永远不完成卡在第一次await async方法里使用了阻塞调用 检查是否有.Result/.Wait()混用 抛出InvalidOperationException重入异常 工厂方法内部直接/间接访问自身Value 重新设计初始化顺序拆分依赖 服务永久不可用初始化异常不恢复 异常被缓存在Task中 参考第3节的重置逻辑4.2 阻塞与异步的混用是死锁的头号根源死锁问题在很多.NET项目里都有AsyncLazy场景下尤其容易踩。典型情况是这样某个调用方在同步上下文里用了.Result而这个AsyncLazy的工厂内部又需要回到同一个上下文继续执行。最直接的表现是程序卡死没有任何异常输出。如果你正在使用Microsoft.VisualStudio.Threading的AsyncLazy它内置的JoinableTaskFactory能解决一大半此类问题。自研版本的话我的建议是定好规矩AsyncLazy工厂内部绝不使用.Result或.Wait()调用方也尽量使用await。如果实在有同步阻塞的需求那就把AsyncLazy的初始化提前到进程启动阶段比如写成“预加载”模式异步完成后再进入同步业务逻辑。4.3 重入检测的边界情况依赖循环很容易被忽略很多人在自测时不会触发重入问题但上了生产环境配置一变就出现。最经典的是两个AsyncLazy互相依赖A的工厂需要访问BB的工厂又需要访问A。重入检测在这里的价值在于系统能快速抛出异常并明确指出问题所在而不是让两个线程互相等待直到超时。我在查看Microsoft.VisualStudio.Threading的实现时注意到它的重入异常信息很详细会包含当前异步操作栈。自研版本至少要做到“明确异常类型 清晰描述信息”这样线上日志才能快速定位。4.4 性能考量AsyncLazy本身的开销很小但别滥用从性能角度看AsyncLazy的开销主要集中在内层的LazyTaskT上。ExecutionAndPublication模式下LazyT内部有一个双检锁的机制首次访问后后续访问只是一次 volatile read 加上一个方法调用开销接近零。ContinueWith回调的ExecuteSynchronously选项能减少线程调度开销但也要注意如果你的工厂返回的任务本身很轻量就直接返回Task.FromResult不需要搞异步状态机。这属于微观优化不急的话可以忽略。我更关心的是不要在初始化逻辑里放太多无关操作。AsyncLazy适合的是“贵且少变”的初始化如果一个操作每次调用都可能有新结果那它不该用AsyncLazy直接每次异步调用就好。4.5 单元测试的写法验证“只初始化一次”和“异常后可重试”单元测试是验证AsyncLazy正确性的重要手段。我常用的测试思路有两个。第一个测试验证“并发访问只初始化一次”。用Task.Run开启多个并发任务同时调用Value在工厂里通过Interlocked.Increment计数断言最终计数为1。注意这里的并发任务数量要超过CPU核心数才有意义我一般开20个。[Fact] public async Task Multiple_waiters_should_trigger_factory_once_only() { var counter 0; var lazy new AsyncLazyint(async () { Interlocked.Increment(ref counter); await Task.Delay(50); return 42; }); var tasks Enumerable.Range(0, 20) .Select(_ Task.Run(async () await lazy.Value)) .ToArray(); await Task.WhenAll(tasks); Assert.Equal(1, counter); }第二个测试验证“失败后可重试”。第一次让工厂抛异常断言调用抛出第二次正常返回断言调用成功。这能确保异常缓存策略生效。[Fact] public async Task Failed_initialization_should_be_retried() { var calls 0; var lazy new AsyncLazyint(async () { Interlocked.Increment(ref calls); if (calls 1) throw new InvalidOperationException(boom); await Task.Delay(10); return 1; }); await Assert.ThrowsAsyncInvalidOperationException(async () await lazy.Value); var result await lazy.Value; Assert.Equal(1, result); Assert.Equal(2, calls); }这两个测试覆盖了AsyncLazy最重要的两个契约只算一次、失败不缓存。5. 拿来即用的成熟方案以及我的最终建议5.1 Nito.AsyncEx跨平台通用的首选如果你的项目没有“禁止第三方依赖”的硬性约束Nito.AsyncEx几乎是最好的选择。它的AsyncLazyT用起来非常简单。private static readonly AsyncLazyMyService _service new AsyncLazyMyService(() CreateAsync()); // 调用方 var service await _service;这里直接await一个AsyncLazy实例是因为作者实现了GetAwaiter()方法让异步等待体验和Task一致。它内部对异常缓存的处理参考了我的经验也是失败后可重试。这个库在GitHub上维护活跃社区使用量大踩坑的人多所以各种边界情况都处理得比较到位。5.2 Microsoft.VisualStudio.ThreadingUI场景别头铁在WPF、WinForms这类有UI线程的项目里自研的AsyncLazy容易搞出死锁。Microsoft.VisualStudio.Threading提供的版本引入了一个额外的JoinableTaskFactory参数它能把异步操作和UI线程的交互纳入统一调度。var asyncLazy new AsyncLazyMyViewModel( async () await LoadAsync(), joinableTaskFactory: JoinableTaskFactory.Context);这种设计不仅解决了死锁问题还保留了AsyncLazy本身的“只初始化一次”特性。如果你在写桌面应用我强烈建议别自己造轮子直接用这个包。5.3 选型对比自研还是引包讲一下我的选型逻辑。如果是业务系统内部使用团队对第三方库的接受度较高优先Nito.AsyncEx。如果是基础组件、被多个团队引用的底层库或者公司有严格的白名单机制那就自研基于第3节的代码做扩展。自研话术上核心关注点就三个异步锁的正确性、异常重置、重入检测。再补一个细节AsyncLazyT本身并不是唯一答案如果你的场景是“每次启动基本都会用到”那不如直接用静态初始化或启动时预加载没有必要绕一道延迟初始化的弯。延迟初始化的价值在于“可能用到但概率不高”的场景别为了用而用。5.4 我的落地心得这套东西我在两个项目里实际用过。第一个是一个网关服务配置中心地址动态下发网关启动时不能等配置才起来否则灾难。我用AsyncLazy把配置加载配置成“首次请求时触发”配置文件一旦更新通过弱引用机制让AsyncLazy失效重新初始化。第二个是一个内部工具需要异步创建一个资源重的工作流引擎实例用AsyncLazy后代码量缩减了将近一半而且并发测试稳稳通过。实际操作中我最大的体会是先想清楚“初始化失败后怎么办”再考虑“线程安全怎么写”。很多团队把注意力放在锁和并发上反而忘了失败恢复这种更影响可用性的逻辑。AsyncLazy的设计哲学是把“异步初始化”这个复杂问题拆成三块并发控制交给LazyTaskT、失败恢复通过重置Lazy实例解决、重入检测用一个标志位兜底。这三块各自独立合在一起就构成了一个可靠、可控的延迟异步初始化方案。