23

I have created a async cache that uses .NET MemoryCache underneath. This is the code:

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);
}

I think the only downside is that I need to do the await outside the lock so the populator isn't thread safe, but since the await can't reside inside a lock I guess this is the best way. Are there any pitfalls that I have missed?

Update: A version of Esers answer that is also threadsafe when another thread invalidates the cache:

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;
}

It can however be slower because it creates Lazy instances that never will be executed and it uses Lazy in full threadsafe mode LazyThreadSafetyMode.ExecutionAndPublication

Update with new benchmark (Higher is better)

Lazy with lock      42535929
Lazy with GetOrAdd  41070320 (Only solution that is completely thread safe)
Semaphore           64573360
bad_coder
  • 11,289
  • 20
  • 44
  • 72
Anders
  • 17,306
  • 10
  • 76
  • 144
  • Suppose a new thread comes with the same key while the first one *awaits* populate. *populator* for the same key will be executed unnecessarily twice. – EZI Aug 05 '15 at 12:02
  • you can use a `SempahoreSlim` with count 1, it has asynchronous wait https://msdn.microsoft.com/en-us/library/hh462805(v=vs.110).aspx – NeddySpaghetti Aug 05 '15 at 12:05
  • Yeah, that's the downside I'm aware of, but it's hard to build around since await can't be locked? – Anders Aug 05 '15 at 12:07
  • @ned, nice, will look at that – Anders Aug 05 '15 at 12:08
  • So far as I know is the `MemoryCache` thread safe. Or did I miss something? – CodeTherapist Aug 05 '15 at 12:13
  • The internal methods are, but if you call contains and then Add those are not threadsafe, plus the async populator is not threadsafe at all even with my above code – Anders Aug 05 '15 at 12:15

4 Answers4

15

A simple solution would be to use SemaphoreSlim.WaitAsync() instead of a lock, and then you could get around the issue of awaiting inside a lock. Although, all other methods of MemoryCache are thread-safe.

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);
}
Dan Atkinson
  • 11,391
  • 14
  • 81
  • 114
Yuval Itzchakov
  • 146,575
  • 32
  • 257
  • 321
  • 3
    Yes but the contains key check and then insert into cache is not – Anders Aug 05 '15 at 12:02
  • Nice solution, Ned was also on the same track – Anders Aug 05 '15 at 12:18
  • But there is a bug ;) The populator execution must be inside the if block, otherwise the semaphore solution is no better than my solution above – Anders Aug 05 '15 at 12:20
  • 3
    What if the moment after you check `!_cache.Contains(key)` but before you do `return (T)_cache.Get` the item gets deleted from the cache due to the expiration policy? The former should be `AddOrGetExisting`. Actually you have the same issue with the inner `_cache.Contains(key)` too. – Ohad Schneider Jul 04 '17 at 09:02
  • @OhadSchneider By former you means the check for `Contains`? It's definitely a possibility that the key would expire prior to the `Get` call. – Yuval Itzchakov Jul 04 '17 at 09:13
  • Yes I mean both `_cache.Contains` calls. On one hand your method provides "GetOrAdd" semantics (should be renamed really), but on the other hand in some edge cases it does not. Moreover, it would return `null` in those cases, which would be indistinguishable from an actual value of `null` for that key in the cache. – Ohad Schneider Jul 04 '17 at 09:14
  • 2
    @OhadSchneider You're right. The main focus of my answer wasn't to redefine the semantics of the call made by the OP, but to show how it's possible to use `SemaphoreSlim` to use in conjunction with an async call. I'll reiterate the code to tackle the expiry issues. – Yuval Itzchakov Jul 04 '17 at 09:18
  • Thanks! BTW I was wrong in my last observation - you can't add null items to a memory cache, possibly due to the the ambiguity reason I mentioned (of course, it is not documented anywhere, you just get an `ArgumentNullException`). – Ohad Schneider Jul 04 '17 at 09:22
  • The nice thing about this implementation is that the expiration can be a function of the populator's result. For example if the latter gets a token from some service, you could set the cache expiration to the token's expiration. – Ohad Schneider Jul 08 '17 at 13:06
  • 2
    This isn't a solution. Every time the cache is called it may have to await other calls. What if you are trying to cache multiple pieces of data? You might find that one thing takes 100 milliseconds, but it keeps having to wait for things that take 2-3+ seconds. – Christian Findlay Jan 09 '21 at 21:12
  • @ChristianFindlay I'm not sure what you mean by "this isn't a solution". It definitely is a solution, which may perform poorly if you have awaitables that a long time to return. But, same is true for all solutions that internally await. If you care about cache performance, don't make the populator an awaitable, make it pass the value directly. – Yuval Itzchakov Jan 10 '21 at 09:18
  • 2
    It's not a solution because it shares a lock for all the caches. You'd at least need a semaphore for each key (i.e. keep a dictionary of keys) to make this work. Someone else posted a solution here: https://stackoverflow.com/a/65643540/1878141 – Christian Findlay Jan 10 '21 at 23:15
  • @ChristianFindlay It doesn't share a lock for all caches, since there's a single cache. It does share a lock for ALL keys, if that's what you mean. You could implement this more efficiently, yes, but that doesn't make this any less of a solution then the link you pasted here. – Yuval Itzchakov Jan 11 '21 at 11:35
  • 1
    I meant it shares the lock for all cache entries (or keys) – Christian Findlay Jan 11 '21 at 21:40
  • @YuvalItzchakov I'd say that it performs suboptimally when the cache is handling 2+ entries. That's the typical use-case for a cache, but the OP's question isn't clear if they are wanting to cache multiple entries. I'd not make that assumption though. In most cases, this would be deal-breaker (it is for me). I'd put a caveat though. Just my 2c. – hIpPy Mar 03 '22 at 19:54
