C # -Implementierungen in C # .NET

Hallo Habr! Im Vorfeld des Kursbeginns "C # ASP.NET Core Developer" haben wir eine Übersetzung von interessantem Material zur Implementierung des Caches in C # vorbereitet. Viel Spaß beim Lesen.



Eines der am häufigsten verwendeten Muster in der Softwareentwicklung ist das Caching . Es ist ein einfaches und gleichzeitig sehr effektives Konzept. Die Idee ist, die Ergebnisse der durchgeführten Operationen wiederzuverwenden. Nach einem zeitaufwändigen Vorgang speichern wir das Ergebnis in unserem Cache-Container . Wenn wir dieses Ergebnis das nächste Mal benötigen, extrahieren wir es aus dem Cache-Container, anstatt erneut eine mühsame Operation ausführen zu müssen.

Um beispielsweise einen Benutzeravatar zu erhalten, müssen Sie ihn möglicherweise von der Datenbank anfordern. Anstatt die Anforderung bei jedem Aufruf auszuführen, speichern wir diesen Avatar im Cache und extrahieren ihn jedes Mal aus dem Speicher, wenn Sie ihn benötigen.

Das Caching eignet sich hervorragend für Daten, die sich nur selten ändern. Oder im Idealfall ändern sie sich nie. Daten, die sich ständig ändern, z. B. die aktuelle Uhrzeit, sollten nicht zwischengespeichert werden. Andernfalls besteht die Gefahr, dass Sie falsche Ergebnisse erhalten.

Lokaler Cache, persistenter lokaler Cache und verteilter Cache


Es gibt 3 Arten von Caches:

  • Der In-Memory-Cache wird in Fällen verwendet, in denen Sie den Cache nur in einem Prozess implementieren müssen. Wenn ein Prozess stirbt, stirbt der Cache damit. Wenn Sie denselben Prozess auf mehreren Servern ausführen, verfügen Sie für jeden Server über einen separaten Cache.
  • Permanenter In-Process-Cache - In diesem Fall sichern Sie den Cache außerhalb des Prozessspeichers. Es kann sich in einer Datei oder in einer Datenbank befinden. Es ist komplexer als der Cache im Speicher, aber wenn Ihr Prozess neu gestartet wird, wird der Cache nicht geleert. Am besten geeignet für Fälle, in denen das Abrufen eines zwischengespeicherten Elements teuer ist und Ihr Prozess häufig neu gestartet wird.
  • Verteilter Cache ist, wenn Sie einen gemeinsam genutzten Cache für mehrere Computer benötigen. Normalerweise sind dies mehrere Server. Der verteilte Cache wird in einem externen Dienst gespeichert. Dies bedeutet, dass wenn ein Server ein Cache-Element beibehalten hat, andere Server es auch verwenden können. Services wie Redis sind dafür großartig.

Wir werden nur über den lokalen Cache sprechen .

Primitive Implementierung


Beginnen wir mit der Erstellung einer sehr einfachen Cache-Implementierung in 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];
    }
}

Verwenden von:

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

Dieser einfache Code löst ein wichtiges Problem. Um einen Benutzeravatar zu erhalten, ist nur die erste Anforderung die tatsächliche Anforderung aus der Datenbank. Die Avatar-Daten ( byte []) des Ergebnisses der Anforderung werden im Prozessspeicher gespeichert. Alle nachfolgenden Avatar-Anfragen rufen es aus dem Speicher ab, was Zeit und Ressourcen spart.

Aber wie die meisten Dinge in der Programmierung sind die Dinge nicht so einfach. Die obige Implementierung ist aus mehreren Gründen keine gute Lösung. Einerseits ist diese Implementierung nicht threadsicher . Bei Verwendung aus mehreren Threads können Ausnahmen auftreten. Darüber hinaus bleiben zwischengespeicherte Elemente für immer im Speicher, was eigentlich sehr schlecht ist.

