高性能内存缓存的线程安全性

5

我有一个静态的内存缓存,每小时(甚至更长时间)只写入一次,并且被许多线程以极高的速率读取。传统智慧建议我遵循以下模式:

public static class MyCache
{
    private static IDictionary<int, string> _cache;
    private static ReaderWriterLockSlim _sharedLock;

    static MyCache()
    {
        _cache = new Dictionary<int, string>();
        _sharedLock = new ReaderWriterLockSlim();
    }

    public static string GetData(int key)
    {
        _sharedLock.EnterReadLock();
        try
        {
            string returnValue;
            _cache.TryGetValue(key, out returnValue);
            return returnValue;
        }
        finally
        {
            _sharedLock.ExitReadLock();
        }
    }

    public static void AddData(int key, string data)
    {
        _sharedLock.EnterWriteLock();
        try
        {
            if (!_cache.ContainsKey(key))
                _cache.Add(key, data);
        }
        finally
        {
            _sharedLock.ExitWriteLock();
        }
    }
}

作为微优化的练习,我该如何在共享锁的相对开销中再节省一些时间?写入时间可能很昂贵,因为它很少发生。我需要尽可能快地进行读取。我可以只删除下面的锁并在这种情况下保持线程安全吗?还是有一个无锁版本可用?我熟悉内存栅栏但不知道如何在这种情况下安全地应用它。
注意:我不拘泥于任何模式,所以欢迎任何建议,只要最终结果更快且在C# 4.x.*中。
public static class MyCache2
{
    private static IDictionary<int, string> _cache;
    private static object _fullLock;

    static MyCache2()
    {
        _cache = new Dictionary<int, string>();
        _fullLock = new object();
    }

    public static string GetData(int key)
    {
        //Note: There is no locking here... Is that ok?
        string returnValue;
        _cache.TryGetValue(key, out returnValue);
        return returnValue;
    }

    public static void AddData(int key, string data)
    {
        lock (_fullLock)
        {
            if (!_cache.ContainsKey(key))
                _cache.Add(key, data);
        }
    }
}

1
对于微小的优化,您确实需要先进行分析。现在读取锁占用了多少百分比? - H H
1
你的第二个版本不是线程安全的。在你写入时没有任何保护读取。 - driis
为了获得读取性能,你准备在写入性能方面牺牲多少?你可以创建一个非锁定版本,每当需要添加数据时就简单地替换字典。读取时没有锁定,但在写入场景下显然会慢很多。 - driis
driis...当完全锁定写入时,是否会阻止读取操作进行? - JoeGeeky
1
@JoeGeeky,这怎么可能呢?GetData()函数中的代码并不知道锁的存在,因此它不会对其做出任何反应。 - svick
显示剩余2条评论
5个回答

12

当只有线程只读取数据结构时,您不需要锁。因此,由于写入非常少(并且我假设不是并发的),一种选择可能是制作字典的完整副本,对副本进行修改,然后以原子方式将旧字典与新字典交换:

public static class MyCache2
{
    private static IDictionary<int, string> _cache;

    static MyCache2()
    {
        _cache = new Dictionary<int, string>();
    }

    public static string GetData(int key)
    {
        string returnValue;
        _cache.TryGetValue(key, out returnValue);
        return returnValue;
    }

    public static void AddData(int key, string data)
    {
        IDictionary<int, string> clone = Clone(_cache);
        if (!clone.ContainsKey(key))
            clone.Add(key, data);
        Interlocked.Exchange(ref _cache, clone);
    }
}

根据上面的一些评论,如果在这种情况下使用GetData()方法将不是线程安全的。因此,我会回到使用某种锁定机制。这并不是一个坏主意,但似乎它并没有解决读取性能问题。 - JoeGeeky
1
如果有一个线程正在操作由“_cache”引用的字典,那么GetData将不是线程安全的。但是现在没有线程在操作它,所以它是线程安全的! - dtb
那是我的最初想法,但之前的评论似乎不同意。无论如何,我现在正在尝试测试这个。谢谢。 - JoeGeeky
1
你第二个示例中的GetData方法不是线程安全的,因为AddData正在操作由_cache引用的字典。 - dtb
2
我认为只有在我能够保证没有两个线程同时尝试AddData()时,这才是安全的,否则一个更新可能会丢失。在我的情况下,这不是问题,我只是想确保我理解了这一点。 - JoeGeeky
经过广泛的测试,除了Marc建议的Hashtable方法在读取短数据时速度更快外,这种模式胜出于我所测试的所有方法。在这种情况下,我需要对小型和大型缓存实现持续的高性能,因此这种方法是最佳选择。此外,ConcurrentDictionary的表现与这个方法非常接近。如果我不需要尽可能地提高性能,这也是一个不错的选择。再次感谢。 - JoeGeeky

