0

Just to avoid reinventing the wheel I'm wondering whether a standard C# implementation already exists to cache the results from a long-running, resource-intensive method. To my mind, the Lazy<T> would be appropriate, but unfortunately it seems to lack the input parameter to index the result. I hope the following helps to clarify: this is my custom solution.

public class Cached<FromT,ToT>
{
    private Func<FromT,ToT> _my_func;
    private Dictionary<FromT,ToT> funcDict;
    public Cached(Func<FromT,ToT> coreFunc, IEqualityComparer<FromT> comparer = null)
    {
        _my_func = coreFunc;
        if (comparer != null) {
            funcDict = new Dictionary<FromT,ToT>(comparer);
        } else {
            funcDict = new Dictionary<FromT,ToT>();
        }
    }
    public ToT Get(FromT fromKey) {
        if (!funcDict.ContainsKey(fromKey)) {
            funcDict.Add(fromKey, _my_func(fromKey) );
        }
        return funcDict[fromKey];
    }
}

Find below my unit-testing code.

string DBSimulation(int example, bool quick = false) {
    if (!quick) Thread.Sleep(15000);
    return example.ToString();
}

[Test]
public void Test03Cached() {
    var testCache = new Functional.Cached<int,string>(x => DBSimulation(x));
    DateTime checkNow = DateTime.Now;
    string logResult = "";
    for (int i = 0; i < 24; i++) {
        Assert.AreEqual(DBSimulation( i % 3, true), testCache.Get( i % 3));
        logResult += String.Format("After {0} seconds => {1} returned from {2} \n",
                                   ((TimeSpan)(DateTime.Now - checkNow)).TotalSeconds,
                                   testCache.Get( i % 3), i);
    }
    Console.WriteLine(logResult);
    double elapsed = ((TimeSpan)(DateTime.Now - checkNow)).TotalSeconds;
    Assert.LessOrEqual(elapsed, 15*3+1,"cache not working, {0} seconds elapsed", elapsed);
}

with its output

After 15,0035002 seconds => 1 returned from 1 
After 30,0050002 seconds => 2 returned from 2 
After 45,0065002 seconds => 0 returned from 3 
After 45,0065002 seconds => 1 returned from 4 
After 45,0065002 seconds => 2 returned from 5 
... 
After 45,0065002 seconds => 0 returned from 21 
After 45,0065002 seconds => 1 returned from 22 
After 45,0065002 seconds => 2 returned from 23 

Edit

For a generic FromT an IEqualityComparer is needed for the Dictionary

1 Answers1

0

Yes, not bad solution, but I offer you some improvements on concurrency(ConcurrentDictionary). You can use this code, for example, at your asp.net application where many threads simultaneously can use this class. Also as this class will be used for longrunning functions it will be good feature to not wait result but instead process result later(Task.ContinueWith) when function will be completed:

public class Cached<FromT, ToT>
{
    private Func<FromT, ToT> _my_func;
    private ConcurrentDictionary<FromT, ToT> funcDict = new ConcurrentDictionary<FromT, ToT>();
    private Random rand = new Random();

    public Cached(Func<FromT, ToT> coreFunc)
    {
        _my_func = coreFunc;
    }

    public Task<ToT> Get(FromT fromKey)
    {
        if (!funcDict.ContainsKey(fromKey))
            return Task.Factory.StartNew(() => {
                var result = _my_func(fromKey);
                do
                {
                    if (!funcDict.ContainsKey(fromKey) && !funcDict.TryAdd(fromKey, result))                        
                        Thread.Sleep(rand.Next(50, 100));                                                    
                    else
                        break;
                } while (true);                    
                return result;
            });
        ToT answer;
        funcDict.TryGetValue(fromKey, out answer);
        return Task.FromResult(answer);
    }
}

public static string DBSimulation(int example)
{
    Thread.Sleep(15000);
    return example.ToString();
}

public static void Main()
{
    var testCache = new Cached<int, string>(x => DBSimulation(x));
    DateTime checkNow = DateTime.Now;
    for (int i = 0; i < 24; i++)
    {
        var j = i;
        testCache.Get(i % 3).ContinueWith(x => 
            Console.WriteLine(String.Format("After {0} seconds => {1} returned from {2}",
                                           ((TimeSpan)(DateTime.Now - checkNow)).TotalSeconds,
                                           x.Result, j)));
    }            
    Console.ReadKey();        
}

