Async threadsafe Получить из MemoryCache

Я создал асинхронный кеш, который использует .NET MemoryCache под ним. Это код

public async Task<T> GetAsync(string key, Func<Task<T>> populator, TimeSpan expire, object parameters)
{
    if(parameters != null)
        key += JsonConvert.SerializeObject(parameters);

    if(!_cache.Contains(key))
    {
        var data = await populator();
        lock(_cache)
        {
            if(!_cache.Contains(key)) //Check again but locked this time
                _cache.Add(key, data, DateTimeOffset.Now.Add(expire));
        }
    }

    return (T)_cache.Get(key);
}

Я думаю, что единственным недостатком является то, что мне нужно ждать ожидания за пределами блокировки, поэтому populator не является потокобезопасным, но, поскольку он не может находиться внутри замка, я думаю, что это лучший способ. Есть ли какие-то подводные камни, которые я пропустил?

Обновление: версия ответа Esers, которая также является потоковой, когда поток antoher делает недействительным кеш

public async Task<T> GetAsync(string key, Func<Task<T>> populator, TimeSpan expire, object parameters)
{
    if(parameters != null)
        key += JsonConvert.SerializeObject(parameters);

    var lazy = new Lazy<Task<T>>(populator, true);
    _cache.AddOrGetExisting(key, lazy, DateTimeOffset.Now.Add(expire));
    return ((Lazy<Task<T>>) _cache.Get(key)).Value;
}

Однако он может быть медленнее, поскольку создает Lazy-экземпляры, которые никогда не будут выполняться, и использует Lazy в полном потоковом режиме LazyThreadSafetyMode.ExecutionAndPublication

Обновление с новым эталоном (более высокое)

Lazy with lock      42535929
Lazy with GetOrAdd  41070320 (Only solution that is completely thread safe)
Semaphore           64573360

Ответы

Ответ 1

Простое решение - использовать SemaphoreSlim.WaitAsync() вместо блокировки, а затем вы можете обойти проблему ожидания внутри блокировки. Хотя все другие методы MemoryCache являются потокобезопасными.

private SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1);
public async Task<T> GetAsync(
            string key, Func<Task<T>> populator, TimeSpan expire, object parameters)
{
    if (parameters != null)
        key += JsonConvert.SerializeObject(parameters);

    if (!_cache.Contains(key))
    {
        await semaphoreSlim.WaitAsync();
        try
        {
            if (!_cache.Contains(key))
            {
                var data = await populator();
                _cache.Add(key, data, DateTimeOffset.Now.Add(expire));
            }
        }
        finally
        {
            semaphoreSlim.Release();
        }
    }

    return (T)_cache.Get(key);
}

Ответ 2

Хотя есть уже принятый ответ, я отправлю новый с подходом Lazy<T>. Идея заключается в следующем: чтобы минимизировать продолжительность блока lock, если ключ не существует в кеше, поместите кеш Lazy<T> в кеш. Таким образом, все потоки, использующие один и тот же ключ, будут ждать одно и то же значение Lazy<T>

public Task<T> GetAsync<T>(string key, Func<Task<T>> populator, TimeSpan expire, object parameters)
{
    if (parameters != null)
        key += JsonConvert.SerializeObject(parameters);

    lock (_cache)
    {
        if (!_cache.Contains(key))
        {
            var lazy = new Lazy<Task<T>>(populator, true);
            _cache.Add(key, lazy, DateTimeOffset.Now.Add(expire));
        }
    }

    return ((Lazy<Task<T>>)_cache.Get(key)).Value;
}

Version2

public Task<T> GetAsync<T>(string key, Func<Task<T>> populator, TimeSpan expire, object parameters)
{
    if (parameters != null)
        key += JsonConvert.SerializeObject(parameters);

    var lazy = ((Lazy<Task<T>>)_cache.Get(key));
    if (lazy != null) return lazy.Value;

    lock (_cache)
    {
        if (!_cache.Contains(key))
        {
            lazy = new Lazy<Task<T>>(populator, true);
            _cache.Add(key, lazy, DateTimeOffset.Now.Add(expire));
            return lazy.Value;
        }
        return ((Lazy<Task<T>>)_cache.Get(key)).Value;
    }
}

