【问题标题】:Is this lazy-loading cache implementation thread-safe?这个延迟加载缓存实现是线程安全的吗?
【发布时间】:2023-03-06 01:33:01
【问题描述】:

我正在使用 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 方法时,它会读取静态共享密钥列表(声明为 volatile)并调用 Contains 方法以查看密钥是否已经存在,如果不存在,则在开始延迟加载之前使用双重检查锁定模式。在加载结束时,会创建一个新的键列表实例并将其存储在静态变量中。

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

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

【问题讨论】:

  • 你看过 ReaderWriterLock 吗? msdn.microsoft.com/en-us/library/…
  • @Kip9000:该类实际上已被弃用,取而代之的是ReaderWriterLockSlim
  • @Jon 我错了,我的意思是写 ReaderWriterLockSlim。根据 MSDN,ReaderWriterLock 似乎并没有被弃用,而是首选并且更好。
  • 你用两次 O(n) 查找来换取一个锁。这只能为琐碎的列表带来回报,不超过少数缓存项目。至少使用 HashSet。然后计时。
  • 为什么不使用Dictionary&lt;TKey, TValue&gt;.ContainsKey 而不是单独跟踪密钥?

标签: c# .net


【解决方案1】:

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

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

return Cache[key];

当另一个人正在写作时:

_Cache.Add(key, item);

正如MSDN documentation for Dictionary&lt;TKey,TValue&gt; 所说:`

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

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

您确实需要使用线程安全的字典,这将极大地简化您的代码(您根本不需要 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;
    }
}

您也可以尝试使用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&lt;T&gt; 实现并执行以下操作:

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&lt;CacheItem&gt; 实例,但实际上只有一个会存储在字典中。 ExpensiveLoad 将在第一次为存储在字典中的实例取消引用 Lazy&lt;CacheItem&gt;.Value 时被调用。 这个 Lazy&lt;T&gt; 构造函数使用 LazyThreadSafetyMode.ExecutionAndPublication,它在内部使用锁,因此确保只有一个线程调用工厂方法 ExpensiveLoad

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

【讨论】:

  • 我的第一个版本的代码没有以return Cache[key]; 语句结尾,所以可能真的是线程安全的,但是有问题,因为它总是在缓存命中的情况下返回 null。所以我编辑和修改添加了线程安全问题。
  • ConcurrentDictionary 替换Dictionary 不会使其成为线程安全的。 ContainsKey 将是线程安全的;但是CacheKeys 仍然有可能在分配给keys 和调用ContainsKeys 之间发生变化。
  • @PeterRitchie,我并没有说用 ConcurrentDictionary 替换 Dictionary 会使这个实现线程安全。我说 ConcurrentDictionary 将使他能够极大地简化他的代码并摆脱列表。我将把完整的实现留作练习,或者让其他回答者提供。
  • @Joe 好吧,问题是关于代码是否是线程安全的;所以,我认为任何答案都会解决这个问题。您没有详细说明如何 ConcurrentDictionaryDictionary 的不同之处在于明显导致不需要List&lt;T&gt;。例如,Dictionary&lt;T1, T2&gt; 具有ContainsKey 方法,因此您可以在不使用List&lt;T&gt; 的情况下检查Dictionary 的键。因此,尚不清楚ConcurrentDictionary 与此有何明显不同。
  • @PeterRitchie,正如我所说,它不是线程安全的,因为一个线程正在读取字典,而另一个线程正在更新它。作家线程将弄乱字典的内部结构(存储桶等),这将导致读者线程时不时地“繁荣”。我使用 ConcurrentDictionary 添加了一个示例,以使事情更清楚。正如 Dictionary 的 MSDN 文档所述:“要允许多个线程访问集合以进行读写,您必须实现自己的同步。”
【解决方案2】:

到目前为止,我看不到任何大问题。我在您的代码中唯一看不到的是您如何将CacheKeys 公开?最简单的一个是IList&lt;string&gt;,由ReadOnlyCollection 填充。这样,您的消费者可以很容易地使用索引运算符或计数属性。在这种情况下,也不需要 volatile 关键字,因为您已经将所有内容都放入了锁中。所以我会按如下方式对你的班级进行拉皮条:

public class CacheItem
{
    public void ExpensiveLoad()
    {
        // some expensive code
    }
}
public class Cache
{
    private static object _SynchObj = new object();
    private static Dictionary<string, CacheItem> _Cache = new Dictionary<string, CacheItem>();
    private static ReadOnlyCollection<string> _CacheKeysReadOnly = new ReadOnlyCollection(new List<string>());

    public IList<string> CacheKeys
    {
        get
        {
            return _CacheKeysReadOnly;
        }
    }

    public CacheItem Get(string key)
    {
        CacheItem item = null;
        ReadOnlyCollection<string> keys = _CacheKeysReadOnly;
        if (!keys.Contains(key))
        {
            lock (_SynchObj)
            {
                keys = _CacheKeysReadOnly;
                if (!keys.Contains(key))
                {
                    item = new CacheItem();
                    item.ExpensiveLoad();
                    _Cache.Add(key, item);
                    List<string> newKeys = new List<string>(_CacheKeysReadOnly);
                    newKeys.Add(key);
                    _CacheKeysReadOnly = newKeys.AsReadOnly();
                }
            }
        }
        return item;
    }
}

如果您已经使用 .Net 4.5,您也可以考虑使用 IReadOnlyList&lt;T&gt; 接口作为 CacheKeys 属性。

【讨论】:

  • 此缓存的目的只是读取元素并在不存在时创建它们。所以现在我只需要通过 Get 方法访问。无论如何感谢您的建议。附言你真的确定我不需要 volatile 关键字吗?在锁外仍然有对 CacheKeys 的读取调用(我想是静态的)。
  • @user1667010:他们将被读取,但没有人可以更改列表,它是只读的。所以我们有一个不可变的列表,并且只有在新列表准备好(并且装箱为只读)时才会与新列表交换它。可能发生的最糟糕的事情是消费者获得了一个旧列表(在访问公共财产时),其中最近添加的项目不可用,但这也可能事先已经发生,无论您是否使用 volatile 。对于Get() 方法,不需要volatile,因为您已经拥有锁定语句。
  • _CacheKeysReadOnly 仍然可以在分配给keys 和在另一个线程上调用keys.Contains(key) 之间切换。这并不比原来的线程安全。
  • @PeterRitchie:但在这种情况下,lock 负责第二次分配和查找keys
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-05-12
  • 2023-04-07
  • 1970-01-01
  • 1970-01-01
  • 2010-10-25
相关资源
最近更新 更多