C # Implementasi dalam C # .NET

Halo, Habr! Untuk mengantisipasi dimulainya kursus "C # ASP.NET Core Developer" , kami menyiapkan terjemahan materi yang menarik tentang implementasi cache di C #. Selamat membaca.



Salah satu pola yang paling umum digunakan dalam pengembangan perangkat lunak adalah caching . Ini adalah konsep yang sederhana dan, pada saat yang sama, sangat efektif. Idenya adalah untuk menggunakan kembali hasil operasi yang dilakukan. Setelah operasi yang memakan waktu, kami menyimpan hasilnya dalam wadah cache kami . Lain kali kita membutuhkan hasil ini, kita akan mengekstraknya dari wadah cache, daripada harus melakukan operasi yang melelahkan lagi.

Misalnya, untuk mendapatkan avatar pengguna, Anda mungkin harus memintanya dari basis data. Alih-alih mengeksekusi permintaan dengan setiap panggilan, kami akan menyimpan avatar ini di cache, mengekstraknya dari memori setiap kali Anda membutuhkannya.

Caching bagus untuk data yang jarang berubah. Atau, idealnya, mereka tidak pernah berubah. Data yang terus berubah, misalnya, waktu saat ini, tidak boleh di-cache, jika tidak, Anda berisiko mengambil hasil yang salah.

Cache lokal, cache lokal persisten, dan cache terdistribusi


Ada 3 jenis cache:

  • In-Memory Cache digunakan untuk kasus-kasus ketika Anda hanya perlu mengimplementasikan cache dalam satu proses. Ketika sebuah proses mati, cache mati bersamanya. Jika Anda menjalankan proses yang sama di beberapa server, Anda akan memiliki cache terpisah untuk setiap server.
  • Cache dalam-proses persisten - ini adalah saat Anda membuat cadangan cache di luar memori proses. Ini dapat ditemukan di file atau di database. Itu lebih kompleks daripada cache di memori, tetapi jika proses Anda restart, cache tidak memerah. Paling cocok untuk kasus-kasus di mana mendapatkan barang yang di-cache mahal dan proses Anda cenderung sering dimulai kembali.
  • Cache Terdistribusi adalah ketika Anda membutuhkan cache bersama untuk beberapa mesin. Biasanya ini adalah beberapa server. Cache yang didistribusikan disimpan dalam layanan eksternal. Ini berarti bahwa jika satu server memiliki elemen cache, server lain juga dapat menggunakannya. Layanan seperti Redis sangat bagus untuk ini.

Kami hanya akan berbicara tentang cache lokal .

Implementasi primitif


Mari kita mulai dengan membuat implementasi cache yang sangat sederhana di 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];
    }
}

Menggunakan:

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

Kode sederhana ini memecahkan masalah penting. Untuk mendapatkan avatar pengguna, hanya permintaan pertama yang akan menjadi permintaan aktual dari basis data. Data avatar ( byte []) oleh hasil permintaan disimpan dalam memori proses. Semua permintaan avatar selanjutnya akan mengambilnya dari memori, menghemat waktu dan sumber daya.

Tetapi, seperti kebanyakan hal dalam pemrograman, banyak hal yang tidak begitu sederhana. Implementasi di atas bukanlah solusi yang baik karena sejumlah alasan. Di satu sisi, implementasi ini tidak aman untuk thread . Ketika digunakan dari banyak utas, pengecualian dapat terjadi. Selain itu, item yang di-cache akan tetap berada dalam memori selamanya, yang sebenarnya sangat buruk.

Inilah mengapa kami harus menghapus item dari cache:

  1. Cache dapat mulai mengambil banyak memori, yang akhirnya mengarah ke pengecualian karena kekurangan dan crash.
  2. Konsumsi memori yang tinggi dapat menyebabkan tekanan memori (juga dikenal sebagai Tekanan GC ). Dalam keadaan ini, pengumpul sampah bekerja lebih dari yang seharusnya, yang mengurangi kinerja.
  3. Cache mungkin perlu diperbarui ketika data berubah. Infrastruktur caching kami harus mendukung fitur ini.

Untuk mengatasi masalah ini ada dalam kerangka kebijakan perpindahan (juga dikenal sebagai penghapusan kebijakan - Kebijakan Penggusuran / Penghapusan ). Ini adalah aturan untuk menghapus item dari cache sesuai dengan logika yang diberikan. Di antara kebijakan penghapusan umum adalah sebagai berikut:

  • Kebijakan Kedaluwarsa Absolut yang menghapus item dari cache setelah waktu yang tetap, apa pun yang terjadi.
  • Kebijakan Kedaluwarsa Sliding yang menghapus item dari cache jika belum diakses selama periode waktu tertentu. Artinya, jika saya mengatur waktu kedaluwarsa menjadi 1 menit, item itu akan tetap ada di cache sementara saya menggunakannya setiap 30 detik. Jika saya tidak menggunakannya lebih dari satu menit, item tersebut akan dihapus.
  • Kebijakan Batas Ukuran , yang akan membatasi ukuran cache.

Sekarang kami telah menemukan semua yang kami butuhkan, mari beralih ke solusi yang lebih baik.

Solusi yang lebih baik


Sangat mengecewakan saya sebagai seorang blogger, Microsoft telah menciptakan implementasi cache yang luar biasa. Ini menghilangkan saya dari kesenangan membuat implementasi yang serupa sendiri, tetapi setidaknya penulisan artikel ini juga kurang.

Saya akan menunjukkan kepada Anda solusi Microsoft, cara menggunakannya secara efektif, dan kemudian bagaimana memperbaikinya untuk beberapa skenario.

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


