应用需要加载数据并缓存一段时间。我希望如果应用程序的多个部分想要同时访问相同的缓存键,缓存应该足够智能,只加载数据一次并将该调用的结果返回给所有调用者。然而,
MemoryCache
并没有这样做。如果您并行访问缓存(这通常发生在应用程序中),它会为每次尝试获取缓存值创建一个任务。我以为这段代码会达到预期的结果,但事实并非如此。我希望缓存只运行一个 GetDataAsync
任务,等待它完成,然后使用结果获取其他调用的值。
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace ConsoleApp4
{
class Program
{
private const string Key = "1";
private static int number = 0;
static async Task Main(string[] args)
{
var memoryCache = new MemoryCache(new MemoryCacheOptions { });
var tasks = new List<Task>();
tasks.Add(memoryCache.GetOrCreateAsync(Key, (cacheEntry) => GetDataAsync()));
tasks.Add(memoryCache.GetOrCreateAsync(Key, (cacheEntry) => GetDataAsync()));
tasks.Add(memoryCache.GetOrCreateAsync(Key, (cacheEntry) => GetDataAsync()));
await Task.WhenAll(tasks);
Console.WriteLine($"The cached value was: {memoryCache.Get(Key)}");
}
public static async Task<int> GetDataAsync()
{
//Simulate getting a large chunk of data from the database
await Task.Delay(3000);
number++;
Console.WriteLine(number);
return number;
}
}
}
事实并非如此。上面显示了这些结果(不一定按照这个顺序):
2
1
3
缓存值为:3
它为每个缓存请求创建一个任务,并丢弃从其他两个请求返回的值。
这不必要地花费了时间,这让我想知道你是否可以说这个类是线程安全的。
ConcurrentDictionary
具有相同的行为。我测试了一下,也发生了同样的事情。
有没有办法实现任务不运行3次的预期行为?
MemoryCache
让您决定如何处理填充缓存键的竞争。在您的情况下,您不希望多个线程竞争填充密钥,可能是因为这样做的成本很高。
要协调多个线程的工作,您需要一个锁,但在异步代码中使用 C#
lock
语句可能会导致线程池饥饿。幸运的是,SemaphoreSlim
提供了一种执行异步锁定的方法,因此只需创建一个包装底层IMemoryCache
的受保护的内存缓存即可。
我的第一个解决方案只有一个用于整个缓存的信号量,将所有缓存填充任务放在一行中,这不是很聪明,因此这里有一个更复杂的解决方案,每个缓存键都有一个信号量。另一种解决方案可能是通过密钥的哈希值选择固定数量的信号量。
sealed class GuardedMemoryCache : IDisposable
{
readonly IMemoryCache cache;
readonly ConcurrentDictionary<object, SemaphoreSlim> semaphores = new();
public GuardedMemoryCache(IMemoryCache cache) => this.cache = cache;
public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<ICacheEntry, Task<TItem>> factory)
{
var semaphore = GetSemaphore(key);
await semaphore.WaitAsync();
try
{
return await cache.GetOrCreateAsync(key, factory);
}
finally
{
semaphore.Release();
RemoveSemaphore(key);
}
}
public object Get(object key) => cache.Get(key);
public void Dispose()
{
foreach (var semaphore in semaphores.Values)
semaphore.Release();
}
SemaphoreSlim GetSemaphore(object key) => semaphores.GetOrAdd(key, _ => new SemaphoreSlim(1));
void RemoveSemaphore(object key)
{
if (semaphores.TryRemove(key, out var semaphore))
semaphore.Dispose();
}
}
如果多个线程尝试填充相同的缓存键,则只有一个线程实际上会执行此操作。其他线程将返回创建的值。
假设您使用依赖注入,您可以通过添加一些转发到底层缓存的方法来让
GuardedMemoryCache
实现IMemoryCache
,以修改整个应用程序的缓存行为,只需很少的代码更改。
有不同的解决方案可用,其中最著名的可能是LazyCache:它是一个很棒的库。
您可能会发现有用的另一个是 FusionCache ⚡🦥,我最近发布了它:它具有完全相同的功能(尽管实现方式不同)以及更多。
您正在寻找的功能已在此处进行了描述,您可以像这样使用它:
var result = await fusionCache.GetOrSetAsync(
Key,
_ => await GetDataAsync(),
TimeSpan.FromMinutes(2)
);
您可能还会发现其他一些有趣的功能,例如故障安全、高级超时以及后台工厂完成功能以及对可选分布式第二级的支持。
如果您愿意给它一个机会,请告诉我您的想法。
/无耻插件