.net multithreading caching atomic

Bibliotecas de caché Thread-safe para.NET



multithreading caching (4)

Conozco tu dolor ya que soy uno de los Arquitectos de Dedoose . He estado jugando con muchas bibliotecas de almacenamiento en caché y terminé construyendo esta después de mucha tribulación. La única suposición para este Administrador de caché es que todas las colecciones almacenadas por esta clase implementan una interfaz para obtener un Guid como una propiedad "Id" en cada objeto. Siendo que esto es para un RIA, incluye una gran cantidad de métodos para agregar / actualizar / eliminar elementos de estas colecciones.

Aquí está mi 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; }

Aquí hay un ejemplo de cómo lo uso:

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

Aquí está la interfaz en cuestión:

public interface IUniqueIdActiveRecord { Guid Id { get; set; } }

Espero que esto ayude, he pasado por infierno y retrocedido un par de veces para finalmente llegar a esto como la solución, y para nosotros ha sido un regalo del cielo, pero no puedo garantizar que sea perfecto, solo que no hemos encontrado un problema todavía .

Fondo:

Mantengo varias aplicaciones de Winforms y bibliotecas de clases que pueden o ya se benefician del almacenamiento en caché. También conozco el bloque de aplicación de almacenamiento en caché y el espacio de nombres System.Web.Caching (que, por lo que he recopilado, está perfectamente bien para usar fuera de ASP.NET).

Descubrí que, aunque las dos clases anteriores son técnicamente "seguras para subprocesos" en el sentido de que los métodos individuales están sincronizados, en realidad no parecen estar diseñados especialmente bien para escenarios de múltiples subprocesos. Específicamente, no implementan un método GetOrAdd similar al de la nueva clase ConcurrentDictionary en .NET 4.0.

Considero que este método es primitivo para la funcionalidad de almacenamiento en caché / búsqueda, y obviamente los diseñadores del Framework también se dieron cuenta de esto; es por eso que los métodos existen en las colecciones concurrentes. Sin embargo, aparte del hecho de que todavía no estoy usando .NET 4.0 en aplicaciones de producción, un diccionario no es un caché completo: no tiene características como caducidad, almacenamiento persistente / distribuido, etc.

Por qué esto es importante:

Un diseño bastante típico en una aplicación de "cliente enriquecido" (o incluso algunas aplicaciones web) es comenzar a precargar un caché tan pronto como se inicie la aplicación, bloqueando si el cliente solicita datos que aún no se han cargado (posteriormente guardándolo en caché para el futuro utilizar). Si el usuario está trabajando en su flujo de trabajo rápidamente, o si la conexión de red es lenta, no es inusual que el cliente compita con el preloader, y realmente no tiene mucho sentido pedir los mismos datos dos veces. , especialmente si la solicitud es relativamente costosa.

Así que parece que me quedan algunas opciones igualmente pésimas:

  • No intente hacer la operación atómica en absoluto, y arriesgue que los datos se carguen dos veces (y posiblemente tengan dos hilos diferentes operando en diferentes copias);

  • Serializar el acceso al caché, lo que significa bloquear todo el caché solo para cargar un solo elemento ;

  • Comience a reinventar la rueda solo para obtener algunos métodos adicionales.

Aclaración: Ejemplo de línea de tiempo

Digamos que cuando se inicia una aplicación, necesita cargar 3 conjuntos de datos que cada uno tarda 10 segundos en cargar. Considere las siguientes dos líneas de tiempo:

00:00 - Start loading Dataset 1 00:10 - Start loading Dataset 2 00:19 - User asks for Dataset 2

En el caso anterior, si no usamos ningún tipo de sincronización, el usuario tiene que esperar 10 segundos completos para los datos que estarán disponibles en 1 segundo, porque el código verá que el elemento aún no se ha cargado en el caché. e intenta volver a cargarlo

00:00 - Start loading Dataset 1 00:10 - Start loading Dataset 2 00:11 - User asks for Dataset 1

