6

This code seems to do a good job of caching async method results. I would like to add some sort of expiration to it. I have tried Tuple but I was not successful in getting it to fully work / compile.

private static readonly ConcurrentDictionary<object, SemaphoreSlim> _keyLocks = new ConcurrentDictionary<object, SemaphoreSlim>();
    private static readonly ConcurrentDictionary<object, Tuple<List<UnitDTO>, DateTime>> _cache = new ConcurrentDictionary<object, Tuple<List<UnitDTO>, DateTime>>();

public async Task<string> GetSomethingAsync(string key)
{   
    string value;
    // get the semaphore specific to this key
    var keyLock = _keyLocks.GetOrAdd(key, x => new SemaphoreSlim(1));
    await keyLock.WaitAsync();
    try
    {
        // try to get value from cache
        if (!_cache.TryGetValue(key, out value))
        {
            // if value isn't cached, get it the long way asynchronously
            value = await GetSomethingTheLongWayAsync();

            // cache value
            _cache.TryAdd(key, value);
        }
    }
    finally
    {
        keyLock.Release();
    }
    return value;
}
jxmiller
  • 78
  • 2
  • 8

1 Answers1

10

Classical approaches and quotations

From msdn, by Stephen Cleary

Asynchronous code is often used to initialize a resource that’s then cached and shared. There isn’t a built-in type for this, but Stephen Toub developed an AsyncLazy that acts like a merge of Task and Lazy. The original type is described on his blog, and an updated version is available in my AsyncEx library.

public class AsyncLazy<T> : Lazy<Task<T>> 
{ 
    public AsyncLazy(Func<T> valueFactory) : 
        base(() => Task.Factory.StartNew(valueFactory)) { }
    public AsyncLazy(Func<Task<T>> taskFactory) : 
        base(() => Task.Factory.StartNew(() => taskFactory()).Unwrap()) { } 
}

Context

Let’s say in our program we have one of these AsyncLazy instances:

static string LoadString() { … }
static AsyncLazy<string> m_data = new AsyncLazy<string>(LoadString);

Usage

Thus, we can write an asynchronous method that does:

string data = await m_data.Value;

The Lazy<T> would be appropriate, but unfortunately it seems to lack the input parameter to index the result. The same issue was solved here where it is explained how to cache the results from a long-running, resource-intensive method, in case it is not async

Back to your proposed solution

Before I show the main changes related to the cache management and specific to your proposed implementation, let me suggest a couple of marginal optimization options, based on the following concerns.

often with locks, when you access them they’re uncontended, and in such cases you really want acquiring and releasing the lock to be as low-overhead as possible; in other words, accessing uncontended locks should involve a fast path

Since they're just performance optimization tricks, I will leave them commented in the code so that you can measure their effects in your specific situation before.

  1. You need to test TryGetValue again after awaiting because another parallel process could have added that value in the meantime
  2. You don't need to keep the lock while you're awaiting

This balance of overhead vs cache misses was already pointed out in a previous answer to a similar question.

Obviously, there's overhead keeping SemaphoreSlim objects around to prevent cache misses so it may not be worth it depending on the use case. But if guaranteeing no cache misses is important than this accomplishes that.

My main answer: the cache management

Regarding the cache expiration, I would suggest to add the creation DateTime to the value of the Dictionary (i.e. the time when the value is returned from GetSomethingTheLongWayAsync) and consequently discard the cached value after a fixed time span.

