深入理解.NET MemoryCache


摘要

MemoryCache是.Net Framework 4.0開始提供的內存緩存類,使用該類型可以方便的在程序內部緩存數據並對於數據的有效性進行方便的管理,借助該類型可以實現ASP.NET中常用的Cache類的相似功能,並且可以適應更加豐富的使用場景。在使用MemoryCache時常常有各種疑問,數據是怎么組織的?有沒有可能用更高效的組織和使用方式?數據超時如何控制?為了知其所以然,本文中對於MemoryCache的原理和實現方式進行了深入分析,同時在分析的過程中學習到了許多業界成熟組件的設計思想,為今后的工作打開了更加開闊的思路

本文面向的是.net 4.5.1的版本,在后續的.net版本中MemoryCache有略微的不同,歡迎補充

文章內容較長,預計閱讀時間1小時左右


MemoryCache類繼承自ObjectCache抽象類,並且實現了IEnumerableIDisposable接口。跟ASP.NET常用的Cache類實現了相似的功能,但是MemoryCache更加通用。使用它的時候不必依賴於System.Web類庫,並且在同一個進程中可以使用MemoryCache創建多個實例。

在使用MemoryCache的時候通常會有些疑問,這個類到底內部數據是如何組織的?緩存項的超時是如何處理的?它為什么宣傳自己是線程安全的?為了回答這些問題,接下來借助Reference Source對於MemoryCache的內部實現一探究竟。

MemoryCache內部數據結構

在MemoryCache類內部,數據的組織方式跟MemoryCacheStore、MemoryCacheKey和MemoryCacheEntry這三個類有關,它們的作用分別是:

  • MemoryCacheStore:承載數據
  • MemoryCacheKey:構造檢索項
  • MemoryCacheEntry:緩存內部數據的真實表現形式

MemoryCache和MemoryCacheStore的關系大致如下圖所示:

MemoryCache主要的三個類型的關系圖

從圖上可以直觀的看出,一個MemoryCache實例對象可以包含多個MemoryCacheStore對象,具體有幾個需要取決於程序所在的硬件環境,跟CPU數目有關。在MemoryCache的內部,MemoryCacheStore對象就像一個個的小數據庫一樣,承載着各種數據。所以,要理解MemoryCache內部的數據結構,就需要先理解MemoryCacheStore的地位和作用。

MemoryCacheStore

該類型是MemoryCache內部真正用於承載數據的容器。它直接管理着程序的內存緩存項,既然要承載數據,那么該類型中必然有些屬性與數據存儲有關。其具體表現是:MemoryCache中有一個類型為HashTable的私有屬性_entries,在該屬性中存儲了它所管理的所有緩存項。

Hashtable _entries = new Hashtable(new MemoryCacheEqualityComparer());

當需要去MemoryCache中獲取數據的時候,MemoryCache所做的第一步就是尋找存儲被查找key的MemoryCacheStore對象,而並非是我們想象中的直接去某個Dictionary類型或者HashTable類型的對象中直接尋找結果。

在MemoryCache中查找MemoryCacheStore的方式也挺有趣,主要的邏輯在MemoryCache的GetStore方法中,源碼如下(為了理解方便增加了部分注釋):

internal MemoryCacheStore GetStore(MemoryCacheKey cacheKey) {
    int hashCode = cacheKey.Hash;//獲取key有關的hashCode值
    if (hashCode < 0) {
        //避免出現負數
        hashCode = (hashCode == Int32.MinValue) ? 0 : -hashCode;
    }
    int idx = hashCode & _storeMask;
    //_storeMask跟CPU的數目一致,通過&進行按位與計算獲取到對應的Store
    //本處代碼是.NET 4.5的樣子,在.NET Framework 4.7.2版本已經改成了使用%進行取余計算,對於正整數來說實際結果是一樣的。
    return _stores[idx];
}

既然可能存在多個MemoryCacheStore對象,那么就需要有一定的規則來決定每個Store中存儲的內容。從源碼中可以看出,MemoryCache使用的是CPU的核數作為掩碼,並利用該掩碼和key的hashcode來計算緩存項的歸屬地,確實是簡單而高效。

MemoryCacheKey

MemoryCacheKey的類功能相對比較簡單,主要用於封裝緩存項的key及相關的常用方法。

上文提到了MemoryCacheStore中_entries的初始化方式,在構造函數的參數是一個MemoryCacheEqualityComparer對象,這是個什么東西,又是起到什么作用的呢?

MemoryCacheEqualityComparer類實現了IEqualityComparer接口,其中便定義了哈希表中判斷值相等的方法,來分析下源碼:

internal class MemoryCacheEqualityComparer: IEqualityComparer {