En este caso, el usuario está solicitando datos que ya están en la caché. Pero si serializamos el acceso a la memoria caché, tendrá que esperar otros 9 segundos sin ningún motivo, porque el administrador de la memoria caché (sea lo que sea) no tiene conocimiento del elemento específico que se solicita, solo que ese "algo" es siendo solicitado y "algo" está en progreso.

La pregunta:

¿Hay alguna biblioteca de almacenamiento en caché para .NET (pre-4.0) que implemente tales operaciones atómicas, como podría esperarse de un caché seguro para subprocesos?

O, como alternativa, ¿hay algún medio para extender un caché "thread-safe" existente para admitir tales operaciones, sin serializar el acceso al caché (lo que en primer lugar frustraría el propósito de usar una implementación segura para subprocesos)? Dudo que exista, pero tal vez estoy cansado e ignorando una solución obvia.

O ... ¿hay algo más que me pierdo? ¿Es una práctica estándar dejar que dos hilos de la competencia se peguen entre sí si, por casualidad, ambos solicitan el mismo artículo, al mismo tiempo, por primera vez o después de un vencimiento?


Finalmente se llegó a una solución viable para esto, gracias a un poco de diálogo en los comentarios. Lo que hice fue crear un contenedor, que es una clase base abstracta parcialmente implementada que usa cualquier biblioteca de caché estándar como la memoria caché de respaldo (solo necesita implementar los métodos Contains , Get , Put y Remove ). Por el momento estoy usando el Bloque de aplicación de caché EntLib para eso, y me tomó un tiempo ponerlo en marcha porque algunos aspectos de esa biblioteca son ... bueno ... no muy bien pensados.

De todos modos, el código total ahora está cerca de 1k líneas, así que no voy a publicar todo aquí, pero la idea básica es:

  1. Interceptar todas las llamadas a los métodos Get , Put/Add y Remove .

  2. En lugar de agregar el elemento original, agregue un elemento de "entrada" que contenga un ManualResetEvent además de una propiedad Value . Según algunos consejos que me han dado en una pregunta anterior hoy, la entrada implementa un bloqueo de cuenta atrás, que se incrementa cada vez que la entrada se adquiere y disminuye cada vez que se lanza. Tanto el cargador como todas las búsquedas futuras participan en el bloqueo de cuenta atrás, por lo que cuando el contador llega a cero, se garantiza que los datos estarán disponibles y ManualResetEvent se destruye para conservar los recursos.

  3. Cuando una entrada tiene que cargarse de forma diferida, la entrada se crea y agrega al caché de respaldo de inmediato, con el evento en un estado no asignado. Las llamadas posteriores al nuevo método GetOrAdd o a los métodos Get recibidos encontrarán esta entrada, y esperarán en el evento (si el evento existe) o devolverán el valor asociado inmediatamente (si el evento no existe).

  4. El método Put agrega una entrada sin evento; estos tienen el mismo aspecto que las entradas para las cuales ya se completó la carga lenta.

  5. Debido a que GetOrAdd aún implementa un Get seguido de un Put opcional, este método se sincroniza (serializando) con los métodos Put y Remove , pero solo para agregar la entrada incompleta, no durante toda la duración de la carga diferida. Los métodos Get no están serializados; efectivamente, toda la interfaz funciona como un bloqueo automático de lector-escritor.

Todavía es un trabajo en progreso, pero lo he pasado por una docena de pruebas unitarias y parece estar resistiendo. Se comporta correctamente para los dos escenarios descritos en la pregunta. En otras palabras:

  • Una llamada a la carga GetOrAdd larga duración ( GetOrAdd ) para la clave X (simulada por Thread.Sleep ) que tarda 10 segundos, seguida de otra GetOrAdd para la misma clave X en una GetOrAdd diferente exactamente 9 segundos después, da como resultado que ambos hilos reciban los datos correctos al mismo tiempo (10 segundos desde T 0 ). Las cargas no están duplicadas.

  • Inmediatamente se carga un valor para la clave X , luego se inicia una carga lenta de larga ejecución para la clave Y , y luego se solicita la clave X sobre otra cadena (antes de que Y finalice), inmediatamente se devuelve el valor de X. Las llamadas de bloqueo están aisladas a la clave relevante.

