多线程环境下耗时操作的异步懒加载失效问题排查
问题分析与解决方案
你的核心问题是并发场景下同一缓存键对应的耗时操作被多次执行,原代码用Lazy<Task<T>>结合ConcurrentDictionary的思路方向是对的,但存在几个细节问题导致重复执行:
Lazy<T>的异常重试机制:默认的Lazy<T>模式下,如果工厂方法(也就是你的异步操作)抛出异常,后续调用Lazy.Value会重新执行工厂方法,导致重复调用耗时操作。- 不必要的
Task.Run包装:Utils.GetProductsList本身是异步方法,用Task.Run包裹会额外占用线程池资源,虽然不是重复执行的直接原因,但属于冗余代码。 - 缺乏缓存过期策略:你的代码是永久缓存,如果业务需要一天过期,原代码没有实现,可能导致你误以为是重复执行(但你说实例没重启,所以更可能是前两个原因)。
方案一:改进ConcurrentDictionary实现(无过期)
直接用ConcurrentDictionary存储Task<T>,利用GetOrAdd的原子性确保同一键仅创建一次异步任务,避免Lazy<T>的潜在问题:
private static readonly ConcurrentDictionary<Guid, Task<List<ProductsList>>> _cache = new ConcurrentDictionary<Guid, Task<List<ProductsList>>>(); public async Task<List<ProductsList>> Get(AnalysisParams aParams, string refUrlApi) { // 原子性获取或创建任务,同一Project_ID只会执行一次工厂函数 var task = _cache.GetOrAdd(aParams.Project_ID, async pid => { // 这里用pid而非直接捕获aParams,避免闭包捕获导致的潜在问题 return await Utils.GetProductsList(aParams, refUrlApi); }); // 所有请求等待同一个任务完成 return await task; }
这个方案中,GetOrAdd会保证同一键对应的工厂函数仅执行一次,后续请求直接复用已有的Task,即使任务执行失败,后续请求也会拿到同一个失败的Task,不会重新执行耗时操作。
方案二:用IMemoryCache实现(带过期,推荐)
ASP.NET自带的IMemoryCache专门处理缓存场景,自带过期策略,且原生支持并发下仅执行一次缓存项创建,是更优的生产级方案:
首先确保已注入IMemoryCache(ASP.NET Core默认已注册):
private readonly IMemoryCache _memoryCache; // 通过构造函数注入IMemoryCache public YourService(IMemoryCache memoryCache) { _memoryCache = memoryCache; } public async Task<List<ProductsList>> Get(AnalysisParams aParams, string refUrlApi) { var cacheKey = aParams.Project_ID; // GetOrCreateAsync会自动处理并发:同一键仅执行一次工厂方法 return await _memoryCache.GetOrCreateAsync(cacheKey, async entry => { // 设置缓存一天后过期,可根据业务调整 entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1); // 执行耗时异步操作 return await Utils.GetProductsList(aParams, refUrlApi); }); }
这个方案不仅解决了并发重复执行的问题,还自带过期清理,符合你“一天内可能需要更新缓存”的隐含需求,同时代码更简洁,是ASP.NET中缓存异步操作的标准做法。
原代码重复执行的关键原因
你看到的重复执行,大概率是因为某次Utils.GetProductsList调用抛出异常,触发了Lazy<T>的重试机制——默认Lazy<T>在工厂方法抛出异常时,下次访问Value会重新执行工厂方法,导致耗时操作被多次调用。而改用Task<T>或IMemoryCache后,即使操作失败,也不会重复执行,而是返回同一个失败的任务/缓存项。
内容的提问来源于stack exchange,提问作者alfoks
相关产品推荐
相关产品推荐