Aus diesem Grund sollten wir Elemente aus dem Cache entfernen:

  1. Ein Cache kann viel Speicherplatz beanspruchen, was letztendlich aufgrund seines Mangels und seiner Abstürze zu Ausnahmen führt.
  2. Ein hoher Speicherverbrauch kann zu Speicherdruck führen (auch als GC-Druck bezeichnet ). In diesem Zustand arbeitet der Garbage Collector viel mehr als er sollte, was die Leistung verringert.
  3. Der Cache muss möglicherweise aktualisiert werden, wenn sich Daten ändern. Unsere Caching-Infrastruktur muss diese Funktion unterstützen.

Um diese Probleme zu lösen, gibt es im Rahmen von Vertreibungsrichtlinien (auch als Entfernen von Richtlinien - Räumungs- / Entfernungsrichtlinien bezeichnet ). Dies sind die Regeln zum Entfernen von Elementen aus dem Cache gemäß der angegebenen Logik. Zu den allgemeinen Richtlinien zum Entfernen gehören die folgenden:

  • Eine Absolute Expiration-Richtlinie , die ein Element nach einer festgelegten Zeit aus dem Cache entfernt, egal was passiert.
  • Eine Sliding Expiration-Richtlinie , die ein Element aus dem Cache entfernt, wenn für einen bestimmten Zeitraum nicht auf es zugegriffen wurde. Das heißt, wenn ich die Ablaufzeit auf 1 Minute einstelle, bleibt das Element im Cache, während ich es alle 30 Sekunden verwende. Wenn ich es länger als eine Minute nicht benutze, wird der Artikel gelöscht.
  • Größenbeschränkungsrichtlinie , die die Größe des Caches begrenzt.

Nachdem wir alles herausgefunden haben, was wir brauchen, gehen wir zu besseren Lösungen über.

Bessere Lösungen


Zu meiner großen Enttäuschung als Blogger hat Microsoft bereits eine wunderbare Cache-Implementierung erstellt. Dies beraubte mich der Freude, selbst eine ähnliche Implementierung zu erstellen, aber zumindest ist das Schreiben dieses Artikels auch weniger.

Ich werde Ihnen eine Microsoft-Lösung zeigen, wie Sie sie effektiv einsetzen und dann für einige Szenarien verbessern können.

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


Microsoft hat 2 Lösungen, 2 verschiedene NuGet-Caching-Pakete. Beide sind großartig. Nach den Empfehlungen von Microsoft ist die Verwendung vorzuziehen Microsoft.Extensions.Caching.Memory, da sie sich besser in Asp integrieren lässt. NET Core. Es kann problemlos in den Asp .NET Core-Abhängigkeitsinjektionsmechanismus integriert werden.

Hier ist ein einfaches Beispiel mit 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;
    }
}

Verwenden von:

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

Es erinnert sehr an mein eigenes NaiveCache, also was hat sich geändert? Erstens ist es eine thread-sichere Implementierung. Sie können es sicher von mehreren Threads gleichzeitig aufrufen.

Zweitens werden MemoryCachealle Verdrängungsrichtlinien berücksichtigt , über die wir zuvor gesprochen haben. Hier ein Beispiel:

IMemoryCache mit Vorkaufsrichtlinien:

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

Lassen Sie uns die neuen Elemente analysieren:

  1. Die MemoryCacheOptions wurden hinzugefügt SizeLimit. Dadurch wird unserem Cache-Container eine Richtlinie zur Größenbeschränkung hinzugefügt. Der Cache verfügt nicht über einen Mechanismus zum Messen der Größe von Datensätzen. Daher müssen wir die Größe jedes Cache-Eintrags festlegen. In diesem Fall setzen wir jedes Mal die Größe auf 1 mit SetSize(1). Dies bedeutet, dass unser Cache ein Limit von 1024 Elementen hat.
  2. , ? .SetPriority (CacheItemPriority.High). : Low (), Normal (), High () NeverRemove ( ).
  3. SetSlidingExpiration(TimeSpan.FromSeconds(2)) 2 . , 2 , .
  4. SetAbsoluteExpiration(TimeSpan.FromSeconds(10)) 10 . , 10 , .

Zusätzlich zu den Optionen im Beispiel können Sie auch einen Delegaten festlegen RegisterPostEvictionCallback, der beim Löschen des Elements aufgerufen wird.

