对改善Dictionary时间性能的思考及一个线程安全的Dictionary实现
在某些场景下, 对于内存数据结构, 当需要用多个键值来唯一确定一个值的时候, 我们经常会面临这样一个选择:
- 组合键: 将多个键组合成一个组合键, 在一个词典中存储和定位数据;
- 多级词典: 使用多级词典, 在多级词典中依次保存键-词典的键值对, 定位数据时由键依次确定下一级词典, 最终确定所在的数据;
抛开内存数据的空间效率不谈, 以上这两类做法的主要时间效率影响在于:
- 使用多级词典可以降低散列冲突及计算的时间消耗, 由于人为地添加了数据的分类, 数据散列的冲突概率也被大大降低.
- 使用多级词典在创建子Dictionary的时候带来额外的时间消耗,
- 使用多级词典可能会影响散列的数据均匀度, 这方面的影响类似于桶算法中的均匀度. 不均匀的桶长度会降低散列的效率.
- 使用组合键时所采用的组合键生成算法的生成效率和算法对散列值分布的影响.
- 在线程安全的要求对性能的影响. 在加锁的情况下, 锁的申请和释放对时间性能也有比较大的影响.
那么, 综合这些因素, 最终哪种做法会有比较好的性能呢?
1. 一个线程安全的词典实现
为了做这个测试, 我们使用如下的线程安全词典. 它借助于ReadWriteLock来保证在多线程访问情况下数据的安全读写.
- public class SynchronisedDictionary<TKey, TValue> : IDictionary<TKey, TValue>
- {
- private Dictionary<TKey, TValue> innerDict;
- private ReaderWriterLockSlim readWriteLock;
- public SynchronisedDictionary()
- {
- this.readWriteLock = new ReaderWriterLockSlim();
- this.innerDict = new Dictionary<TKey, TValue>();
- }
- public void Add(KeyValuePair<TKey, TValue> item)
- {
- using (new AcquireWriteLock(this.readWriteLock))
- {
- this.innerDict[item.Key] = item.Value;
- }
- }
- public void Add(TKey key, TValue value)
- {
- using (new AcquireWriteLock(this.readWriteLock))
- {
- this.innerDict[key] = value;
- }
- }
- public void Clear()
- {
- using (new AcquireWriteLock(this.readWriteLock))
- {
- this.innerDict.Clear();
- }
- }
- public bool Contains(KeyValuePair<TKey, TValue> item)
- {
- using (new AcquireReadLock(this.readWriteLock))
- {
- return this.innerDict.Contains<KeyValuePair<TKey, TValue>>(item);
- }
- }
- public bool ContainsKey(TKey key)
- {
- using (new AcquireReadLock(this.readWriteLock))
- {
- return this.innerDict.ContainsKey(key);
- }
- }
- public void CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex)
- {
- using (new AcquireReadLock(this.readWriteLock))
- {
- this.innerDict.ToArray<KeyValuePair<TKey, TValue>>().CopyTo(array, arrayIndex);
- }
- }
- public IEnumerator GetEnumerator()
- {
- using (new AcquireReadLock(this.readWriteLock))
- {
- return this.innerDict.GetEnumerator();
- }
- }
- IEnumerator<KeyValuePair<TKey, TValue>> IEnumerable<KeyValuePair<TKey, TValue>>.GetEnumerator()
- {
- using (new AcquireReadLock(this.readWriteLock))
- {
- return this.innerDict.GetEnumerator();
- }
- }
- public bool Remove(TKey key)
- {
- bool isRemoved;
- using (new AcquireWriteLock(this.readWriteLock))
- {
- isRemoved = this.innerDict.Remove(key);
- }
- return isRemoved;
- }
- public bool Remove(KeyValuePair<TKey, TValue> item)
- {
- using (new AcquireWriteLock(this.readWriteLock))
- {
- return this.innerDict.Remove(item.Key);
- }
- }
- public bool TryGetValue(TKey key, out TValue value)
- {
- using (new AcquireReadLock(this.readWriteLock))
- {
- return this.innerDict.TryGetValue(key, out value);
- }
- }
- public int Count
- {
- get
- {
- using (new AcquireReadLock(this.readWriteLock))
- {
- return this.innerDict.Count;
- }
- }
- }
- public bool IsReadOnly
- {
- get
- {
- return false;
- }
- }
- public TValue this[TKey key]
- {
- get
- {
- using (new AcquireReadLock(this.readWriteLock))
- {
- return this.innerDict[key];
- }
- }
- set
- {
- using (new AcquireWriteLock(this.readWriteLock))
- {
- this.innerDict[key] = value;
- }
- }
- }
- public ICollection<TKey> Keys
- {
- get
- {
- using (new AcquireReadLock(this.readWriteLock))
- {
- return this.innerDict.Keys;
- }
- }
- }
- public ICollection<TValue> Values
- {
- get
- {
- using (new AcquireReadLock(this.readWriteLock))
- {
- return this.innerDict.Values;
- }
- }
- }
- private class AcquireReadLock : IDisposable
- {
- private ReaderWriterLockSlim rwLock;
- private bool disposedValue;
- public AcquireReadLock(ReaderWriterLockSlim rwLock)
- {
- this.rwLock = new ReaderWriterLockSlim();
- this.disposedValue = false;
- this.rwLock = rwLock;
- this.rwLock.EnterReadLock();
- }
- public void Dispose()
- {
- this.Dispose(true);
- GC.SuppressFinalize(this);
- }
- protected virtual void Dispose(bool disposing)
- {
- if (!this.disposedValue && disposing)
- {
- this.rwLock.ExitReadLock();
- }
- this.disposedValue = true;
- }
- }
- private class AcquireWriteLock : IDisposable
- {
- private ReaderWriterLockSlim rwLock;
- private bool disposedValue;
- public AcquireWriteLock(ReaderWriterLockSlim rwLock)
- {
- this.rwLock = new ReaderWriterLockSlim();
- this.disposedValue = false;
- this.rwLock = rwLock;
- this.rwLock.EnterWriteLock();
- }
- public void Dispose()
- {
- this.Dispose(true);
- GC.SuppressFinalize(this);
- }
- protected virtual void Dispose(bool disposing)
- {
- if (!this.disposedValue && disposing)
- {
- this.rwLock.ExitWriteLock();
- }
- this.disposedValue = true;
- }
- }
- }
2. 试验流程说明:
基本的实验步骤是:
1.创建不同规模的样本数据, 测试对于全部的样本数据, 词典的读写时间.
2.创建相同规模的样本数据, 改变两级级词典的键重叠率(键重叠率越高, 键的生成范围越小, 父级词典中的键值对越少, 每个子级词典中的键值对越多), 测试词典的读写时间. 键重叠率指的是父级词典中键的生成范围对样本数据规模的比值.
测试程序如下:
- public void Add(TKey key, TSubKey subKey, TValue value)
- {
- string compositeKey = key.ToString() + "+" + subKey.ToString();
- this.innerDictionary.Add(compositeKey, value);
- }
- public bool TryGetValue(TKey key, TSubKey subKey, out TValue value)
- {
- value = default(TValue);
- string compositeKey = key.ToString() + subKey.ToString();
- return this.innerDictionary.TryGetValue(compositeKey, out value);
- }
作为测试, 我们使用string作为主键, 用string的联接作为联合主键. 字符串的联接既不会浪费太多时间从而干扰我们的测试, 又不会降低散列值的分布区间范围.
- public void Add(TKey key, TSubKey subKey, TValue value)
- {
- Dictionary<TSubKey, TValue> subDictionary;
- if (this.rootDictionary.TryGetValue(key, out subDictionary))
- {
- subDictionary.Add(subKey, value);
- }
- else
- {
- subDictionary = new Dictionary<TSubKey, TValue>();
- subDictionary.Add(subKey, value);
- this.rootDictionary.Add(key, subDictionary);
- }
- }
- public bool TryGetValue(TKey key, TSubKey subKey, out TValue value)
- {
- value = default(TValue);
- Dictionary<TSubKey, TValue> subDictionary;
- if (this.rootDictionary.TryGetValue(key, out subDictionary))
- {
- return subDictionary.TryGetValue(subKey, out value);
- }
- else
- {
- return false;
- }
- }
在不确定数据是否存在的前提下, TryGetValue比ContainsKey+下标索引方式有更好的性能. 我们使用这种方法来读写数据.
- public Dictionary<string, string> InitializeTestData(int sampleRecordsCount, int subKeyRange)
- {
- Random rd = new Random();
- Random subRd = new Random();
- string key;
- string subKey;
- Dictionary<string, string> sampleStore = new Dictionary<string, string>();
- for (int i = 0; i < sampleRecordsCount; i++)
- {
- key = rd.Next(0, sampleRecordsCount).ToString();
- subKey = subRd.Next(0, subKeyRange).ToString();
- if (!sampleStore.ContainsKey(key))
- {
- sampleStore.Add(key, subKey);
- }
- }
- return sampleStore;
- }
我们把样本数据(两级主键)存储在一个样例词典中, 词典的键用作级联词典中的子级词典的键, 词典中的值用作级联词典中父级词典的键, 实现简单的一对多关系.
- List<long> addMilliseconds = new List<long>();
- List<long> readMilliseconds = new List<long>();
- for (int i = 0; i < 3; i++)
- {
- long add, read;
- recordsCount = RunDictionaryPerformanceTest(dict, sampleStore, out add, out read);
- dict.Clear();
- GC.Collect(2);
- addMilliseconds.Add(add);
- readMilliseconds.Add(read);
- }
- addAvg = (long)addMilliseconds.Average();
- readAvg = (long)readMilliseconds.Average();
对应的测试函数如下:
- public int RunDictionaryPerformanceTest(ITestDictionary<string, string, object> dictToTest,
- Dictionary<string,string> sampleStore, out long addingMilliseconds, out long readingMilliseconds)
- {
- object obj = new object();
- Stopwatch swAdd = new Stopwatch();
- foreach (KeyValuePair<string, string> keyPair in sampleStore)
- {
- swAdd.Start();
- dictToTest.Add(keyPair.Value, keyPair.Key, obj);
- swAdd.Stop();
- }
- addingMilliseconds = swAdd.ElapsedMilliseconds;
- Stopwatch swRead = new Stopwatch();
- foreach (KeyValuePair<string, string> keyPair in sampleStore)
- {
- object ob;
- swRead.Start();
- dictToTest.TryGetValue(keyPair.Value, keyPair.Key, out ob);
- swRead.Stop();
- }
- readingMilliseconds = swRead.ElapsedMilliseconds;
- return sampleStore.Count;
- }
3.实验结果及分析
这是随着样本数量的上升, 向待测试的词典中添加新项(对应图一)/读取所有项(对应图二)所耗费的时间对应图. 从图中可以看出, 在十万的样本数量级上, 非线程安全的词典(包括组合键和级联), 时间性能差别微乎其微: Flatten对应的组合键方式有微弱的优势, 不过和Casade方式差别很小, 两者的读写时间从十几微妙到一百多微妙, 读写速度极快, 可以预见很少有可能在这上面成为系统瓶颈.
在线程安全的词典中, 这两种方案的性能差别就比较明显了.
在添加新项方面, 数据规模较小时, 组合键方式有比较好的性能; 随着数据规模的增大, 级联方式在性能上逐渐超越组合键方式; 级联的原理决定了它比组合键方式有更快捷更直接的定位数据方式(级别分类), 比组合键方式更直接; 不过当数据规模较小时, 这种原理上带来的好处不足以抵消因为创建子级词典对象所带来的额外的时间消耗.
在读取所有项方面, 级联方式的行为表现比较奇怪. 我们期待它有比组合键方式更好的读取性能. 但是在Windows 7操作系统上, 2GB RAM, 整个测试过程无内存换页发生的情况下的多次测试, 均指向这一结果 - 组合键方式的读取速度几乎是级联方式的两倍!. 但是在Windows XP操作系统上, 2G RAM, 无内存换页上的测试, 却完全相反 - 级联方式是组合键方式的两倍!
- 我向上帝保证我没在代码上犯低级错误. 查看词典的源代码, 应该是FindEntry的时候决定了读取效率:
- private int FindEntry(TKey key) {
- if( key == null) {
- ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
- }
- if (buckets != null) {
- int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
- for (int i = buckets[hashCode % buckets.Length]; i >= 0; i = entries[i].next) {
- if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) return i;
- }
- }
- return -1;
- }
看起来很普通的一段桶映射关系查找, 依然不知道为什么, 猜测是操作系统调度的问题...各位谁有兴趣可以试一下 :)
上面两图是指在一定的数据规模下, 父级词典容量对读写性能的影响; 读写均指向同一趋势: 父级词典的容量越大, 读写时间越长. 这是因为在性能指向上, 父级词典越大, 每个子级词典存储的项越少, 存储效率越低, 因而额外的创建多级词典所带来的时间消耗比重越来越大.
4. 结论
结论很简单, 级联式词典的数据结构并没有带来相当大的好处, 却更不稳定(指时间性能), 更庞大(指内存结构), 更敏感(指重叠率).
另外需要指出的一点, 本文没有对比级联式词典和组合键式词典在多线程读写条件下的性能对比, 因为这是没有疑问的. 级联式词典在在锁的申请和释放方面会更有效率, 因为锁而带来的等待会因级联的结构而大幅度减少, 这恰恰和重叠率是一对矛盾统一.所以如果你对要存储的数据的特征和分类相当明确, 并且处在一个多线程环境中, 建议尝试一下级联结构.
还是那句话, 具体问题具体具体分析, 要用实验的方法证明猜测. 祝各位周末愉快!
作者:Jeffrey Sun
出处:http://sun.cnblogs.com/
本文以“现状”提供且没有任何担保,同时也没有授予任何权利。本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。