背景
我维护了几个可以或已经从缓存中受益的Winforms应用程序和类库。我也知道Caching Application Block和System.Web.Caching命名空间(根据我的收集,在ASP.NET之外使用它是完全可以的。)
我发现,虽然上述两个类在技术上都是“线程安全的”,因为单个方法是同步的,但它们似乎并没有真正适用于多线程方案。具体来说,它们没有实现类似于.NET 4.0中新ConcurrentDictionary
类中的GetOrAdd
method。{/ p>
我认为这样的方法是用于缓存/查找功能的原语,显然框架设计者也意识到了这一点 - 这就是为什么方法存在于并发集合中的原因。但是,除了我还没有在生产应用程序中使用.NET 4.0这一事实,字典不是一个完整的缓存 - 它没有像过期,持久/分布式存储等功能。
为什么这很重要:
“富客户端”应用程序(甚至某些网络应用程序)中的一个相当典型的设计是在应用程序启动时立即开始预加载缓存,阻止客户端请求尚未加载的数据(随后缓存)它将来使用)。如果用户正在快速浏览他的工作流程,或者网络连接速度很慢,那么客户端与预加载器竞争并不常见,并且两次请求相同的数据真的没有多大意义。 ,特别是如果请求相对昂贵。
所以我似乎留下了一些同样糟糕的选择:
不要试图使操作成为原子,并冒着两次加载数据的风险(并且可能有两个不同的线程在不同的副本上运行);
序列化对缓存的访问,这意味着锁定整个缓存只是为了加载单项;
开始重新发明轮子只是为了获得一些额外的方法。
澄清:示例时间表
假设当应用程序启动时,它需要加载3个数据集,每个数据集需要10秒才能加载。请考虑以下两个时间表:
00:00 - Start loading Dataset 1 00:10 - Start loading Dataset 2 00:19 - User asks for Dataset 2
在上述情况下,如果我们不使用任何类型的同步,则用户必须等待整整10秒才能获得将在1秒内可用的数据,因为代码将看到该项目尚未加载进入缓存并尝试重新加载。
00:00 - Start loading Dataset 1 00:10 - Start loading Dataset 2 00:11 - User asks for Dataset 1
在这种情况下,用户要求缓存中 的数据。但是如果我们序列化对缓存的访问,他将不得不等待另外9秒,因为缓存管理器(无论是什么)都没有意识到特定项目被要求,只是要求“某事”和“某事”正在进行中。
问题:
是否有任何.NET(4.0之前版本)的缓存库执行实现此类原子操作,正如人们对线程安全缓存所期望的那样?
或者,有一些方法可以扩展现有的“线程安全”缓存来支持这样的操作,没有序列化对缓存的访问(这会破坏使用线程的目的 - 安全实施首先)?我怀疑是否存在,但也许我只是厌倦了,忽略了一个明显的解决方法。
或者......还有什么我想念的吗?如果两个竞争线程碰巧同时第一次或在到期后同时请求相同的项目,那么让两个竞争线程相互流动只是标准做法吗?
答案 0 :(得分:6)
我知道你的痛苦,因为我是Dedoose的建筑师之一。我已经搞砸了许多缓存库,最终在经历了多次灾难之后构建了这个库。此缓存管理器的一个假设是,此类存储的所有集合都实现了一个接口,以便将Guid作为每个对象的“Id”属性。因为这是一个RIA,它包含了很多方法来添加/更新/删除这些集合中的项目。
这是我的CollectionCacheManager
public class CollectionCacheManager
{
private static readonly object _objLockPeek = new object();
private static readonly Dictionary<String, object> _htLocksByKey = new Dictionary<string, object>();
private static readonly Dictionary<String, CollectionCacheEntry> _htCollectionCache = new Dictionary<string, CollectionCacheEntry>();
private static DateTime _dtLastPurgeCheck;
public static List<T> FetchAndCache<T>(string sKey, Func<List<T>> fGetCollectionDelegate) where T : IUniqueIdActiveRecord
{
List<T> colItems = new List<T>();
lock (GetKeyLock(sKey))
{
if (_htCollectionCache.Keys.Contains(sKey) == true)
{
CollectionCacheEntry objCacheEntry = _htCollectionCache[sKey];
colItems = (List<T>) objCacheEntry.Collection;
objCacheEntry.LastAccess = DateTime.Now;
}
else
{
colItems = fGetCollectionDelegate();
SaveCollection<T>(sKey, colItems);
}
}
List<T> objReturnCollection = CloneCollection<T>(colItems);
return objReturnCollection;
}
public static List<Guid> FetchAndCache(string sKey, Func<List<Guid>> fGetCollectionDelegate)
{
List<Guid> colIds = new List<Guid>();
lock (GetKeyLock(sKey))
{
if (_htCollectionCache.Keys.Contains(sKey) == true)
{
CollectionCacheEntry objCacheEntry = _htCollectionCache[sKey];
colIds = (List<Guid>)objCacheEntry.Collection;
objCacheEntry.LastAccess = DateTime.Now;
}
else
{
colIds = fGetCollectionDelegate();
SaveCollection(sKey, colIds);
}
}
List<Guid> colReturnIds = CloneCollection(colIds);
return colReturnIds;
}
private static List<T> GetCollection<T>(string sKey) where T : IUniqueIdActiveRecord
{
List<T> objReturnCollection = null;
if (_htCollectionCache.Keys.Contains(sKey) == true)
{
CollectionCacheEntry objCacheEntry = null;
lock (GetKeyLock(sKey))
{
objCacheEntry = _htCollectionCache[sKey];
objCacheEntry.LastAccess = DateTime.Now;
}
if (objCacheEntry.Collection != null && objCacheEntry.Collection is List<T>)
{
objReturnCollection = CloneCollection<T>((List<T>)objCacheEntry.Collection);
}
}
return objReturnCollection;
}
public static void SaveCollection<T>(string sKey, List<T> colItems) where T : IUniqueIdActiveRecord
{
CollectionCacheEntry objCacheEntry = new CollectionCacheEntry();
objCacheEntry.Key = sKey;
objCacheEntry.CacheEntry = DateTime.Now;
objCacheEntry.LastAccess = DateTime.Now;
objCacheEntry.LastUpdate = DateTime.Now;
objCacheEntry.Collection = CloneCollection(colItems);
lock (GetKeyLock(sKey))
{
_htCollectionCache[sKey] = objCacheEntry;
}
}
public static void SaveCollection(string sKey, List<Guid> colIDs)
{
CollectionCacheEntry objCacheEntry = new CollectionCacheEntry();
objCacheEntry.Key = sKey;
objCacheEntry.CacheEntry = DateTime.Now;
objCacheEntry.LastAccess = DateTime.Now;
objCacheEntry.LastUpdate = DateTime.Now;
objCacheEntry.Collection = CloneCollection(colIDs);
lock (GetKeyLock(sKey))
{
_htCollectionCache[sKey] = objCacheEntry;
}
}
public static void UpdateCollection<T>(string sKey, List<T> colItems) where T : IUniqueIdActiveRecord
{
lock (GetKeyLock(sKey))
{
if (_htCollectionCache.ContainsKey(sKey) == true)
{
CollectionCacheEntry objCacheEntry = _htCollectionCache[sKey];
objCacheEntry.LastAccess = DateTime.Now;
objCacheEntry.LastUpdate = DateTime.Now;
objCacheEntry.Collection = new List<T>();
//Clone the collection before insertion to ensure it can't be touched
foreach (T objItem in colItems)
{
objCacheEntry.Collection.Add(objItem);
}
_htCollectionCache[sKey] = objCacheEntry;
}
else
{
SaveCollection<T>(sKey, colItems);
}
}
}
public static void UpdateItem<T>(string sKey, T objItem) where T : IUniqueIdActiveRecord
{
lock (GetKeyLock(sKey))
{
if (_htCollectionCache.ContainsKey(sKey) == true)
{
CollectionCacheEntry objCacheEntry = _htCollectionCache[sKey];
List<T> colItems = (List<T>)objCacheEntry.Collection;
colItems.RemoveAll(o => o.Id == objItem.Id);
colItems.Add(objItem);
objCacheEntry.Collection = colItems;
objCacheEntry.LastAccess = DateTime.Now;
objCacheEntry.LastUpdate = DateTime.Now;
}
}
}
public static void UpdateItems<T>(string sKey, List<T> colItemsToUpdate) where T : IUniqueIdActiveRecord
{
lock (GetKeyLock(sKey))
{
if (_htCollectionCache.ContainsKey(sKey) == true)
{
CollectionCacheEntry objCacheEntry = _htCollectionCache[sKey];
List<T> colCachedItems = (List<T>)objCacheEntry.Collection;
foreach (T objItem in colItemsToUpdate)
{
colCachedItems.RemoveAll(o => o.Id == objItem.Id);
colCachedItems.Add(objItem);
}
objCacheEntry.Collection = colCachedItems;
objCacheEntry.LastAccess = DateTime.Now;
objCacheEntry.LastUpdate = DateTime.Now;
}
}
}
public static void RemoveItemFromCollection<T>(string sKey, T objItem) where T : IUniqueIdActiveRecord
{
lock (GetKeyLock(sKey))
{
List<T> objCollection = GetCollection<T>(sKey);
if (objCollection != null && objCollection.Count(o => o.Id == objItem.Id) > 0)
{
objCollection.RemoveAll(o => o.Id == objItem.Id);
UpdateCollection<T>(sKey, objCollection);
}
}
}
public static void RemoveItemsFromCollection<T>(string sKey, List<T> colItemsToAdd) where T : IUniqueIdActiveRecord
{
lock (GetKeyLock(sKey))
{
Boolean bCollectionChanged = false;
List<T> objCollection = GetCollection<T>(sKey);
foreach (T objItem in colItemsToAdd)
{
if (objCollection != null && objCollection.Count(o => o.Id == objItem.Id) > 0)
{
objCollection.RemoveAll(o => o.Id == objItem.Id);
bCollectionChanged = true;
}
}
if (bCollectionChanged == true)
{
UpdateCollection<T>(sKey, objCollection);
}
}
}
public static void AddItemToCollection<T>(string sKey, T objItem) where T : IUniqueIdActiveRecord
{
lock (GetKeyLock(sKey))
{
List<T> objCollection = GetCollection<T>(sKey);
if (objCollection != null && objCollection.Count(o => o.Id == objItem.Id) == 0)
{
objCollection.Add(objItem);
UpdateCollection<T>(sKey, objCollection);
}
}
}
public static void AddItemsToCollection<T>(string sKey, List<T> colItemsToAdd) where T : IUniqueIdActiveRecord
{
lock (GetKeyLock(sKey))
{
List<T> objCollection = GetCollection<T>(sKey);
Boolean bCollectionChanged = false;
foreach (T objItem in colItemsToAdd)
{
if (objCollection != null && objCollection.Count(o => o.Id == objItem.Id) == 0)
{
objCollection.Add(objItem);
bCollectionChanged = true;
}
}
if (bCollectionChanged == true)
{
UpdateCollection<T>(sKey, objCollection);
}
}
}
public static void PurgeCollectionByMaxLastAccessInMinutes(int iMinutesSinceLastAccess)
{
DateTime dtThreshHold = DateTime.Now.AddMinutes(iMinutesSinceLastAccess * -1);
if (_dtLastPurgeCheck == null || dtThreshHold > _dtLastPurgeCheck)
{
lock (_objLockPeek)
{
CollectionCacheEntry objCacheEntry;
List<String> colKeysToRemove = new List<string>();
foreach (string sCollectionKey in _htCollectionCache.Keys)
{
objCacheEntry = _htCollectionCache[sCollectionKey];
if (objCacheEntry.LastAccess < dtThreshHold)
{
colKeysToRemove.Add(sCollectionKey);
}
}
foreach (String sKeyToRemove in colKeysToRemove)
{
_htCollectionCache.Remove(sKeyToRemove);
}
}
_dtLastPurgeCheck = DateTime.Now;
}
}
public static void ClearCollection(String sKey)
{
lock (GetKeyLock(sKey))
{
lock (_objLockPeek)
{
if (_htCollectionCache.ContainsKey(sKey) == true)
{
_htCollectionCache.Remove(sKey);
}
}
}
}
#region Helper Methods
private static object GetKeyLock(String sKey)
{
//Ensure even if hell freezes over this lock exists
if (_htLocksByKey.Keys.Contains(sKey) == false)
{
lock (_objLockPeek)
{
if (_htLocksByKey.Keys.Contains(sKey) == false)
{
_htLocksByKey[sKey] = new object();
}
}
}
return _htLocksByKey[sKey];
}
private static List<T> CloneCollection<T>(List<T> colItems) where T : IUniqueIdActiveRecord
{
List<T> objReturnCollection = new List<T>();
//Clone the list - NEVER return the internal cache list
if (colItems != null && colItems.Count > 0)
{
List<T> colCachedItems = (List<T>)colItems;
foreach (T objItem in colCachedItems)
{
objReturnCollection.Add(objItem);
}
}
return objReturnCollection;
}
private static List<Guid> CloneCollection(List<Guid> colIds)
{
List<Guid> colReturnIds = new List<Guid>();
//Clone the list - NEVER return the internal cache list
if (colIds != null && colIds.Count > 0)
{
List<Guid> colCachedItems = (List<Guid>)colIds;
foreach (Guid gId in colCachedItems)
{
colReturnIds.Add(gId);
}
}
return colReturnIds;
}
#endregion
#region Admin Functions
public static List<CollectionCacheEntry> GetAllCacheEntries()
{
return _htCollectionCache.Values.ToList();
}
public static void ClearEntireCache()
{
_htCollectionCache.Clear();
}
#endregion
}
public sealed class CollectionCacheEntry
{
public String Key;
public DateTime CacheEntry;
public DateTime LastUpdate;
public DateTime LastAccess;
public IList Collection;
}
以下是我如何使用它的示例:
public static class ResourceCacheController
{
#region Cached Methods
public static List<Resource> GetResourcesByProject(Guid gProjectId)
{
String sKey = GetCacheKeyProjectResources(gProjectId);
List<Resource> colItems = CollectionCacheManager.FetchAndCache<Resource>(sKey, delegate() { return ResourceAccess.GetResourcesByProject(gProjectId); });
return colItems;
}
#endregion
#region Cache Dependant Methods
public static int GetResourceCountByProject(Guid gProjectId)
{
return GetResourcesByProject(gProjectId).Count;
}
public static List<Resource> GetResourcesByIds(Guid gProjectId, List<Guid> colResourceIds)
{
if (colResourceIds == null || colResourceIds.Count == 0)
{
return null;
}
return GetResourcesByProject(gProjectId).FindAll(objRes => colResourceIds.Any(gId => objRes.Id == gId)).ToList();
}
public static Resource GetResourceById(Guid gProjectId, Guid gResourceId)
{
return GetResourcesByProject(gProjectId).SingleOrDefault(o => o.Id == gResourceId);
}
#endregion
#region Cache Keys and Clear
public static void ClearCacheProjectResources(Guid gProjectId)
{ CollectionCacheManager.ClearCollection(GetCacheKeyProjectResources(gProjectId));
}
public static string GetCacheKeyProjectResources(Guid gProjectId)
{
return string.Concat("ResourceCacheController.ProjectResources.", gProjectId.ToString());
}
#endregion
internal static void ProcessDeleteResource(Guid gProjectId, Guid gResourceId)
{
Resource objRes = GetResourceById(gProjectId, gResourceId);
if (objRes != null)
{ CollectionCacheManager.RemoveItemFromCollection(GetCacheKeyProjectResources(gProjectId), objRes);
}
}
internal static void ProcessUpdateResource(Resource objResource)
{
CollectionCacheManager.UpdateItem(GetCacheKeyProjectResources(objResource.Id), objResource);
}
internal static void ProcessAddResource(Guid gProjectId, Resource objResource)
{
CollectionCacheManager.AddItemToCollection(GetCacheKeyProjectResources(gProjectId), objResource);
}
}
以下是相关界面:
public interface IUniqueIdActiveRecord
{
Guid Id { get; set; }
}
希望这会有所帮助,我已经经历过几次地狱并最终回到这里作为解决方案,对我们来说这是天赐之物,但我不能保证它是完美的,只有我们还没有找到一个问题。
答案 1 :(得分:3)
看起来.NET 4.0并发集合利用在切换上下文之前旋转的新同步原语,以防快速释放资源。所以他们仍然以更机会主义的方式锁定。如果您认为数据检索逻辑比时间片短,那么这似乎是非常有益的。但你提到网络,这让我觉得这不适用。
我会等到你有一个简单的同步解决方案,并在假设你遇到与并发相关的性能问题之前测量性能和行为。
如果您真的担心缓存争用,可以利用现有的缓存基础架构并将其逻辑分区到区域中。然后独立地同步对每个区域的访问。
示例策略如果您的数据集包含键入数字ID的项目,并且您希望将缓存划分为10个区域,则可以(修改10)ID来确定它们所在的区域。您将保持一个10个对象的数组来锁定。所有代码都可以为可变数量的区域编写,可以通过配置设置,也可以在应用启动时确定,具体取决于您预测/打算缓存的项目总数。
如果您的缓存命中以异常方式键入,则必须提供一些自定义启发式来对缓存进行分区。
更新(每条评论):
这很有趣。我认为以下是关于细粒度的锁定,你可以希望没有完全疯狂(或维护/同步每个缓存键的锁字典)。我没有测试它,所以可能存在错误,但应该说明这个想法。跟踪请求的ID列表,然后使用它来决定是否需要自己获取项目,或者只需要等待先前的请求完成。等待(和缓存插入)与使用Wait
和PulseAll
的严格范围的线程阻塞和信令同步。对请求的ID列表的访问与范围紧密的ReaderWriterLockSlim
。
这是一个只读缓存。如果您正在创建/更新/删除,则必须确保在收到requestedIds
之后删除ID(在致电Monitor.PulseAll(_cache)
之前,您需要添加另一个try..finally
获取_requestedIdsLock
写锁定。此外,通过创建/更新/删除,管理缓存的最简单方法是仅在基础创建/更新/删除操作成功的情况下从_cache
中删除现有项目。
(糟糕,请参阅下面的更新2 。)
public class Item
{
public int ID { get; set; }
}
public class AsyncCache
{
protected static readonly Dictionary<int, Item> _externalDataStoreProxy = new Dictionary<int, Item>();
protected static readonly Dictionary<int, Item> _cache = new Dictionary<int, Item>();
protected static readonly HashSet<int> _requestedIds = new HashSet<int>();
protected static readonly ReaderWriterLockSlim _requestedIdsLock = new ReaderWriterLockSlim();
public Item Get(int id)
{
// if item does not exist in cache
if (!_cache.ContainsKey(id))
{
_requestedIdsLock.EnterUpgradeableReadLock();
try
{
// if item was already requested by another thread
if (_requestedIds.Contains(id))
{
_requestedIdsLock.ExitUpgradeableReadLock();
lock (_cache)
{
while (!_cache.ContainsKey(id))
Monitor.Wait(_cache);
// once we get here, _cache has our item
}
}
// else, item has not yet been requested by a thread
else
{
_requestedIdsLock.EnterWriteLock();
try
{
// record the current request
_requestedIds.Add(id);
_requestedIdsLock.ExitWriteLock();
_requestedIdsLock.ExitUpgradeableReadLock();
// get the data from the external resource
#region fake implementation - replace with real code
var item = _externalDataStoreProxy[id];
Thread.Sleep(10000);
#endregion
lock (_cache)
{
_cache.Add(id, item);
Monitor.PulseAll(_cache);
}
}
finally
{
// let go of any held locks
if (_requestedIdsLock.IsWriteLockHeld)
_requestedIdsLock.ExitWriteLock();
}
}
}
finally
{
// let go of any held locks
if (_requestedIdsLock.IsUpgradeableReadLockHeld)
_requestedIdsLock.ExitReadLock();
}
}
return _cache[id];
}
public Collection<Item> Get(Collection<int> ids)
{
var notInCache = ids.Except(_cache.Keys);
// if some items don't exist in cache
if (notInCache.Count() > 0)
{
_requestedIdsLock.EnterUpgradeableReadLock();
try
{
var needToGet = notInCache.Except(_requestedIds);
// if any items have not yet been requested by other threads
if (needToGet.Count() > 0)
{
_requestedIdsLock.EnterWriteLock();
try
{
// record the current request
foreach (var id in ids)
_requestedIds.Add(id);
_requestedIdsLock.ExitWriteLock();
_requestedIdsLock.ExitUpgradeableReadLock();
// get the data from the external resource
#region fake implementation - replace with real code
var data = new Collection<Item>();
foreach (var id in needToGet)
{
var item = _externalDataStoreProxy[id];
data.Add(item);
}
Thread.Sleep(10000);
#endregion
lock (_cache)
{
foreach (var item in data)
_cache.Add(item.ID, item);
Monitor.PulseAll(_cache);
}
}
finally
{
// let go of any held locks
if (_requestedIdsLock.IsWriteLockHeld)
_requestedIdsLock.ExitWriteLock();
}
}
if (requestedIdsLock.IsUpgradeableReadLockHeld)
_requestedIdsLock.ExitUpgradeableReadLock();
var waitingFor = notInCache.Except(needToGet);
// if any remaining items were already requested by other threads
if (waitingFor.Count() > 0)
{
lock (_cache)
{
while (waitingFor.Count() > 0)
{
Monitor.Wait(_cache);
waitingFor = waitingFor.Except(_cache.Keys);
}
// once we get here, _cache has all our items
}
}
}
finally
{
// let go of any held locks
if (_requestedIdsLock.IsUpgradeableReadLockHeld)
_requestedIdsLock.ExitReadLock();
}
}
return new Collection<Item>(ids.Select(id => _cache[id]).ToList());
}
}
更新2 :
我误解了UpgradeableReadLock的行为......一次只有一个线程可以容纳UpgradeableReadLock。所以上面的内容应该重构为最初只抓取Read锁,并在将项添加到_requestedIds
时完全放弃它们并获得完整的Write锁。
答案 2 :(得分:2)
我实现了一个名为MemoryCacheT的简单库。它位于GitHub和NuGet上。它基本上将项目存储在ConcurrentDictionary中,您可以在添加项目时指定过期策略。欢迎任何反馈,评论和建议。
答案 3 :(得分:0)
最后提出了一个可行的解决方案,感谢评论中的一些对话。我所做的是创建一个包装器,它是一个部分实现的抽象基类,它使用任何标准缓存库作为后备缓存(只需要实现Contains
,Get
,Put
和Remove
方法)。目前我正在使用EntLib缓存应用程序块,并且需要一段时间才能启动并运行,因为该库的某些方面是......好吧......不是那么经过深思熟虑。
无论如何,总代码现在接近1k行,所以我不会在这里发布整个内容,但基本的想法是:
拦截对Get
,Put/Add
和Remove
方法的所有来电。
除了ManualResetEvent
属性之外,添加一个包含Value
的“条目”项目,而不是添加原始项目。根据今天在早期问题上给我的一些建议,该条目实现了倒计时锁存器,每当获取条目时递增,并且每当释放时递减。加载程序和所有将来的查找都参与倒计时锁存,因此当计数器达到零时,数据保证可用,并且ManualResetEvent
被销毁以节省资源。
当条目必须延迟加载时,将创建条目并立即将其添加到后备缓存中,事件处于未签名状态。对新GetOrAdd
方法或截获的Get
方法的后续调用将找到此条目,并等待事件(如果事件存在)或立即返回关联值(如果事件不是存在)。
Put
方法添加一个没有事件的条目;这些看起来与已经完成延迟加载的条目相同。
由于GetOrAdd
仍然实现Get
后跟可选Put
,因此此方法与Put
和{{1}同步(序列化)方法,但仅添加不完整的条目,而不是延迟加载的整个持续时间。 Remove
方法不序列化;实际上,整个界面就像一个自动读写器锁。
这仍然是一项正在进行中的工作,但我已经通过十几个单元测试进行了测试,而且似乎正在坚持下去。它对于问题中描述的两种情况都表现正确。换句话说:
对密钥 X (由Get
模拟)的长时间运行延迟加载(GetOrAdd
)的调用需要10秒,然后是另一个{ {1}}对于9秒后的另一个线程上的相同键 X ,导致两个线程同时接收到正确的数据(距离T 0 10秒) )。载荷不重复。
立即加载密钥 X 的值,然后为密钥 Y 启动长时间运行的延迟加载,然后请求密钥 X 在另一个线程上(在 Y 完成之前),立即返回 X 的值。阻止呼叫与相关密钥隔离。
它还提供了我认为当你开始延迟加载然后立即从缓存中删除密钥时最直观的结果;最初请求该值的线程将获得实际值,但在删除后随时请求相同键的任何其他线程将不会返回任何内容(Thread.Sleep
)并立即返回。
总而言之,我对它很满意。我仍然希望有一个图书馆为我做了这个,但我想,如果你想做正确的事......好吧,你知道。