资讯动态

C# 高并发异步懒加载:AsyncLazy<T> 核心实现与避坑指南

发布时间:2026/10/10 14:00:33 来源:尧图企业网站定制
写了很多年 C# 并发代码AsyncLazyT是我用得最多、也最容易被低估的类型。多数团队对它的认知停留在“异步场景下的单例初始化”但当服务真正进入高并发——比如早上九点几十台实例同时首次启动、几百个请求聚在一处等同一个数据库连接创建完成——随手写一个LazyTaskT或者带volatile bool的手工类就会接连踩坑。这篇文章不喊空泛的“使用 AsyncLazy 能提升性能”这种口号而是把它的实现拆开怎么从一把 lock 和一个 Task 出发做到高并发下稳定、可靠又拆得开、看得懂。先说清楚一个基本事实AsyncLazyT的核心价值不是“更快”而是“更可控”。它把异步初始化这件小事从一个容易引发锁竞争、死锁、资源泄漏的模糊地带变成一个语义清晰的组件。性能提升是正确设计之后顺带出现的红利而不是目的本身。下面我按自己的工程实践把设计、性能、可靠性、可维护性这几个角度逐步展开。1. 为什么 AsyncLazy 不是“Lazy 加 async”这么简单1.1 从 LazyTask 说起很多人第一次实现“异步懒加载”时都会写出这样的代码private readonly LazyTaskHttpClient _clientLazy new(() CreateClientAsync()); public TaskHttpClient GetClientAsync() _clientLazy.Value;这段代码可以工作而且比LazyHttpClient配合.Result那种写法安全得多——至少没有在第一次访问时用同步阻塞把线程池拖死。但从性能角度看它有一个隐蔽缺陷LazyT内部的初始化执行者是第一个访问Value的那个线程。也就是说如果某个请求线程第一次访问_clientLazy.ValueCreateClientAsync()的同步部分就运行在这个请求线程上。假如创建客户端需要先读取配置文件、做复杂计算、初始化本地缓存这些操作会直接占用请求线程的可执行时间。更麻烦的是如果这段同步代码里出现了线程亲和或者依赖AsyncLocal的上下文信息初始化结果就可能被“第一个调用者”的身份污染。LazyTaskT能解决的只是“别让调用方同步等待”这一层问题。它没有真正解决“初始化代码运行在谁那里”的语义问题。1.2 手工并发初始化方案的两个经典翻车点不使用现成库时很多人会手工写一个带锁的异步初始化。最常见的版本是private readonly object _sync new(); private HttpClient? _client; private bool _started; public async TaskHttpClient GetClientAsync() { lock (_sync) { if (_client ! null) return _client; if (!_started) { _started true; return await CreateClientAsync().ConfigureAwait(false); } } // 等待初始化完成 }这个版本在“初始化进行中另一个请求进来了”的时候会卡住第二个请求拿不到_client又不知道去哪里等待第一个请求创建的实例只能在锁外面轮询或者直接抛异常。于是有人引入SemaphoreSlim在初始化完成时释放信号量让后面的请求排队等待。这个方案在小并发下看起来还行一旦初始化抛异常SemaphoreSlim的释放点没走到后面的所有请求全部永久卡死。这类问题不是代码写得不够细而是没有把“初始化未完成时的等待语义”当做一个一等公民来处理。AsyncLazyT存在的意义就是提供一个专门的并发原语来管理这个状态。1.3 一次“高并发首次访问”的真实推演假设有一个服务内部持有配置中心的客户端第一次请求时需要远程拉取配置。同时有 200 个请求线程在服务启动瞬间涌进来。正确的行为应该是只有一个请求真正负责拉配置其余 199 个请求安静地等待同一份结果。如果实现里每个请求都“检查一下配置客户端是否为空”然后有各自的初始化逻辑那高并发下会出现两个问题多个请求同时触发初始化重复拉取配置远程服务被打爆初始化失败后每个请求各自重试造成短时间内反复冲击依赖服务。这类“惊群效应”在生产环境里特别明显。AsyncLazyT要消除的就是这个效应。它的并发模型天然是“第一个到达者启动初始化后续到达者都挂在同一个 Task 上”这也是后面所有性能讨论的基础。2. 核心并发内核重构状态机、无锁快速路径与一次性的初始化语义2.1 用状态机替代“锁 标志位”我重构AsyncLazyT时第一件事是把内部状态从隐式的“字段有没有值”改成显式状态机Created尚未触发初始化Initializing有线程正在执行初始化工厂Completed初始化成功结果可用Faulted初始化失败异常已保存。表面上看这只是加了几个枚举值但它带来了两个实质好处一是并发代码可以通过Interlocked.CompareExchange原子地推进状态不再需要每次访问都拿锁二是排错时能直接看到组件处于哪个状态而不是靠猜引用是不是 null。核心实现大致如下public sealed class AsyncLazyT { private readonly FuncCancellationToken, TaskT _factory; private readonly object _gate new(); private volatile TaskT? _task; public AsyncLazy(FuncTaskT factory) : this((_ factory())) { } public AsyncLazy(FuncCancellationToken, TaskT factory) { _factory factory ?? throw new ArgumentNullException(nameof(factory)); } public async TaskT GetValueAsync(CancellationToken cancellationToken default) { // 快速路径初始化已经完成直接读取已缓存的任务 TaskT? task Volatile.Read(ref _task); if (task is not null) { return await task.ConfigureAwait(false); } // 慢速路径第一次访问需要用锁保证只启动一次 lock (_gate) { task _task; if (task is null) { task _task StartFactoryAsync(); } } return await task.ConfigureAwait(false); } private async TaskT StartFactoryAsync() { try { return await _factory(CancellationToken.None).ConfigureAwait(false); } catch { // 异常原样保留等待时自然抛给调用方 throw; } } }这个骨架兼顾了正确性和性能。快速路径上没有任何锁只有一次Volatile.Read和一次await开销与直接await一个普通Task几乎一致。2.2 双检锁中那个 lock 到底保护什么有人会问既然有volatile TaskT为什么不干脆直接比较交换因为TaskT的首次创建需要调用_factory而_factory可能非常昂贵也可能显著耗时。如果不用锁两个线程同时看到_task null就会各自启动一次初始化破坏一次性语义。所以这里必须用锁保护“创建一个 Task 并赋值给_task”这个过程。但要注意锁保护的只是“赋值”这个瞬间不是“初始化完成后才能解锁”。初始化本身在锁外执行这一点非常关键。正确写法是在锁内创建 Task 引用然后立刻解锁让所有人都在锁外等待同一个 Task。如果反过来在锁内等待初始化完成那高并发场景下所有等待线程都会挤在锁内部互相排队锁竞争指数级上升甚至有可能把线程池资源耗尽。2.3 不要在锁内等待 Task 完成我见过一段生产代码把GetValueAsync写成这样lock (_sync) { _task ?? RunInitialization(); return await _task; }表面看没问题但 C# 的lock块并不跟随await保持持有状态实际上编译器会因为无法在await表达式中保持锁而报错。于是有人改用Monitor.Enter和Monitor.Exit手工加锁await之前释放看起来能编译。但紧接着又出现新问题等待线程在任务完成后排队进入锁最终全部在Monitor.Exit处争抢锁竞争一样严重。本质上AsyncLazyT的锁应该设计成“一次性保护”。初始化完成后这个锁就不再参与热路径。锁的粒度越小高并发下的可扩展性越好。这也是我始终强调“拿到 Task 引用后立刻出锁”的原因。3. 性能优化的发力点上下文控制、分配控制与首次访问流量3.1 初始化器运行在谁的上下文里必须由你决定AsyncLazyT最常见的性能隐患不是组件本身而是初始化工厂的上下文失控。前面提到LazyTaskT会把第一个调用者的线程身份传给工厂的同步段。在重构后的AsyncLazyT里也面临这个问题StartFactoryAsync()会直接在第一个调用者的异步上下文里开始执行_factory。大多数场景下这没问题但如果工厂内部使用了线程亲和组件比如绑定到特定线程的本地存储、依赖AsyncLocal的事务上下文它拿到的就是“第一个请求者”的上下文。我在生产环境里遇到过一个问题初始化工厂需要访问一个AsyncLocal里保存的租户 ID而第一个请求的租户恰好是租户 A结果整个进程都用租户 A 的身份初始化共享组件。这不是组件 bug而是上下文语义设计的问题。所以我的建议是工厂内部不要隐式依赖调用者的上下文。如果需要特定上下文就在工厂里显式处理比如用Task.Run换线程或者在工厂开头主动设置AsyncLocal的值。AsyncLazyT不应该也不需要在内部帮你做这件事因为“正确的上下文”只有业务代码知道。3.2 热路径上的分配越少越好一个被反复访问的AsyncLazyT热路径上的内存分配会直接反映到 GC 压力上。基础实现中每次GetValueAsync都会分配一个环绕 Task 的状态机对象吗答案是不一定。关键在于快速路径。如果初始化已经完成GetValueAsync走到“读取_task并await”这条路径时编译器生成的异步状态机虽然存在但通常会被优化为很小的分配。相比每次创建一个新的包装 Task或者每次调用都新建一个TaskCompletionSource这个开销要小得多。真正应该避免的是这类写法public TaskT GetValueAsync() WrapInNewTask(_coreTask);每次调用都包一层Task热路径上会产生不必要的对象。直接用同一个TaskT引用等待端通过await读取结果是最省内存的做法。有些团队会考虑用ValueTaskT进一步优化热路径。但AsyncLazyT的场景是一次性初始化、多等待者共享结果底层本来就是同一个TaskTValueTask只对“有时候异步、有时候同步”的高频调用有价值。这里换来换去不会有明显收益反而增加复杂度。我的结论是在这个组件内部TaskT够用了。3.3 高并发首次访问如何避免把流量全部压进初始化器即使AsyncLazyT保证了只有一次初始化高并发首次访问时仍然可能把大量流量瞬时打入初始化器。原因在于初始化工厂运行时200 个等待者全部以“继续操作”的形式挂在同一个 Task 上。初始化一完成这 200 个 continuation 会同时被调度执行。如果初始化结果需要立刻做后续处理比如建立连接池、预热缓存这些处理会被 200 个线程同时触发。这不是AsyncLazyT的缺陷而是使用方式的问题。我的经验是把“初始化资源”和“使用资源”分层隔离。比如初始化一个数据库连接池工厂里只负责创建连接池对象本身不要在里面同步预热所有连接。连接池自己会有懒加载和限流。如果确实需要预热把预热任务放进后台队列而不是让第一个访问者全干了。这样即使 200 个请求同时涌入实际打到依赖系统上的请求也只是连接池自己的并发策略而不是 200 个请求的累积效果。另外一个相关的优化点是异步初始化完成后的 continuation 默认在哪里执行。标准await会尽量回到调用者线程。在库代码、后台服务代码里这个“回调用者线程”往往没有意义反而会造成线程切换开销。所以ConfigureAwait(false)在AsyncLazyT内部必须用上这个细节虽然小但在高并发服务里会直接影响吞吐量。4. 可靠性边界异常缓存、取消语义与重入检测4.1 异常策略不能一刀切AsyncLazyT最容易被忽视的可靠性问题是“初始化异常后到底怎么办”。不同业务对失败的态度完全不一样我通常把策略分成三类策略行为适用场景永久缓存异常工厂抛错后异常被永久保存后续等待者全部收到同一个异常配置加载失败等不应该重试的场景避免反复冲击依赖失败可重试工厂抛错后重置状态仅成功结果被缓存依赖远程服务、可能临时故障的场景显式 Reset默认缓存但提供 Reset 方法手动刷新配置热更新、主动淘汰旧资源的场景实现失败可重试模型需要注意一个细节重置状态要在异常发生时立刻完成否则其他等待者会读到“初始化未完成”然后又启动一次新的初始化反而造成重复。正确方式是捕获异常后在异常传播之前先把_task置回 null并做好同步确保只有一个线程接管重试。下面是一个带失败重置的示例行为private async TaskT StartFactoryAsync() { try { return await _factory(CancellationToken.None).ConfigureAwait(false); } catch { lock (_gate) { // 仅当当前任务还是我们启动的这个任务时才清空状态 // 否则可能清掉别人新启动的任务 if (ReferenceEquals(_task, currentTask)) { _task null; } } throw; } }这个“仅清空自己启动的任务”的判断很重要。实践上如果另一个线程在失败瞬间已经发起了新的初始化不清空就是对的选择。4.2 取消令牌的两种职责必须分开AsyncLazyT的取消语义非常容易做错。有一种取消叫“取消本次等待”意思是某个请求不想等了可以直接走人但不影响其他等待者。这是GetValueAsync(CancellationToken)参数应该表达的语义。还有一种取消叫“取消初始化本身”意思是初始化还没完成我想让整个初始化过程停下来。这个应该在构造函数里传入取消令牌或者由工厂内部自己控制。很多手工实现把这两者混在一起第一个请求超时取消直接把共享的CancellationTokenSource取消了结果所有后续请求都拿到取消异常初始化也被打断。这是高并发场景里的重大事故。我的建议是 API 设计上就分开var lazy new AsyncLazyHttpClient(ct CreateClientAsync(ct), sharedCt);GetValueAsync(token)里的 token 只作用于“等待”这个动作构造时的 token 才作用于初始化工厂。这样语义清晰等待者取消也不会误伤他人。4.3 重入检测比死锁更好用的失败方式高并发异步代码里重入问题很隐蔽。设想场景初始化工厂内部需要调用当前组件的GetValueAsync()来获取同一份资源但资源本身就是正在初始化的那一份。这会造成死锁而且由于异步等待并不会阻塞线程死锁时线程池可能照样忙碌就是任务永远不完成。与其让这种死锁难以排查不如在组件内部主动检测重入并抛出明确异常。实现思路可以用AsyncLocalboolprivate readonly AsyncLocalbool _factoryRunning new(); private async TaskT StartFactoryAsync() { if (_factoryRunning.Value) { throw new InvalidOperationException( AsyncLazyT 检测到初始化工厂内部重入请检查是否存在循环依赖。); } _factoryRunning.Value true; try { return await _factory(CancellationToken.None).ConfigureAwait(false); } finally { _factoryRunning.Value false; } }这段代码在业务上几乎不可能误报但一旦触发能省掉你半天的排查时间。这个“直接失败”的可靠性设计比让程序默默卡死要健康得多。5. 可维护性设计让异步单例初始化不再是一块黑盒5.1 暴露状态、耗时和等待者数量而不是只有一个布尔量很多AsyncLazy版本只提供IsValueCreated一个布尔属性。用过的都知道这不够用IsValueCreated false时你不知道是因为没人访问过还是正在初始化还是初始化失败了。我在组件里增加了几个诊断属性public enum AsyncLazyState { Created, Initializing, Completed, Faulted } public AsyncLazyState State { get; } public Exception? LastError { get; } public TimeSpan? LastInitializationDuration { get; private set; } public int WaitersCount { get; }WaitersCount实现起来并不复杂在GetValueAsync慢路径开始前Interlocked.Increment结束后Decrement用一对计数就行。这个数字在排查“为什么启动时服务响应这么慢”的时候非常有价值如果WaitersCount长期很高说明初始化流程过长或者资源不足。5.2 异常堆栈的保留与诊断日志异步初始化里还有一个常见痛点异常堆栈被async/await层层包裹之后原始抛出点经常被吞掉或者变得非常难看。虽然await比.Result保留的信息多得多但在组件边界做一层包装时还是要注意不要丢掉原始异常。我建议在向外暴露异常时始终让原始异常自然传播不要为了“包装友好信息”而 new 一个新异常把内部异常包起来除非你确实需要业务语义上的分类错误。诊断代码希望看到的是最原始的堆栈。日志方面我一般会记录这些事件初始化开始时间、触发线程初始化耗时初始化完成还是出错完成后等待者数量变化。记录这些不需要额外依赖用Debug.WriteLine或者注入一个日志回调都行。不要过度设计成事件总线一个简单的Actionstring注入足够应付绝大多数场景。5.3 显式 Reset 的滚动刷新模型可维护性不只是“看得懂”还包含“能控制”。我前面提到了Reset()方法它让资源可以在运行期强制刷新。一个实用的模式是“滚动刷新”旧实例继续被现有请求使用新请求触发新一次初始化两边互不干扰。实现上Reset()只是把_task置回 null已经拿到旧 Task 的请求仍然持有旧引用不受影响。但这里有个并发细节需要注意如果老初始化还没结束你就Reset()那么新旧两个初始化可能同时运行。某些资源比如只能存在一个实例的本地端口监听器不能接受这种情况。所以我会提供一个“重置换代”语义public async Task ResetAsync() { TaskT? old; lock (_gate) { old _task; _task null; } if (old is not null) { await old.ConfigureAwait(false); // 等旧初始化结束再返回 } }这样调用方可以确信ResetAsync返回后旧的初始化流程已结束后续新请求一定会触发全新初始化。这个设计对“配置热更新”场景尤为重要。6. 实测对比与容易翻车的操作清单6.1 基准测试要看哪几个数字优化到底有没有效果不能靠感觉。我对AsyncLazyT做基准时通常关注三个数字初始化完成后的热路径耗时首次初始化时多线程并发竞争的开销每次调用产生的 GC 分配。一个简化的 BenchmarkDotNet 例子[MemoryDiagnoser] public class AsyncLazyBench { private LazyTaskint _lazyTask new(() Task.FromResult(1)); private AsyncLazyint _asyncLazy new(() Task.FromResult(1)); [GlobalSetup] public void Setup() { _ _lazyTask.Value.GetAwaiter().GetResult(); _ _asyncLazy.GetValueAsync().GetAwaiter().GetResult(); } [Benchmark] public int LazyTask_HotPath() _lazyTask.Value.GetAwaiter().GetResult(); [Benchmark(Baseline true)] public int AsyncLazy_HotPath() _asyncLazy.GetValueAsync().GetAwaiter().GetResult(); }说句实在话在“初始化已完成”的热路径上LazyTaskT和优化后的AsyncLazyT差距通常很小因为前者也只是LazyT内部加一次 volatile 读加一个Task等待。真正的差距在并发竞争场景200 个线程同时首次访问时LazyT内部的锁会放大成高竞争点而AsyncLazyT把竞争压缩到一次短临界区扩展性明显更好。6.2 三个容易翻车的操作第一不要在GetValueAsync里把业务逻辑包装成每次都执行的 Task。有人为了让调用方拿到“新鲜任务”会在方法里写return Task.Run(...)这会让AsyncLazy失去“共享同一个初始化”的意义。第二不要用SemaphoreSlim手工实现等待初始化完成。信号量一旦遗漏Release整个组件就永久性卡死。这个问题在线上最难发现因为进程看起来还活着只是所有等待者都不往下走了。第三不要在未完全理解取消语义时把 API 的取消令牌下传给初始化工厂。前面说过这会造成“一个等待者取消全场取消”的连锁事故。如果一定要把取消传递到工厂请单独设计初始化令牌的生命周期明确由谁负责取消而不是让每个调用者都能影响初始化。我实际使用这个组件的体会是性能优化从来不是只有“快”这一个指标。AsyncLazyT最值得优化的地方恰恰是把并发竞争压缩到最小、把失败语义明确下来、把诊断信息暴露出来。做到这几点高并发场景下自然稳定热路径性能也不会差。如果你的系统里还在用LazyTaskT凑合或者自己维护一套“锁 状态 异常重试”的手写逻辑不妨试试把这个组件做成通用基础类收益会远比想象中大。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