Find a draft below

    private static readonly ConcurrentDictionary<object, SemaphoreSlim> _keyLocks = new ConcurrentDictionary<object, SemaphoreSlim>();
    private static readonly ConcurrentDictionary<object, Tuple<string, DateTime>> _cache = new ConcurrentDictionary<object, Tuple<string, DateTime>>();


    private static bool IsExpiredDelete(Tuple<string, DateTime> value, string key)
    {
        bool _is_exp = (DateTime.Now - value.Item2).TotalMinutes > Expiration;
        if (_is_exp)
        {
            _cache.TryRemove(key, out value);
        }
        return _is_exp;
    }
    public async Task<string> GetSomethingAsync(string key)
    {
        Tuple<string, DateTime> cached;
        // get the semaphore specific to this key
        var keyLock = _keyLocks.GetOrAdd(key, x => new SemaphoreSlim(1));
        await keyLock.WaitAsync();
        try
        {
            // try to get value from cache
            if (!_cache.TryGetValue(key, out cached) || IsExpiredDelete(cached,key))
            {
                //possible performance optimization: measure it before uncommenting
                //keyLock.Release();
                string value = await GetSomethingTheLongWayAsync(key);
                DateTime creation = DateTime.Now; 
                // in case of performance optimization
                // get the semaphore specific to this key
                //keyLock = _keyLocks.GetOrAdd(key, x => new SemaphoreSlim(1));
                //await keyLock.WaitAsync();
                bool notFound;
                if (notFound = !_cache.TryGetValue(key, out cached) || IsExpiredDelete(cached, key))
                {
                    cached = new Tuple<string, DateTime>(value, creation);
                    _cache.TryAdd(key, cached);
                }
                else
                {
                    if (!notFound && cached.Item2 < creation)
                    {
                        cached = new Tuple<string, DateTime>(value, creation);
                    _cache.TryAdd(key, cached);
                    }
                }
            }
        }
        finally
        {
            keyLock.Release();
        }
        return cached?.Item1;
    }

Please, adapt the above code to your specific needs.

Making it more generic

Finally you may want to generalize it a little bit.

By the way, notice that the Dictionary are not static since one could cache two different methods with the same signature.

public class Cached<FromT, ToT>
{
    private Func<FromT, Task<ToT>> GetSomethingTheLongWayAsync;
    public Cached (Func<FromT, Task<ToT>> _GetSomethingTheLongWayAsync, int expiration_min ) {
        GetSomethingTheLongWayAsync = _GetSomethingTheLongWayAsync;
        Expiration = expiration_min;
}

    int Expiration = 1;

    private ConcurrentDictionary<FromT, SemaphoreSlim> _keyLocks = new ConcurrentDictionary<FromT, SemaphoreSlim>();
    private ConcurrentDictionary<FromT, Tuple<ToT, DateTime>> _cache = new ConcurrentDictionary<FromT, Tuple<ToT, DateTime>>();


    private bool IsExpiredDelete(Tuple<ToT, DateTime> value, FromT key)
    {
        bool _is_exp = (DateTime.Now - value.Item2).TotalMinutes > Expiration;
        if (_is_exp)
        {
            _cache.TryRemove(key, out value);
        }
        return _is_exp;
    }
    public async Task<ToT> GetSomethingAsync(FromT key)
    {
        Tuple<ToT, DateTime> cached;
        // get the semaphore specific to this key
        var keyLock = _keyLocks.GetOrAdd(key, x => new SemaphoreSlim(1));
        await keyLock.WaitAsync();
        try
        {
            // try to get value from cache
            if (!_cache.TryGetValue(key, out cached) || IsExpiredDelete(cached, key))
            {
                //possible performance optimization: measure it before uncommenting
                //keyLock.Release();
                ToT value = await GetSomethingTheLongWayAsync(key);
                DateTime creation = DateTime.Now;
                // in case of performance optimization
                // get the semaphore specific to this key
                //keyLock = _keyLocks.GetOrAdd(key, x => new SemaphoreSlim(1));
                //await keyLock.WaitAsync();
                bool notFound;
                if (notFound = !_cache.TryGetValue(key, out cached) || IsExpiredDelete(cached, key))
                {
                    cached = new Tuple<ToT, DateTime>(value, creation);
                    _cache.TryAdd(key, cached);
                }
                else
                {
                    if (!notFound && cached.Item2 < creation)
                    {
                        cached = new Tuple<ToT, DateTime>(value, creation);
                        _cache.TryAdd(key, cached);
                    }
                }
            }
        }
        finally
        {
            keyLock.Release();
        }
        return cached.Item1;
    }

}

For a generic FromT an IEqualityComparer is needed for the Dictionary

