在 MemoryCache 调用上停止重入

Mel*_*per 6 .net c# concurrency memorycache concurrentdictionary

应用程序需要加载数据并缓存一段时间。我希望如果应用程序的多个部分想要同时访问同一个缓存键,缓存应该足够智能,只加载一次数据并将该调用的结果返回给所有调用者。然而,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;
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

这不是发生的事情。以上显示了这些结果(不一定按此顺序):

2

1

3

缓存的值为:3

它为每个缓存请求创建一个任务,并丢弃从其他两个请求返回的值。

这不必要地花费了时间,这让我想知道您是否可以说这个类甚至是线程安全的。ConcurrentDictionary有相同的行为。我测试了它,同样的事情发生了。

有没有办法在任务不运行 3 次的情况下实现所需的行为?

Mar*_*age 4

MemoryCache让您决定如何处理填充缓存键的竞争。在您的情况下,您不希望多个线程竞争填充密钥,可能是因为这样做的成本很高。

要像这样协调多个线程的工作,您需要一个锁,但lock在异步代码中使用 C# 语句可能会导致线程池饥饿。幸运的是,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();
    }
}
Run Code Online (Sandbox Code Playgroud)

如果多个线程尝试填充相同的缓存键,则只有一个线程实际上会执行此操作。其他线程将返回创建的值。

假设您使用依赖注入,您可以通过添加一些转发到底层缓存的方法来GuardedMemoryCache实现,以修改整个应用程序的缓存行为,只需很少的代码更改。IMemoryCache

  • 对整个内存缓存使用单个“SemaphoreSlim”意味着在异步创建单个缓存键时,所有其他键都将被阻止。这是一个非常严重的缺陷,因此我投了反对票。您也许可以通过每个键使用一个“SemaphoreSlim”来修复它,但我认为“AsyncLazy”解决方案更容易实现。 (3认同)
  • Martin,您更新的解决方案存在竞争条件:线程 A 获取并释放特定键的信号量。线程 B 立即获取信号量。线程 A 从字典中删除信号量(“RemoveSemaphore(key)”)。线程 C 为特定键创建一个新信号量。结果:B 和 C 线程同时调用特定键的“工厂”。正确地做到这一点是相当棘手的。如果需要,请查看[此](/sf/ask/2179672561/)问题。 (2认同)