También da lo que creo que es el resultado más intuitivo para cuando comienzas una carga diferida y luego inmediatamente quitas la clave del caché; el subproceso que originalmente solicitó el valor obtendrá el valor real, pero cualquier otro subproceso que solicite la misma clave en cualquier momento después de la eliminación no obtendrá nada atrás ( null ) y devolverá inmediatamente.

En general estoy bastante feliz con eso. Todavía me gustaría que hubiera una biblioteca que hiciera esto por mí, pero supongo que si quieres hacer algo bien ... bueno, ya sabes.


Implementé una biblioteca simple llamada MemoryCacheT. Está en GitHub y NuGet . Básicamente almacena elementos en un ConcurrentDictionary y puede especificar la estrategia de caducidad al agregar artículos. Cualquier comentario, revisión, sugerencia es bienvenido.


Parece que las colecciones simultáneas de .NET 4.0 utilizan nuevas primitivas de sincronización que giran antes de cambiar de contexto, en caso de que un recurso se libere rápidamente. Entonces todavía están bloqueando, solo de una manera más oportunista. Si crees que la lógica de recuperación de datos es más corta que el ciclo de tiempo, parece que esto sería muy beneficioso. Pero mencionó la red, lo que me hace pensar que esto no se aplica.

Esperaría hasta que tuviese una solución simple y sincronizada y mediría el desempeño y el comportamiento antes de asumir que tendrá problemas de rendimiento relacionados con la concurrencia.

Si realmente le preocupa la contención del caché, puede utilizar una infraestructura de caché existente y dividirla de manera lógica en regiones. Luego, sincronice el acceso a cada región de forma independiente.

Una estrategia de ejemplo si su conjunto de datos consiste en elementos que están codificados en ID numéricos, y desea dividir su caché en 10 regiones, puede (mod 10) la ID para determinar en qué región se encuentran. Usted mantendría una matriz de 10 objetos para enganchar. Todo el código se puede escribir para un número variable de regiones, que se pueden configurar a través de la configuración o determinadas al inicio de la aplicación, dependiendo de la cantidad total de elementos que prediga / tenga la intención de almacenar en caché.

Si los accesos directos a la caché están codificados de forma anormal, deberá crear una heurística personalizada para particionar el caché.

Actualización (por comentario): Bueno, esto ha sido divertido. Creo que lo siguiente es un bloqueo tan preciso como puede esperar sin volverse totalmente loco (o mantener / sincronizar un diccionario de bloqueos para cada clave de caché). No lo he probado, por lo que probablemente haya errores, pero la idea debería ilustrarse. Haga un seguimiento de una lista de ID solicitados, y luego use eso para decidir si necesita obtener el artículo usted mismo, o si simplemente necesita esperar que termine una solicitud anterior. La espera (y la inserción de la memoria caché) se sincroniza con el bloqueo y la señalización de subprocesos de PulseAll mediante Wait y PulseAll . El acceso a la lista de ID solicitada se sincroniza con un ReaderWriterLockSlim alcance.

Esta es una caché de solo lectura. Si realiza creaciones / actualizaciones / eliminaciones, deberá asegurarse de eliminar las ID de requestedIds una vez que las haya recibido (antes de la llamada a Monitor.PulseAll(_cache) , querrá agregar otra try..finally y adquirir el _requestedIdsLock write-lock). Además, con crea / actualiza / elimina, la forma más fácil de administrar la memoria caché sería simplemente eliminar el elemento existente de _cache si / cuando la operación de creación / actualización / eliminación subyacente tiene éxito.

(Vaya, consulte la actualización 2 a continuación).

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

Actualización 2 :

No entendí el comportamiento de UpgradeableReadLock ... solo un hilo a la vez puede contener un ActualizableReadLock. Por lo tanto, lo anterior se debe refactorizar para que solo capture los bloqueos de lectura inicialmente, y para que los abandone por completo y adquiera un bloqueo de escritura completo cuando agregue elementos a _requestedIds .