Implementaciones de C # en C # .NET

Hola Habr! En previsión del inicio del curso "C # ASP.NET Core Developer" , preparamos una traducción de material interesante sobre la implementación de la memoria caché en C #. Disfruta leyendo.



Uno de los patrones más utilizados en el desarrollo de software es el almacenamiento en caché . Es un concepto simple y, al mismo tiempo, muy efectivo. La idea es reutilizar los resultados de las operaciones realizadas. Después de una operación que lleva mucho tiempo, guardamos el resultado en nuestro contenedor de caché . La próxima vez que necesitemos este resultado, lo extraeremos del contenedor de caché, en lugar de tener que realizar una operación laboriosa nuevamente.

Por ejemplo, para obtener un avatar de usuario, es posible que deba solicitarlo desde la base de datos. En lugar de ejecutar la solicitud con cada llamada, almacenaremos este avatar en el caché, extrayéndolo de la memoria cada vez que lo necesite.

El almacenamiento en caché es excelente para los datos que cambian con poca frecuencia. O, idealmente, nunca cambian. Los datos que cambian constantemente, por ejemplo, la hora actual, no deben almacenarse en caché, de lo contrario corre el riesgo de obtener resultados incorrectos.

Caché local, caché local persistente y caché distribuida


Hay 3 tipos de cachés:

  • La memoria caché en memoria se usa para casos en los que solo necesita implementar la memoria caché en un proceso. Cuando un proceso muere, el caché muere con él. Si está ejecutando el mismo proceso en varios servidores, tendrá un caché separado para cada servidor.
  • Caché en proceso persistente : esto es cuando realiza una copia de seguridad del caché fuera de la memoria del proceso. Se puede ubicar en un archivo o en una base de datos. Es más complejo que el caché en la memoria, pero si el proceso se reinicia, el caché no se vacía. Se adapta mejor a los casos en los que obtener un elemento almacenado en caché es costoso y su proceso tiende a reiniciarse con frecuencia.
  • La caché distribuida es cuando necesita una caché compartida para varias máquinas. Por lo general, estos son varios servidores. El caché distribuido se almacena en un servicio externo. Esto significa que si un servidor ha retenido un elemento de caché, otros servidores también pueden usarlo. Servicios como Redis son excelentes para esto.

Solo hablaremos sobre el caché local .

Implementación primitiva


Comencemos creando una implementación de caché muy simple en C #:

public class NaiveCache<TItem>
{
    Dictionary<object, TItem> _cache = new Dictionary<object, TItem>();
 
    public TItem GetOrCreate(object key, Func<TItem> createItem)
    {
        if (!_cache.ContainsKey(key))
        {
            _cache[key] = createItem();
        }
        return _cache[key];
    }
}

Utilizando:

var _avatarCache = new NaiveCache<byte[]>();
// ...
var myAvatar = _avatarCache.GetOrCreate(userId, () => _database.GetAvatar(userId));

Este código simple resuelve un problema importante. Para obtener un avatar de usuario, solo la primera solicitud será la solicitud real de la base de datos. Los datos de avatar ( byte []) por el resultado de la solicitud se almacenan en la memoria del proceso. Todas las solicitudes de avatar posteriores lo recuperarán de la memoria, ahorrando tiempo y recursos.

Pero, como la mayoría de las cosas en programación, las cosas no son tan simples. La implementación anterior no es una buena solución por varias razones. Por un lado, esta implementación no es segura para subprocesos . Cuando se usa desde múltiples hilos, pueden ocurrir excepciones. Además, los elementos en caché permanecerán en la memoria para siempre, lo que en realidad es muy malo.

Es por eso que debemos eliminar elementos del caché:

  1. Un caché puede comenzar a ocupar mucha memoria, lo que en última instancia conduce a excepciones debido a su escasez y bloqueos.
  2. El alto consumo de memoria puede conducir a la presión de la memoria (también conocida como Presión GC ). En este estado, el recolector de basura funciona mucho más de lo que debería, lo que reduce el rendimiento.
  3. Es posible que sea necesario actualizar el caché cuando cambien los datos. Nuestra infraestructura de almacenamiento en caché debe ser compatible con esta característica.

