c# - posibles - patrones de bloqueo faciles
PatrĂ³n de bloqueo para el uso adecuado de.NET MemoryCache (5)
Supongo que este código tiene problemas de concurrencia:
const string CacheKey = "CacheKey";
static string GetCachedData()
{
string expensiveString =null;
if (MemoryCache.Default.Contains(CacheKey))
{
expensiveString = MemoryCache.Default[CacheKey] as string;
}
else
{
CacheItemPolicy cip = new CacheItemPolicy()
{
AbsoluteExpiration = new DateTimeOffset(DateTime.Now.AddMinutes(20))
};
expensiveString = SomeHeavyAndExpensiveCalculation();
MemoryCache.Default.Set(CacheKey, expensiveString, cip);
}
return expensiveString;
}
El motivo del problema de concurrencia es que varios subprocesos pueden obtener una clave nula y luego intentar insertar datos en la memoria caché.
¿Cuál sería la forma más corta y limpia de hacer que este código sea a prueba de concurrencia? Me gusta seguir un buen patrón en mi código relacionado con la caché. Un enlace a un artículo en línea sería de gran ayuda.
ACTUALIZAR:
Se me ocurrió este código basado en la respuesta de @Scott Chamberlain. ¿Alguien puede encontrar algún problema de rendimiento o concurrencia con esto? Si esto funciona, salvaría muchas líneas de código y errores.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Runtime.Caching;
namespace CachePoc
{
class Program
{
static object everoneUseThisLockObject4CacheXYZ = new object();
const string CacheXYZ = "CacheXYZ";
static object everoneUseThisLockObject4CacheABC = new object();
const string CacheABC = "CacheABC";
static void Main(string[] args)
{
string xyzData = MemoryCacheHelper.GetCachedData<string>(CacheXYZ, everoneUseThisLockObject4CacheXYZ, 20, SomeHeavyAndExpensiveXYZCalculation);
string abcData = MemoryCacheHelper.GetCachedData<string>(CacheABC, everoneUseThisLockObject4CacheXYZ, 20, SomeHeavyAndExpensiveXYZCalculation);
}
private static string SomeHeavyAndExpensiveXYZCalculation() {return "Expensive";}
private static string SomeHeavyAndExpensiveABCCalculation() {return "Expensive";}
public static class MemoryCacheHelper
{
public static T GetCachedData<T>(string cacheKey, object cacheLock, int cacheTimePolicyMinutes, Func<T> GetData)
where T : class
{
//Returns null if the string does not exist, prevents a race condition where the cache invalidates between the contains check and the retreival.
T cachedData = MemoryCache.Default.Get(cacheKey, null) as T;
if (cachedData != null)
{
return cachedData;
}
lock (cacheLock)
{
//Check to see if anyone wrote to the cache while we where waiting our turn to write the new value.
cachedData = MemoryCache.Default.Get(cacheKey, null) as T;
if (cachedData != null)
{
return cachedData;
}
//The value still did not exist so we now write it in to the cache.
CacheItemPolicy cip = new CacheItemPolicy()
{
AbsoluteExpiration = new DateTimeOffset(DateTime.Now.AddMinutes(cacheTimePolicyMinutes))
};
cachedData = GetData();
MemoryCache.Default.Set(cacheKey, cachedData, cip);
return cachedData;
}
}
}
}
}
Supongo que este código tiene problemas de concurrencia:
En realidad, es muy posible, aunque con una posible mejora.
Ahora, en general, el patrón donde tenemos múltiples hilos que establecen un valor compartido en el primer uso, para no bloquear el valor que se obtiene y establece, puede ser:
- Desastroso: otro código supondrá que solo existe una instancia.
- Desastroso: el código que obtiene la instancia no solo puede tolerar una operación concurrente (o tal vez un número pequeño).
- Desastroso: los medios de almacenamiento no son seguros para subprocesos (p. Ej., Tener dos subprocesos añadidos a un diccionario y obtener todo tipo de errores desagradables).
- Subóptimo: el rendimiento general es peor que si el bloqueo hubiera asegurado que solo un hilo hizo el trabajo de obtener el valor.
- Óptimo: el costo de tener múltiples hilos para hacer el trabajo redundante es menor que el costo de prevenirlo, especialmente dado que eso solo puede ocurrir durante un período relativamente breve.
Sin embargo, considerando aquí que MemoryCache
puede desalojar las entradas, entonces:
- Si es desastroso tener más de una instancia,
MemoryCache
es el enfoque equivocado. - Si debe evitar la creación simultánea, debe hacerlo en el momento de la creación.
-
MemoryCache
es seguro para subprocesos en términos de acceso a ese objeto, por lo que no es una preocupación aquí.
Ambas posibilidades deben tenerse en cuenta, por supuesto, aunque la única vez que haya dos instancias de la misma cadena existente puede ser un problema si se realizan optimizaciones muy particulares que no se aplican aquí *.
Entonces, nos quedan las posibilidades:
- Es más barato evitar el costo de llamadas duplicadas a
SomeHeavyAndExpensiveCalculation()
. - Es más barato no evitar el costo de las llamadas duplicadas a
SomeHeavyAndExpensiveCalculation()
.
Y trabajar en eso puede ser difícil (de hecho, el tipo de cosas que vale la pena perfilar en lugar de asumir que puede resolverlo). Sin embargo, vale la pena considerar aquí que las formas más obvias de bloquear el inserto evitarán todas las adiciones al caché, incluidas las que no están relacionadas.
Esto significa que si tuviéramos 50 hilos tratando de establecer 50 valores diferentes, entonces tendremos que hacer que los 50 hilos se esperen el uno al otro, incluso aunque ni siquiera hicieran el mismo cálculo.
Como tal, probablemente estés mejor con el código que tienes, que con un código que evite la condición de carrera, y si la condición de carrera es un problema, es muy probable que necesites manejarlo en otro lugar o necesitar un diferente estrategia de almacenamiento en caché que una que expulsa entradas antiguas †.
Lo único que cambiaría es que reemplazaría la llamada a Set()
por una a AddOrGetExisting()
. De lo anterior, debe quedar claro que probablemente no sea necesario, pero permitirá que se recopile el elemento recién obtenido, reduciendo el uso general de memoria y permitiendo una mayor proporción de colecciones de baja generación a colecciones de alta generación.
Así que sí, podría usar el doble bloqueo para evitar la concurrencia, pero o la concurrencia no es realmente un problema, o almacenar los valores de la manera incorrecta, o el doble bloqueo en la tienda no sería la mejor manera de resolverlo .
* Si conoce solo uno de cada conjunto de cadenas, puede optimizar las comparaciones de igualdad, que es la única vez que tener dos copias de una cadena puede ser incorrecta en lugar de simplemente no ser óptima, pero querría estar haciendo muy diferentes tipos de almacenamiento en caché para que tenga sentido. Por ejemplo, el tipo de XmlReader
hace internamente.
† Muy probablemente sea uno que se almacene indefinidamente, o uno que haga uso de referencias débiles, por lo que solo expulsará las entradas si no hay usos existentes.
Esta es mi segunda iteración del código. Como MemoryCache
es seguro para subprocesos, no necesita bloquear la lectura inicial, solo puede leer y si la caché devuelve un valor nulo, realice la comprobación de bloqueo para ver si necesita crear la cadena. Simplifica enormemente el código.
const string CacheKey = "CacheKey";
static readonly object cacheLock = new object();
private static string GetCachedData()
{
//Returns null if the string does not exist, prevents a race condition where the cache invalidates between the contains check and the retreival.
var cachedString = MemoryCache.Default.Get(CacheKey, null) as string;
if (cachedString != null)
{
return cachedString;
}
lock (cacheLock)
{
//Check to see if anyone wrote to the cache while we where waiting our turn to write the new value.
cachedString = MemoryCache.Default.Get(CacheKey, null) as string;
if (cachedString != null)
{
return cachedString;
}
//The value still did not exist so we now write it in to the cache.
var expensiveString = SomeHeavyAndExpensiveCalculation();
CacheItemPolicy cip = new CacheItemPolicy()
{
AbsoluteExpiration = new DateTimeOffset(DateTime.Now.AddMinutes(20))
};
MemoryCache.Default.Set(CacheKey, expensiveString, cip);
return expensiveString;
}
}
EDITAR : El código a continuación no es necesario, pero quería dejarlo para mostrar el método original. Puede ser útil para futuros visitantes que estén usando una colección diferente que tenga lecturas seguras de subprocesos pero escrituras que no sean seguras para subprocesos (casi todas las clases en el espacio de nombres System.Collections
son así).
Aquí es cómo lo haría usando ReaderWriterLockSlim
para proteger el acceso. Debe hacer una especie de " Bloqueo doble controlado " para ver si alguien más creó el elemento almacenado en la memoria caché mientras esperábamos para tomar el bloqueo.
const string CacheKey = "CacheKey";
static readonly ReaderWriterLockSlim cacheLock = new ReaderWriterLockSlim();
static string GetCachedData()
{
//First we do a read lock to see if it already exists, this allows multiple readers at the same time.
cacheLock.EnterReadLock();
try
{
//Returns null if the string does not exist, prevents a race condition where the cache invalidates between the contains check and the retreival.
var cachedString = MemoryCache.Default.Get(CacheKey, null) as string;
if (cachedString != null)
{
return cachedString;
}
}
finally
{
cacheLock.ExitReadLock();
}
//Only one UpgradeableReadLock can exist at one time, but it can co-exist with many ReadLocks
cacheLock.EnterUpgradeableReadLock();
try
{
//We need to check again to see if the string was created while we where waiting to enter the EnterUpgradeableReadLock
var cachedString = MemoryCache.Default.Get(CacheKey, null) as string;
if (cachedString != null)
{
return cachedString;
}
//The entry still does not exist so we need to create it and enter the write lock
var expensiveString = SomeHeavyAndExpensiveCalculation();
cacheLock.EnterWriteLock(); //This will block till all the Readers flush.
try
{
CacheItemPolicy cip = new CacheItemPolicy()
{
AbsoluteExpiration = new DateTimeOffset(DateTime.Now.AddMinutes(20))
};
MemoryCache.Default.Set(CacheKey, expensiveString, cip);
return expensiveString;
}
finally
{
cacheLock.ExitWriteLock();
}
}
finally
{
cacheLock.ExitUpgradeableReadLock();
}
}
Hay una biblioteca de código abierto [descargo de responsabilidad: que escribí]: LazyCache que IMO cubre su requisito con dos líneas de código:
IAppCache cache = new CachingService();
var cachedResults = cache.GetOrAdd("CacheKey",
() => SomeHeavyAndExpensiveCalculation());
Se ha incorporado el bloqueo por defecto, por lo que el método de caché solo se ejecutará una vez por falta de caché, y utiliza una lambda para que pueda hacer "obtener o agregar" de una vez. Su valor predeterminado es 20 minutos de vencimiento deslizante.
Incluso hay un paquete NuGet ;)
He resuelto este problema haciendo uso del método AddOrGetExisting en MemoryCache y el uso de la inicialización Lazy .
Esencialmente, mi código se ve así:
static string GetCachedData(string key, DateTimeOffset offset)
{
Lazy<String> lazyObject = new Lazy<String>(() => SomeHeavyAndExpensiveCalculationThatReturnsAString());
var returnedLazyObject = MemoryCache.Default.AddOrGetExisting(key, lazyObject, offset);
if (returnedLazyObject == null)
return lazyObject.Value;
return ((Lazy<String>) returnedLazyObject).Value;
}
El peor escenario aquí es que crea el mismo objeto Lazy
dos veces. Pero eso es bastante trivial. El uso de AddOrGetExisting
garantiza que solo obtendrá una instancia del objeto Lazy
, por lo que también se garantiza que solo llamará al costoso método de inicialización una vez.
Ejemplo de consola de MemoryCache , "Cómo guardar / obtener objetos de clase simples"
Salida después de iniciar y presionar Cualquier tecla excepto Esc :
Guardando en el caché!
Obteniendo del caché!
Some1
Some2
class Some
{
public String text { get; set; }
public Some(String text)
{
this.text = text;
}
public override string ToString()
{
return text;
}
}
public static MemoryCache cache = new MemoryCache("cache");
public static string cache_name = "mycache";
static void Main(string[] args)
{
Some some1 = new Some("some1");
Some some2 = new Some("some2");
List<Some> list = new List<Some>();
list.Add(some1);
list.Add(some2);
do {
if (cache.Contains(cache_name))
{
Console.WriteLine("Getting from cache!");
List<Some> list_c = cache.Get(cache_name) as List<Some>;
foreach (Some s in list_c) Console.WriteLine(s);
}
else
{
Console.WriteLine("Saving to cache!");
cache.Set(cache_name, list, DateTime.Now.AddMinutes(10));
}
} while (Console.ReadKey(true).Key != ConsoleKey.Escape);
}