    bool IEqualityComparer.Equals(Object x, Object y) {
        Dbg.Assert(x != null && x is MemoryCacheKey);
        Dbg.Assert(y != null && y is MemoryCacheKey);

        MemoryCacheKey a, b;
        a = (MemoryCacheKey)x;
        b = (MemoryCacheKey)y;
        //MemoryCacheKey的Key屬性就是我們在獲取和設置緩存時使用的key值
        return (String.Compare(a.Key, b.Key, StringComparison.Ordinal) == 0);
    }

    int IEqualityComparer.GetHashCode(Object obj) {
        MemoryCacheKey cacheKey = (MemoryCacheKey) obj;
        return cacheKey.Hash;
    }
}

從代碼中可以看出,MemoryCacheEqualityComparer的真正作用就是定義MemoryCacheKey的比較方法。判斷兩個兩個MemoryCacheKey是否相等使用的就是MemoryCacheKey中的Key屬性。因此我們在MemoryCache中獲取和設置相關的內容時,使用的都是對於MemoryCacheKey的相關運算結果。

MemoryCacheEntry

此類型是緩存項在內存中真正的存在形式。它繼承自MemoryCacheKey類型,並在此基礎上增加了很多的屬性和方法,比如判斷是否超時等。

先來看下該類的整體情況:

MemoryCacheEntry的方法和屬性

總的來說,MemoryCacheEntry中的屬性和方法主要為三類:

  1. 緩存的內容相關,如Key、Value
  2. 緩存內容的狀態相關,如State、HasExpiration方法等
  3. 緩存內容的相關事件相關,如CallCacheEntryRemovedCallback方法、CallNotifyOnChanged方法等

理解了MemoryCache中數據的組織方式后,可以幫助理解數據是如何從MemoryCache中被一步步查詢得到的。

如何從MemoryCahe中查詢數據

從MemoryCache中獲取數據經歷了哪些過程呢?從整體來講,大致可以分為兩類:獲取數據和驗證有效性。

以流程圖的方式表達上述步驟如下:

MemoryCache查詢數據主要流程

詳細的步驟是這樣的:

  1. 校驗查詢參數RegionName和Key,進行有效性判斷
  2. 構造MemoryCacheKey對象,用於后續步驟查詢和比對現有數據
  3. 獲取MemoryCacheStore對象,縮小查詢范圍
  4. 從MemoryCacheStore的HashTable類型屬性中提取MemoryCacheEntry對象,得到key對應的數據
  5. 判斷MemoryCacheEntry對象的有效性,進行數據驗證工作
  6. 處理MemoryCacheEntry的滑動超時時間等訪問相關的邏輯

看到此處,不禁想起之前了解的其他緩存系統中的設計,就像歷史有時會有驚人的相似性,進行了良好設計的緩存系統在某些時候看起來確實有很多相似的地方。通過學習他人的優良設計,從中可以學到很多的東西,比如接下來的緩存超時機制。

MemoryCache超時機制

MemoryCache在設置緩存項時可以選擇永久緩存或者在超時后自動消失。其中緩存策略可以選擇固定超時時間和滑動超時時間的任意一種(注意這兩種超時策略只能二選一,下文中會解釋為什么有這樣的規則)。

緩存項的超時管理機制是緩存系統(比如Redis和MemCached)的必備功能,Redis中有主動檢查和被動觸發兩種,MemCached采用的是被動觸發檢查,那么內存緩存MemoryCache內部是如何管理緩存項的超時機制?

MemoryCache對於緩存項的超時管理機制與Redis類似,也是有兩種:定期刪除和惰性刪除。

定期刪除

既然MemoryCache內部的數據是以MemoryCacheStore對象為單位進行管理,那么定期檢查也很有可能是MemoryCacheStore對象內部的一種行為。

通過仔細閱讀源碼,發現MemoryCacheStore的構造函數中調用了InitDisposableMembers()這個方法,該方法的代碼如下:

private void InitDisposableMembers() {
    //_insertBlock是MemoryCacheStore的私有屬性
    //_insertBlock的聲明方式是:private ManualResetEvent _insertBlock;
    _insertBlock = new ManualResetEvent(true);
    //_expires是MemoryCacheStore的私有屬性
    //_expires的聲明方式是:private CacheExpires _expires;
    _expires.EnableExpirationTimer(true);
}

其中跟本章節討論的超時機制有關的就是_expires這個屬性。由於《.NET reference source》中並沒有這個CacheExpires類的相關源碼,無法得知具體的實現方式,因此從Mono項目中找到同名的方法探索該類型的具體實現。


class CacheExpires : CacheEntryCollection
{

    public static TimeSpan MIN_UPDATE_DELTA = new TimeSpan (0, 0, 1);
    public static TimeSpan EXPIRATIONS_INTERVAL = new TimeSpan (0, 0, 20);
    public static CacheExpiresHelper helper = new CacheExpiresHelper ();

