上一题下一题
跳转到
 
 
  世界如此多姿,发展如此迅速,窥一斑未必还能知全豹。但正如万花筒一样,每一个管窥都色彩斑斓。  
 
 
  知识通道 | 学习首页 | 教师首页 | PK首页 | 知识创造首页 | 企业首页 | 登录
 
本文对应知识领域
并发环境下的缓存容器性能优化:不可变的哈希表
作者:赵劼 申领版权
2010年10月13日 共有 1414 次访问 【添加到收藏夹】 【我要附加题目
受欢迎度:
    

我们在项目中经常会遇到这样的场景:一些信息读取开销较大,但只需要生成一次便可反复使用,因此我们会将其永久地缓存起来。例如在ASP.NET MVC中,系统会根据Controller的名称来缓存对应的元数据。这些缓存容器都有一些共同的特点,便是存储的对象数量有限(少则几十,多不过数千),但都需要在并发环境下被大量地读取,因此必须是线程安全的。那么,我们该如何设计这样的容器呢?

一个非常常见的做法是使用字典(System.Collections.Generic.Dictionary<TKey, TValue>)进行存储。字典使用了“哈希表”这个数据结构,其读取和写入的时间复杂度几乎都是O(1),性能可谓非常之高。只不过,字典不是线程安全的,例如它在添加元素的时候可能会对内部的bucket数组进行动态调整,这显然不是一个原子操作。因此,它无法直接用户并行环境的读写,我们对它的操作必须加锁。

说起加锁,最简单的方式便是在读和写方法中使用同一个互斥体(mutex)进行lock:

object mutex = new object();
Dictionary<int, int> dict = new Dictionary<int, int>();

// read with lock
lock (mutex)
{
    var value = dict[1];
}

// write with lock
lock(mutex)
{
    dict[1] = 1;
}

这么做可以保证在任意时刻只有单个线程在访问字典,无论读写,这自然做到了线程安全。但是,这么做的话对于并发环境并不友好。因为一个字典是完全可以同时被多个线程“读”的,也就是说,如果我们简单使用lock便会大大降低“读”操作的可并发性,从而对性能产生负面影响。

那么我们能否对“写”操作进行lock,而让“读”操作完全自由呢?例如:

object mutex = new object();
Dictionary<int, int> dict = new Dictionary<int, int>();

// read directly
var value = dict[1];

// write with lock
lock(mutex)
{
    dict[1] = 1;
}

 

这也是不允许的。因为这样做无法避免在某个线程读取字典的同时,另一个线程正在修改字典。一旦出现读写操作同时进行的情况,字典就很可能被破坏了。因此,其实我们的要求有两点:

  • 只能由单个线程写,但可以由多个线程同时读。
  • 在进行读操作时,不可同时进行写操作。反之亦然。

这便是“读写锁”使用场景。只要使用“写”锁来保护修改操作,而使用“读”锁来保护读取操作,便可以让字典正确并高效地工作在并发环境下。“读写锁”在.NET框架中有着两个实现:ReaderWriterLock和ReaderWriterLockSlim。前者出现在.NET 1.x中,而后者随.NET 2.0发布。两者的区别在于前者性能低,后者性能高,因此在目前,基本上需要读写锁的场景都会建议使用ReaderWriterLockSlim(除了稍后会提到的情况)。

但是,ReaderWriterLockSlim并非不会带来开销,我们接下来的试验便要来验证这一点。首先,我们准备1000个数字,它们便是我们使用的测试数据源:

var source = Enumerable.Range(1, 1000).ToArray();
var dict = source.ToDictionary(i => i);

然后,我们使用CodeTimer测试它在三种情况下的性能:

int iteration = 10000;
CodeTimer.Initialize();

CodeTimer.Time("Read Free", iteration, () =>
{
    foreach (var i in source)
    {
        var ignore = dict[i];
    }
});

var rwLockSlim = new ReaderWriterLockSlim();
CodeTimer.Time("ReaderWriterLockSlim", iteration, () =>
{
    foreach (var i in source)
    {
        rwLockSlim.EnterReadLock();
        var ignore = dict[i];
        rwLockSlim.ExitReadLock();
    }
});

var rwLock = new ReaderWriterLock();
CodeTimer.Time("ReaderWriterLock", iteration, () =>
{
    foreach (var i in source)
    {
        rwLock.AcquireReaderLock(0);
        var ignore = dict[i];
        rwLock.ReleaseReaderLock();
    }
});

最后一种方式使用了性能较差的ReaderWriterLock,我的目的除了表现出它与ReaderWriterLockSlim的性能差距之外,还有一个原因在于.NET 3.5的ReaderWriterLockSlim类会在某些时候读取Environment.ProcesserCount,这在Medium Trust的环境下会引发SecurityException。因此您会发现,在目前的ASP.NET MVC框架中,所有需要读写锁的地方都使用了ReaderWriterLock而不是Slim组件。

试验结果如下:

Read Free
        Time Elapsed:   228ms
        CPU Cycles:     538,190,052
        Gen 0:          0
        Gen 1:          0
        Gen 2:          0

ReaderWriterLockSlim
        Time Elapsed:   1,161ms
        CPU Cycles:     2,783,025,072
        Gen 0:          0
        Gen 1:          0
        Gen 2:          0

ReaderWriterLock
        Time Elapsed:   2,792ms
        CPU Cycles:     6,682,368,396
        Gen 0:          0
        Gen 1:          0
        Gen 2:          0

