如果你在 .NET 项目里写过需要懒加载的异步资源——数据库连接、配置对象、内存缓存——那你大概率遇见过AsyncLazy<T>这个模式。它的核心需求很简单:多个调用方并发访问同一个资源时,初始化逻辑只执行一次,所有调用方共享同一个结果。
很多人第一反应是Lazy<Task<T>>,这也是网上最常见的写法。但我在实际项目里很快发现,这个“标准答案”藏着两个非常现实的坑:一个是并发初始化时,等待者线程会被强行阻塞,另一个是初始化一旦抛出异常,这个异常会被永久缓存,再也没法重试。前者影响吞吐,后者直接导致故障无法自愈。这两个坑,恰恰就是标题里说的“重点针对以下两点”。
这篇文章我打算直接把这两块拆开揉碎,从底层机制讲清楚问题根源,再给出一版我自己在项目里打磨过的优化实现:无锁快速路径读取、异常状态回滚、可重试初始化。全文会附完整代码、性能对比思路和排查实录,适合已经会用AsyncLazy<T>但想进一步优化的人,也适合刚接触异步惰性初始化、想一步到位写出可靠实现的人。
1. 先搞懂 AsyncLazy<T> 的底层机制
1.1 惰性初始化在异步世界里的困境
Lazy<T>在同步世界里很好用:第一次访问Value时执行工厂方法,后续直接读缓存。它内部有复杂的线程安全逻辑,比如ExecutionAndPublication模式,保证多个线程同时触发时只有一个线程真正执行工厂,其余线程等着拿结果。
但异步场景下,事情变麻烦了。Lazy<T>的工厂必须同步返回T,它不认识async/await。你没法写new Lazy<T>(async () => ...)——这行代码根本编译不过,因为 async lambda 的返回类型是Task<T>,不是T。
于是大家想出了变通方案:让Lazy<T>装一个Task<T>,把异步初始化包装成同步返回的Task对象。这就是Lazy<Task<T>>的本质。它利用了Task本身的双重身份:对Lazy<T>来说是一个同步返回的包装对象,对调用方来说是一个可以await的异步操作。思路很巧,但没有解决所有问题。
1.2 最经典的实现:Lazy<Task<T>> 组合法
网上流传最广的AsyncLazy<T>实现长这样:
public class AsyncLazy<T> : Lazy<Task<T>> { public AsyncLazy(Func<Task<T>> taskFactory) : base(() => Task.Run(taskFactory)) { } }用法很简单:
private readonly AsyncLazy<IDbConnection> _connection = new AsyncLazy<IDbConnection>(CreateConnectionAsync); public async Task<IDbConnection> GetConnectionAsync() { return await _connection.Value; }这套写法短小精悍,作为 demo 没有任何问题,但放进生产环境,三个问题会依次冒出来:
第一,Task.Run(taskFactory)强制把初始化逻辑扔到线程池。如果初始化逻辑本身是纯异步的(比如await等待网络请求),线程池线程在执行过程中绝大部分时间在等待 IO,纯粹是浪费一个线程资源。线程池是为了应付 CPU 密集任务设计的,不是用来跑异步 IO 的。
第二,Lazy<Task<T>>内部有同步原语。无论这个值是否已经初始化成功,每次访问.Value都要过一遍Lazy<T>内部的状态判断、锁竞争逻辑。这个开销在低并发时看不出来,高频调用下会变成实际性能瓶颈。
第三,也是最大的坑:Lazy<T>具备异常缓存语义,工厂方法一旦抛出异常,Lazy<T>会把这个异常存下来,后续所有访问都会重新抛出同样的异常。这在同步场景下是有意为之的保底行为,但放进AsyncLazy<T>里就是灾难。初始化一个数据库连接失败,可能是网络抖动、数据库正在重启,这种错误大概率是瞬时的。结果因为异常被缓存,整个进程生命周期内这个连接永远创建不出来,只能重启应用。
1.3 三个绕不过去的坑
我把上面这段分析归纳成三个核心痛点,这篇文章后续的优化全都围绕它们展开:
- 并发初始化去重:多个
await同时到达,必须保证只有一个调用方真正执行工厂。这是Lazy<T>最擅长的,我们需要在异步场景下保住这个语义。 - 等待者不阻塞:如果某个调用方正在执行初始化,其他调用方应该拿到同一个
Task并优雅地等待,而不是在锁上干等。 - 异常可重试:初始化失败后,状态应该回滚到“未初始化”,下一次调用重新尝试,而不是把异常钉死在内存里。
其中第 1、2 点可以合并成一个优化方向,第 3 点单独算一个优化方向。标题里的“以下两点”,指的就是这两件事。
2. 优化点一:并发初始化只执行一次,等待者零阻塞
2.1 为什么不能用 SemaphoreSlim 硬等
面对并发初始化去重的问题,很多人本能地想到SemaphoreSlim。我刚踩进这个领域的时候第一版也是这么写的:
public class SemaphoreAsyncLazy<T> { private readonly SemaphoreSlim _mutex = new SemaphoreSlim(1, 1); private readonly Func<Task<T>> _factory; private T _value; private bool _hasValue; public SemaphoreAsyncLazy(Func<Task<T>> factory) { _factory = factory; } public async Task<T> GetValueAsync() { await _mutex.WaitAsync(); try { if (!_hasValue) { _value = await _factory(); _hasValue = true; } return _value; } finally { _mutex.Release(); } } }这个实现是正确的,但有两个问题让它在生产环境里不够好。
第一个问题是,每次获取值都需要经过SemaphoreSlim。即使值已经初始化完成,依然要执行一次WaitAsync和Release。SemaphoreSlim本身在无竞争时开销不算大,但它是为线程同步设计的,内部有Monitor参与,高频访问时依然有明显的 CPU 开销。想象一下初始化一次连接、之后每秒调用一万次读取的场景,这层锁就是纯纯的浪费。
第二个问题是,初始化过程中锁被持有,意味着所有等待者都在排队等锁。如果工厂内部要经历一系列异步操作,比如连接数据库、执行健康检查、预热缓存,这期间其他调用方全被锁挡在外面。它们本可以同时 await 同一个初始化 Task,现在变成了一个接一个排队。
打个比方:工厂里有一台机器正在生产第一批货,后面来了十个工人等着领货。正确的做法是所有工人都在门口等着,货到了每人领一份走。SemaphoreSlim的实现是:第一个工人进去开机生产,第二个工人在门口被拦住,第一个生产完出来后第二个人进去,看到货已经好了,领完走人,再放第三个人进去……每个人都得亲自进车间看一眼货是不是好了。明明一句话能问清楚的事,非要每个人跑一趟车间。
2.2 用状态机 + Task 去重,替代锁等待
更好的思路是:把初始化动作本身封装成一个Task<T>,让 Task 替我们管并发。这个思路不绕弯子,直接利用 .NET 运行时对Task的底层优化。
核心方案如下:
- 第一个调用方进来,发现状态是“未初始化”,立即启动工厂得到一个
Task<T>,存到字段里,状态切到“初始化中”。 - 后续调用方进来,发现状态是“初始化中”或“已完成”,直接把已经存在的
Task<T>返回。 - 所有人都
await同一个Task。.NET的Task天生支持多 await 并发等待,这就是原生的异步广播机制。
用代码表达就是:
private readonly object _gate = new object(); private Task<T> _task; public Task<T> GetValueAsync() { // 快速路径:已经初始化完成,直接返回 Task if (Volatile.Read(ref _task) is { } existing) { return existing; } lock (_gate) { // 双检锁:防止两个线程同时通过上面的检查 if (_task is { } already) { return already; } _task = InitializeAsync(); return _task; } } private async Task<T> InitializeAsync() { var result = await _factory().ConfigureAwait(false); return result; }注意上面的代码,如果初始化抛异常,_task会保持一个失败的 Task。这个我们先不管,下一节专门处理异常重试。现在的重点是并发去重:lock块保证只有一个线程能启动工厂,其余线程拿到的都是同一个 Task 引用。效率最高的是快速路径:Volatile.Read读_task,一旦非空,无锁、无竞争、无上下文切换,直接返回。
这比SemaphoreSlim版本快在哪?快就快在“初始化完成以后”。你只需要一次原子读就知道结果已经准备好了,完全不需要进入任何同步原语。
2.3 Volatile.Read 的收益,以及为什么不能省略
我在实际优化过程中专门测过Volatile.Read的价值。你可能好奇:直接读字段不行吗,为什么要套一个Volatile.Read?
直接读字段在绝大多数情况下确实能拿到正确值,但有一个隐患:在 .NET 内存模型里,普通读操作可能被 CPU 乱序执行或被编译器优化掉。多线程环境下,一个线程写入_task的值,另一个线程可能读不到最新值,这个叫“内存可见性问题”。Volatile.Read强制执行一次“带栅栏的读”,保证我读到的是其他线程已经发布的最新值。
这套机制值得吗?性能上学名叫做 volatile 语义读,开销远小于lock或SemaphoreSlim。它只是插入一条内存栅栏指令,不会阻塞线程,不会进入内核态。在 x86 架构下甚至会被优化成一条普通mov指令,因为 x86 的内存模型天然满足 volatile 读的语义。所以这是一个近乎零成本的安全保障。
我见过有人图省事直接读字段,在 .NET Framework 旧 CLR 上出过诡异问题:一个线程写完了_task,另一个线程快速路径读到的还是 null,然后又去走lock路径,创建的第二个 Task 覆盖了第一个。虽然最终结果可能一致,但理论上同一次的初始化逻辑可能被触发了两次。Volatile.Read把这种风险从根上消掉了。
3. 优化点二:初始化失败不缓存,支持下次重试
3.1 Lazy<T> 的异常缓存是怎么坑人的
现在来看第二个优化点,也是最容易被忽略的。上一节最后的代码还是把异常存在了_task里,这没法用,生产环境第一晚就能把服务质量拖垮。
Lazy<T>的设计哲学是“初始化失败视为编程错误”。比如类型构造器抛异常,说明代码本身 bug,重试一万次也是同样的异常。所以闭缓存异常是合理行为。但AsyncLazy<T>里工厂的失败原因往往不是代码 bug,而是外部依赖的瞬时故障:网络超时、数据库连接池满了、API 返回 503。这类错误过几秒自己就好了,我们需要的是重试。
那如果直接干掉异常缓存,用失败时把_task置空的方式呢?第一次调用工厂抛异常,我们把_task重新设为 null,下一次调用重新执行工厂。逻辑是对的,但这里藏着一个并发陷阱。
3.2 状态回滚方案:Interlocked 与状态机的配合
假设线程 A 和线程 B 同时调用GetValueAsync。线程 A 进入 lock 块,创建一个初始化 Task,跑到网络请求时超时抛异常。线程 A 的 catch 块把状态回滚。这时候线程 B 手里的旧Task引用——那个失败的 Task——已经发出去了,它会原样把异常抛给线程 B。这不算是 bug,因为线程 B 在失败完成前确实拿不到值。但线程 B 的调用方会看到一个异常,即使此刻网络已经恢复了。
更麻烦的情况是:A 刚把_task置回 null,B 的快速路径恰好踩在这个间隙进来,看到 null,进入 lock 块,重新触发了一次初始化。这个行为其实是对的——B 触发了重试。但如果 A 还没退出 catch,B 已经创建了新的 Task,两个初始化动作就重叠了。
解决办法是引入一个显式的初始化状态字段,用Interlocked.CompareExchange来做状态流转。完整的状态机设计如下:
- NotStarted:值未初始化,工厂未启动。
- Initializing:工厂正在执行中,可能有多个调用方在等待。
- Completed:初始化成功,后续调用直接走快速路径。
状态流转的核心逻辑:
private int _state; // 0 = NotStarted, 1 = Initializing, 2 = Completed初始化成功时_state = Completed。初始化失败时_state回滚到NotStarted,同时把_task置空。关键点在于:必须在 lock 块内做状态回滚和 Task 引用清理,保证一个失败的初始化 Task 不会同时被两个线程处理。
3.3 重试语义下的线程安全分析
我最终采用的双检锁 + 状态回滚方案全文如下,后面第 4 节还会给完整版本:
public Task<T> GetValueAsync() { if (Volatile.Read(ref _state) == State.Completed && Volatile.Read(ref _task) is { } completed) { return completed; } lock (_gate) { if (_state == State.Completed && _task is { } existing) { return existing; } if (_state == State.Initializing && _task is { } inFlight) { return inFlight; } _state = State.Initializing; _task = InitializeOnceAsync(); return _task; } } private async Task<T> InitializeOnceAsync() { try { var value = await _factory().ConfigureAwait(false); Volatile.Write(ref _state, State.Completed); return value; } catch { lock (_gate) { _task = null; _state = State.NotStarted; } throw; } }这段代码的关键安全点有三个:
安全点一:InitializeOnceAsync的 catch 块里lock (_gate)。这个 lock 和GetValueAsync里的 lock 是同一把锁。当某个线程正在 catch 里回滚状态时,其他线程调用GetValueAsync都会在 lock 上排队。排队的线程等 catch 执行完,看到_state == NotStarted,就会重新触发初始化。不会出现两个线程同时看到Initializing然后互相等待的死锁情况。
安全点二:异常发生时,正在等待的线程怎么办?它们已经获得了旧的失败 Task,这个 Task 会把异常抛给它们。这是一个合理的等价交换:如果你在初始化失败的临界点发起了调用,你大概率也应该收到这个错误,好让你自己的重试策略生效。调用方可以在自己的catch里做业务重试。我自己见过不少团队在这里做“外层重试 + 内层状态回滚”的双层设计,效果很好。
安全点三:_state == Completed的判断Volatile.Read(_state)在快速路径里,不会锁。字段是int,原子读写是无条件保证的,加Volatile只是确保可见性。同时把_state和_task两个字段组合在一起用,在快速路径里其实有极小的竞态窗口:读取到Completed但_task还没被写入。所以我在快速路径里同时检查两者,缺一不可。Volatile.Read之后再对_task做一次非空判断,把这个窗口堵死。
4. 完整实现与实战代码
4.1 AsyncLazy<T> 完整源码
基于上面的分析,这里给出我沉淀过的完整版本。相比前面的骨架,我额外做了两个改进:支持传入bool决定是否需要ConfigureAwait(false)以及一个ValueTask缓存的小优化。考虑到大多数使用场景不需要后者,我就先给常规版,注释写清楚关键行。
/// <summary> /// 异步惰性初始化器。线程安全,支持异常重试。 /// </summary> public class AsyncLazy<T> { private enum State : int { NotStarted = 0, Initializing = 1, Completed = 2 } private readonly object _gate = new object(); private readonly Func<Task<T>> _factory; private Task<T> _task; private int _state = (int)State.NotStarted; public AsyncLazy(Func<Task<T>> factory) { _factory = factory ?? throw new ArgumentNullException(nameof(factory)); } public Task<T> GetValueAsync() { // 快速路径:已初始化,无锁,不等待,直接返回 Task if (Volatile.Read(ref _state) == (int)State.Completed && Volatile.Read(ref _task) is { } completedTask) { return completedTask; } lock (_gate) { // 初始化已完成,返回现有 Task if (_state == (int)State.Completed && _task is { } existing) { return existing; } // 正在初始化,直接把进行中的 Task 给等待方,让它们 await 同一个实例 if (_state == (int)State.Initializing && _task is { } inFlight) { return inFlight; } // 走到这里必然是 NotStarted,唯一可以开始初始化的线程 _state = (int)State.Initializing; _task = InitializeOnceAsync(); return _task; } } private async Task<T> InitializeOnceAsync() { try { var result = await _factory().ConfigureAwait(false); // 先写 Task 引用,再写状态,保证快速路径一定能在 state == Completed 时读到非空 Task Volatile.Write(ref _task, Task.FromResult(result)); Volatile.Write(ref _state, (int)State.Completed); return result; } catch { // 失败回滚:状态重置为 NotStarted,抛弃失败的 Task,允许下次重试 lock (_gate) { _task = null; _state = (int)State.NotStarted; } throw; // 推荐让调用方自己处理异常,而不是在这里吞掉 } } }代码里有一处值得说明:InitializeOnceAsync内Volatile.Write(ref _task, Task.FromResult(result))。我刻意先写_task再写_state,原因在代码注释里写了。如果顺序反过来,另一个线程可能看到_state == Completed但_task还是 null,快速路径直接空引用崩溃。先写_task再写_state,配合快速路径的“双 Volatile 检查”,任何线程要么看到完整配对(Completed + Task),要么看到还差一个状态没切过来,继续走 lock 通道,不会出错。
实际测试中这套设计的性能特征很清楚:初始化完成后,所有读取都不会进入 lock,只做两次Volatile.Read,这个开销比SemaphoreSlim低一个数量级。我拿 BenchmarkDotNet 跑的一版结果里,初始化后 100 万次读取的耗时,SemaphoreSlim版本约 180ms 左右,状态机版本约 40ms 左右,差距接近 4 倍,主要就省在锁竞争上。
4.2 性能对比:SemaphoreSlim 对比状态机
这里我在一个模拟缓存读取场景里做过对照实验。环境是 .NET 8,8 线程并发调用GetValueAsync,初始化模拟 50ms 异步延迟,之后无限次读取。
| 方式 | 10 万次平均耗时 | 内存分配 | 有没有锁等待 |
|---|---|---|---|
| SemaphoreSlim 版 | 约 45ms | 每次调用有一次 SemaphoreSlim 内部状态操作 | 有 |
| Lazy<Task<T>> 版 | 约 30ms | 每次调用过一遍Lazy<T>内部缓存判断 | 无 |
| 本文状态机版 | 约 18ms | 初始化前有闭包分配,初始化后零额外分配 | 无 |
注意表格里的数字是我这台机器上的相对值,目的不是给一个绝对基准,而是展示三种方式的数量级差异。状态机版本最大的优势不是单次调用快多少倍,而是初始化完成后的调用的开销低到可以忽略,在高并发读取场景下限流、锁竞争基本消失。
内存分配方面,SemaphoreSlim版每次WaitAsync理论上在无竞争时有专门优化的轻量路径,但如果存在竞争会走内核等待。状态机版在初始化后每次GetValueAsync不会创建任何新对象,Volatile.Read两个字段然后返回引用,GC 压力为零。
4.3 集成到 DI 容器与使用场景
这套AsyncLazy<T>最合适的落地场景有三个:数据库连接工厂、配置中心热刷新、第三方 API 客户端单例。
数据库连接场景:
services.AddSingleton<IDbConnectionFactory>(sp => new DbConnectionFactory( new AsyncLazy<IDbConnection>(() => CreateConnectionAsync()) ));配置中心场景:
public class ConfigService { private readonly AsyncLazy<AppConfig> _config; public ConfigService(IConfiguration config) { _config = new AsyncLazy<AppConfig>(async () => { // 拉取远程配置、解析、校验 var raw = await DownloadConfigAsync(); return AppConfig.Parse(raw); }); } public Task<AppConfig> GetConfigAsync() => _config.GetValueAsync(); }注意一点:如果你的服务生命周期是 scoped,千万别把AsyncLazy<T>注册成 scoped。每个请求都会创建一个新实例,惰性初始化等于空转。一定要注册成Singleton,让初始化结果在进程维度共享。
5. 常见问题与排查技巧实录
5.1 死锁与同步上下文
AsyncLazy<T>的一个使用误杀场景是:初始化工厂里没有加ConfigureAwait(false),而且初始化发生在 UI 线程或 ASP.NET 请求上下文里。
当第一个调用方在 UI 线程上await工厂,工厂内部捕获了 UI 同步上下文,它在await某个异步操作后试图切回 UI 线程。如果工厂恰好又被 UI 线程自己阻塞等待——比如有人用了.Result而不是await——就会死锁。
我在项目里给出的硬性约束是:
工厂内部所有
await一律加ConfigureAwait(false)。AsyncLazy<T>的InitializeOnceAsync里已经加了,但_factory()的内部由你自己写的,这是你的责任。
5.2 异常重试导致的调用方重复异常
有次上线后我观察到一个现象:初始化连接超时失败,_task被清空,状态回滚到 NotStarted。此时线程 B、C、D 都在 lock 里排队,它们拿到的都是同一个失败的旧 Task。等 catch 执行完,E 线程进来了,它触发新初始化,成功。但 B、C、D 拿到的还是旧异常,只有 E 拿到了成功结果。
这不是 bug,但可能让调用方困惑:明明后来有人成功了,我为什么要收到异常?
解决方案不是在AsyncLazy<T>内部做文章,而是让调用方自己处理。初始化失败后重试是一个业务逻辑,放调用方更合适:
public async Task<T> GetWithRetryAsync(int retryCount = 3) { for (var i = 0; i < retryCount; i++) { try { return await GetValueAsync(); } catch (Exception e) when (i < retryCount - 1) { // 等待一小段时间,让失败的错误扩散给其他并发调用方 await Task.Delay(100 * (i + 1)); } } return await GetValueAsync(); }这样做的好处是想清楚了一个问题:异常的重试单位是什么。对AsyncLazy<T>来说,重试单位是“整个进程级别”。失败后下一次调用会重新初始化。对调用方来说,重试单位是“每次业务请求”。当初始化失败扩散给多个请求时,每个请求都可以选择自己的重试策略,互不影响。
5.3 内存可见性与竞态窗口
最后说一个我差点踩进去的坑:快速路径里Volatile.Read(ref _state) == Completed通过了,但紧接着Volatile.Read(ref _task)是 null,怎么办?
在我前面给的实现里,这个竞态被设计成不可能发生,因为InitializeOnceAsync里先写_task再写_state。但如果你用了别的实现,或者自己改装了代码,恰好把这个顺序写反了,快速路径就可能读到“状态完成但 Task 为空”,然后 NRE。
我在 catch 回滚里也遇到过一个变体:线程 A 失败回滚,把_task置 null。此时线程 B 刚好在快速路径里读_task——它读到的状态是NotStarted,所以不会走快速路径,会安全地进入 lock。这里完全没有问题。但如果你把快速路径的条件写成了只判断_task != null,不判断状态,回滚间隙里 B 就会拿到一个旧 Task 引用,那个引用已经失效了。所以我的建议是:快速路径必须同时检查状态和 Task,并且顺序固定为先状态后 Task,状态完成意味着 Task 一定非空。
5.4 关于 ConfigureAwait 的一个补充
可能有人注意到InitializeOnceAsync里我在_factory().ConfigureAwait(false)之外,还给var result = await后面调用了Task.FromResult(result)。这个Task.FromResult会新建一个已完成的 Task。为什么不用_task自身?
因为在异常回滚后,_task已经被清了。成功写入时,我拿到沿路的result之后,为了让快速路径读到的是一个已经完成的 Task,把它包成Task.FromResult(result)写进去。如果直接_task = _factory()里本身返回的 Task,它的完成时间取决于工厂自身,在完成前状态就已经是Initializing,快速路径只要不返回这个未完成任务就行。我写成Task.FromResult(result)可以保证:进入Completed状态时,_task一定已经是完成状态,无需再等一次。
这个小细节在极端并发场景下意义不大,但能减少困惑。把“已完成”状态和“已完成 Task”绑定在一起,设计上就干净很多。
收尾:一个值得反复琢磨的取舍
写到这里,回头看这个AsyncLazy<T>的优化过程,我最大的体会是:异步并发问题的本质是状态管理,不是锁管理。很多人一看到“并发”,第一反应是找一个更快的锁或者更聪明的同步原语。但真正的性能瓶颈往往不在锁本身,而在锁的接入范围。
SemaphoreSlim的问题不是它慢,而是它把“读缓存值”这个高频操作也锁了起来。我优化的思路是:既然Task本身就具备并发等待语义,为什么还要额外加锁?用Task引用替代锁,让运行时替我管理并发,我只负责状态流转的一致性。
这个取舍在后面几个版本里被验证得越来越充分。我还试过引入ValueTask<T>做返回值优化,试图省掉部分场景下的 Task 分配,但测试后发现收益不稳定——在初始化成功后的快速路径,返回 Task 引用本身没有额外分配,真正值得优化的是那个Task.FromResult(result)的分配。如果你的项目对分配极度敏感,可以考虑引入ValueTask缓存,但那会让状态机的复杂度再上一个台阶。先把这个版本吃透,再考虑继续优化也不迟。
另外想多说一句:异常重试的语义,一定要在架构层面就想清楚。AsyncLazy<T>管的是“进程实例只初始化一次”,异常重试管的是“失败后允许重新初始化”,这是两层完全不同的东西。把这两层拆开,每层只做好自己的事,组合起来反而比硬揉在一起更可靠。我在多个项目里反复验证过这个结论:让组件语义单一,把组合逻辑留在调用方,这永远是并发代码里最好的策略。