Version3

public Task<T> GetAsync<T>(string key, Func<Task<T>> populator, TimeSpan expire, object parameters)
{
    if (parameters != null)
        key += JsonConvert.SerializeObject(parameters);

    var task = (Task<T>)_cache.Get(key);
    if (task != null) return task;

    var value = populator();
    return 
     (Task<T>)_cache.AddOrGetExisting(key, value, DateTimeOffset.Now.Add(expire)) ?? value;
}

Ответ 3

Текущие ответы используют несколько устаревшие System.Runtime.Caching.MemoryCache. Они также содержат тонкие условия гонки (см. комментарии). Наконец, не все из них позволяют тайм-ауту зависеть от кэшируемого значения.

Вот моя попытка использовать новый Microsoft.Extensions.Caching.Memory (используется ASP.NET Core):

//Add NuGet package: Microsoft.Extensions.Caching.Memory    

using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Primitives;

MemoryCache _cache = new MemoryCache(new MemoryCacheOptions());

public Task<T> GetOrAddAsync<T>(
        string key, Func<Task<T>> factory, Func<T, TimeSpan> expirationCalculator)
{    
    return _cache.GetOrCreateAsync(key, async cacheEntry => 
    {
        var cts = new CancellationTokenSource();
        cacheEntry.AddExpirationToken(new CancellationChangeToken(cts.Token));
        var value = await factory().ConfigureAwait(false);
        cts.CancelAfter(expirationCalculator(value));
        return value;
    });
}

Пример использования:

await GetOrAddAsync("foo", () => Task.Run(() => 42), i  => TimeSpan.FromMilliseconds(i)));

Обратите внимание, что фабричный метод не может быть вызван только один раз (см. https://github.com/aspnet/Caching/issues/240).

Ответ 4

Это попытка улучшения ответа Eser answer (версия 2). Класс Lazy по умолчанию является потокобезопасным, поэтому lock можно удалить. Возможно, для данного ключа будет создано несколько объектов Lazy, но только у одного из них будет запрошено свойство Value, что вызовет запуск тяжелого Task. Другой Lazy останется неиспользованным, выйдет из области видимости и вскоре станет сборщиком мусора.

Первая перегрузка является гибкой и общей и принимает аргумент Func<CacheItemPolicy>. Я включил еще две перегрузки для наиболее распространенных случаев абсолютного и скользящего истечения. Для удобства можно добавить еще много перегрузок.

using System.Runtime.Caching;

static partial class MemoryCacheExtensions
{
    public static Task<T> GetOrCreateLazyAsync<T>(this MemoryCache cache, string key,
        Func<Task<T>> valueFactory, Func<CacheItemPolicy> cacheItemPolicyFactory)
    {
        var lazyTask = (Lazy<Task<T>>)cache.Get(key);
        if (lazyTask != null) return lazyTask.Value.ToAsyncConditional();
        lazyTask = new Lazy<Task<T>>(valueFactory);
        var cacheItem = new CacheItem(key, lazyTask);
        var cacheItemPolicy = cacheItemPolicyFactory?.Invoke();
        var existingCacheItem = cache.AddOrGetExisting(cacheItem, cacheItemPolicy);
        return ((Lazy<Task<T>>)(existingCacheItem?.Value ?? cacheItem.Value)).Value
            .ToAsyncConditional();
    }

    public static Task<T> GetOrCreateLazyAsync<T>(this MemoryCache cache, string key,
        Func<Task<T>> valueFactory, DateTimeOffset absoluteExpiration)
    {
        return cache.GetOrCreateLazyAsync(key, valueFactory, () => new CacheItemPolicy()
        {
            AbsoluteExpiration = absoluteExpiration,
        });
    }

    public static Task<T> GetOrCreateLazyAsync<T>(this MemoryCache cache, string key,
        Func<Task<T>> valueFactory, TimeSpan slidingExpiration)
    {
        return cache.GetOrCreateLazyAsync(key, valueFactory, () => new CacheItemPolicy()
        {
            SlidingExpiration = slidingExpiration,
        });
    }

    private static Task<TResult> ToAsyncConditional<TResult>(this Task<TResult> task)
    {
        if (task.IsCompleted) return task;
        return task.ContinueWith(async t => await t,
            default, TaskContinuationOptions.RunContinuationsAsynchronously,
            TaskScheduler.Default).Unwrap();
    }

}

Пример использования:

string html = await MemoryCache.Default.GetOrCreateLazyAsync("MyKey", async () =>
{
    return await new WebClient().DownloadStringTaskAsync("https://stackoverflow.com");
}, DateTimeOffset.Now.AddMinutes(10));

HTML-код этого сайта загружается и кэшируется в течение 10 минут. Несколько одновременных запросов await будут выполнять одну и ту же задачу.

Класс System.Runtime.Caching.MemoryCache прост в использовании, но имеет ограниченную поддержку для определения приоритетов записей кэша. В основном есть только две опции, Default и NotRemovable, что означает, что они вряд ли подходят для сложных сценариев. Более новый класс Microsoft.Extensions.Caching.Memory.MemoryCache (из этого пакета) предлагает дополнительные параметры в отношении приоритетов кэша (Low, Normal, High и NeverRemove ]), но в остальном менее интуитивно понятен и более громоздок в использовании. Он предлагает асинхронные возможности, но не ленивый. Итак, вот эквивалентные расширения LazyAsync для этого класса:

using Microsoft.Extensions.Caching.Memory;

static partial class MemoryCacheExtensions
{
    public static Task<T> GetOrCreateLazyAsync<T>(this MemoryCache cache, object key,
        Func<ICacheEntry, Task<T>> factory)
    {
        return cache.GetOrCreate(key, e =>
        {
            return new Lazy<Task<T>>(() => factory(e));
        }).Value.ToAsyncConditional();
    }

    public static Task<T> GetOrCreateLazyAsync<T>(this MemoryCache cache, object key,
        Func<Task<T>> valueFactory, DateTimeOffset absoluteExpiration)
    {
        return cache.GetOrCreateLazyAsync(key, e =>
        {
            e.AbsoluteExpiration = absoluteExpiration;
            return valueFactory();
        });
    }

    public static Task<T> GetOrCreateLazyAsync<T>(this MemoryCache cache, object key,
        Func<Task<T>> valueFactory, TimeSpan slidingExpiration)
    {
        return cache.GetOrCreateLazyAsync(key, e =>
        {
            e.SlidingExpiration = slidingExpiration;
            return valueFactory();
        });
    }
}

Пример использования:

var cache = new MemoryCache(new MemoryCacheOptions());
string html = await cache.GetOrCreateLazyAsync("MyKey", async () =>
{
    return await new WebClient().DownloadStringTaskAsync("https://stackoverflow.com");
}, DateTimeOffset.Now.AddMinutes(10));

Обновление: я только что узнал о специфической функции механизма async - await. Когда неполное Task ожидается несколько раз одновременно, продолжения будут выполняться синхронно (в одном и том же потоке) одно за другим (при условии отсутствия контекста синхронизации). Это может быть проблемой для вышеупомянутых реализаций GetOrCreateLazyAsync, потому что блокировка кода может существовать сразу после ожидаемого вызова GetOrCreateLazyAsync, и в этом случае будут затронуты другие ожидающие (задержанные или даже заблокированные). Возможное решение этой проблемы - вернуть асинхронное продолжение лениво созданного Task вместо самой задачи, но только если задача не завершена. Это является причиной для введения метода ToAsyncConditional выше.