可见,尽管ReaderWriterLockSlim的性能比ReaderWriterLock要高出许多,但相对于无锁的读取还是有明显差距。这让我感到很不合算,因为这是由缓存容器的使用性质决定的。在一般情况下,这种容器都是“有限次地修改”,而几近“无限次地读取”,尤其到了程序运行后期,所以该使用的对象都已经缓存下来,字典内容更不会产生任何改变——但是,每一次读取还要继续承受ReaderWriterLockSlim的开销,这笔帐相信人人会算。那么,我们又如何能够做到“无锁”地读取呢?既然我们“锁”的目的是因为需要“修改”,那么我们使用“不可变(Immutable)”的容器是否就能有所帮助呢?为此,我向F#借来了Immutable Map。

F#中的Map类定义在Microsoft.FSharp.Core.dll中,这需要安装F# SDK for Visual Studio 2008,当然如果您直接安装了VS 2010自然也就有了相应的程序集——不过一些辅助类库(如Microsoft.FSharp.PowerPack),以及源文件(F#是个开源的语言)还是需要去之前的链接里下载一个压缩包。在项目中引用了程序集后,便可以使用Microsoft.FSharp.Collections命名空间下的FSharpMap类型了。我们也为它准备了同样的试验代码:

var map = source.Aggregate(FSharpMap<int, int>.Empty, (m, i) => m.Add(i, i));
CodeTimer.Time("Immutable Map", iteration, () =>
{
    foreach (var i in source)
    {
        var ignore = map[i];
    }
});

把它和ReaderWriterLockSlim相比,结果如下:

ReaderWriterLockSlim
        Time Elapsed:   1,232ms
        CPU Cycles:     2,963,830,380
        Gen 0:          0
        Gen 1:          0
        Gen 2:          0

Immutable Map
        Time Elapsed:   2,055ms
        CPU Cycles:     4,956,894,996
        Gen 0:          0
        Gen 1:          0
        Gen 2:          0

嗯?为什么使用Immutable Map之后,即便没有锁,性能还是不如ReaderWriterLockSlim的版本呢?阅读代码(fsharp\FSharp.Core\map.fs)之后发现,其原因在于数据结构的选取上。FSharpMap的数据结构是“AVL树”,即自平衡(self-balancing)的二叉搜索树,它的Get操作时间复杂度是O(logN),N为节点个数——这自然比不过字典的O(1)时间复杂度了。那么,我们能否得到一个Get操作时间复杂度为O(1)的Immutable Map呢?

我在推特上提出这个问题之后不多久,许式伟大牛谈了他的看法:

我的理解中,FP中线性数据结构如数组,Hash等都会失效,所以没有O(1)的Immutable Map。

我同意这个看法。字典的高性能,在于求得key的Hash之后,可以通过数组下标“瞬间”定位到元素所在的bucket,但是在FP中无法做到这一点。不过幸好,我们用的C#并非是FP,我们有自己的“保底”方案:添加元素时将现有的元素完整复制到新的字典中,最后返回的是新的字典。例如:

public static class DictionaryExtensions
{
    public static Dictionary<TKey, TValue> AddImmutably<TKey, TValue>(
        this Dictionary<TKey, TValue> source, TKey key, TValue value)
    {
        var result = source.ToDictionary(p => p.Key, p => p.Value);
        result.Add(key, value);
        return result;
    }
}

这不就是一个“不可变的哈希表”吗?当然,对于真正可应用的缓存容器来说,还需要考虑更多一些东西。我在这里准备了两个缓存容器的基类,可用于此类容器的实现。其中ReadWriteCache类基于ReaderWriterLockSlim,而ReadFreeCache类则基于不可变的字典。那么,这两种做法的性能究竟如何,又分别适合什么样的场景,我们还有没有其他的做法呢?

 

相关新闻

SQL优化34条
MS SQL Server查询优化方法
编译优化概述
高考物理知识点优化训练-分子动理论
并行思维2
C#高手之路详解解析Hashtable、Dictionary、SortedDictionary、SortedList的比较应用
优化系统设置 提移动存储设备读写速度
清理和关闭多余的Windows 7系统服务
剔除多余赘肉 让Windows 7系统轻装上阵

您可能对这些感兴趣  

用VB做定时断线程序
VisualBasic中的界面设计原则和编程技巧
VB6.0与Windows API 间的呼叫技巧
制作可以自动隐藏的弹出式菜单
ListBox中的字符串超长显示的解决方法
VB中的Unicode 和 Ansi 格式
优化程序显示速度
Visual Basic 产生渐层的 Form 背景
用VB实现客户——服务器(TCP/IP)
用VB制作注册软件的方法

题目筛选器
日期:
类型:
状态:
得分: <=
分类:
作者:
职业:
关键字:
搜索

 
 
 
  焦点事件
 
  知识体系
 
  职业列表
 
 
  最热文章
 
 
  最多引用文章
 
 
  最新文章
 
 
 
 
网站介绍 | 广告服务 | 招聘信息 | 保护隐私权 | 免责条款 | 法律顾问 | 意见反馈
版权所有 不得转载
沪ICP备 10203777 号 联系电话:021-54428255
  帮助提示    
《我的太学》是一种全新的应用,您在操作中遇到疑问或者问题,请拨打电话13564659895,15921448526。
《我的太学》