Dies ist ein ziemlich breiter Funktionsumfang, aber wir müssen uns dennoch überlegen, ob wir noch etwas hinzufügen können. Es gibt tatsächlich ein paar Dinge.

Probleme und fehlende Funktionen


In dieser Implementierung fehlen einige wichtige Teile.

  1. Während Sie eine Größenbeschränkung festlegen können, steuert das Caching den Speicherdruck nicht. Wenn wir überwachen würden, könnten wir die Politik mit hohem Druck straffen und die Politik mit niedrigem Druck schwächen.
  2. , . . , , , 10 . 2 , , ( ), .

Zum ersten Problem des Drucks auf Gleichstrom: Es ist möglich, den Druck auf Gleichstrom durch verschiedene Methoden und Heuristiken zu steuern. In diesem Beitrag geht es nicht darum, aber Sie können meinen Artikel „Suchen, Beheben und Verhindern von Speicherlecks in C # .NET: 8 Best Practices“ lesen , um einige nützliche Methoden zu erfahren.

Das zweite Problem ist leichter zu lösen . Eigentlich ist hier eine Implementierung MemoryCache, die es vollständig löst:

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

Verwenden von:

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

Wenn Sie in dieser Implementierung versuchen, ein Element abzurufen, warten Sie, bis der erste Thread abgeschlossen ist, wenn dasselbe Element bereits von einem anderen Thread erstellt wird. Dann erhalten Sie ein bereits zwischengespeichertes Element, das von einem anderen Thread erstellt wurde.

Code-Analyse


Diese Implementierung blockiert die Erstellung eines Elements. Ein Schlüssel wird gesperrt. Wenn wir beispielsweise auf Alexeys Avatar warten, können wir die zwischengespeicherten Werte von Zhenya oder Barbara immer noch in einem anderen Thread abrufen.

Das Wörterbuch _locksspeichert alle Sperren. Normale Sperren funktionieren nicht async/await, daher müssen wir SemaphoreSlim verwenden .

Es gibt 2 Überprüfungen, um zu überprüfen, ob der Wert bereits zwischengespeichert ist, wenn (!_Cache.TryGetValue(key, out cacheEntry)). Der im Schloss ist derjenige, der die einzige Erstellung des Elements bereitstellt. Eine, die sich zur Optimierung außerhalb des Schlosses befindet.

Wann zu verwenden WaitToFinishMemoryCache


Diese Implementierung hat offensichtlich einen gewissen Aufwand. Schauen wir uns an, wann es relevant ist.

Verwenden Sie, WaitToFinishMemoryCachewenn:

  • Wenn die Erstellungszeit eines Elements einen Wert hat und Sie die Anzahl der Erstellungen so gering wie möglich halten möchten.
  • Wenn die Zeit zum Erstellen eines Elements sehr lang ist.
  • Wenn ein Element für jeden Schlüssel einmal erstellt werden muss.

Nicht verwenden, WaitToFinishMemoryCachewenn:

  • Es besteht keine Gefahr, dass mehrere Threads Zugriff auf dasselbe Cache-Element erhalten.
  • Sie sind nicht kategorisch dagegen, Elemente mehr als einmal zu erstellen. Zum Beispiel, wenn eine zusätzliche Abfrage an die Datenbank keine großen Auswirkungen hat.

Zusammenfassung


Caching ist ein sehr mächtiges Muster. Und es ist auch gefährlich und hat seine Tücken. Wenn Sie zu viel zwischenspeichern, können Sie Druck auf den GC ausüben. Wenn Sie zu wenig zwischenspeichern, können Leistungsprobleme auftreten. Es gibt auch verteiltes Caching, das eine ganz neue Welt zum Erkunden darstellt. Dies ist Softwareentwicklung, es gibt immer etwas Neues, das gemeistert werden kann.

Ich hoffe, Ihnen hat dieser Artikel gefallen. Wenn Sie an Speicherverwaltung interessiert sind, wird sich mein nächster Artikel auf die Gefahren des Drucks auf den GC und dessen Verhinderung konzentrieren. Melden Sie sich an . Viel Spaß beim Codieren.



Erfahren Sie mehr über den Kurs.



All Articles