The relevant difference is considering what happens when the method is called multiple times before the cache has been populated.
If you only cache the result, as is done in the first snippet, then if two (or three, or fifty) calls to the method are made before any of them has finished, they'll all start the actual operation to generate the results (in this case, performing a network request). So you now have two, three, fifty, or whatever network requests that you're making, all of which are going to put their results in the cache when they finish.
When you cache the task, rather than the results of the operation, if a second, third, or fiftieth call is made to this method after someone else starts their request, but before any of those requests have completed, they're all going to be given the same task representing that one network operation (or whatever long-running operation it is). That means that you're only ever sending one network request, or only ever performing one expensive computation, rather than duplicating that work when you have multiple requests for the same result.
Also, consider the case where one request gets sent, and when it's 95% done, a second call is made to the method. In the first snippet, since there is no result, it'll start from scratch and do 100% of the work. The second snippet is going to result in that second invocation being handed a Task
that's 95% done, so that second invocation is going to get its result much sooner than it would if using the first approach, in addition to the whole system just doing a lot less work.
In both cases, if you don't ever call the method when there is no cache, and another method has already started doing the work, then there is no meaningful difference between the two approaches.
You can create a fairly simple reproducible example to demonstrate this behavior. Here we have a toy long running operation, and methods that either cache the result or cache the Task
it returns. When we fire off 5 of the operations all at once you'll see that the result caching performs the long running operation 5 times, and the task caching performs it just once.
public class AsynchronousCachingSample
{
private static async Task<string> SomeLongRunningOperation()
{
Console.WriteLine("I'm starting a long running operation");
await Task.Delay(1000);
return "Result";
}
private static ConcurrentDictionary<string, string> resultCache =
new ConcurrentDictionary<string, string>();
private static async Task<string> CacheResult(string key)
{
string output;
if (!resultCache.TryGetValue(key, out output))
{
output = await SomeLongRunningOperation();
resultCache.TryAdd(key, output);
}
return output;
}
private static ConcurrentDictionary<string, Task<string>> taskCache =
new ConcurrentDictionary<string, Task<string>>();
private static Task<string> CacheTask(string key)
{
Task<string> output;
if (!taskCache.TryGetValue(key, out output))
{
output = SomeLongRunningOperation();
taskCache.TryAdd(key, output);
}
return output;
}
public static async Task Test()
{
int repetitions = 5;
Console.WriteLine("Using result caching:");
await Task.WhenAll(Enumerable.Repeat(false, repetitions)
.Select(_ => CacheResult("Foo")));
Console.WriteLine("Using task caching:");
await Task.WhenAll(Enumerable.Repeat(false, repetitions)
.Select(_ => CacheTask("Foo")));
}
}
It's worth noting that the specific implementation of the second approach you've provided has a few notable properties. It's possible for the method to be called twice in such a way that both of them will start the long running operation before either task can finish starting the operation, and therefore cache the Task
that represents that operation. So while it'd be much harder than with the first snippet, it is possible for whatever the long running operation is to be run twice. There would need to be more robust locking around checking the cache, starting a new operation, and then populating the cache, in order to prevent that. If doing whatever the long running task is multiple times on rare occasions would just be wasting a bit of time, then the current code is probably fine, but if it's important that the operation never be performed multiple times (say, because it cases side effects) then the current code isn't complete.