这种延迟加载缓存实现是否是线程安全的?

use*_*010 1 .net c#

我正在使用3.5 .NET Framework进行开发,我需要在多线程senario中使用缓存,并为其项目添加延迟加载模式.在网上阅读了几篇文章后,我试着编写自己的实现.这是我的代码.

public class CacheItem
{
    public void ExpensiveLoad()
    {
        // some expensive code
    }
}
public class Cache
{
    static object SynchObj = new object();
    static Dictionary<string, CacheItem> Cache = new Dictionary<string, CacheItem>();
    static volatile List<string> CacheKeys = new List<string>();

    public CacheItem Get(string key)
    {
        List<string> keys = CacheKeys;
        if (!keys.Contains(key))
        {
            lock (SynchObj)
            {
                keys = CacheKeys;
                if (!keys.Contains(key))
                {
                    CacheItem item = new CacheItem();
                    item.ExpensiveLoad();
                    Cache.Add(key, item);
                    List<string> newKeys = new List<string>(CacheKeys);
                    newKeys.Add(key);
                    CacheKeys = newKeys;
                }
            }
        }
        return Cache[key];
    }
}
Run Code Online (Sandbox Code Playgroud)

正如您所看到的,Cache对象既使用存储真实键值对的字典,也使用仅复制键的列表.当一个线程调用Get方法时,它会读取静态共享密钥列表(它被声明为volatile)并调用Contains方法以查看密钥是否已经存在,如果不是,则在开始延迟加载之前使用双重检查的锁定模式.在加载结束时,创建密钥列表的新实例并将其存储在静态变量中.

显然,我处于重建整个密钥列表的成本几乎与单个项目加载的成本无关的情况.

我希望有人能告诉我它是否真的是线程安全的.当我说"线程安全"时,我的意思是每个读者线程都可以避免损坏或脏读,每个编写器线程只加载丢失的项目一次.

谢谢.

Joe*_*Joe 7

这不是线程安全的,因为在读取Dictionary时没有锁定.

有一个竞争条件,一个线程可以读取:

return Cache[key];
Run Code Online (Sandbox Code Playgroud)

而另一个是写作:

_Cache.Add(key, item);
Run Code Online (Sandbox Code Playgroud)

作为状态的MSDN文档Dictionary<TKey,TValue>:`

要允许多个线程访问集合以进行读写,您必须实现自己的同步.

并且您的同步不包括读者.

你真的需要使用一个线程安全的字典,这将极大地简化你的代码(你根本不需要List)

我建议获取.NET 4 ConcurrentDictionary的源代码.

正确地获得线程安全是很困难的,正如其他一些回答者错误地声明您的实现是线程安全的事实所证明的那样.因此,我相信微软在自制之前的实施.

如果您不想使用线程安全字典,那么我建议一些简单的:

public CacheItem Get(string key)
{
    lock (SynchObj)
    {
        CacheItem item;
        if (!Cache.TryGetValue(key, out item))
        {
            item = new CacheItem();
            item.ExpensiveLoad();
            Cache.Add(key, item);
        }
        return item;
    }
}
Run Code Online (Sandbox Code Playgroud)

您也可以尝试使用a实现ReaderWriterLockSlim,但可能无法获得显着的性能提升(google for ReaderWriterLockSlim性能).

至于使用ConcurrentDictionary的实现,在大多数情况下我只会使用如下内容:

static ConcurrentDictionary<string, CacheItem> Cache = 
    new ConcurrentDictionary<string, CacheItem>(StringComparer.Ordinal);
...
CacheItem item = Cache.GetOrAdd(key, key => ExpensiveLoad(key));
Run Code Online (Sandbox Code Playgroud)

这可能会导致ExpensiveLoad每个键被调用一次,但我敢打赌,如果你对你的应用进行分析,你会发现这是非常罕见的,不会出现问题.

如果你真的坚持确保它只被调用一次,那么你就可以掌握.NET 4的Lazy<T>实现并执行以下操作:

static ConcurrentDictionary<string, Lazy<CacheItem>> Cache = 
    new ConcurrentDictionary<string, Lazy<CacheItem>>(StringComparer.Ordinal);
...

CacheItem item = Cache.GetOrAdd(key, 
               new Lazy<CacheItem>(()=> ExpensiveLoad(key))
             ).Value;
Run Code Online (Sandbox Code Playgroud)

在此版本中,Lazy<CacheItem>可能会创建多个实例,但实际上只有一个实例存储在字典中. ExpensiveLoad将在第一次被调用时Lazy<CacheItem>.Value被解除引用存储在字典中的实例.此Lazy<T>构造函数使用LazyThreadSafetyMode.ExecutionAndPublication,它在内部使用锁,因此请确保只有一个线程调用工厂方法ExpensiveLoad.

另外,在使用字符串键构造任何字典时,我总是使用IEqualityComparer<string>参数(通常是StringComparer.Ordinal或StringComparer.OrdinalIgnoreCase)来明确记录有关区分大小写的意图.