2

我正在使用.Net 3.5 开发一个多线程应用程序,它从存储在数据库中的不同表中读取记录。读数非常频繁,因此需要延迟加载缓存实现。每个表都映射到一个 C# 类,并有一个字符串列,可用作缓存中的键。此外,还需要定期刷新所有缓存的记录。我本可以在每次读取时锁定缓存以确保线程安全的环境,但后来我想到了另一种解决方案,它依赖于获取所有可能键的列表很简单这一事实。

所以这是我写的第一个类,它存储了使用双重检查锁模式延迟加载的所有键的列表。它还有一个方法可以在静态变量中存储上次请求刷新的时间戳。

public class Globals
{
    private static object _KeysLock = new object();
    public static volatile List<string> Keys;
    public static void LoadKeys()
    {
        if (Keys == null)
        {
            lock (_KeysLock)
            {
                if (Keys == null)
                {
                    List<string> keys = new List<string>();
                    // Filling all possible keys from DB
                    // ...
                    Keys = keys;
                }
            }
        }
    }

    private static long refreshTimeStamp = DateTime.Now.ToBinary();
    public static DateTime RefreshTimeStamp
    {
        get { return DateTime.FromBinary(Interlocked.Read(ref refreshTimeStamp)); }
    }
    public static void NeedRefresh()
    {
        Interlocked.Exchange(ref refreshTimeStamp, DateTime.Now.ToBinary());
    }
}

然后我写了一个CacheItem<T>类,它是一个指定表T的缓存单项的实现,通过键过滤。它具有Load记录列表延迟加载的方法和LoadingTimeStamp存储最后一条记录加载的时间戳的属性。请注意,静态记录列表被本地填充的新记录覆盖,然后LoadingTimeStamp也被覆盖。

public class CacheItem<T>
{
    private List<T> _records;
    public List<T> Records
    {
        get { return _records; }
    }

    private long loadingTimestampTick;
    public DateTime LoadingTimestamp
    {
        get { return DateTime.FromBinary(Interlocked.Read(ref loadingTimestampTick)); }
        set { Interlocked.Exchange(ref loadingTimestampTick, value.ToBinary()); }
    }

    public void Load(string key)
    {
        List<T> records = new List<T>();
        // Filling records from DB filtered on key
        // ...
        _records = records;
        LoadingTimestamp = DateTime.Now;
    }
}

最后,这里是Cache<T>将表 T 的缓存存储为静态字典的类。如您所见,该Get方法首先在缓存中加载所有可能的键(如果尚未完成),然后检查刷新的时间戳(两者都使用双重检查锁定模式完成)。调用返回的实例中的记录列表Get可以被线程安全地读取,即使锁内有另一个线程正在执行刷新,因为刷新线程不会修改列表本身,而是创建一个新列表。

public class Cache<T>
{
    private static object _CacheSynch = new object();
    private static Dictionary<string, CacheItem<T>> _Cache = new Dictionary<string, CacheItem<T>>();
    private static volatile bool _KeysLoaded = false;

    public static CacheItem<T> Get(string key)
    {
        bool checkRefresh = true;
        CacheItem<T> item = null;
        if (!_KeysLoaded)
        {
            lock (_CacheSynch)
            {
                if (!_KeysLoaded)
                {
                    Globals.LoadKeys(); // Checks the lazy loading of the common key list
                    foreach (var k in Globals.Keys)
                    {
                        item = new CacheItem<T>();
                        if (k == key)
                        {
                            // As long as the lock is acquired let's load records for the requested key
                            item.Load(key);
                            // then the refresh is no more needed by the current thread
                            checkRefresh = false;
                        }
                        _Cache.Add(k, item);
                    }
                    _KeysLoaded = true;
                }
            }
        }
        // here the key is certainly contained in the cache
        item = _Cache[key];
        if (checkRefresh)
        {
            // let's check the timestamps to know if refresh is needed
            DateTime rts = Globals.RefreshTimeStamp;
            if (item.LoadingTimestamp < rts)
            {
                lock (_CacheSynch)
                {
                    if (item.LoadingTimestamp < rts)
                    {
                        // refresh is needed
                        item.Load(key);
                    }
                }
            }
        }
        return item;
    }
}

定期Globals.NeedRefresh()调用以确保刷新记录。此解决方案可以避免每次读取缓存时锁定,因为缓存中预先填充了所有可能的键:这意味着内存中将存在等于所有可能键的数量的实例数(大约 20 ) 对于每个请求的类型 T(所有 T 类型大约为 100),但仅对于请求的键,记录列表不为空。请让我知道此解决方案是否存在线程安全问题或任何错误。非常感谢。

4

1 回答 1

0

鉴于:

  • 您加载所有密钥一次并且永远不会更改它们
  • 您创建每个字典一次并且永远不会更改它
  • CacheItem.Load 是线程安全的,因为它只会List<T>用新的完全初始化的列表替换私有字段。

您根本不需要任何锁,因此可以简化代码。

唯一可能需要锁是为了防止并发尝试运行CacheItem.Load。就我个人而言,我只是让并发数据库访问运行,但如果你想阻止它,你可以在CacheItem.Load. 或者从 .NET 4 中捏一下,然后按照我对上一个问题Lazy<T>的回答中的建议使用它。

另一条评论是,您的刷新逻辑使用DateTime.Now,因此(a)在夏令时结束时时钟返回期间和(b)如果系统时钟已更新,则不会按预期运行。

我会简单地使用每次NeedRefresh调用时递增的静态整数值。

来自评论:

例如,如果两个线程......尝试同时加载公共 Globals.Keys 会发生什么?”

在应用程序启动时可能会发生一次这种情况的风险很小,但那又如何呢?这将导致从数据库中读取 20 个键两次,但性能影响可能可以忽略不计。如果你真的想防止这种情况,任何锁定都可以封装在一个类中,比如Lazy<T>.

关于使用 DateTime.Now 的评论实际上是一个有趣的点,但我想也许我可以假设这些事件可能在应用程序未使用时发生。

您可以“假设”,但不能保证。您的机器可能随时决定将其时间与时间服务器同步。

关于在 NeedRefresh 中使用整数的建议,我不明白如何将它与由 DateTime 表示的每个记录列表状态进行比较。

据我所知,您只使用DateTime来检查您的数据是在最近一次调用之前还是之后加载的NeedRefresh。因此,您可以将其替换为:

public static class Globals
{
    ...

    public static int Version { get {return _version; } }
    private static int _version;

    public static void NeedRefresh()
    {
        Interlocked.Increment(ref _version);
    }
}

public class CacheItem<T>
{
    public int Version {get; private set; }

    ...

    public void Load(string key)
    {
        Version = Globals.Version;

        List<T> records = new List<T>();
        // Filling records from DB filtered on key
        // ...
        _records = records;
    }
}

然后在访问缓存时:

item = _Cache[key];
if (item.Version < Globals.Version) item.Load();

** 更新 2 **

回应评论:

...如果一个线程尝试读取字典,而另一个线程在锁内向其中添加项目,则可能存在真正的完整性风险,不是吗?

您现有的代码仅在加载全局键后立即将所有键添加到字典中,并且随后永远不会修改字典。因此,只要在字典完全构建之前不分配 _Cache 属性,这是线程安全的:

var dictionary = new Dictionary<string, CacheItem<T>>(Global.Keys.Count);
foreach (var k in Globals.Keys)                       
{                           
    dictionary.Add(k, new CacheItem<T>());
}
_Cache = dictionary;
于 2012-09-18T15:45:55.617 回答