Microsoft memiliki 2 solusi, 2 paket caching NuGet yang berbeda. Keduanya bagus. Menurut rekomendasi Microsoft, lebih baik digunakan Microsoft.Extensions.Caching.Memory, karena terintegrasi lebih baik dengan Asp. Core NET. Ini dapat dengan mudah diintegrasikan ke dalam mekanisme injeksi dependensi Asp .NET Core.

Berikut adalah contoh sederhana dengan 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;
    }
}

Menggunakan:

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

Ini sangat mengingatkan saya pada saya sendiri NaiveCache, jadi apa yang telah berubah? Yah, pertama, ini implementasi yang aman . Anda dapat dengan aman menyebutnya dari beberapa utas sekaligus.

Kedua, ini MemoryCachememperhitungkan semua kebijakan crowding-out yang kita bicarakan sebelumnya. Berikut ini contohnya:

IMemoryCache dengan kebijakan preemption:

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

Mari kita menganalisis elemen-elemen baru:

  1. MemoryCacheOptions telah ditambahkan SizeLimit. Ini menambahkan kebijakan batas ukuran ke wadah cache kami. Cache tidak memiliki mekanisme untuk mengukur ukuran catatan. Karena itu, kita perlu mengatur ukuran setiap entri cache. Dalam hal ini, setiap kali kita mengatur ukuran ke 1 dengan SetSize(1). Ini berarti bahwa cache kami akan memiliki batas 1024 elemen.
  2. , ? .SetPriority (CacheItemPriority.High). : Low (), Normal (), High () NeverRemove ( ).
  3. SetSlidingExpiration(TimeSpan.FromSeconds(2)) 2 . , 2 , .
  4. SetAbsoluteExpiration(TimeSpan.FromSeconds(10)) 10 . , 10 , .

Selain opsi dalam contoh, Anda juga dapat menetapkan delegasi RegisterPostEvictionCallbackyang akan dipanggil saat item dihapus.

Ini adalah rentang fungsi yang cukup luas, tetapi, bagaimanapun, kita perlu memikirkan apakah ada hal lain untuk ditambahkan. Sebenarnya ada beberapa hal.

Masalah dan fitur yang hilang


Ada beberapa bagian penting yang hilang dari implementasi ini.

  1. Meskipun Anda dapat menetapkan batas ukuran, caching sebenarnya tidak mengontrol tekanan memori. Jika kita memonitor, kita bisa memperketat kebijakan dengan tekanan tinggi dan melemahkan kebijakan dengan rendah.
  2. , . . , , , 10 . 2 , , ( ), .

Mengenai masalah tekanan pertama pada gc: dimungkinkan untuk mengontrol tekanan pada gc dengan beberapa metode dan heuristik. Posting ini bukan tentang ini, tetapi Anda dapat membaca artikel saya โ€œMenemukan, Memperbaiki, dan Mencegah Kebocoran Memori di C # .NET: 8 Praktik Terbaikโ€ untuk mempelajari tentang beberapa metode yang berguna.

Masalah kedua lebih mudah dipecahkan . Sebenarnya, ini adalah implementasi MemoryCacheyang sepenuhnya menyelesaikannya:

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

Menggunakan:

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

Dalam implementasi ini, ketika Anda mencoba untuk mendapatkan elemen, jika elemen yang sama sudah dalam proses dibuat oleh utas lain, Anda akan menunggu sampai utas pertama selesai. Kemudian Anda akan mendapatkan item yang sudah di-cache dibuat oleh utas lain.

Penguraian kode


Implementasi ini memblokir pembuatan elemen. Penguncian terjadi pada tombol. Misalnya, jika kita menunggu avatar Alexey, kita masih bisa mendapatkan nilai cache dari Zhenya atau Barbara di utas lain.

Kamus _locksmenyimpan semua kunci. Kunci biasa tidak berfungsi async/await, jadi kita perlu menggunakan SemaphoreSlim .

Ada 2 cek untuk memeriksa apakah nilainya sudah di-cache jika (!_Cache.TryGetValue(key, out cacheEntry)). Yang ada di kunci adalah yang menyediakan satu-satunya kreasi elemen. Yang ada di luar kunci, untuk optimasi.

Kapan harus digunakan WaitToFinishMemoryCache


Implementasi ini jelas memiliki beberapa overhead. Mari kita lihat kapan itu relevan.

Gunakan WaitToFinishMemoryCachesaat:

  • Ketika waktu pembuatan item memiliki nilai, dan Anda ingin meminimalkan jumlah kreasi sebanyak mungkin.
  • Ketika waktu untuk membuat item sangat panjang.
  • Ketika suatu item harus dibuat sekali untuk setiap tombol.

Jangan gunakan WaitToFinishMemoryCachesaat:

  • Tidak ada bahaya bahwa banyak utas akan mendapatkan akses ke elemen cache yang sama.
  • Anda tidak kategoris menentang membuat elemen lebih dari sekali. Misalnya, jika satu kueri tambahan ke database tidak terlalu memengaruhi apa pun.

Ringkasan


Caching adalah pola yang sangat kuat. Dan juga berbahaya dan memiliki jebakannya. Cache terlalu banyak dan Anda dapat menyebabkan tekanan pada GC. Tembolok terlalu sedikit dan Anda dapat menyebabkan masalah kinerja. Ada juga caching terdistribusi, yang mewakili dunia baru untuk dijelajahi. Ini adalah pengembangan perangkat lunak, selalu ada sesuatu yang baru yang dapat dikuasai.

Saya harap Anda menikmati artikel ini. Jika Anda tertarik pada manajemen memori, artikel saya berikutnya akan fokus pada bahaya tekanan pada GC dan bagaimana mencegahnya, jadi daftar . Nikmati pengkodean Anda.



Pelajari lebih lanjut tentang kursus.



All Articles