Usage/Demo

    private static async Task<string> GetSomethingTheLongWayAsync(int key)
    {
        await Task.Delay(15000);
        Console.WriteLine("Long way for: " + key);
        return key.ToString();
    }

    static void Main(string[] args)
    {
        Test().Wait();
    }

    private static async Task Test()
    {
        int key;
        string val;
        key = 1;
        var cache = new Cached<int, string>(GetSomethingTheLongWayAsync, 1);
        Console.WriteLine("getting " + key);
        val = await cache.GetSomethingAsync(key);
        Console.WriteLine("getting " + key + " resulted in " + val);

        Console.WriteLine("getting " + key);
        val = await cache.GetSomethingAsync(key);
        Console.WriteLine("getting " + key + " resulted in " + val);

        await Task.Delay(65000);

        Console.WriteLine("getting " + key);
        val = await cache.GetSomethingAsync(key);
        Console.WriteLine("getting " + key + " resulted in " + val);
        Console.ReadKey();
    }

Sophisticated alternatives

There are also more advanced possibilities like the overload of GetOrAdd that takes a delegate and Lazy objects to ensure that a generator function is called only once (instead of semaphores and locks).

   public class AsyncCache<FromT, ToT>
    {
        private Func<FromT, Task<ToT>> GetSomethingTheLongWayAsync;
        public AsyncCache(Func<FromT, Task<ToT>> _GetSomethingTheLongWayAsync, int expiration_min)
        {
            GetSomethingTheLongWayAsync = _GetSomethingTheLongWayAsync;
            Expiration = expiration_min;
        }

        int Expiration;

        private ConcurrentDictionary<FromT, Tuple<Lazy<Task<ToT>>, DateTime>> _cache = 
            new ConcurrentDictionary<FromT, Tuple<Lazy<Task<ToT>>, DateTime>>();


        private bool IsExpiredDelete(Tuple<Lazy<Task<ToT>>, DateTime> value, FromT key)
        {
            bool _is_exp = (DateTime.Now - value.Item2).TotalMinutes > Expiration;
            if (_is_exp)
            {
                _cache.TryRemove(key, out value);
            }
            return _is_exp;
        }
        public async Task<ToT> GetSomethingAsync(FromT key)
        {
            var res = _cache.AddOrUpdate(key,
                t =>  new Tuple<Lazy<Task<ToT>>, DateTime>(new Lazy<Task<ToT>>(
                      () => GetSomethingTheLongWayAsync(key)
                    )
                , DateTime.Now) ,
                (k,t) =>
                {
                    if (IsExpiredDelete(t, k))
                    {
                        return new Tuple<Lazy<Task<ToT>>, DateTime>(new Lazy<Task<ToT>>(
                      () => GetSomethingTheLongWayAsync(k)
                    ), DateTime.Now);
                    }
                    return t;
                }

                );
            return await res.Item1.Value;
        }

    }

Same usage, just replace AsyncCache instead of Cached.