Para resolver estos problemas existen en el marco de las políticas de desplazamiento (también conocido como la eliminación de la política - Políticas de desalojo / eliminación ). Estas son las reglas para eliminar elementos del caché de acuerdo con la lógica dada. Entre las políticas de eliminación comunes se encuentran las siguientes:

  • Una política de caducidad absoluta que elimina un elemento del caché después de un período de tiempo fijo, pase lo que pase.
  • Una política de caducidad deslizante que elimina un elemento de la memoria caché si no se ha accedido a él durante un cierto período de tiempo. Es decir, si configuro el tiempo de caducidad en 1 minuto, el elemento permanecerá en el caché mientras lo uso cada 30 segundos. Si no lo uso durante más de un minuto, el elemento se eliminará.
  • Política de límite de tamaño , que limitará el tamaño de la memoria caché.

Ahora que hemos descubierto todo lo que necesitamos, pasemos a mejores soluciones.

Mejores soluciones


Para mi gran decepción como blogger, Microsoft ya ha creado una maravillosa implementación de caché. Esto me privó del placer de crear una implementación similar por mí mismo, pero al menos la redacción de este artículo también es menor.

Le mostraré una solución de Microsoft, cómo usarla de manera efectiva y luego cómo mejorarla para algunos escenarios.

System.Runtime.Caching / MemoryCache vs Microsoft.Extensions.Caching.Memory


Microsoft tiene 2 soluciones, 2 paquetes diferentes de almacenamiento en caché de NuGet. Ambos son geniales. Según las recomendaciones de Microsoft, es preferible utilizarlo Microsoft.Extensions.Caching.Memory, ya que se integra mejor con Asp. NET Core. Se puede integrar fácilmente en el mecanismo de inyección de dependencia de Asp .NET Core.

Aquí hay un ejemplo simple con Microsoft.Extensions.Caching.Memory:

public class SimpleMemoryCache<TItem>
{
    private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
 
    public TItem GetOrCreate(object key, Func<TItem> createItem)
    {
        TItem cacheEntry;
        if (!_cache.TryGetValue(key, out cacheEntry)) //    .
        {
            //    ,   .
            cacheEntry = createItem();
            
            //    . 
            _cache.Set(key, cacheEntry);
        }
        return cacheEntry;
    }
}

Utilizando:

var _avatarCache = new SimpleMemoryCache<byte[]>();
// ...
var myAvatar = _avatarCache.GetOrCreate(userId, () => _database.GetAvatar(userId));

Me recuerda mucho al mío NaiveCache, entonces, ¿qué ha cambiado? Bueno, en primer lugar, es una implementación segura para subprocesos . Puede llamarlo de manera segura desde múltiples hilos a la vez.

En segundo lugar, MemoryCachetiene en cuenta todas las políticas de exclusión de las que hablamos anteriormente. Aquí hay un ejemplo:

IMemoryCache con políticas de preferencia:

public class MemoryCacheWithPolicy<TItem>
{
    private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions()
    {
        SizeLimit = 1024
    });
 
    public TItem GetOrCreate(object key, Func<TItem> createItem)
    {
        TItem cacheEntry;
        if (!_cache.TryGetValue(key, out cacheEntry))//    .
        {
            //    ,   . 
            cacheEntry = createItem();
 
            var cacheEntryOptions = new MemoryCacheEntryOptions()
         	.SetSize(1)// 
         	//        (  )
                .SetPriority(CacheItemPriority.High)
                //       ,    .
                 .SetSlidingExpiration(TimeSpan.FromSeconds(2))
                //       ,    .
                .SetAbsoluteExpiration(TimeSpan.FromSeconds(10));
 
            //    .
            _cache.Set(key, cacheEntry, cacheEntryOptions);
        }
        return cacheEntry;
    }
}

Analicemos los nuevos elementos:

  1. MemoryCacheOptions ha sido agregado SizeLimit. Esto agrega una política de límite de tamaño a nuestro contenedor de caché. El caché no tiene un mecanismo para medir el tamaño de los registros. Por lo tanto, necesitamos establecer el tamaño de cada entrada de caché. En este caso, cada vez que establecemos el tamaño en 1 con SetSize(1). Esto significa que nuestro caché tendrá un límite de 1024 elementos.
  2. , ? .SetPriority (CacheItemPriority.High). : Low (), Normal (), High () NeverRemove ( ).
  3. SetSlidingExpiration(TimeSpan.FromSeconds(2)) 2 . , 2 , .
  4. SetAbsoluteExpiration(TimeSpan.FromSeconds(10)) 10 . , 10 , .

Además de las opciones del ejemplo, también puede establecer un delegado RegisterPostEvictionCallbackque se llamará cuando se elimine el elemento.

