1: public class CacheDictionary
2: {
3: ReaderWriterLockSlim _cacheLock = new ReaderWriterLockSlim();
4: Dictionary> _cacheItemDictionary = new Dictionary >();
5:
6: public TValue Fetch(TKey key, Funcproducer)
7: {
8: LazyInitcacheItem;
9: bool found;
10:
11: _cacheLock.EnterReadLock();
12: try
13: {
14: found = _cacheItemDictionary.TryGetValue(key, out cacheItem);
15: }
16: finally
17: {
18: _cacheLock.ExitReadLock();
19: }
20:
21: if (!found)
22: {
23: _cacheLock.EnterWriteLock();
24: try
25: {
26: if (!_cacheItemDictionary.TryGetValue(key, out cacheItem))
27: {
28: cacheItem = new LazyInit(producer);
29: _cacheItemDictionary.Add(key, cacheItem);
30: }
31: }
32: finally
33: {
34: _cacheLock.ExitWriteLock();
35: }
36: }
37:
38: return cacheItem.Value;
39: }
40: }
This Implementation uses the (thread safe version of) the double-check locking pattern. This code first checks if the item exists using only a read lock, and only if the item is not found it acquire a write lock. To be sure the item was not added in between it checks again and adds the new item if it is still not found. Assuming there will be a fairly large amount of cache hits compared to cache misses, this is (in theory) more efficient than exclusively locking the dictionary right away, because multiple readers can get items from the cache concurrently and we only block other threads if we need to add a new item.
Just like in my previous implementation, I did not want to keeping the cache locked while actually retrieving or creating the item that needs to be cached. In the PFX version I did this by using LazyInit
1: public class LazyInit
2: {
3: Func_producer;
4: object _lock = new object();
5: T _data;
6: volatile bool _created;
7:
8: public LazyInit(Funcproducer)
9: {
10: _producer = producer;
11: }
12:
13: public T Value
14: {
15: get
16: {
17: if (!_created)
18: {
19: lock (_lock)
20: {
21: if (!_created)
22: {
23: _data = _producer.Invoke();
24: _created = true;
25: _producer = null;
26: }
27: }
28: }
29: return _data;
30: }
31: }
32: }
Only A short time after I created this implementation, a blog post by Joe Duffy, the Lead developer / Architect behind PFX, showed up in my feed reader:
Reader/writer locks and their (lack of) applicability to fine-grained synchronization
In this post Joe compares the ReaderWriterLockSlim to (among others) a normal Mutex better known as the C# lock() statement. On a 4 core machine, when there are mostly readers and only a few writers, the reader/writer lock should theoretically be about 4 times as fast as the mutex. The reader / writer lock will allow multiple reader to execute concurrent on all four cores and the mutex only allows one thread to execute at a time leaving 3 cores idle while the other is holding the lock.As it turns out, the mutex outperforms the ReaderWriterLockSlim in many scenario’s, especially in those cases where the locks are held for a relatively short time (like in a single peek in a dictionary). In stead of being 4 times faster the ReaderWriterLockSlim is actually about half as fast as the mutex! It seems that the internal book-keeping that needs to be done by the ReaderWriterLockSlim is way more expensive than the penalty of 3 cores being idle for a short time while 1 thread is holding the lock. Even Joe’s experimental highly optimized spinning ReaderWriterLock does not do a much better job in these scenario’s
ReaderWriterLockSlim start to outperform the mutex when the lock needs to be kept for a longer time (which you should generally avoid anyway). The number of cores will probably influence the results as well, I think running the same test on a 256 core box will increase the performance of the reader writer lock compared to the mutex, but unfortunately I have not been able to test this.
To test how this influenced my CacheDictionary, I created yet another implementation using a mutex in stead of the reader writer lock. Compared to the ReaderWriterLockSlim version this code is actually quite simple because it does not need to do the double check locking and the lock() statement does not require an explicit finally block. I did stick to the LazyInit aproach the avoid locking the whole cache while creating the items to cache.
1: public class CacheDictionary
2: {
3: object _cacheLock = new object();
4: Dictionary> _cacheItemDictionary = new Dictionary >();
5:
6: public TValue Fetch(TKey key, Funcproducer)
7: {
8: LazyInitcacheItem;
9:
10: lock (_cacheLock)
11: {
12: if (!_cacheItemDictionary.TryGetValue(key, out cacheItem))
13: {
14: cacheItem = new LazyInit(producer);
15: _cacheItemDictionary.Add(key, cacheItem);
16: }
17: }
18: return cacheItem.Value;
19: }
20: }
For the near future, I will stay away from reader writer locks unless it is absolutely necessary to hold a lock for a fair amount of time. Maybe I will revisit this statement when commodity hardware reaches 32 or 64 cores.
Joe Duffy concludes his post on this topic with: “Sharing is evil, fundamentally limits scalability, and is best avoided.” While in general I think he is right, the whole point of a cache is to share the result of a previous request with future requests in order to improve performance. Since caching implies sharing you should always be aware of the concurrency issues involved, that is why I tried to handle most of these issues in this generic utility.
No comments:
Post a Comment