Eşzamanlılık, Go'nun en güçlü satış noktalarından biridir ancak paylaşılan durumu yönetirken karmaşıklık getirir. Yüksek performanslı Go uygulamalarında, En Az Kullanılanı (LRU) atma politikasına sahip bir bellek içi önbellek yaygın bir gereksinimdir. Go'nun standart kütüphanesi eşzamanlı anahtar-değer depoları için sync.Map sunsa da, önbellekleme iş yükleri için optimize edilmemiştir. Bu yazıda, bir sync.Mutex ve sync.Cond kullanarak sağlam ve thread-safe bir LRU önbelleği nasıl uygulanacağını keşfedecek ve sync.Map ile karşılaştırmalı performans testi yaparak aralarındaki farklılıkları anlayacağız.
Paylaşılan Durumun Zorlukları
Bir LRU önbelleği iki temel veri yapısı gerektirir: erişim sırasını korumak için çift bağlı bir liste ve O(1) aramalar için bir hash haritası. Birden fazla goroutine bu yapıya eşzamanlı olarak eriştiğinde, yarış durumları liste işaretçilerini veya harita girdilerini bozabilir. Global bir değişken kullanmanın saf yaklaşımı güvensizdir. Tıkanıklık yaratmadan yüksek veri işleme hızına olanak tanıyan eşzamanlılık ilke bileşenlerine ihtiyacımız var.
Burada iki ana mimari karar vardır:
- Kaba taneli kilitleme: Tek bir
sync.Mutexkullanarak tüm önbellek yapısını korumak. Bu, uygulanması basittir ve genellikle birçok kullanım durumu için yeterlidir. - İnce taneli kilitleme (Parçalama): Önbelleği, her birinin kendi mutex'ine sahip olan birden fazla segmente bölerek kilitlenme rekabetini azaltmak.
Bu gösterim için, orta boyutlu önbellekler için genellikle en performanslı başlangıç noktası olduğu için tek bir mutex kullanan temiz ve basit bir uygulamaya odaklanacağız.
Thread-Safe LRU Önbelleğinin Uygulanması
Uygulamamız, bir çift bağlı liste ve bir haritayı saracaktır. sync.Mutex, Get, Set ve Evict gibi işlemlerin atomik olmasını sağlayacaktır.
package lru
import (
"container/list"
"sync"
)
// Cache, thread-safe bir LRU önbelleğini temsil eder.
type Cache struct {
mu sync.Mutex
items map[string]*list.Element
ll *list.List
maxSize int
}
// element, bağlı listede anahtarı ve değeri saklar.
type element struct {
key string
value interface{}
}
// New, belirtilen maksimum boyuta sahip yeni bir LRU önbelleği oluşturur.
func New(maxSize int) *Cache {
return &Cache{
items: make(map[string]*list.Element),
ll: list.New(),
maxSize: maxSize,
}
}
// Get, önbellekten bir değer getirir. Anahtar varsa, öne taşınır.
func (c *Cache) Get(key string) (interface{}, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if elem, ok := c.items[key]; ok {
// Öne taşı (en son kullanılan)
c.ll.MoveToFront(elem)
return elem.Value.(*element).value, true
}
return nil, false
}
// Set, önbelleğe bir anahtar-değer çifti ekler. Doluysa, en az kullanılan öğeyi atar.
func (c *Cache) Set(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
// Anahtar zaten var mı kontrol et
if elem, ok := c.items[key]; ok {
c.ll.MoveToFront(elem)
elem.Value.(*element).value = value
return
}
// Kapasite doluyorsa at
if c.ll.Len() >= c.maxSize {
oldest := c.ll.Back()
if oldest != nil {
c.ll.Remove(oldest)
delete(c.items, oldest.Value.(*element).key)
}
}
// Yeni öğeyi öne ekle
newElem := c.ll.PushFront(&element{key: key, value: value})
c.items[key] = newElem
}
Performans Testi: sync.Mutex vs. sync.Map
Go'nun sync.Map'ı belirli kullanım durumları için optimize edilmiştir: anahtar kümeleri goroutineler arasında ayrışmışsa veya yazmalardan çok daha fazla okuma varsa. Ancak, karışık okuma/yazma işlemleri ve sık anahtar güncellemeleri olan genel amaçlı bir önbellek için, sync.Map içsel karmaşıklığı ve bellek aşırı yüklemesi nedeniyle genellikle daha düşük performans gösterir.
Kavramsal bir performans testi senaryosuna bakalım. 100.000 eşzamanlı okuma ve 10.000 yazma simüle edeceğiz.
package main
import (
"testing"
"sync"
)
func BenchmarkLRUCache(b *testing.B) {
cache := New(10000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Set(string(rune(i)), i)
}
}
func BenchmarkSyncMap(b *testing.B) {
var m sync.Map
b.ResetTimer()
for i := 0; i < b.N; i++ {
m.Store(string(rune(i)), i)
}
}
Rastgele anahtar erişimi ve sık güncellemeler içeren tipik performans testlerinde, mutex tabanlı LRU önbelleği sync.Map'ı önemli ölçüde aşar. sync.Map içsel olarak dolaylı erişim ve koşullu kilitleme kullanır ki bu da gecikme ekler. Modern Go uygulamaları (Go 1.12+) ile standart mutex, uyarlanabilir döngü (spinning) ve verimli futexler kullanarak, önbellek işlemlerimizdeki kısa kritik bölümler için son derece verimli hale gelir.
Ne Zaman Hangisini Kullanmalı?
Aşağıdaki durumlarda özel sync.Mutex LRU önbelleğini kullanın:
- Sıkı atma politikalarına (LRU, LFU, FIFO) ihtiyacınız varsa.
- İş yükünüz karışık okuma/yazma desenleri içeriyorsa.
- Tahmin edilebilir bellek kullanımı ve daha düşük aşırı yük istiyorsanız.
Aşağıdaki durumlarda sync.Map kullanın:
- Nadiren silinen statik veriniz varsa.
- Okuyucular ve yazıcılar tamamen ayrışmış anahtar kümeleri üzerinde çalışıyorsa.
- Atma mantığı uygulamadan hızlı, aşırı yükü olmayan eşzamanlı bir harita gerekiyorsa.
Sonuç
Go'da thread-safe bir LRU önbelleği oluşturmak, eşzamanlılık ilke bileşenlerini anlamaya yönelik basit bir egzersizdir. sync.Map standart kütüphanede güçlü bir araç olsa da, özel önbellek yapıları için yerini tutacak bir yedek değildir. sync.Mutex ile özel bir önbellek uygulayarak geliştiriciler, atma mantığı üzerinde ince taneli kontrol elde eder ve genellikle genel amaçlı önbellekleme senaryolarında daha iyi performans sağlarlar.
İntense eşzamanlılık içeren üretim sistemleri için, önbelleğinizi parçalayıp groupcache veya bigcache gibi yerleşik kütüphaneleri kullanmayı düşünün. Ancak, çoğu uygulama için basit mutex tabanlı yaklaşım, sadelik, performans ve kontrol arasında en iyi dengeyi sağlar.