Esta es una gama bastante amplia de funciones, pero, sin embargo, debemos pensar si hay algo más que agregar. En realidad hay un par de cosas.

Problemas y características faltantes


Hay varias partes importantes que faltan en esta implementación.

  1. Si bien puede establecer un límite de tamaño, el almacenamiento en caché en realidad no controla la presión de la memoria. Si monitoreamos, podríamos endurecer la política con alta presión y debilitar la política con baja.
  2. , . . , , , 10 . 2 , , ( ), .

Respecto al primer problema de presión sobre gc: es posible controlar la presión sobre gc por varios métodos y heurísticas. Esta publicación no trata sobre esto, pero puede leer mi artículo "Encontrar, corregir y prevenir pérdidas de memoria en C # .NET: 8 mejores prácticas" para conocer algunos métodos útiles.

El segundo problema es más fácil de resolver . En realidad, aquí hay una implementación MemoryCacheque lo resuelve por completo:

public class WaitToFinishMemoryCache<TItem>
{
    private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
    private ConcurrentDictionary<object, SemaphoreSlim> _locks = new ConcurrentDictionary<object, SemaphoreSlim>();
 
    public async Task<TItem> GetOrCreate(object key, Func<Task<TItem>> createItem)
    {
        TItem cacheEntry;
 
        if (!_cache.TryGetValue(key, out cacheEntry))//    .
        {
            SemaphoreSlim mylock = _locks.GetOrAdd(key, k => new SemaphoreSlim(1, 1));
 
            await mylock.WaitAsync();
            try
            {
                if (!_cache.TryGetValue(key, out cacheEntry))
                {
                    //    ,   .
                    cacheEntry = await createItem();
                    _cache.Set(key, cacheEntry);
                }
            }
            finally
            {
                mylock.Release();
            }
        }
        return cacheEntry;
    }
}

Utilizando:

var _avatarCache = new WaitToFinishMemoryCache<byte[]>();
// ...
var myAvatar =
await _avatarCache.GetOrCreate(userId, async () => await _database.GetAvatar(userId));

En esta implementación, cuando intente obtener un elemento, si el mismo elemento ya está en proceso de ser creado por otro hilo, esperará hasta que se complete el primer hilo. Luego obtendrá un elemento ya almacenado en caché creado por otro hilo.

Análisis de código


Esta implementación bloquea la creación de un elemento. El bloqueo ocurre en una llave. Por ejemplo, si estamos esperando el avatar de Alexey, aún podemos obtener los valores en caché de Zhenya o Barbara en otro hilo.

El diccionario _locksalmacena todas las cerraduras. Los bloqueos regulares no funcionan async/await, por lo que debemos usar SemaphoreSlim .

Hay 2 comprobaciones para verificar si el valor ya está almacenado en caché si (!_Cache.TryGetValue(key, out cacheEntry)). El que está en la cerradura es el que proporciona la única creación del elemento. Uno que está fuera de la cerradura, para la optimización.

Cuándo usar WaitToFinishMemoryCache


Esta implementación obviamente tiene algunos gastos generales. Veamos cuando es relevante.

Usar WaitToFinishMemoryCachecuando:

  • Cuando el tiempo de creación de un elemento tiene algún valor, y desea minimizar el número de creaciones tanto como sea posible.
  • Cuando el tiempo para crear un artículo es muy largo.
  • Cuando un elemento debe crearse una vez para cada clave.

No usar WaitToFinishMemoryCachecuando:

  • No hay peligro de que varios subprocesos obtengan acceso al mismo elemento de caché.
  • No está categóricamente en contra de crear elementos más de una vez. Por ejemplo, si una consulta adicional a la base de datos no afecta mucho a nada.

Resumen


El almacenamiento en caché es un patrón muy poderoso. Y también es peligroso y tiene sus peligros. Caché demasiado y puede causar presión sobre el GC. Caché muy poco y puede causar problemas de rendimiento. También hay almacenamiento en caché distribuido, que representa un mundo completamente nuevo para explorar. Este es el desarrollo de software, siempre hay algo nuevo que se puede dominar.

Espero que hayas disfrutado este artículo. Si está interesado en la gestión de la memoria, mi próximo artículo se centrará en los peligros de la presión sobre el GC y cómo evitarlo, así que regístrese . Disfruta tu codificación.



Aprende más sobre el curso.



All Articles