    Timer timer;

    public CacheExpires (MemoryCacheStore store)
        : base (store, helper)
    {
    }

    public new void Add (MemoryCacheEntry entry)
    {
        entry.ExpiresEntryRef = new ExpiresEntryRef ();
        base.Add (entry);
    }

    public new void Remove (MemoryCacheEntry entry)
    {
        base.Remove (entry);
        entry.ExpiresEntryRef = ExpiresEntryRef.INVALID;
    }

    public void UtcUpdate (MemoryCacheEntry entry, DateTime utcAbsExp)
    {
        base.Remove (entry);
        entry.UtcAbsExp = utcAbsExp;
        base.Add (entry);
    }

    public void EnableExpirationTimer (bool enable)
    {
        if (enable) {
            if (timer != null)
                return;

            var period = (int) EXPIRATIONS_INTERVAL.TotalMilliseconds;
            timer = new Timer ((o) => FlushExpiredItems (true), null, period, period);
        } else {
            timer.Dispose ();
            timer = null;
        }
    }

    public int FlushExpiredItems (bool blockInsert)
    {
        return base.FlushItems (DateTime.UtcNow, CacheEntryRemovedReason.Expired, blockInsert);
    }
}

通過Mono中的源代碼可以看出,在CacheExpires內部使用了一個定時器,通過定時器觸發定時的檢查。在觸發時使用的是CacheEntryCollection類的FlushItems方法。該方法的實現如下;

protected int FlushItems (DateTime limit, CacheEntryRemovedReason reason, bool blockInsert, int count = int.MaxValue)
{
    var flushedItems = 0;
    if (blockInsert)
        store.BlockInsert ();

    lock (entries) {
        foreach (var entry in entries) {
            if (helper.GetDateTime (entry) > limit || flushedItems >= count)
                break;

            flushedItems++;
        }

        for (var f = 0; f < flushedItems; f++)
            store.Remove (entries.Min, null, reason);
    }

    if (blockInsert)
        store.UnblockInsert ();

    return flushedItems;
}

FlushItems(***)的邏輯中,通過遍歷所有的緩存項並且比對了超時時間,將發現的超時緩存項執行Remove操作進行清理,實現緩存項的定期刪除操作。通過Mono項目中該類的功能推斷,在.net framework中的實現應該也是有類似的功能,即每一個MemoryCache的實例都會有一個負責定時檢查的任務,負責處理掉所有超時的緩存項。

惰性刪除

除了定時刪除以外,MemoryCache還實現了惰性刪除的功能,這項功能的實現相對於定時刪除簡單的多,而且非常的實用。

惰性刪除是什么意思呢?簡單的講就是在使用緩存項的時候判斷緩存項是否應該被刪除,而不用等到被專用的清理任務清理。

前文描述過MemoryCache中數據的組織方式,既然是在使用時觸發的邏輯,因此惰性刪除必然與MemoryCacheStore獲取緩存的方法有關。來看下它的Get方法的內部邏輯:

internal MemoryCacheEntry Get(MemoryCacheKey key) {
    MemoryCacheEntry entry = _entries[key] as MemoryCacheEntry;
    // 判斷是否超時
    if (entry != null && entry.UtcAbsExp <= DateTime.UtcNow) {
        Remove(key, entry, CacheEntryRemovedReason.Expired);
        entry = null;
    }
    // 更新滑動超時的時間和相關的計數器
    UpdateExpAndUsage(entry);
    return entry;
}

從代碼中可以看出,MemoryCacheStore查找到相關的key對應的緩存項以后,並沒有直接返回,而是先檢查了緩存項目的超時時間。如果緩存項超時,則刪除該項並返回null。這就是MemoryCache中惰性刪除的實現方式。

MemoryCache的緩存過期策略

向MemoryCache實例中添加緩存項的時候,可以選擇三種過期策略

  1. 永不超時
  2. 絕對超時
  3. 滑動超時

緩存策略在緩存項添加/更新緩存時(無論是使用Add或者Set方法)指定,通過在操作緩存時指定CacheItemPolicy對象來達到設置緩存超時策略的目的。

緩存超時策略並不能隨意的指定,在MemoryCache內部對於CacheItemPolicy對象有內置的檢查機制。先看下源碼:

