In this post I want to talk about building an async cache in C# that fulfils the following requirements:
The cache handles read requests to a database
Assume that there are no writes to the database, so no need to worry about cache invalidation
Multi-threaded: Multiple callers are calling from multiple threads concurrently
Non-blocking: No sleeps, no locks
Avoid making requests to the database while there’s an identical outstanding request
Code outline
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interfaceIDataFetcher{// Performs the read from the databaseTask<string>ReadDataAsync(stringkey);}classCache{IDataFetcherfetcher;asyncTask<string>GetDataAsync(stringkey,boolforce){// to do// if key is not present in cache, read from fetcher// if force is set, read from fetcher (even if key is present in cache)}}
usingSystem.Collections.Concurrent;usingSystem.Threading.Tasks;interfaceIDataFetcher{// Performs the read from the databaseTask<string>ReadDataAsync(stringkey);}classCache{IDataFetcherfetcher;staticreadonlyConcurrentDictionary<string,string>dataCache=newConcurrentDictionary<string,string>();staticreadonlyConcurrentDictionary<string,Task<string>>taskCache=newConcurrentDictionary<string,Task<string>>();asyncTask<string>GetDataAsync(stringkey,boolforce){// When force is 'false' AND dataCache contains the keyif(force==false&&dataCache.ContainsKey(key)){dataCache.TryGetValue(key,outstringcachedValue);returncachedValue;}// When force is 'true' OR dataCache does not contain the keyelse{if(taskCache.ContainsKey(key)==false){taskCache.TryAdd(key,fetcher.ReadDataAsync(key));// TryAdd() may return 'false' but that's okay because that means the key already exists}taskCache.TryGetValue(key,outTask<string>cachedTask);// Await on cachedTask until we have a fresh valuestringfreshvalue=awaitcachedTask;// Update cachesdataCache.TryAdd(key,freshvalue);taskCache.TryRemove(key,outTask<string>_);returnfreshValue;}}}
Notes on the implementation
I created two caches: dataCache and taskCache. dataCache caches results from ReadDataAsync(). taskCache caches the last outstanding request (Task) made to ReadDataAsync() (this fulfils requirement #5 defined above).
The usage of ConcurrentDictionary is to ensure that GetDataAsync can be called from multiple threads safely. (fulfils requirement #3).