c# - 这个延迟加载缓存实现是线程安全的吗?

标签 c# .net

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

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];
    }
}

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

显然,我所处的情况是,重新创建整个键列表的成本与加载单个项目的成本几乎无关。

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

最佳答案

这不是线程安全的,因为您在阅读字典时没有锁定。

存在一个线程可以读取的竞争条件:

return Cache[key];

当另一个人在写的时候:

_Cache.Add(key, item);

作为MSDN documentation for Dictionary<TKey,TValue> 状态:`

To allow the collection to be accessed by multiple threads for reading and writing, you must implement your own synchronization.

并且您的同步不包括阅读器。

您确实需要使用线程安全的字典,这将极大地简化您的代码(您根本不需要 List)

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

获得正确的线程安全性很困难,一些其他回答者错误地声明您的实现是线程安全的这一事实就证明了这一点。因此,在自制之前,我会相信 Microsoft 的实现。

如果您不想使用线程安全的字典,那么我会推荐一些简单的方法,例如:

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;
    }
}

您也可以尝试使用 ReaderWriterLockSlim 来实现,尽管您可能不会获得显着的性能提升(谷歌搜索 ReaderWriterLockSlim 性能)。

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

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

这可能导致 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;

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

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

关于c# - 这个延迟加载缓存实现是线程安全的吗?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/12403440/

相关文章:

c# - 规范模式和性能

.net - 何时使用 CultureInfo.GetCultureInfo(String) 或 CultureInfo.CreateSpecificCulture(String)

c# - 使用 Import-Module 导入时,C# 二进制文件中的 Cmdlet 不会导出

c# - 通过 C# 打开 VB 程序时日期格式不正确

c# - ToString() 方法的意义是什么?

c# - 全局命名空间 C# 中的命名空间

c# - 如何创建通用 Func 委托(delegate)

c# - 尝试将密码历史记录与 SqlMembershipProvider 创建的散列密码进行比较

c# - 伪造的 ASMX Web 服务调用

c# - DependencyProperty 字符串,onChange while typing