RESULT:

After 15.0164309 seconds => 0 returned from 6
After 15.0164309 seconds => 2 returned from 5
After 15.0164309 seconds => 1 returned from 4
After 15.0164309 seconds => 0 returned from 3
After 15.0164309 seconds => 0 returned from 0
......
After 26.5133477 seconds => 1 returned from 19
After 27.5112726 seconds => 2 returned from 20
After 28.5127277 seconds => 0 returned from 21
After 29.5126096 seconds => 1 returned from 22
After 30.0204739 seconds => 2 returned from 23

P.S. as you can see time elapsed to perform all operations reduced from 45 to 30 seconds, not to 15, because it depends on number of cores, number of started threads and other stuff, also some of time is expended to dictionary's synchronization.

Slava Utesinov
  • 13,410
  • 2
  • 19
  • 26
  • Thanks, I'm going to play with your code (I want to better understand the time reduction, but yes it is reasonable, due to parallel execution). Just a minor note, I've deleted the static keyword from the dictionary because I could have two different funcs with the same signature, returning different results to be separately cached –  Apr 20 '16 at 09:26
  • Ok, I understood you about static keyword and have modified answer. – Slava Utesinov Apr 20 '16 at 09:54
  • Thanks, +1 and accepted. Fyi, out of curiosity, it seems working fine also in the old .NET 4.0 when replacing Task.FromResult with this [FromResult](http://stackoverflow.com/a/14244239/6155207) –  Apr 20 '16 at 10:07
  • Some further comments. 1) the concurrent solution doesn't cache anything at first round (all for-iterations begin with an empty dictionary), it starts caching only from the second execution on. 2) there is a very serious issue in my original code, because for a generic FromT an IEqualityComparer is needed for the Dictionary... I'm going to fix this, then I will edit my question, just for future reference 3) in conclusion the answer is saying that there is no standard solution, it has to be implemented anew –  Apr 21 '16 at 08:19
  • About 1) and 2) it is happens because all threads start immediately and all of them attempt to write function's result to dictionary at one time and no one can perform it due to native dictionary's lock and only at second iteration they can do it, because of loss of sync. I have modified my code to fix this problem. – Slava Utesinov Apr 21 '16 at 08:57
  • Thanks, I was not asking you to update your solution. My point 2) is because I have a more complex/generic situation, where the input is a class (not a simple int) and I need a comparer for the ContainsKey. Your code was OK. I've not yet understood why you updated it, thanks anyway. –  Apr 21 '16 at 09:12
  • Ah... Understood what you did in the last edit, but never mind :-) it was *not* due to dictionary's lock, it was due to the 15 seconds delay... In my real word example the concucurrent implementation is saving the same number of calls to the DB but it has a slight overhead (tasks mgmt). I will keep my sequential cache, but I assume it depends on the specific situation. –  Apr 21 '16 at 09:28
  • I've found an improvement: it is saving 15 seconds after 30 iterations, as far as I can see –  Apr 21 '16 at 10:16
  • I don't think [this](http://stackoverflow.com/review/suggested-edits/12096653) deviates from your intent, because hopefully you don't want to call the function again when it has already been called. It's difficult to explain it better in comments... –  Apr 21 '16 at 11:53
  • This editing connected with details of implementation, you and I can improve my answer: add another useful stuff, various checkings and optimizations, but main idea will remain the same. – Slava Utesinov Apr 21 '16 at 12:18
  • But devil hides in details :-) the improvement is relevant for caching&performance purposes. Btw you've confirmed that the reason why they rejected my edit is the wrong one, to say the least. Actually the main idea has not much to do with concurrency: for example, look at this [Q&A](http://stackoverflow.com/a/4042214/6155207). 6 years ago, there was only one paid [off-the-shelf solution](http://kellermansoftware.com/products/net-caching-library), so my custom implementation looks valuable :-) –  Apr 22 '16 at 08:03