Community
  • 1
  • 1
  • 1. No, another value *couldn't* have added a value in the meantime, because of the lock. If someone else tries to get/compute the value while you have the lock they'll be forced to wait on you. 2. By releasing the lock the operation is no longer logically atomic, allowing the value to be created by different concurrent calls to this method, resulting in the expensive computation being done multiple times when it shouldn't be. – Servy Aug 30 '16 at 17:54
  • @Servy the answer to the first point is because of the second point. The answer to the second is more complex and has to do with the Expiration. The meaning is that a second computation could make sense since it is not an immutable value... –  Aug 30 '16 at 17:58
  • @Servy of course there is a balance: if the long way is much longer that the expiration, I should be right, otherwise I agree with your objection –  Aug 30 '16 at 17:59
  • Yes, you are correct that by making the change you describe in your second point you create the problem of the first point. But the OP's code doesn't have that problem, No, computing the value again *doesn't* make sense. This is a cache. The whole *point* is to compute it once and re-use it. If you're just going to compute a new value each time then there's no point in *any* of this; you'd just compute the value every time and be done with it. The whole point of doing *any* of this is to avoid the expensive computation from being performed any more than necessary. – Servy Aug 30 '16 at 18:03
  • @Servy I'm not doing what you describe, unless there is a wrong copy/paste in my code. I'm calling the "long way" get only if there is no cache or the cache is expired. I don't mean to call that process if the value is in the cache. Then I'm only checking if it has been inserted in the meantime but again I'm not recalling the expensive method. –  Aug 30 '16 at 18:08
  • @Servy What you say is false; I've tested my code (fixed also a different bug) and my code **does** re-use the cache –  Aug 30 '16 at 18:15
  • If the method is ever called while the value is being computed, the value will be computed *again*, rather than the subsequent call waiting for the first call to finish, and using it's value. That is the consequence of you releasing the lock. If you simply hold onto it, like the OP's code does, then you don't have that bug. Your proposed change only introduces bugs, and doesn't fix any. – Servy Aug 30 '16 at 18:24
  • 1
    @Servy The fact that the lock is released and another process can compute the value in the meantime is a marginal suggestion. My code does achieve what the OP is asking in terms of keeping the cache for int Expiration minutes. The optimization of releasing and recreating the lock can be skipped, but the reason why it is there is just to get a quicker result if there are many parallel access to the key. I had a similar pgm accessing the db for an hierarchy tree and I had to do that. Again, those 2 initial pnts are not the main part of my answer (and they are not a bug, they are an opt. option) –  Aug 30 '16 at 18:38
  • But they *are* a bug. They result in a computation, for which the whole purpose of the class is to avoid computing multiple times, being performed multiple times. It's also not going to improve performance, it'll harm it as an expensive computation is going to be performed unnecessarily. The class not doing exactly what it's supposed to do *is* a bug. – Servy Aug 30 '16 at 18:42
  • @Servy that's wrong, again. TryGet is in fact a Try because it can fail and that can happen when there are too many processes resulting in the same "computation" (or better db access or similar) and that is what I have experienced during a real business implementation not during a vague, biased chat –  Aug 30 '16 at 18:45
  • You're asserting that `TryGetValue` is going to fail to find a value even when that value has been successfully added to the list? What's your basis for that assertion? It's named "Try" because the value may not exist in the collection, so it only has a value to return if the key exists in the dictionary. Additionally, how does your change in any way address that problem? – Servy Aug 30 '16 at 18:51
  • 1
    @Servy This is my example. LongWayDBGet takes 2 minutes and I want to cache for a hour. I start the first Get, it is not *already* cached. Now I go parallel to be quick and I start a second Get after 1 second. I'm going parallel to be quick, that's the point. Now it is debatable that I have to wait 1 minute and 59 seconds plus maybe another 2 minutes and it is not a bug to have the possibility to go parallel. Caching is achieved and it is a different matter. In fact - after 15 minutes - I start another Get, this has nothing to do with optimization and it is being cached of course. –  Aug 30 '16 at 18:58
  • @Servy and my main change is addressing the problem of caching plus just few easily skippable lines of optimization for the above described reason –  Aug 30 '16 at 19:00
  • 1
    In your example, using your code, the second call is going to perform the expensive DB operation again. If 100 more calls are made during those 2 minutes, 100 more expensive DB calls are all going to go out, drowning the database and slowing it to a crawl. Using the OP's code, the second call, or the 100 other calls, are all simply going to wait for the first call to finish it's 2 minute operation, and then all 2 or 100 calls all then use that same value, immediately after that *one* computation of it finishes. – Servy Aug 30 '16 at 19:03
  • Your code *introduces a very serious problem*. That's not just something to overlook. You're stating that the correct implementation is wrong, erroneously, and you're introducing a "fix" that causes serious problems. If you don't want your answer to be judged based on that, then don't include it. – Servy Aug 30 '16 at 19:05
  • 1
    @Servy my code is in production environment, is tested for that specific situation and I have timers tracking the situation with and without the optimization –  Aug 30 '16 at 19:05
  • 2
    It's certainly possible that in your situation your code doesn't manage to hit this bug, or that you're simply okay with the performance problems that happen as a result of your cache not working properly. That doesn't mean you should go out of your way to try to get other people to actively make their caches worse. – Servy Aug 30 '16 at 19:07
  • @Servy absolutely no. It is not a bug. My code does reach that situation under a controlled amount of parallelism and that is measured to result in an objective optimization that I'm happy to show to my colleagues. –  Aug 30 '16 at 19:09
  • What makes you think that performing an operation multiple times is going to be faster than performing that operation just once? – Servy Aug 30 '16 at 19:11
  • 1
    @Servy there are 2 possible answers. The first (and it was not my case fyi but it is irrelevant) is because the result of that "same" operation is mutable. The second possible reason is because those 2 "same" operations are a sub-execution of a tree, they are done in parallel for performance reason and they *can happen* to be the same operation with a certain frequency. But one can have **measured** that the total time to get the full tree is longer if you **always** wait for the previous key to be fetched while it is in execution (of course you get the cached value when it is already cached). –  Aug 30 '16 at 19:22
  • 1
    If it's *important* that the result be re-computed then the whole class is pointless. If you need the results to be different, because they're mutated separately, then it's important that you *never* cache it, so that situation clearly isn't applicable here. You didn't answer the question. What makes you think that performing that operation multiple times is going to be faster than performing it just once and then re-using the result? You've asserted this, but you've provided no basis for that assertion. – Servy Aug 30 '16 at 19:32
  • @Servy I've answered with a second case that is *not* mutable. I'm saying that I have a tree of operations to be performed and a *certain* percentage of those operations can happen to be the same, but each step through the tree can cost a db access when not in the cache... –  Aug 30 '16 at 19:40
  • anyway I've proposed a code that correctly answers the question plus a possible optimization that I have tested and that can help at least in similar situations while I'm getting a downvote and there are answers with hundreds of upvotes (just to do a honest comparison). I've explained the scope of my optimization. I don't have time to waste. –  Aug 30 '16 at 19:45
  • `I have a tree of operations to be performed and a certain percentage of those operations can happen to be the same, but each step through the tree can cost a db access when not in the cache` And in that situation, where it's not in the cache, it's going to be faster to let just one of those several callers actually do the work, and have the rest use the same value. It's not going to be faster for the second caller to start the work over from scratch when another caller is half way finished with it. – Servy Aug 30 '16 at 19:47
  • @Servy in that situation I have **measured** what happens with that certain frequency of same operations and with those db response times. End of it –  Aug 30 '16 at 19:48
  • 1
    Then you measured wrong, or you didn't realize what you measured. That, or you weren't actually performing the same operation due to some other flaw in your program's logic. – Servy Aug 30 '16 at 19:53
  • @Servy what you say is offensive. You start from the biased opinion that you are right and I'm wrong even when I measure something. You are not understanding that to proceed in the tree you need to execute the previous operations because otherwise you don't have the following key and you can't get the time savings you think to get (so the other calls that you think can start will be actually queued). Again, I'm wasting time now. Bye –  Aug 30 '16 at 20:00
  • 2
    You're asserting that performing the exact same operation many times is faster than performing that same operation just once, and your support for that assertion is your word that you timed it and it was faster. If someone came up to you and told you that it's faster to walk 5 miles than it is to walk 1 mile, and that you should trust them because they timed it, would you take their word for it and forever start walking 5 times the distance everywhere you go? – Servy Aug 30 '16 at 20:10
  • 1
    @Servy I'm only saying that there can be situations in which the shortest road is not the fastest. Traffic jam, accidents, roadwork maintenance correspond to the fact that the slow db response could be cached by the dbms in the immediate instants after the call, the connection pool could do the same, there could be an overhead associated with the semaphore... Anyway, I've edited the answer, performance optimization options are commented till one actually measures the specific results. –  Aug 30 '16 at 22:43
  • Thank you for this answer, very detailed. I was not expecting this. I got it all working thanks to this. I had a few snags but this answer helped. – jxmiller Sep 14 '16 at 20:34
  • 2
    Downvoter, care to add a comment? 10x ... To be clear, @Servy wrote that, if I start the same query on the same db after t0, it will return the same results with the same delay t0, whilst I replied - based on *real* world programming, that t0 is nor a constant neither a *monotonic* function. If the new, *late coming* downvoter has different arguments, I'm curious to read them ;-) –  Dec 14 '16 at 22:25
  • 1
    I didn't down vote BUT While you debate is interesting I think both of you actually forgot the biggest flaw of this design. Your cache is infinite. There is nothing to stop the dictionary to get bigger and bigger. It will result in a nice crash. I know the question was about expire buuuuuuuut not limiting the size is dangerous.... – Igor Beaufils Apr 08 '21 at 12:34
  • Saying that I still want to say thank you, especially user6996876. I've learn a lot with this example. – Igor Beaufils Apr 08 '21 at 12:47