尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

AsyncLazy<T> 实战:解开异步懒加载的线程安全与落地姿势

AsyncLazy<T> 实战:解开异步懒加载的线程安全与落地姿势 做后端服务的人大概都经历过这种尴尬一个资源明明想做懒加载结果初始化逻辑偏偏是async的LazyT用不上硬着头皮不用Lazy自己写状态判断又怕高并发首次访问时把下游数据库直接打爆。AsyncLazyT就是专门收拾这个局面的工具它是LazyT和异步编程的结合体把“延迟初始化”和“异步结果”绑在一起用线程安全的方式保证并发首击时异步工厂方法只执行一次所有等待方复用同一个TaskT结果高效且可控。这篇文章我会把AsyncLazyT的原理、实现、线程安全边界、生产级代码和真实场景完整过一遍。适合正在写服务端缓存、连接池复用、配置加载、令牌预热的 .NET 开发者尤其是刚接触异步懒加载、想知道“到底怎么落地”的人。1. Lazy 在异步世界的硬伤三种伪做法和一个真实事故1.1 Lazy 的同步天花板先回顾一下LazyT是什么。它是 .NET 内置的延迟初始化器默认工作在ExecutionAndPublication模式多个线程同时访问Value只有一个线程会执行valueFactory其余线程阻塞等待最终所有人拿到同一个实例。这套机制本身非常稳唯一的问题是valueFactory的类型是FuncT它是同步委托。现实里的懒加载需求越来越是异步的。拉取远程配置、创建数据库连接池、获取 OAuth 令牌、预热一个重量级客户端对象这些初始化动作要么涉及 IO要么涉及网络几乎都长着一张TaskT的脸。把它们塞进FuncT里就意味着必须在同步方法内部去等待一个异步操作——要么.Result要么.Wait()。这就是所谓的 sync-over-async在带同步上下文的框架里最容易引发死锁即使侥幸不锁死也会白白占着线程池线程等 IO造成线程饥饿。1.2 三种伪异步懒加载的姿势我见过不少同事栽在这几种自创写法上逐一拆给你看。第一种直接用LazyTaskT。这种其实已经摸到AsyncLazyT的边了等会儿我会专门讲它为什么是基石。问题在于它默认没有失败重试能力一个人失败了全站永久拿到失败结果非常硬。第二种把异步工厂包进Task.Run再用.Result拿结果。var cache new LazyMyClient(() Task.Run(CreateClientAsync).Result); public static MyClient CreateClientAsync() { // 一堆 IO 操作... return client; }这是纯粹的错误示范。Task.Run没有把异步变同步它只是换一个线程池线程去阻塞。IO 一点没减少死锁风险一点没降线程池调度成本反而更高。每次冷启动都要卡一个线程等网络返回这种写法在生产环境留下的故障案例多得数不过来。第三种自己写标志位判断初始化状态。private TaskMyClient? _task; public TaskMyClient GetClientAsync() { if (_task is not null) { return _task; } _task CreateClientAsync(); return _task; }单线程下这段代码看起来没问题一旦多个请求同时首次访问_task可能被两个线程同时写成不同的值CreateClientAsync被执行两次两个调用方各自等一个 Task下游数据库被重复请求打到怀疑人生。这就是典型的 check-then-act 竞态条件。1.3 一个真实事故并发首击把数据库打爆之前接手过一个内部报表服务启动后第一个小时高概率报警数据库连接数飙满大量超时。排查到最后根因就是上面第三种写法。那段代码负责缓存一份配置表服务重启后的第一波并发请求同时涌入_task判断为空十几个线程各自触发了LoadConfigAsync一个查询变成了十几份重复查询把数据库连接池瞬间占满。改法其实不复杂就是把那段手写标志位换成线程安全的一次性初始化。但这引出了一个更通用的问题我们需要一个工具它既能像LazyT一样保证“只创建一次”又能承载异步结果。这就是AsyncLazyT存在的意义。2. AsyncLazy的内在逻辑Lazy负责单次创建Task负责异步发布2.1 最简实现继承 LazyTask AsyncLazyT最朴素的写法可能比你想象中短得多public sealed class AsyncLazyT(FuncTaskT taskFactory) : LazyTaskT(taskFactory, LazyThreadSafetyMode.ExecutionAndPublication) { }你没看错核心就这么多。为什么这能成立因为LazyT保证taskFactory这个委托在整个进程生命周期内最多执行一次而TaskT本身就是一个“未来会有的结果”。多个线程await同一个TaskT是完全安全的不管有多少消费者它们最终看到的都是同一个完成状态、同一个结果值。这相当于做了一个职责拆分LazyT负责“单次创建”的发布语义解决竞态条件。TaskT负责“异步结果”的承载和等待语义让调用方能await。微软官方文档里也常推荐这种组合方式来包装异步懒加载。很多第三方库比如 Nito.AsyncEx、Prism里的AsyncLazyT都是从这个模式演化出来的只是加了更多工程化能力。2.2 ExecutionAndPublication模式到底保护了什么要真正理解这个工具得知道Lazy在ExecutionAndPublication模式下做了什么。它内部是一套标准双重检查锁Double-Checked Locking多个线程同时访问Value先做一次快速读。锁竞争只有一个线程进入临界区执行valueFactory。其余线程在锁上等待工厂执行完成后拿到同一个m_value引用。m_value的写入带 volatile 语义保证对其他线程可见。打个比方一群人同时走进一间屋子想抢同一个签名板屋子里只有一把钥匙。拿到钥匙的那个人进去签好名把签名板挂到窗口后面的人不用再进屋子直接看窗口上那块板就行。谁也不会重复签名。在AsyncLazyT里这块“签名板”就是那个唯一的TaskT。一个线程触发异步工厂其他人等待最终所有人拿到的都是同一个异步操作。2.3 失败缓存在异步世界是个大问题LazyTaskT用起来虽然爽但它有一个隐藏的硬伤默认语义下工厂创建的TaskT一旦失败这个失败结果也会被永久缓存。网络抖动、超时、依赖服务临时不可用这些在异步世界里太常见了而Lazy的哲学是“一辈子只初始化一次”。结果就是初始化方法第一次抛异常以后每次访问都拿到同一个 Faulted Task翻译成人话就是服务永久坏掉了直到进程重启。很多人在这个坑里栽了跟头才意识到异步懒加载需要的不只是“只初始化一次”还要“初始化失败后可以重来”。这就引出了生产级AsyncLazyT设计的核心问题状态可控失败可重置。3. AsyncLazy的线程安全边界在哪3.1 发布原子性 ≠ 业务逻辑原子性社区里有个经久不衰的问题Java 的AtomicInteger线程安全吗答案是安全但只对单个原子操作安全。你用i没问题但用它实现check-then-act先判断再操作就可能不安全。AsyncLazyT的“线程安全”也是这个层次。它能保证的是初始化动作本身是原子的多个线程并发访问时工厂只会执行一次。它不能保证的是你围绕它写的业务判断也是原子的。举个例子if (ShouldRefresh()) { _cache.Reset(); } await _cache.Value;ShouldRefresh()判断和Reset()这两个动作之间是有间隙的。两个线程同时判断都返回 true就可能连续 Reset 两次触发两次初始化。这种问题AsyncLazyT本身管不了需要你设计好业务层的状态管理要么把判断放进工厂方法内部要么引入版本号机制做 CAS。3.2 AsyncLocal执行上下文最隐蔽的串线问题这是我在生产环境里踩过最隐蔽的一个坑。AsyncLocalT可以在异步流程中传递数据它底层会处理线程池线程切换时的上下文恢复。问题来了AsyncLazyT创建的 Task 是进程级共享的它内部捕获到的是“第一个触发初始化线程”的AsyncLocal值。所有后续消费者虽然await到了同一个 Task但 Task 内部看到的上下文永远是最初那个人的。典型事故场景是租户系统。你在工厂方法内部通过AsyncLocal读取当前租户 ID再按租户拉取配置public class TenantConfigCache { private readonly AsyncLazyTenantConfig _cache new(TenantConfigFactory.LoadAsync); private static async TaskTenantConfig LoadAsync() { // 错误示范依赖AsyncLocal中的租户信息 var tenantId TenantContext.Current!.TenantId; var config await HttpService.GetConfigAsync(tenantId); return config; } }第一个请求 A 触发了初始化Task 捕获了 A 的租户上下文加载了 A 的配置。之后请求 B、C 全部复用同一个 Task拿到 A 的配置。这种串线比普通缓存串数据还难排查因为第一次调试永远是对的第二次才开始“随机”错。对策很简单如果初始化结果本身和请求维度强相关就不要用进程级单例AsyncLazyT。把租户 ID 作为缓存键用键级缓存去隔离工厂方法内部也不要读AsyncLocal数据要从参数显式传进来。3.3 同步上下文死锁的两种表现AsyncLazyT的Value属性必须返回TaskT让调用方去await。一旦有人图省事在同步上下文环境下用.Result或.GetAwaiter().GetResult()去拿结果就可能踩死锁。最典型的场景是 UI 框架。WPF 或 WinForms 的 UI 线程首次访问一个AsyncLazyT工厂方法内部的某个await想回到 UI 线程执行后续代码而 UI 线程现在正卡在等待这个 Task 上互相等待成了死结。在 ASP.NET Core 这类没有同步上下文的环境里死锁表现可能变成线程池饥饿大量请求线程被.Result卡住线程池被榨干吞吐量骤降。所以使用AsyncLazyT有一条铁律库代码中绝对不要同步阻塞等待这个 Task。对外只暴露TaskT消费者自己决定await。同时工厂方法内部对不依赖调用方上下文的中途等待尽量用ConfigureAwait(false)减少上下文捕获带来的死锁面和调度开销。4. 生产级实现可重置、可取消、可重试4.1 一个自绘版的完整代码继承LazyTaskT虽然简洁但无法做到“失败后重置”“检查是否已创建”这些工程化需求。我自己更常用一个自绘的双重检查锁版本逻辑透明边界可控public sealed class AsyncLazyT { private readonly FuncTaskT _factory; private readonly object _gate new(); private TaskT? _task; public AsyncLazy(FuncTaskT factory) { _factory factory ?? throw new ArgumentNullException(nameof(factory)); } /// summary /// 获取异步初始化的结果。外部调用方应始终 await 这个 Task。 /// /summary public TaskT Value GetOrCreate(); /// summary /// 是否已经执行过异步工厂。 /// /summary public bool IsValueCreated { get { var task Volatile.Read(ref _task); return task is not null; } } /// summary /// 重置状态允许下一次访问时重新初始化。 /// 如果当前初始化已失败调用 Reset 后即可重试。 /// /summary public void Reset() { lock (_gate) { _task null; } } private TaskT GetOrCreate() { // 快速路径已经发布过任务直接返回 if (Volatile.Read(ref _task) is { } existing) { return existing; } lock (_gate) { // 第二次检查防止多个线程同时进入临界区 if (_task is { } created) { return created; } TaskT task; try { task _factory(); } catch (Exception ex) { // 工厂方法在返回 Task 之前就可能同步抛异常 // 统一包装成 Faulted Task保持对外语义一致。 task Task.FromExceptionT(ex); } // 即使是失败结果也暂时缓存调用方捕获异常后可自行 Reset _task task; return task; } } }几个设计要点说明一下快速路径的Volatile.Read保证了多线程下读取_task的可见性代价比每次都进锁小很多。双重检查锁保证了并发首击只有一次真实初始化。try/catch把同步异常和异步异常统一成 Task 语义。很多人忽略这一点如果工厂方法在返回 Task 之前就抛异常没有这个 try/catch_task根本不会被赋值下一次访问又会重新执行工厂造成另类重复初始化。失败结果默认缓存但多了一个Reset()逃生通道业务层可以决定何时重试。4.2 失败重试该怎么设计Reset优先内置重试要慎重很多刚接触AsyncLazyT的人会问为什么不在内部自动重试答案是自动重试会让并发控制变得复杂。假如 Task 失败后十个并发请求同时发现这个 Task 是 Faulted它们同时调用Reset然后重新触发工厂结果就是失败的请求被放大成十倍的下游重击。要避免这种“重建风暴”你必须额外加一层协调机制要么用锁把重建动作也串行化要么引入状态机把“重建中”的状态也管理起来。这些东西不是做不到而是会让AsyncLazyT从 30 行膨胀到一百多行且每个边界都很难测到。我的实践经验是核心类保持单次初始化的纯净语义失败重试交给上层。最常用的失败重试写法就两行try { return await _config.Value; } catch (Exception) { // 记录日志后允许下次访问时重新初始化 _config.Reset(); throw; }如果服务是后台任务驱动也可以定时检查 Task 状态做成主动刷新if (cache.IsValueCreated cache.Value.IsFaulted) { cache.Reset(); }这种方案的优点是失败处理策略完全可见没有隐式的重试魔法出问题的时候好定位。4.3 取消与超时等待可以取消初始化本身难取消如果你的异步工厂支持CancellationToken可以给AsyncLazyT加一个重载让首次触发初始化的调用方把 token 传进去public TaskT GetValueAsync(CancellationToken cancellationToken) { // 首次创建时token生效后续等待时token可以取消等待 }但这里有个必须先想清楚的语义一旦工厂方法开始执行后续所有消费者传入的 token 都无法取消这个已经开始的初始化。token 只能取消“等待”这个动作不能取消“正在跑”的工厂。如果真的有“初始化跑太久我要中断它”的需求正确的做法是让工厂内部自己实现超时比如用CancellationTokenSource.CancelAfter或显式的Task.WhenAny超时逻辑而不是指望调用方传入的 token 能把任务拽停。4.4 性能开销参考有人担心AsyncLazyT是不是每次访问都有锁开销。实际不是。初始化完成后快速路径就是一个Volatile.Read加一次空判断开销在几十纳秒量级对你整个请求的耗时来说可以忽略不计。真正的成本只发生在并发首击的那一瞬间抢锁、执行工厂、其他线程等待。这部分成本不是锁带来的而是异步初始化本身的工作量。所以使用AsyncLazyT不需要有性能顾虑它真正的远大于锁开销的价值是帮你把“重复初始化造成下游压力”这个风险消灭在架构层面。5. 能落地的场景和不该用它的地方5.1 服务端配置缓存与首请求预热服务端最常见的需求启动后第一次请求到达时异步加载配置中心快照以后全部复用。用AsyncLazyT实现非常顺public sealed class AppConfigProvider { private readonly AsyncLazyIReadOnlyDictionarystring, string _configCache; public AppConfigProvider(IConfigurationSource source) { _configCache new AsyncLazyIReadOnlyDictionarystring, string(async () { var snapshot await source.GetSnapshotAsync().ConfigureAwait(false); return snapshot.ToDictionary(x x.Key, x x.Value); }); } public TaskIReadOnlyDictionarystring, string GetConfigAsync() { return _configCache.Value; } }并发首击时只有一个请求真正执行GetSnapshotAsync其余请求全部等待同一个 Task。服务从冷启动到稳定数据库或配置中心只需要一次请求。这类场景的问题通常不是“要不要用”而是“什么时候失效”解决办法就是前面讲的Reset配合定时刷新任务。5.2 键级并发缓存AsyncLazy × ConcurrentDictionary当缓存数据本身是由维度切分的比如每个租户一份配置、每个用户一份资料进程级单例就不好使了。这种情况用ConcurrentDictionaryTKey, AsyncLazyTValue是标准解法public sealed class AsyncCacheTKey, TValue where TKey : notnull { private readonly ConcurrentDictionaryTKey, AsyncLazyTValue _cache new(); private readonly FuncTKey, TaskTValue _factory; public AsyncCache(FuncTKey, TaskTValue factory) { _factory factory; } public async TaskTValue GetOrAddAsync(TKey key) { var lazy _cache.GetOrAdd(key, k new AsyncLazyTValue(() _factory(k))); try { return await lazy.Value.ConfigureAwait(false); } catch { // 失败时移除对应键避免坏结果永久驻留 if (lazy.IsValueCreated lazy.Value.IsFaulted) { _cache.TryRemove(key, out _); } throw; } } }它的含义是同一个 key 的首次并发访问只有一次底层 IO不同 key 之间完全隔离。这个模式我在实际项目中做了很多微服务间配置拉取、白名单加载效果都非常稳定。注意失败后要删掉键否则坏 Task 会一直挂在字典里。5.3 连接池与令牌预热另一个高频场景是重量级客户端的按需预热。例如连接事件中心、消息队列或者构造一个内部服务 SDK 的客户端对象。很多 SDK 的构造函数本身很轻真正要命的是第一次使用时的握手、鉴权、建链。用AsyncLazyT把整个“创建并预热”的流程包起来public sealed class EventHubPublisher : IAsyncDisposable { private readonly AsyncLazyEventHubProducerClient _clientLazy; public EventHubPublisher(string connectionString) { _clientLazy new AsyncLazyEventHubProducerClient(async () { var client new EventHubProducerClient(connectionString); await client.ConnectAsync().ConfigureAwait(false); // 伪代码示意 return client; }); } public async Task SendAsync(byte[] payload) { var client await _clientLazy.Value.ConfigureAwait(false); await client.SendAsync(payload).ConfigureAwait(false); } public async ValueTask DisposeAsync() { if (_clientLazy.IsValueCreated) { var client await _clientLazy.Value.ConfigureAwait(false); await client.DisposeAsync().ConfigureAwait(false); } } }注意第二处不能偷懒既然AsyncLazyT缓存的是一个需要释放的客户端那释放逻辑也必须由你负责。它只管创建和发布不管销毁。5.4 不建议用 AsyncLazy 的三个地方第一初始化结果依赖请求上下文。比如必须根据当前用户、当前租户动态计算的不适合进程级缓存。硬要用数据串线的问题会让你踩到怀疑人生。第二需要精细化生命周期管理的资源。AsyncLazyT不是IAsyncDisposable它不感知资源释放。如果缓存对象生命周期要和请求绑定、和单元绑定需要你自己在外面管理。第三初始化动作本身是由业务事件驱动的状态流。比如“用户登录后加载退出后清空”这种AsyncLazyT帮不上什么忙直接用命令式填充缓存更清晰。关于AsyncLazy使用体会的一点补充AsyncLazyT真正解决的是并发下的单次初始化问题但真正让它在生产环境稳定的不只是那几行核心代码而是围绕它的工程决策什么数据该缓存、失败后怎么重试、过期了怎么刷新、键怎么设计。这些决策做对了哪怕核心实现只有几十行整个系统也会非常稳。我个人的经验是服务端场景优先选择键级缓存 Reset模式进程级单例只用于真正全局唯一的数据工厂方法内部不要依赖AsyncLocal所有对外暴露的入口都返回TaskT拒绝任何 sync-over-async 的诱惑。做到这四点你基本不会再被“异步懒加载”这个问题折腾第二次。
返回列表