Go Programming

ساخت یک حافظه پنهان LRU ایمن برای رشته‌ها در Go: الگوهای همزمانی و مقایسه عملکرد با sync.Map

همزمانی یکی از نقاط قوت اصلی Go است، اما مدیریت وضعیت مشترک را پیچیده می‌کند. یکی از نیازهای رایج در برنامه‌های Go با عملکرد بالا، یک حافظه پنهان درون‌حافظه‌ای با سیاست حذف LRU (کمترین استفاده اخیر) است. اگرچه کتابخانه استاندارد Go sync.Map را برای ذخایر کلید-مقدار همزمان ارائه می‌دهد، اما برای بارهای کاری کش بهینه‌سازی نشده است. در این پست، ما بررسی خواهیم کرد که چگونه می‌توان یک حافظه پنهان LRU ایمن برای رشته‌ها و مقاوم را با استفاده از sync.Mutex و sync.Cond پیاده‌سازی کرد و آن را با sync.Map مقایسه عملکرد کرد تا مزایا و معایب هر کدام را درک کنیم.

چالش‌های وضعیت مشترک

یک حافظه پنهان LRU به دو ساختار داده اصلی نیاز دارد: یک لیست پیوندی دوطرفه برای حفظ ترتیب دسترسی و یک نقشه هش برای جستجوی O(1). هنگامی که چندین گوروتین به طور همزمان به این ساختار دسترسی دارند، شرایط مسابقه (Race Conditions) می‌تواند نشانگرهای لیست یا ورودی‌های نقشه را خراب کند. رویکرد ساده استفاده از یک متغیر سراسری ناامن است. ما به اصول همزمان‌سازی نیاز داریم که امکان عبور داده (Throughput) بالا را بدون ایجاد گلوگاه فراهم کنند.

در اینجا دو تصمیم معماری اصلی وجود دارد:

  • قفل‌گذاری درشت‌دانه (Coarse-grained locking): استفاده از یک sync.Mutex واحد برای محافظت از کل ساختار حافظه پنهان. این روش پیاده‌سازی ساده‌ای دارد و معمولاً برای بسیاری از موارد استفاده کافی است.
  • قفل‌گذاری ریزدانه (Fine-grained locking - Sharding): تقسیم حافظه پنهان به چندین بخش، که هر کدام دارای قفل (mutex) خود هستند، برای کاهش رقابت بر سر قفل‌ها.

برای این نمایش، ما بر یک پیاده‌سازی تمیز و ساده با استفاده از یک قفل متمرکز خواهیم کرد، زیرا این روش اغلب نقطه شروع با عملکرد بالاتری برای حافظه‌های پنهان در اندازه متوسط است.

پیاده‌سازی حافظه پنهان LRU ایمن برای رشته‌ها

پیاده‌سازی ما یک لیست پیوندی دوطرفه و یک نقشه را در بر می‌گیرد. sync.Mutex تضمین می‌کند که عملیاتی مانند Get، Set و Evict اتمی باشند.

package lru

import (
    "container/list"
    "sync"
)

// Cache represents a thread-safe LRU cache.
type Cache struct {
    mu    sync.Mutex
    items map[string]*list.Element
    ll    *list.List
    maxSize int
}

// element stores the key and value in the linked list.
type element struct {
    key   string
    value interface{}
}

// New creates a new LRU cache with a specified maximum size.
func New(maxSize int) *Cache {
    return &Cache{
        items:   make(map[string]*list.Element),
        ll:      list.New(),
        maxSize: maxSize,
    }
}

// Get retrieves a value from the cache. If the key exists, it is moved to the front.
func (c *Cache) Get(key string) (interface{}, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()

    if elem, ok := c.items[key]; ok {
        // Move to front (most recently used)
        c.ll.MoveToFront(elem)
        return elem.Value.(*element).value, true
    }
    return nil, false
}