private void ValidatePolicy(CacheItemPolicy policy) {
    //檢查過期時間策略的組合設置
    if (policy.AbsoluteExpiration != ObjectCache.InfiniteAbsoluteExpiration
        && policy.SlidingExpiration != ObjectCache.NoSlidingExpiration) {
        throw new ArgumentException(R.Invalid_expiration_combination, "policy");
    }
    //檢查滑動超時策略
    if (policy.SlidingExpiration < ObjectCache.NoSlidingExpiration || OneYear < policy.SlidingExpiration) {
        throw new ArgumentOutOfRangeException("policy", RH.Format(R.Argument_out_of_range, "SlidingExpiration", ObjectCache.NoSlidingExpiration, OneYear));
    }
    //檢查CallBack設置
    if (policy.RemovedCallback != null
        && policy.UpdateCallback != null) {
        throw new ArgumentException(R.Invalid_callback_combination, "policy");
    }
    //檢查優先級的設置
    if (policy.Priority != CacheItemPriority.Default && policy.Priority != CacheItemPriority.NotRemovable) {
        throw new ArgumentOutOfRangeException("policy", RH.Format(R.Argument_out_of_range, "Priority", CacheItemPriority.Default, CacheItemPriority.NotRemovable));
    }
}

總結下源碼中的邏輯,超時策略的設置有如下幾個規則:

  1. 絕對超時和滑動超時不能同時存在(這是前文中說兩者二選一的原因)
  2. 如果滑動超時時間小於0或者大於1年也不行
  3. RemovedCallbackUpdateCallback不能同時設置
  4. 緩存的Priority屬性不能是超出枚舉范圍(Default和NotRemovable)

MemoryCache線程安全機制

根據MSDN的描述:MemoryCache是線程安全的。那么說明,在操作MemoryCache中的緩存項時,MemoryCache保證程序的行為都是原子性的,而不會出現多個線程共同操作導致的數據污染等問題。

那么,MemoryCache是如何做到這一點的?

MemoryCache在內部使用加鎖機制來保證數據項操作的原子性。該鎖以每個MemoryCacheStore為單位,即同一個MemoryCacheStore內部的數據共享同一個鎖,而不同MemoryCacheStore之間互不影響。

存在加鎖邏輯的有如下場景:

  1. 遍歷MemoryCache緩存項
  2. 向MemoryCache添加/更新緩存項
  3. 執行MemoryCache析構
  4. 移除MemoryCache中的緩存項

其他的場景都比較好理解,其中值得一提的就是場景1(遍歷)的實現方式。在MemoryCache中,使用了鎖加復制的方式來處理遍歷的需要,保證在遍歷過程中不會發生異常。

在.net 4.5.1中的遍歷的實現方式是這樣的:

protected override IEnumerator<KeyValuePair<string, object>> GetEnumerator() {
    Dictionary<string, object> h = new Dictionary<string, object>();
    if (!IsDisposed) {
        foreach (MemoryCacheStore store in _stores) {
            store.CopyTo(h);
        }
    }
    return h.GetEnumerator();
}

其中store.CopyTo(h);的實現方式是在MemoryCacheStore中定義的,也就是說,每個Store的加鎖解鎖都是獨立的過程,縮小鎖機制影響的范圍也是提升性能的重要手段。CopyTo方法的主要邏輯是在鎖機制控制下的簡單的遍歷:

internal void CopyTo(IDictionary h) {
    lock (_entriesLock) {
        if (_disposed == 0) {
            foreach (DictionaryEntry e in _entries) {
                MemoryCacheKey key = e.Key as MemoryCacheKey;
                MemoryCacheEntry entry = e.Value as MemoryCacheEntry;
                if (entry.UtcAbsExp > DateTime.UtcNow) {
                    h[key.Key] = entry.Value;
                }
            }
        }
    }
}

有些出乎意料,在遍歷MemoryCache的時候,為了實現遍歷過程中的線程安全,實現的方式居然是將數據另外拷貝了一份。當然了,說是完全拷貝一份也不盡然,如果緩存項本來就是引用類型,被拷貝的也只是個指針而已。不過看起來最好還是少用為妙,萬一緩存的都是些基礎類型,一旦數據量較大,在遍歷過程中的內存壓力就不是可以忽略的問題了。

總結

在本文中以MemoryCache對於數據的組織管理和使用為軸線,深入的分析了MemoryCache對於一些日常應用有直接關聯的功能的實現方式。MemoryCache通過多個MemoryCacheStore對象將數據分散到不同的HastTable中,並且使用加鎖的方式在每個Store內部保證操作是線程安全的,同時這種邏輯也在一定程度上改善了全局鎖的性能問題。為了實現對於緩存項超時的管理,MemoryCache采取了兩種不同的管理措施,雙管齊下,有效保證了緩存項的超時管理的有效性,並在超時后及時移除相關的緩存以釋放內存資源。通過對於這些功能的分析,了解了MemoryCache內部的數據結構和數據查詢方式,為今后的工作掌握了許多有指導性意義的經驗。

本文還會有后續的篇章,敬請期待~~

參考資料


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM