Probably using a dedicated memory cache (like the new or the old MemoryCache classes, or this third-party library) should be preferable to using a simple ConcurrentDictionary. Unless you don't really need commonly used functionality like time-based expiration, size-based compacting, automatic eviction of entries that are dependent on other entries that have expired, or dependent on mutable external resources (like files, databases etc). It should be noted though that the MemoryCache may still need some work in order to handle asynchronous delegates properly, since its out-of-the-box behavior is not ideal.
Below is a custom extension method GetOrAddAsync for ConcurrentDictionarys that have Task<TValue> values. It accepts a factory method, and ensures that the method will be invoked at most once. It also ensures that failed tasks are removed from the dictionary.
/// <summary>
/// Returns an existing task from the concurrent dictionary, or adds a new task
/// using the specified asynchronous factory method. Concurrent invocations for
/// the same key are prevented, unless the task is removed before the completion
/// of the delegate. Failed tasks are evicted from the concurrent dictionary.
/// </summary>
public static Task<TValue> GetOrAddAsync<TKey, TValue>(
this ConcurrentDictionary<TKey, Task<TValue>> source, TKey key,
Func<TKey, Task<TValue>> valueFactory)
{
if (!source.TryGetValue(key, out var currentTask))
{
Task<TValue> newTask = null;
var newTaskTask = new Task<Task<TValue>>(async () =>
{
try { return await valueFactory(key).ConfigureAwait(false); }
catch
{
source.TryRemove(KeyValuePair.Create(key, newTask));
throw;
}
});
newTask = newTaskTask.Unwrap();
currentTask = source.GetOrAdd(key, newTask);
if (currentTask == newTask) newTaskTask.Start(TaskScheduler.Default);
}
return currentTask;
}
Usage example:
var cache = new ConcurrentDictionary<string, Task<HttpResponseMessage>>();
var response = await cache.GetOrAddAsync("https://stackoverflow.com", async url =>
{
return await _httpClient.GetAsync(url);
});
Overload with synchronous valueFactory delegate:
public static Task<TValue> GetOrAddAsync<TKey, TValue>(
this ConcurrentDictionary<TKey, Task<TValue>> source, TKey key,
Func<TKey, TValue> valueFactory)
{
if (!source.TryGetValue(key, out var currentTask))
{
Task<TValue> newTask = null;
newTask = new Task<TValue>(() =>
{
try { return valueFactory(key); }
catch
{
source.TryRemove(KeyValuePair.Create(key, newTask));
throw;
}
});
currentTask = source.GetOrAdd(key, newTask);
if (currentTask == newTask) newTask.Start(TaskScheduler.Default);
}
return currentTask;
}
Both overloads invoke the valueFactory delegate on the ThreadPool, ensuring that the current thread will not be blocked.
If you have some reason to prefer invoking the delegate on the current thread, you can just replace the Start with the RunSynchronously.
For a version of the GetOrAddAsync method that compiles on the .NET Framework and the .NET Core, you can look at the 3rd revision of this answer.