6
我希望您能够在这里提供无锁的解决方案,通过“不更改任何已发布的字典”来实现线程安全。我的意思是:当您需要添加数据时,请创建字典的完整副本,并追加/更新等操作该副本。由于这是每小时一次,即使对于大型数据,也不应该成为问题。然后,当您进行更改时,只需将旧字典的引用与新字典交换(引用读/写保证是原子的)。
一个注意事项:任何需要在多个操作之间保持一致状态的代码都应该首先将字典捕获到变量中,即:
var snapshot = someField;
// multiple reads on snapshot

这确保了任何相关逻辑都是使用相同的数据版本进行的,以避免在操作期间引用交换时出现混乱。
我还会在写入时(而不是读取时)锁定,以确保数据不会争抢。也有无锁多写者方法(主要是Interlocked.CompareExchange和如果失败则重新应用),但我首先会使用最简单的方法,而单个写者正是如此。

关于捕获变量以保持操作一致性的问题。这是否适用于if条件内的一致性,例如if(someField == null || someField.SomeProp == null),在条件之间someField是否可能会改变? - Daniel Orlan

2
替代方案:.Net 1.x 的Hashtable(本质上是Dictionary,但不包含泛型)有一个有趣的线程故事;读取时无需锁定即可保证线程安全 - 只需要在写入时使用锁定。

因此,您可以考虑使用非泛型的Hashtable,在读取时不需要加锁,但在写入时需要加锁。

这就是我仍然发现自己有时会在.net 4.x应用程序中使用Hashtable的主要原因。

但是有一个问题 - 它将导致int键在存储和查询时被装箱。

有趣,我不知道这个... 我将其添加到测试列表中,目前这个列表已经变得很长了 :-). 但是装箱???疼! - JoeGeeky
测试完成了... Hashtable表现非常出色。在短时间内(例如,在我的硬件上小于1分钟),它在高并发高性能读取方面的所有测试中都表现得比我测试过的其他任何东西都要好。这包括无锁字典、并发字典等等...然而,在持续负载下,它落后于dtd建议的无锁字典方法。在指标中有一个奇怪的节拍以固定间隔发生,导致其长时间运行性能略有下降。总的来说,在某些情况下不是一个坏选择。 - JoeGeeky
@Joe 我想知道那个勾号是否是装箱 imt 值的集合? - Marc Gravell
@Joe,你可以通过使用Try*方法来避免这些问题,例如if(data.TryGetValue(key, out value)) {...} - Marc Gravell
是的,我知道,在所有支持的测试中我都使用了Try*。正如你所想象的那样,在这些调用背后有更多的IL代码,因此x = y[z]稍微更加简洁。 - JoeGeeky
显示剩余3条评论

1

仅当添加数据时才会复制字典。使用锁进行添加,但如果您不打算从多个线程添加,则可以将其取消。如果没有副本,则从原始字典中提取数据,否则在添加时使用副本。

以防万一,在检查并确认为非null之后,复制被清空但尚未能够检索该值,我添加了一个try catch,在那种罕见情况下,它将从原始数据中提取数据,然后再次锁定,但如果有任何情况发生,这应该非常罕见。

public static class MyCache2
{
    private static IDictionary<int, string> _cache;
    private static IDictionary<int, string> _cacheClone;
    private static Object _lock;

    static MyCache2()
    {
        _cache = new Dictionary<int, string>();
        _lock = new Object();
    }

    public static string GetData(int key)
    {
        string returnValue;
        if (_cacheClone == null)
        {
            _cache.TryGetValue(key, out returnValue);
        }
        else
        {
            try
            {
                _cacheClone.TryGetValue(key, out returnValue);
            }
            catch
            {
                lock (_lock)
                {
                    _cache.TryGetValue(key, out returnValue);
                }
            }
        }
        return returnValue;
    }

    public static void AddData(int key, string data)
    {
        lock (_lock)
        {
            _cacheClone = Clone(_cache);
            if (!_cache.ContainsKey(key))
                _cache.Add(key, data);
            _cacheClone = null;
        }
    }
}

0

这是一篇非常有趣的阅读...写得也很好。在这种情况下,这些都是顺序访问结构,不适合我上面提出的情况。话虽如此,我现在正在启动一个性能测试平台,因为我很好奇他的无锁队列实现与内置的.NET版本相比表现如何。 :-) - JoeGeeky
如承诺,我测试了无锁队列的实现。不幸的是,在每个测试用例中,无锁版本都比使用完全锁定的通用队列慢。在几乎每种情况下,完全锁定队列的速度快了2倍。我喜欢这个想法,但数字说明了一切。 - JoeGeeky
1
忘了提一下,你给我介绍的无锁队列在处理少量项目时比微软的ConcurrentQueue更快。随着队列大小的增加,无锁队列落后于ConcurrentQueue,后者变得更快。对于小型队列,请使用带有完整锁定的通用队列...在这种情况下它要快得多。 - JoeGeeky

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接