ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AsyncLazy<T>优化:无锁快速路径与异常重试的异步惰性初始化实现

AsyncLazy<T>优化:无锁快速路径与异常重试的异步惰性初始化实现 如果你在 .NET 项目里写过需要懒加载的异步资源——数据库连接、配置对象、内存缓存——那你大概率遇见过AsyncLazyT这个模式。它的核心需求很简单多个调用方并发访问同一个资源时初始化逻辑只执行一次所有调用方共享同一个结果。很多人第一反应是LazyTaskT这也是网上最常见的写法。但我在实际项目里很快发现这个“标准答案”藏着两个非常现实的坑一个是并发初始化时等待者线程会被强行阻塞另一个是初始化一旦抛出异常这个异常会被永久缓存再也没法重试。前者影响吞吐后者直接导致故障无法自愈。这两个坑恰恰就是标题里说的“重点针对以下两点”。这篇文章我打算直接把这两块拆开揉碎从底层机制讲清楚问题根源再给出一版我自己在项目里打磨过的优化实现无锁快速路径读取、异常状态回滚、可重试初始化。全文会附完整代码、性能对比思路和排查实录适合已经会用AsyncLazyT但想进一步优化的人也适合刚接触异步惰性初始化、想一步到位写出可靠实现的人。1. 先搞懂 AsyncLazyT 的底层机制1.1 惰性初始化在异步世界里的困境LazyT在同步世界里很好用第一次访问Value时执行工厂方法后续直接读缓存。它内部有复杂的线程安全逻辑比如ExecutionAndPublication模式保证多个线程同时触发时只有一个线程真正执行工厂其余线程等着拿结果。但异步场景下事情变麻烦了。LazyT的工厂必须同步返回T它不认识async/await。你没法写new LazyT(async () ...)——这行代码根本编译不过因为 async lambda 的返回类型是TaskT不是T。于是大家想出了变通方案让LazyT装一个TaskT把异步初始化包装成同步返回的Task对象。这就是LazyTaskT的本质。它利用了Task本身的双重身份对LazyT来说是一个同步返回的包装对象对调用方来说是一个可以await的异步操作。思路很巧但没有解决所有问题。1.2 最经典的实现LazyTaskT 组合法网上流传最广的AsyncLazyT实现长这样public class AsyncLazyT : LazyTaskT { public AsyncLazy(FuncTaskT taskFactory) : base(() Task.Run(taskFactory)) { } }用法很简单private readonly AsyncLazyIDbConnection _connection new AsyncLazyIDbConnection(CreateConnectionAsync); public async TaskIDbConnection GetConnectionAsync() { return await _connection.Value; }这套写法短小精悍作为 demo 没有任何问题但放进生产环境三个问题会依次冒出来第一Task.Run(taskFactory)强制把初始化逻辑扔到线程池。如果初始化逻辑本身是纯异步的比如await等待网络请求线程池线程在执行过程中绝大部分时间在等待 IO纯粹是浪费一个线程资源。线程池是为了应付 CPU 密集任务设计的不是用来跑异步 IO 的。第二LazyTaskT内部有同步原语。无论这个值是否已经初始化成功每次访问.Value都要过一遍LazyT内部的状态判断、锁竞争逻辑。这个开销在低并发时看不出来高频调用下会变成实际性能瓶颈。第三也是最大的坑LazyT具备异常缓存语义工厂方法一旦抛出异常LazyT会把这个异常存下来后续所有访问都会重新抛出同样的异常。这在同步场景下是有意为之的保底行为但放进AsyncLazyT里就是灾难。初始化一个数据库连接失败可能是网络抖动、数据库正在重启这种错误大概率是瞬时的。结果因为异常被缓存整个进程生命周期内这个连接永远创建不出来只能重启应用。1.3 三个绕不过去的坑我把上面这段分析归纳成三个核心痛点这篇文章后续的优化全都围绕它们展开并发初始化去重多个await同时到达必须保证只有一个调用方真正执行工厂。这是LazyT最擅长的我们需要在异步场景下保住这个语义。等待者不阻塞如果某个调用方正在执行初始化其他调用方应该拿到同一个Task并优雅地等待而不是在锁上干等。异常可重试初始化失败后状态应该回滚到“未初始化”下一次调用重新尝试而不是把异常钉死在内存里。其中第 1、2 点可以合并成一个优化方向第 3 点单独算一个优化方向。标题里的“以下两点”指的就是这两件事。2. 优化点一并发初始化只执行一次等待者零阻塞2.1 为什么不能用 SemaphoreSlim 硬等面对并发初始化去重的问题很多人本能地想到SemaphoreSlim。我刚踩进这个领域的时候第一版也是这么写的public class SemaphoreAsyncLazyT { private readonly SemaphoreSlim _mutex new SemaphoreSlim(1, 1); private readonly FuncTaskT _factory; private T _value; private bool _hasValue; public SemaphoreAsyncLazy(FuncTaskT factory) { _factory factory; } public async TaskT 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 去重替代锁等待更好的思路是把初始化动作本身封装成一个TaskT让 Task 替我们管并发。这个思路不绕弯子直接利用 .NET 运行时对Task的底层优化。核心方案如下第一个调用方进来发现状态是“未初始化”立即启动工厂得到一个TaskT存到字段里状态切到“初始化中”。后续调用方进来发现状态是“初始化中”或“已完成”直接把已经存在的TaskT返回。所有人都await同一个Task。.NET的Task天生支持多 await 并发等待这就是原生的异步广播机制。用代码表达就是private readonly object _gate new object(); private TaskT _task; public TaskT 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 TaskT 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 LazyT 的异常缓存是怎么坑人的现在来看第二个优化点也是最容易被忽略的。上一节最后的代码还是把异常存在了_task里这没法用生产环境第一晚就能把服务质量拖垮。LazyT的设计哲学是“初始化失败视为编程错误”。比如类型构造器抛异常说明代码本身 bug重试一万次也是同样的异常。所以闭缓存异常是合理行为。但AsyncLazyT里工厂的失败原因往往不是代码 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置回 nullB 的快速路径恰好踩在这个间隙进来看到 null进入 lock 块重新触发了一次初始化。这个行为其实是对的——B 触发了重试。但如果 A 还没退出 catchB 已经创建了新的 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 TaskT 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 TaskT 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 AsyncLazyT 完整源码基于上面的分析这里给出我沉淀过的完整版本。相比前面的骨架我额外做了两个改进支持传入bool决定是否需要ConfigureAwait(false)以及一个ValueTask缓存的小优化。考虑到大多数使用场景不需要后者我就先给常规版注释写清楚关键行。/// summary /// 异步惰性初始化器。线程安全支持异常重试。 /// /summary public class AsyncLazyT { private enum State : int { NotStarted 0, Initializing 1, Completed 2 } private readonly object _gate new object(); private readonly FuncTaskT _factory; private TaskT _task; private int _state (int)State.NotStarted; public AsyncLazy(FuncTaskT factory) { _factory factory ?? throw new ArgumentNullException(nameof(factory)); } public TaskT 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 TaskT 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 88 线程并发调用GetValueAsync初始化模拟 50ms 异步延迟之后无限次读取。方式10 万次平均耗时内存分配有没有锁等待SemaphoreSlim 版约 45ms每次调用有一次 SemaphoreSlim 内部状态操作有LazyTaskT 版约 30ms每次调用过一遍LazyT内部缓存判断无本文状态机版约 18ms初始化前有闭包分配初始化后零额外分配无注意表格里的数字是我这台机器上的相对值目的不是给一个绝对基准而是展示三种方式的数量级差异。状态机版本最大的优势不是单次调用快多少倍而是初始化完成后的调用的开销低到可以忽略在高并发读取场景下限流、锁竞争基本消失。内存分配方面SemaphoreSlim版每次WaitAsync理论上在无竞争时有专门优化的轻量路径但如果存在竞争会走内核等待。状态机版在初始化后每次GetValueAsync不会创建任何新对象Volatile.Read两个字段然后返回引用GC 压力为零。4.3 集成到 DI 容器与使用场景这套AsyncLazyT最合适的落地场景有三个数据库连接工厂、配置中心热刷新、第三方 API 客户端单例。数据库连接场景services.AddSingletonIDbConnectionFactory(sp new DbConnectionFactory( new AsyncLazyIDbConnection(() CreateConnectionAsync()) ));配置中心场景public class ConfigService { private readonly AsyncLazyAppConfig _config; public ConfigService(IConfiguration config) { _config new AsyncLazyAppConfig(async () { // 拉取远程配置、解析、校验 var raw await DownloadConfigAsync(); return AppConfig.Parse(raw); }); } public TaskAppConfig GetConfigAsync() _config.GetValueAsync(); }注意一点如果你的服务生命周期是 scoped千万别把AsyncLazyT注册成 scoped。每个请求都会创建一个新实例惰性初始化等于空转。一定要注册成Singleton让初始化结果在进程维度共享。5. 常见问题与排查技巧实录5.1 死锁与同步上下文AsyncLazyT的一个使用误杀场景是初始化工厂里没有加ConfigureAwait(false)而且初始化发生在 UI 线程或 ASP.NET 请求上下文里。当第一个调用方在 UI 线程上await工厂工厂内部捕获了 UI 同步上下文它在await某个异步操作后试图切回 UI 线程。如果工厂恰好又被 UI 线程自己阻塞等待——比如有人用了.Result而不是await——就会死锁。我在项目里给出的硬性约束是工厂内部所有await一律加ConfigureAwait(false)。AsyncLazyT的InitializeOnceAsync里已经加了但_factory()的内部由你自己写的这是你的责任。5.2 异常重试导致的调用方重复异常有次上线后我观察到一个现象初始化连接超时失败_task被清空状态回滚到 NotStarted。此时线程 B、C、D 都在 lock 里排队它们拿到的都是同一个失败的旧 Task。等 catch 执行完E 线程进来了它触发新初始化成功。但 B、C、D 拿到的还是旧异常只有 E 拿到了成功结果。这不是 bug但可能让调用方困惑明明后来有人成功了我为什么要收到异常解决方案不是在AsyncLazyT内部做文章而是让调用方自己处理。初始化失败后重试是一个业务逻辑放调用方更合适public async TaskT 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(); }这样做的好处是想清楚了一个问题异常的重试单位是什么。对AsyncLazyT来说重试单位是“整个进程级别”。失败后下一次调用会重新初始化。对调用方来说重试单位是“每次业务请求”。当初始化失败扩散给多个请求时每个请求都可以选择自己的重试策略互不影响。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”绑定在一起设计上就干净很多。收尾一个值得反复琢磨的取舍写到这里回头看这个AsyncLazyT的优化过程我最大的体会是异步并发问题的本质是状态管理不是锁管理。很多人一看到“并发”第一反应是找一个更快的锁或者更聪明的同步原语。但真正的性能瓶颈往往不在锁本身而在锁的接入范围。SemaphoreSlim的问题不是它慢而是它把“读缓存值”这个高频操作也锁了起来。我优化的思路是既然Task本身就具备并发等待语义为什么还要额外加锁用Task引用替代锁让运行时替我管理并发我只负责状态流转的一致性。这个取舍在后面几个版本里被验证得越来越充分。我还试过引入ValueTaskT做返回值优化试图省掉部分场景下的 Task 分配但测试后发现收益不稳定——在初始化成功后的快速路径返回 Task 引用本身没有额外分配真正值得优化的是那个Task.FromResult(result)的分配。如果你的项目对分配极度敏感可以考虑引入ValueTask缓存但那会让状态机的复杂度再上一个台阶。先把这个版本吃透再考虑继续优化也不迟。另外想多说一句异常重试的语义一定要在架构层面就想清楚。AsyncLazyT管的是“进程实例只初始化一次”异常重试管的是“失败后允许重新初始化”这是两层完全不同的东西。把这两层拆开每层只做好自己的事组合起来反而比硬揉在一起更可靠。我在多个项目里反复验证过这个结论让组件语义单一把组合逻辑留在调用方这永远是并发代码里最好的策略。
返回列表