12

The current answers use the somewhat outdated System.Runtime.Caching.MemoryCache. They also contain subtle race conditions (see comments). Finally, not all of them allow the timeout to be dependent on the value to be cached.

Here's my attempt using the new Microsoft.Extensions.Caching.Memory (used by 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;
    });
}

Sample usage:

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

Note that it is not guaranteed for the factory method to be called only once (see https://github.com/aspnet/Caching/issues/240).

Ohad Schneider
  • 36,600
  • 15
  • 168
  • 198
11

Although there is an already accepted answer, I'll post a new one with Lazy<T> approach. Idea is: to minimize the duration of lock block, if the key doesn't exists in cache, put a Lazy<T> to cache. That way all threads using the same key at the same time will be waiting the same Lazy<T>'s value

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;
}
Ohad Schneider
  • 36,600
  • 15
  • 168
  • 198
Eser
  • 12,346
  • 1
  • 22
  • 32
  • 1
    With your new pattern you can get rid of the lock entirely by dropping the `Contains` check and switching the the `Add` call to `AddOrGetExisting`. – Scott Chamberlain Aug 05 '15 at 12:57
  • With Scotts additon its even thread safe when removing entries (Wich not mine nor Yuvals is) – Anders Aug 05 '15 at 13:21
  • @Anders I tried it after Scott's comment, but it requires unnecessary Lazy's (although they are not evaluated) be created. So I didn't post it. – Eser Aug 05 '15 at 13:23
  • True, also a Lazy set to full thread safe mode is alot slower than Yuval's solution – Anders Aug 05 '15 at 13:26
  • 1
    @Anders Also no need for async/await :) – Eser Aug 05 '15 at 13:46
  • Did a bench, your version is slowest actually – Anders Aug 05 '15 at 13:56
  • @Anders I changed the code a little bit, and my results are not similar to yours. Can you test also the *version2* – Eser Aug 05 '15 at 14:35
  • Version two is much faster then Semaphore. Version of that that only uses thread saftly of MemoryCache is this http://pastebin.com/ggfxqGkp My test looks like this btw http://pastebin.com/FSjayVXz – Anders Aug 05 '15 at 16:00
  • @Anders One more optimization can be done. Just drop all Lazy's. *Task.Result* (or await) is thread safe. See Version3. – Eser Aug 05 '15 at 17:20
  • @Eser thats just test code actually, hmm, which makes the test bad. How can I execute the result in a manner that Web API does for more correct test? – Anders Aug 05 '15 at 18:37
  • @Anders Yes, Sorry for disturbance. – I4V Aug 05 '15 at 18:59
  • Versions 1 and 2 suffer from a race condition where the item gets cleared right before you return it using `_cache.Get`. – Ohad Schneider Jul 08 '17 at 12:51
  • @OhadSchneider Your edit revision 9 of the code for **Version3** has introduced a bug. The code as you have written it can return `null`, since `AddOrGetExisting` returns `null` in the cases when it _Adds_. You should rollback that change to revision 8. @Eser could also do the rollback. If the goal of the change was to make the code shorter, consider this: `return (Task)(_cache.AddOrGetExisting(key, populator(), DateTimeOffset.Now.Add(expire)) ?? _cache.Get(key));`. (_Although some will argue it is less readable that the original `if` statement._) – Mike Jul 21 '17 at 01:51
  • 1
    @Mike Thanks, I assumed `AddOrGetExisting` had the same contract as `GetOrAdd` in `ConcurrentDictionary` (which makes much more sense IMO). Anyway, your proposed fix contains a race condition (imagine the value is added but then is then immediately removed before you get to `_cache.Get`) so I fixed it slightly differently. – Ohad Schneider Jul 24 '17 at 21:33
0

This thread is a bit old but I hit this recently and I thought I'd leave this answer hoping it helps.

With async, there are few things to keep in mind:

  1. An "uber lock" approach is not quick as it blocks the factory operations on other keys when performing one on a key.
  2. A "lock per key" (SemaphoreSlim) has 2 things going on: a. It's disposable, so disposing it after could race. b. Live with not disposing it.