// Set adds a key-value pair to the cache. If full, it evicts the least recently used item.
func (c *Cache) Set(key string, value interface{}) {
    c.mu.Lock()
    defer c.mu.Unlock()

    // Check if key already exists
    if elem, ok := c.items[key]; ok {
        c.ll.MoveToFront(elem)
        elem.Value.(*element).value = value
        return
    }

    // Evict if at capacity
    if c.ll.Len() >= c.maxSize {
        oldest := c.ll.Back()
        if oldest != nil {
            c.ll.Remove(oldest)
            delete(c.items, oldest.Value.(*element).key)
        }
    }

    // Add new item to front
    newElem := c.ll.PushFront(&element{key: key, value: value})
    c.items[key] = newElem
}

مقایسه عملکرد: sync.Mutex در برابر sync.Map

sync.Map در Go برای موارد استفاده خاص بهینه‌سازی شده است: زمانی که مجموعه کلیدها بین گوروتین‌ها جدا از هم هستند، یا زمانی که تعداد خواندن‌ها بسیار بیشتر از نوشتن‌هاست. با این حال، برای یک حافظه پنهان با هدف عمومی که دارای الگوهای خواندن/نوشتن ترکیبی و به‌روزرسانی‌های مکرر کلید است، sync.Map اغلب به دلیل پیچیدگی داخلی و سربار حافظه عملکرد کمتری دارد.

بیایید به یک سناریوی مقایسه عملکرد مفهومی نگاه کنیم. ما 100,000 خواندن همزمان و 10,000 نوشتن را شبیه‌سازی خواهیم کرد.

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

در مقایسه‌های عملکرد معمولی که شامل دسترسی به کلیدهای تصادفی و به‌روزرسانی‌های مکرر است، حافظه پنهان LRU مبتنی بر ماکسیموم از sync.Map با اختلاف قابل توجهی عملکرد بهتری دارد. sync.Map از غیرمستقیم‌سازی (indirection) و قفل‌گذاری شرطی در داخل استفاده می‌کند که تأخیر را افزایش می‌دهد. ماکسیموم استاندارد، به ویژه با پیاده‌سازی‌های مدرن Go (Go 1.12 به بعد)، از چرخش تطبیقی (adaptive spinning) و futexهای کارآمد استفاده می‌کند که آن را برای بخش‌های بحرانی کوتاه مانند عملیات حافظه پنهان ما بسیار کارآمد می‌سازد.

چه زمانی از چه چیزی استفاده کنیم؟

از حافظه پنهان LRU سفارشی sync.Mutex زمانی استفاده کنید که:

  • به سیاست‌های حذف دقیق (LRU، LFU، FIFO) نیاز دارید.
  • بار کاری شما شامل الگوهای ترکیبی خواندن/نوشتن است.
  • استفاده پیش‌بینی‌شده از حافظه و سربار کمتر را می‌خواهید.

از sync.Map زمانی استفاده کنید که:

  • داده‌های ایمنی دارید که به ندرت حذف می‌شوند.
  • خوانندگان و نویسندگان روی مجموعه‌های کاملاً جداگانه‌ای از کلیدها عمل می‌کنند.
  • به یک نقشه همزمان سریع بدون سربار نیاز دارید بدون اینکه منطق حذف را پیاده‌سازی کنید.

نتیجه‌گیری

ساخت یک حافظه پنهان LRU ایمن برای رشته‌ها در Go یک تمرین straightforward در درک اصول همزمان‌سازی است. اگرچه sync.Map یک ابزار قدرتمند در کتابخانه استاندارد است، اما جایگزین مستقیم برای ساختارهای کش تخصصی نیست. با پیاده‌سازی یک حافظه پنهان سفارشی با sync.Mutex، توسعه‌دهندگان کنترل دقیقی بر منطق حذف کسب می‌کنند و اغلب عملکرد بهتری در سناریوهای کش با هدف عمومی به دست می‌آورند.

برای سیستم‌های تولیدی با همزمانی شدید، در نظر بگیرید که حافظه پنهان خود را به بخش‌های کوچک‌تر تقسیم کنید یا از کتابخانه‌های تثبیت‌شده مانند groupcache یا bigcache استفاده کنید. با این حال، برای بیشتر برنامه‌ها، رویکرد ساده مبتنی بر ماکسیموم بهترین تعادل بین سادگی، عملکرد و کنترل را فراهم می‌کند.

Share: