
MemoryCache i jego pułapki
Wstęp
IMemoryCache
to jeden z mechanizmów pamięci podręcznej (cache) jakie mamy dostępne w .NET. Wykorzystujemy go w sytuacji, kiedy nasza aplikacja działa tylko na jednej maszynie (nie jest skalowana).
Jednakże domyślna implementacja tego interfejsu może czasem odnieść odwrotny skutek lub przyprawić nas o dziwne problemy. Chyba że poznamy jej zawiłości. I o tym jest ten artykuł.
IMemoryCache czyli najprostsze cachowanie w pamięci
IMemoryCache
domyślnie jest zaimplementowany przez… niesamowite… MemoryCache
. A, żeby móc tego użyć, wystarczy zarejestrować sobie ten cache podczas rejestracji usług:
builder.Services.AddMemoryCache();
To rejestruje nam klasę MemoryCache
jako singleton z ewentualnymi opcjami. Żeby potem użyć tego w swoim serwisie, wystarczy wstrzyknąć IMemoryCache
.
Pobieranie i dodawanie danych
I tutaj zaczyna się ciekawie. Najpierw powiedzmy sobie o ICacheEntry
.
ICacheEntry
To właśnie obiekty implementujące ICacheEntry
mogą być elementami IMemoryCache
. ICacheEntry
trzyma w sobie kilka przydatnych dla mechanizmu informacji. Poza oczywiście wartością, którą trzymamy w cache, posiada jeszcze informacje na temat długości swojego życia, priorytetu itd. Dojdziemy do szczegółów później.
Domyślnie ICacheEntry
jest implementowany przez wewnętrzną klasę CacheEntry
. Wewnętrzną (internal), a więc nie możemy utworzyć sami jej obiektu.
Dodawanie elementu do cache
IMemoryCache
ma kilka metod do dodawania i pobierania danych, które jednak potrafią nam dołożyć kilku siwych włosów. Zacznijmy bez extensionów, od zupełnej podstawy
{
using var entry = memoryCache.CreateEntry(city);
entry.Value = new WeatherForecast();
}
Żeby lepiej to uwidocznić, to:
var entry = memoryCache.CreateEntry(city);
entry.Value = new WeatherForecast();
entry.Dispose();
W powyższych kodach city
to po prostu miasto dla którego chcemy zapamiętać pogodę, a więc KLUCZ, pod którym zapisujemy konkretną wartość.
Tak, żeby dodać wartość do cache, musimy:
- utworzyć
CacheEntry
za pomocą metodyCreateEntry
zIMemoryCache
– inaczej się nie da, boCacheEntry
jest tylko internal, nie możemy sami utworzyć jego instancji, - przypisać wartość do
CacheEntry
– bez tego nie zadziała - wywołać
Dispose
na rzecz tegoCacheEntry
– albo jawnie, albo tak jak wyżej za pomocąusing
– bez tego nie zadziała.
Jak widać, musimy zrobić z tym CacheEntry
zrobić dwie rzeczy:
- dodać wartość (Value)
- wywołać
Dispose()
Dyskusji na temat tego Dispose() było już kilka, m.in. tu: https://github.com/dotnet/runtime/pull/42355 i tu: https://github.com/dotnet/runtime/issues/36392.
Nie jest to naturalne użycie, ponieważ po tym jak wywołamy Dispose()
, to CacheEntry
dopiero jest wrzucane do MemoryCache
i tam wykorzystywane (sic!). Nie mniej jednak MS tłumaczy, że zrobienie tego inaczej byłoby breaking changem (prawda) i nie przyniosłoby wartości (zasadniczo też prawda). Więc… „jest jak jest”. I trzeba o tym pamiętać.
Musimy ten Dispose()
potraktować bardziej jako „koniec konfiguracji CacheEntry
” niż koniec jego życia.
Dodawanie za pomocą rozszerzenia
Dodawanie CacheEntry
za pomocą metody rozszerzeń jest zdecydowanie bardziej naturalne dla nas. Rozszerzenie już samo w sobie pamięta o wykonaniu Dispose()
, więc my się tym nie musimy przejmować. Wystarczy:
memoryCache.Set(city, new WeatherForecast());
Jak widać, takie dodawanie jest zdecydowanie bardziej naturalne. Musimy jednak być świadomi, że Set
jest tu metodą rozszerzenia (extension method).
Używając powyższych rozwiązań musimy zrobić dwie rzeczy, żeby cache zadziałał. Spójrz na poniższy kod:
public async Task<WeatherForecast?> GetWeatherFor(string city)
{
if (memoryCache.TryGetValue<WeatherForecast>(city, out var weather))
{
return weather!;
}
return memoryCache.Set(city, new WeatherForecast());
}
Jeśli mamy w serwisie metodą GetWeatherFor
, która w parametrze przyjmuje nazwę miasta, dla którego chcemy zwrócić prognozę pogody, to żeby to wszystko elegancko zadziałało z cachem, musimy:
- sprawdzić, czy mamy taką wartość już w cache zapisaną, jeśli tak, zwrócić ją stamtąd
- jeśli wartości nie ma w cache, musimy ją w jakiś sposób zdobyć (np. poprzez zewnętrzne API – nie jest to pokazane w kodzie) i dopiero potem zapisać w cache i zwrócić. Metoda
Set
na szczęście zwraca nam wartość, którą przekazaliśmy w parametrze, więc możemy po prostu zrobić return z niej.
To jest taka opcja „krok po kroku”, która zasadniczo nie powinna rodzić pytań. Wszyscy się spodziewamy, że cache właśnie tak działa. Ale jest inna fajna metoda rozszerzenia:
public async Task<WeatherForecast?> GetWeatherFor(string city)
{
return await memoryCache.GetOrCreateAsync(city.ToString(), async (entry) =>
{
await Task.Delay(1000); //kwestia poglądowa, symulacja długiej operacji
var result = new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = "Sunny"
};
entry.SetValue(result);
return result;
});
}
GetOrCreateAsync
. Co to robi? W pierwszej kolejności próbuje zwrócić z cache istniejącą wartość na podstawie przekazanego klucza. Jeśli jest, to super, zwraca i tyle. Jeśli jednak takiej wartości nie ma, to ta metoda odpala naszą funkcję, którą podaliśmy w drugim parametrze – to jest to miejsce do pobrania wartości i SKONFIGUROWANIA CacheEntry
.
Co się dzieje w tej lambdzie?
Na początku robię jakiś Task.Delay
, ale tylko jako kwestia poglądowa, że tu może mieć miejsce jakaś długotrwała operacja (np. pobranie danych z zewnętrznego API).
Dalej w jakiś sposób pozyskuję WeatherForecast
, ustawiam go jako wartość w entry
i zwracam.
Parametr entry
, który otrzymujemy w parametrze naszej funkcji to jest właśnie utworzony CacheEntry
. Dlatego w naszej funkcji musimy go skonfigurować.
Którą opcję wybierzesz do dodawania elementu do cache jest Twoją indywidualną sprawą. Wszystkie te metody zasadniczo robią to samo, tylko w nieco inny sposób. No i oczywiście GetOrCreateAsync
jest na tyle uniwersalne, że daje nam jakby 100% użyteczności całego cache.
Typ klucza
Każda wartość w cache musi mieć swój klucz. Inaczej nie wiedzielibyśmy co dodajemy lub pobieramy. I mimo, że parametr klucz jest zawsze typu object
, to tak naprawdę może być czystym stringiem. Ważne, żeby klucz miał dobrze zaprogramowaną metodęy GetHashCode
i Equals
.
To może być szczegół implementacyjny, ale pod spodem mamy dwa słowniki, które przechowują nasze wartości. A konkretniej dwa ConcurrentDictionary
(ale nie daj się zwieść, że MemoryCache jest thread safe – o tym później). Jeden z tych słowników przechowuje wartości z kluczem object
, a drugi ze stringiem. I po prostu mechanizm sam rozpoznaje, czy podaliśmy w kluczu string, czy nie (if key is string...
). Następnie dodaje wartość do odpowiedniego słownika.
Dlaczego tak? To jest tak naprawdę zależne od wersji .NET. Domyślny hash stringów kiedyś pozwalał na atak Hash Collision Atack. Natomiast teraz używany jest przy stringach „Marvin Hash”, który jest odporny na te ataki, czyli mamy zwiększone bezpieczeństwo.
Jednak trzeba pamiętać, że stringi są porównywane w zależności od wielkości znaków. Czyli klucze „Łódź” i „łódź” będą innymi kluczami.
Ustawienia CacheEntry
CacheEntry
, jak i całe MemoryCache
ma swoje ustawienia, które KONIECZNIE powinniśmy uwzględnić. Albo podczas dodawania (konfiguracji tak naprawdę) CacheEntry
do cache, albo podczas rejestracji usług.
Co jest problemem, jeśli tego nie zrobimy? Cache jest przechowywany w pamięci RAM. Domyślnie wszelkie wartości jakie wrzucamy, nigdy nie giną. No, dopóki aplikacja żyje oczywiście 🙂
Co nam to daje? Pewnie się domyślasz. Ciągle tylko dokładamy do RAMu nowe dane, co w końcu doprowadzi do Out Of Memory.
Zatem musimy koniecznie zatroszczyć się o odpowiedni czas przechowywania danych w cache. Co to znaczy „odpowiedni”? To oczywiście zależy od konkretnej aplikacji. Bo czasem wartości mogą żyć dłużej (jak w przypadku prognozy pogody), czasem powinny nieco krócej, a w specyficznych aplikacjach używanie cache w ogóle nie ma sensu. Dlatego sam musisz odpowiedzieć na pytanie – jak długo ta dana może leżeć w cache.
Czas życia CacheEntry
Mamy kilka metod, żeby określić czas życia konkretnej danej w cache:
AbsoluteExpiration
– gdzie podajemy konkretną datę i czas, do której wartość ma żyć (np. do 5 stycznia 2026 roku, do godziny 17:03)AbsoluteExpirationRelativeToNow
– zdecydowanie bardziej użyteczna, gdzie podajemy po prostu ile czasu ma żyć jakoTimeSpan
. Czyli konkretnie: 10 minut, 1 dzień itd.SlidingExpiration
– analogicznie jakAbsoluteExpirationRelativeToNow
z tą różnicą, że ten czas odświeża się zawsze po pobraniu danej.ExpirationToken
– o tym niżej.
Czyli jeśli aktualnie mamy godzinę 17:00:00 i ustawimy AbsoluteExpirationRelativeToNow
na 10 minut, to niezależnie od tego, ile razy w ciągu tych 10 minut będziemy pobierać daną z cache, to o 17:10:00 skończy się jej życie i zostanie usunięta.
Jeśli jednak użyjemy SlidingExpiration
, to te 10 minut będzie liczone od nowa po każdym pobraniu danej. Czyli jeśli daną pobierzemy o 17:05, to życie skończy się jej dopiero o 17:15. Ale jeśli pobierzemy ją znowu o 17:07, to wydłuży jej życie do 17:17. Właściwość SlidingExpiration
możesz znać z mechanizmu ciastek, gdzie działa dokładnie w taki sam sposób.
Ale uwaga, tutaj jest kolejna pułapka. Mechanizm odpowiedzialny za usuwanie danych z cache, bo ich czas się skończył, nie obsługuje żadnego timera. Czas ich życia jest sprawdzany po prostu przy każdej operacji z nimi związanej, czyli dodawanie nowej wartości, odczyt itd. Więc w bardzo specyficznych warunkach (raz dodamy daną do cache i nigdy nic więcej z tym cachem nie zrobimy) wartość może żyć wiecznie 🙂 Ale to raczej warunki niespotykane.
ExpirationToken
Mamy w .NET taki interfejs jak IChangeToken
. Służy on do implementacji klas, które „monitorują” jakieś zdarzenia. Ustawienie ExpirationToken
spowoduje usunięcie wartości z cache, gdy dane zdarzenie wystąpi. A co to może być za zdarzenie? Jakiekolwiek. Zmiana zawartości pliku na dysku, event związany z bazą danych albo cokolwiek sobie wymyślimy. Weźmy pod uwagę przykładowo zmianę zawartości pliku na dysku.
Scenariusz jest taki, że trzymamy dane z pliku w cache i monitorujemy zmianę tego pliku. Można by to ogarnąć w taki sposób:
Najpierw rejestrujemy odpowiedniego FileProvidera:
builder.Services.AddSingleton<IFileProvider>(new PhysicalFileProvider(@"C:\temp"));
A teraz możemy już pobrać token, który powie nam, czy plik się zmienił i dodać go do CacheEntry
:
public class WeatherService(IMemoryCache memoryCache,
IFileProvider _fileProvider) : IWeatherService
{
public async Task<WeatherForecast?> GetWeatherFor(string city)
{
return await memoryCache.GetOrCreateAsync(city.ToString(), async (entry) =>
{
// zaczynamy plik obserwować
IChangeToken changeToken = _fileProvider.Watch("data.txt");
// pobieramy dane z pliku
var data = await File.ReadAllTextAsync(@"C:\temp\data.txt");
// dodajemy token zmiany do pamięci podręcznej
entry.AddExpirationToken(changeToken);
// zapisujemy dane do pamięci podręcznej
entry.SetValue(data);
return JsonSerializer.Deserialize<WeatherForecast>(data);
});
}
}
Tylko UWAGA!
Tutaj za czas życia wartości w cache odpowiada ten token, a nie czas. Natomiast zasada sprawdzania, czy czas życia danej w cache się skończył jest dokładnie taka sama jak w poprzednim przypadku. Czyli mechanizm sprawdza tylko przy jakiejś operacji na danych (dodawanie, odczyt, usuwanie).
Możemy też jednocześnie ustawić konkretny czas życia (np. na 5 minut), jak również dodać token. Jednak nie łączmy ze sobą różnych form wygasania po czasie, bo to po prostu nie ma sensu.
A może by tak IDisposable?
A gdybyśmy chcieli trzymać obiekt implementujący IDisposable
? Hmm. Stwórzmy sobie do tego celu czystą klasę implementującą IDisposable
:
public class MyDisposable : IDisposable
{
private bool disposedValue;
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
I teraz lekko zmienię metodę GetWeatherFor
, żebyśmy mogli tę klasę wykorzystać:
public async Task<IDisposable?> GetWeatherFor(string city)
{
return await memoryCache.GetOrCreateAsync(city.ToString(), async (entry) =>
{
var d = new MyDisposable();
entry.SetValue(d);
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(10);
return d;
});
}
Tworzymy sobie nasz obiekt i dodajemy go do cache w taki sposób, żeby żył tylko 10 sekund. I co się okazuje? Klops, dupa zbita. Obiekt implementujący IDisposable
nigdy nie jest zwalniany, co może doprowadzić do poważnych wycieków pamięci i w gruncie rzeczy do Out Of Memory.
Jak sobie z tym poradzić?
Na szczęście CacheEntry
ma specjalną metodę, którą wywołuje w momencie gdy jest usuwany z cache. Czyli nasz kod, powinniśmy zmienić na taki:
public async Task<IDisposable?> GetWeatherFor(string city)
{
return await memoryCache.GetOrCreateAsync(city.ToString(), async (entry) =>
{
var d = new MyDisposable();
entry.SetValue(d);
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(10);
entry.RegisterPostEvictionCallback((key, value, reason, state) =>
{
if (value is IDisposable disposable)
{
disposable.Dispose();
}
});
return d;
});
}
Metoda rozszerzenia RegisterPostEvictionCallback
rejestruje nam w prosty sposób właśnie taki callback, który zostanie wywołany, gdy z jakiegoś powodu wartość będzie usuwana z cache. To może być po prostu koniec życia albo po prostu zwykłe usunięcie przez użytkownika. To jest to miejsce, gdzie możemy po sobie posprzątać.
A kiedy wybuchnie?
Niestety trzymanie w cache obiektu implementującego IDisposable
niesie za sobą jeszcze jedno niebezpieczeństwo. Mianowicie możemy ten obiekt zdisposować w innym miejscu.
Wtedy w cache będziemy przetrzymywać obiekt, który… no jest trupem. Takie „zombie”. Jeśli teraz pobierzemy takie zombie i będziemy chcieli użyć, no to BUM!
Zatem rada jest taka:
Nie przechowuj w cache obiektów implementujących IDisposable
A jak z wielowątkowością?
Memory cache NIE JEST thread safe
W związku z tym, że MemoryCache
pod spodem używa ConcurrentDictionary
, uważa się, że jest wątkowo bezpieczny. Ale nie jest tak do końca.
Fakt – MemoryCache
jest bezpieczny pod kątem samego dodawania lub pobierania wartości (w końcu mamy Concurrent Dictionary
). Ale problemem jest to, co dzieje się na zewnętrz tego cache – w naszym kodzie.
Możemy doświadczyć race condition i innych problemów z wątkami. Przykładowo callback zarejestrowany w CacheEntry
może przyjść z zupełnie innego wątku, niż ten, w którym utworzyliśmy i dodaliśmy obiekt do cache. To może być istotne w pewnych sytuacjach. A co do race condition… Spójrz na ten prosty kodzik:
if (!cache.TryGetValue("key", out var value))
{
// <- w tym momencie inny wątek może już dodać wartość
value = LoadFromDb();
cache.Set("key", value);
}
Co się może zadziać w tym przypadku?
- wątek A robi
TryGetValue
– dostaje false, bo w cache nie ma jeszcze takiej wartości - wątek B robi
TryGetValue
– dostaje false, bo w cache nie ma jeszcze takiej wartości - oba wątki pobierają dane z bazy
- oba wątki dodają wartość do cache
W efekcie:
- powielamy zapytanie do bazy danych (co może być kosztowną operacją)
- jeden wpis nadpisuje drugi
Ale jeszcze ciekawszy przypadek, który może pomóc zobrazować skalę problemu:
int data = 0;
Parallel.For(0, 10, _ =>
{
var cachedItem = memoryCache.GetOrCreate<int>("data", _ => Interlocked.Increment(ref data));
Console.WriteLine($"Cached Item: {cachedItem}");
});
Co tu chcemy osiągnąć?
Robimy równoległą pętlę, która ma 10 iteracji. W każdej iteracji pętli pobieramy z cache wartość pod kluczem „data” lub dodajemy, jeśli takiej nie ma. W efekcie chcielibyśmy otrzymać na konsoli dziesięć jedynek – ponieważ na początku dodaliśmy wartość 1 pod kluczem data. A co otrzymujemy? U mnie to było:
Cached Item: 3
Cached Item: 5
Cached Item: 6
Cached Item: 2
Cached Item: 1
Cached Item: 4
Cached Item: 4
Cached Item: 4
Cached Item: 4
Cached Item: 7
U Ciebie będą pewnie nieco inne wyniki, natomiast nie uzyskamy tego, czego się spodziewamy. Ponieważ operujemy na MemoryCache
z różnych wątków.
Zatem mimo wątkowo bezpiecznych mechanizmów w środku, MemoryCache
nie jest wątkowo bezpieczny.
Jak temu zaradzić? Mamy dwie opcje:
- używać podwójnych locków (podobnie jak przy klasycznym singletonie)
- używać biblioteki
LazyCache
.
Biblioteka LazyCache
gwarantuje nam już jednorazową inicjalizację cache nawet z wielu wątków. Nie jest jednak spójna z interfejsem IMemoryCache
, bo służy do trochę innych rzeczy. Ale to nie jest artykuł o tej bibliotece.
Usuwanie danych
Pomówmy sobie jeszcze o usuwaniu danych. Mamy do tego właściwie trzy możliwości:
- automatyczne usuwanie danych na podstawie czasu ich życia lub tokenu,
- usunięcie danych ręcznie,
- usuwanie danych, gdy cache przekroczy ustawiony limit.
O pierwszym sposobie już pisałem wyżej. Drugi – no to po prostu wywołanie metody Remove
, więc nie ma o czym gadać. Natomiast dość ciekawy jest trzeci sposób.
Limitowanie wielkości cache
Jak już pisałem wyżej, domyślnie MemoryCache
nie ma żadnych ograniczeń. Więc jeśli nie ustawiamy czasu życia poszczególnych elementów, możemy dojść do OutOfMemory.
Ale jest dodatkowa opcja limitowania cache, którą warto rozważyć. A mianowicie ustawianie limitu wielkości cache. Robimy to podczas tworzenia cache lub jego rejestracji:
builder.Services.AddMemoryCache(o =>
{
o.SizeLimit = 100;
o.CompactionPercentage = 0.2f;
});
I co tu się zadziało?
Ustawiliśmy limit cache na 100. Teraz pytanie – czym jest to 100. To jest jednostka wybrana przez Ciebie. Sam decydujesz o tym, ile to jest 1. Tu nie chodzi o bajty, megabajty ani inne takie. Programista sam sobie ustala jednostkę, a potem musi się jej trzymać.
To teraz drugie pytanie – skąd cache ma wiedzieć, kiedy doszedł do limitu pamięci?
Ponieważ, gdy ustawiasz limit pamięci, to każdemu elementowi, który wkładasz do cache, musisz ustawić rozmiar, jaki zajmuje w TWOJEJ JEDNOSTCE (jeśli tego nie ustawisz, apka się wywali).
Np.:
memoryCache.Set("test", 200, new MemoryCacheEntryOptions
{
Size = 1
});
Dodaję wartość 200 do cache. Dodatkowo podaję, ile przestrzeni zajmuje w moich jednostkach. W moim przypadku wartość 200 zajmuje 1.
Takie zarządzanie ma swoje plusy i minusy. Z jednej strony, przy moim pierwszym spotkaniu z tym limitem byłem przekonany, że wszędzie są po prostu bajty. A to oczywiście bzdura. Dajmy na to, jeśli w cache umieszczasz stringi, sam określasz ich długość. Czy to jest JEDEN string, czy ten string zajmuje przestrzeń równą ilości znaków.
Czyli sam sobie ustalasz tę jednostkę i musisz się jej spójnie trzymać w systemie.
To teraz kolejne pytanie – czym jest CompactionPercentage
?
To jest informacja, o ile procent ma się zmniejszyć cache w momencie przekroczenia limitu.
Czyszczenie cache po przekroczeniu limitu
Weźmy pod uwagę taki kodzik:
for (int i = 0; i < 200; i++)
{
memoryCache.Set("test" + i, i, new MemoryCacheEntryOptions
{
Size = 1
});
}
Wcześniej ustawiłem rozmiar cache na 100. Tutaj każdy element ma rozmiar 1. To znaczy, że w cache mogę mieć 100 takich elementów. W momencie, gdy będę chciał dodać kolejny, mechanizm się jorgnie, że cache przekroczyłby swój limit, zatem robi „kompakt”. A więc usuwa inne elementy. Ile? Tyle procent, ile ma określone w CompactionPercentage
. Określiłem to na 20%, więc w tym wypadku usunie 20 elementów (bo każdy z nich ma rozmiar 1). A skąd wie, co ma usunąć?
Najpierw usuwa elementy, którym skończył się czas życia (jeśli w ogóle takie są).
Jeśli nadal musi usuwać elementy, to usuwa elementy względem nadanego im priorytetu:
- Niski –
CacheItemPriority.Low
- Normalny (domyślny priorytet dla CacheEntry) –
CacheItemPriority.Normal
- Wysoki –
CacheItemPriority.High
Z każdej tej listy usuwa elementy zgodnie z polityką LRU – Least Recently Used. To znaczy, że zaczyna usuwać najpierw te elementy, które były najdłużej nieużywane.
Mechanizm cache po każdym pobraniu elementu zmienia czas jego ostatniego użycia. To znaczy, że jeśli długo nie odczytujesz jakiegoś elementu, to ostatnio był używany „dawno temu”. Dla cache to informacja, że możliwe że już nie będzie używany.
Więc usuwa te elementy w konkretnej kolejności – od najdłużej nieużywanego (czas ostatniego dostępu najmniejszy).
Rzecz jasna po usunięciu każdego elementu sprawdza, ile odzyskał miejsca. Jeśli dojdzie do tej wartości określonej w CompactionPercentage
przestaje usuwać dalej.
Chyba nie ma więcej problemów związanych z tym mechanizmem, jednak jeśli mi coś umknęło koniecznie daj znać w komentarzu.
Dzięki za przeczytanie tego artykułu. Jeśli coś nie jest jasne lub zauważyłeś gdzieś błąd, koniecznie daj znać. A może chcecie więcej artykułów o cache? W sumie i tak zamierzam je napisać 😀