I chose to solve it using a pool of locks. It's not required to have a lock per key, but just enough locks as the max active threads possible. I then assign the same lock to the key through hashing. The pool size is a function of ProcessorCount. The valueFactory is executed only once. Since multiple keys map to a lock (a key always maps to the same lock), operations for keys with the same hash will get synchronized. So this loses some parallelism, and this compromise may not work for all cases. I'm OK with this compromise. This is the approach that LazyCache, and FusionCache (one of its many approaches) use, among other things. So I'd use one of them, but it's good to know the trick though as it's pretty nifty.

private readonly SemaphoreSlimPool _lockPool = new SemaphoreSlimPool(1, 1);

private async Task<TValue> GetAsync(object key, Func<ICacheEntry, Task<TValue>> valueFactory)
{
    if (_cache.TryGetValue(key, out var value))
    {
        return value;
    }

    // key-specific lock so as to not block operations on other keys
    var lockForKey = _lockPool[key];
    await lockForKey.WaitAsync().ConfigureAwait(false);
    try
    {
        if (_cache.TryGetValue(key, out value))
        {
            return value;
        }

        value = await _cache.GetOrCreateAsync(key, valueFactory).ConfigureAwait(false);
        return value;
    }
    finally
    {
        lockForKey.Release();
    }
}

// Dispose SemaphoreSlimPool

And here's the SemaphoreSlimPool impl (source, nuget).

/// <summary>
/// Provides a pool of SemaphoreSlim objects for keyed usage.
/// </summary>
public class SemaphoreSlimPool : IDisposable
{
    /// <summary>
    /// Pool of SemaphoreSlim objects.
    /// </summary>
    private readonly SemaphoreSlim[] pool;

    /// <summary>
    /// Size of the pool.
    /// <para></para>
    /// Environment.ProcessorCount is not always correct so use more slots as buffer,
    /// with a minimum of 32 slots.
    /// </summary>
    private readonly int poolSize = Math.Max(Environment.ProcessorCount << 3, 32);

    private const int NoMaximum = int.MaxValue;

    /// <summary>
    /// Ctor.
    /// </summary>
    public SemaphoreSlimPool(int initialCount)
        : this(initialCount, NoMaximum)
    { }

    /// <summary>
    /// Ctor.
    /// </summary>
    public SemaphoreSlimPool(int initialCount, int maxCount)
    {
        pool = new SemaphoreSlim[poolSize];
        for (int i = 0; i < poolSize; i++)
        {
            pool[i] = new SemaphoreSlim(initialCount, maxCount);
        }
    }

    /// <inheritdoc cref="Get(object)" />
    public SemaphoreSlim this[object key] => Get(key);

    /// <summary>
    /// Returns a <see cref="SemaphoreSlim"/> from the pool that the <paramref name="key"/> maps to.
    /// </summary>
    /// <exception cref="ArgumentNullException"></exception>
    public SemaphoreSlim Get(object key)
    {
        _ = key ?? throw new ArgumentNullException(nameof(key));
        return pool[GetIndex(key)];
    }

    private uint GetIndex(object key)
    {
        return unchecked((uint)key.GetHashCode()) % (uint)poolSize;
    }

    private bool disposed = false;

    public void Dispose()
    {
        Dispose(true);
    }

    public void Dispose(bool disposing)
    {
        if (!disposed)
        {
            if (disposing)
            {
                if (pool != null)
                {
                    for (int i = 0; i < poolSize; i++)
                    {
                        pool[i].Dispose();
                    }
                }
            }

            disposed = true;
        }
    }
}

I've thrown quite a bit of threads on this with a lot of churn due to low ttl and it's not bombing out. So far it looks good to me, but I'd like to see if anyone can find bugs.

hIpPy
  • 4,649
  • 6
  • 51
  • 65
  • 1
    I don't like this solution. The number of stored entries in a `MemoryCache` is not related to the number of processors in any way. A `MemoryCache` may store thousands of keys, in a machine having 4 cores. What this solution does is to use the same synchronization primitive (a `SemaphoreSlim`) for all different keys that may have the same `key.GetHashCode() % poolSize` value. So the `GetAsync("Light")` may have to wait for the completion of the unrelated `GetAsync("Heavy")`, in case the "Light" and "Heavy" keys happens to have the same hashcode modulo 32. – Theodor Zoulias Mar 03 '22 at 10:48
  • Also the `valueFactory` might not be invoked on the current context, because of the `ConfigureAwait(false)`, with unknown consequences. – Theodor Zoulias Mar 03 '22 at 10:53
  • Yup, that's a compromise that's ok for me. Cache is typically not write-heavy. All other solutions I seen so far either have a race for valueFactory or live with not disposing, which is worse imo. – hIpPy Mar 03 '22 at 17:25
  • The consuming code can not use ConfigureAwait(false). In my case, I need it. – hIpPy Mar 03 '22 at 17:27