TMCache原始碼分析(一)—TMMemoryCache記憶體快取

蹺腳啖牛肉發表於2019-03-03

原文在這裡

快取是我們移動端開發必不可少的功能, 目前提及的快取按照儲存形式來分主要分為:

  • 記憶體快取: 快速, 讀寫資料量小
  • 磁碟快取: 慢速, 讀寫資料量大(慢速是相對於記憶體快取而言)

那快取的目的是什麼呢? 大概分為以下幾點:

  • 複用資料,避免重複計算.
  • 緩解服務端壓力.
  • 提高使用者體驗,比如離線瀏覽, 節省流量等等.

簡言之,快取的目的就是:

以空間換時間.

目前 gitHub 上開源了很多快取框架, 著名的 TMCache, PINCache, YYCache等, 接下來我會逐一分析他們的原始碼實現, 對比它們的優缺點.

TMCache, PINCache, YYCache基本框架結構都相同, 介面 API 類似, 所以只要會使用其中一個框架, 另外兩個上手起來非常容易, 但是三個框架的內部實現原理略有不同.

TMMemoryCache

TMMemoryCacheTMCache 框架中針對記憶體快取的實現, 在系統 NSCache 快取的基礎上增加了很多方法和屬性, 比如數量限制、記憶體總容量限制、快取存活時間限制、記憶體警告或應用退到後臺時清空快取等功能. 並且TMMemoryCache能夠同步和非同步的對記憶體資料進行操作,最重要的一點是TMMemoryCache是執行緒安全的, 能夠確保在多執行緒情況下資料的安全性.

首先來看一下 TMMemoryCache 提供什麼功能, 按照功能來分析它的實現原理:

  1. 同步/非同步的儲存物件到記憶體中.
  2. 同步/非同步的從記憶體中獲取物件.
  3. 同步/非同步的從記憶體中刪除指定 key 的物件,或者全部物件.
  4. 增加/刪除資料, 記憶體警告, 退回後臺的非同步回撥事件.
  5. 設定記憶體快取使用上限.
  6. 設定記憶體快取過期時間.
  7. 記憶體警告或退到後臺清空快取.
  8. 根據時間或快取大小來清空指定時間段或快取範圍的資料.

同步/非同步的儲存物件到記憶體中

相關 API:

// 同步
- (void)setObject:(id)object forKey:(NSString *)key;
- (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost;

// 非同步
- (void)setObject:(id)object forKey:(NSString *)key block:(TMMemoryCacheObjectBlock)block;
- (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost block:(TMMemoryCacheObjectBlock)block;
複製程式碼

非同步儲存

首先看一下非同步儲存物件, 因為同步儲存裡面會呼叫非同步儲存操作, 採用 dispatch_semaphore 訊號量的方式強制把非同步操作轉換成同步操作.
記憶體快取的核心是建立字典把需要儲存的物件按照 key, value的形式存進字典中, 這是一條主線, 然後在主線上分發出許多分支, 比如:快取時間, 快取大小, 執行緒安全等, 都是圍繞著這條主線來的. TMMemoryCache 也不例外, 在呼叫+ (instancetype)sharedCache方法建立並初始化的時候會建立三個可變字典_dictionary, _dates, _costs,這三個字典分別儲存三種鍵值對:

Key value
_dictionary 儲存物件的 key 儲存物件的值
_dates 儲存物件的 key 儲存物件時的時間
_costs 儲存物件的 key 儲存物件所佔記憶體大小

實現資料儲存的核心方法:

- (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost block:(TMMemoryCacheObjectBlock)block {
    NSDate *now = [[NSDate alloc] init];

    if (!key || !object)
        return;

    __weak TMMemoryCache *weakSelf = self;

    // 0.競態條件下, 在併發佇列中保護寫操作
    dispatch_barrier_async(_queue, ^{
        TMMemoryCache *strongSelf = weakSelf;
        if (!strongSelf)
            return;

        // 1.呼叫 will add block
        if (strongSelf->_willAddObjectBlock)
            strongSelf->_willAddObjectBlock(strongSelf, key, object);

        // 2.儲存 key 對應的資料,時間,快取大小到相應的字典中
        [strongSelf->_dictionary setObject:object forKey:key];
        [strongSelf->_dates setObject:now forKey:key];
        [strongSelf->_costs setObject:@(cost) forKey:key];

        _totalCost += cost;

        // 3.呼叫 did add block
        if (strongSelf->_didAddObjectBlock)
            strongSelf->_didAddObjectBlock(strongSelf, key, object);

        // 4.根據時間排序來清空指定快取大小的記憶體
        if (strongSelf->_costLimit > 0)
            [strongSelf trimToCostByDate:strongSelf->_costLimit block:nil];

        // 5.非同步回撥
        if (block) {
            __weak TMMemoryCache *weakSelf = strongSelf;
            dispatch_async(strongSelf->_queue, ^{
                TMMemoryCache *strongSelf = weakSelf;
                if (strongSelf)
                    block(strongSelf, key, object);
            });
        }
    });
}
複製程式碼

在上面的程式碼中我標出了核心儲存方法做了幾件事, 其中最為核心的是保證執行緒安全的dispatch_barrier_async方法, 在 GCD 中稱之為柵欄方法, 一般跟併發佇列一起用, 在多執行緒中對同一資源的競爭條件下保護共享資源, 確保在同一時間片段只有一個執行緒資源, 這是不擴充套件講 GCD 的相關知識.

dispatch_barrier_async 方法一般都是跟併發佇列搭配使用,下面的圖解很清晰(侵刪), 在併發佇列中有很多工(block), 這些block都是按照 FIFO 的順序執行的, 當要執行用 dispatch_barrier_async 方法提交到併發佇列queue的 block 的時候, 該併發佇列暫時會`卡住`, 等待之前的任務 block 執行完畢, 再執行dispatch_barrier_async 提交的 block, 在此 block 之後提交到併發佇列queue的 block 不會被執行,會一直等待 dispatch_barrier_async block 執行完畢後才開始併發執行, 我們可以看出, 在併發佇列遇到 dispatch_barrier_async block 時就處於一直序列佇列狀態, 等待執行完畢後又開始併發執行.
由於TMMemoryCache中所有的讀寫操作都是在一個 concurrent queue(併發佇列)中, 所以使用 dispatch_barrier_async 能夠保證寫操作的執行緒安全, 在同一時間只有一個寫任務在執行, 其它讀寫操作都處於等待狀態, 這是 TMMemoryCache 保證執行緒安全的核心, 但也是它最大的毛病, 容易造成效能下降和死鎖.

Barrier_on_queue

從上面程式碼中可以看出, 在該方法中把需要儲存的資料按照 key-value 的形式儲存進了_dictionary字典中, 其它操作無非就是增加功能的配料,後面會抽絲剝繭的捋清楚, 到此處我們的任務完成, 知道是怎麼儲存資料的, 非常簡單:

  1. 使用 GCD 的 dispatch_barrier_async 方法保證寫操作執行緒安全.
  2. 把需要儲存的資料存進可變字典中.

同步儲存

根據上文所說, 同步儲存中會呼叫非同步儲存操作, 來看一下程式碼:

- (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost {
    if (!object || !key)
        return;

    // 1.建立訊號量
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);

    // 2.非同步存資料
    [self setObject:object forKey:key withCost:cost block:^(TMMemoryCache *cache, NSString *key, id object) {
        
        // 3.非同步儲存完畢傳送 signal 訊號
        dispatch_semaphore_signal(semaphore);
    }];

    // 4.等待非同步儲存完畢
    dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);

}
複製程式碼

從上面程式碼可以看出,同步的儲存資料使用了 GCD 的 dispatch_semaphore_t 訊號量, 這是一個非常古老又複雜的執行緒概念, 有興趣的話可以看看 <<UNIX 環境高階程式設計>> 這本經典之作, 因為它的複雜是建立在作業系統的複雜性上的.但是這並不影響我們使用 dispatch_semaphore_t 訊號量. 怎麼使用 GCD 的訊號量以及原理下面大概描述一下:

訊號量在競態條件下能夠保證執行緒安全,在建立訊號量 dispatch_semaphore_create 的時候設定訊號量的值, 這個值表示允許多少個執行緒可同時訪問公共資源, 就好比我們的車位一樣, 執行緒就是我們的車子,這個訊號量就是停車場的管理者, 他知道什麼時候有多少車位, 是不是該把車子放進停車場, 當沒有車位或者車位不足時, 這個管理員就會把司機卡在停車場外不準進, 那麼被攔住的司機按照 FIFO 的佇列排著隊, 有足夠位置的時候,管理員就方法閘門, 大吼一聲: 孩子們去吧. 那麼肯定有司機等不耐煩了, 就想著等十分鐘沒有車位就不等了,就可以在 dispatch_semaphore_wait 方法中設定等待時間, 等待超過設定時間就不等待.
那麼把上面的場景應用在 dispatch_semaphore_create 訊號量中就很容易理解了, 建立訊號量並設定最大併發執行緒數, dispatch_semaphore_wait 設定等待時間,在等待時間未到達或者訊號量值沒有達到初始值時會一直等待, 呼叫 dispatch_semaphore_wait 方法會使訊號量的值+1, 表示增加一個執行緒等待處理共用資源, 當 dispatch_semaphore_signal 時會使訊號量的值-1, 表示該執行緒不再佔用共用資源.

根據上面對 dispatch_semaphore_t 訊號量的描述可知, 訊號量的初始值為0,當前執行緒執行 dispatch_semaphore_wait 方法就會一直等待, 此時就相當於同步操作, 當在併發佇列中非同步儲存完資料呼叫dispatch_semaphore_signal 方法, 此時訊號量的值變成0,跟初始值一樣,當前執行緒立即結束等待, 同步設定方法執行完畢.

其實同步實現儲存資料的方式很多, 主要就是要序列執行寫操作採用 dispatch_sync的方式, 但是基於 TMMemoryCache 所有的操作都是在併發佇列上的, 所以才採用訊號量的方式.

其實只要知道dispatch_barrier_async, dispatch_semaphore_t 的用法,後面的都可以不用看了, 自己去找原始碼看看就明白了.


休息一下吧,後面的簡單了


同步/非同步的從記憶體中獲取物件.

有了上面的同步/非同步儲存的理論, 那麼同步/非同步獲取物件簡直易如反掌, 不就是從_dictionary字典中根據 key 取出對應的 value 值, 在取的過程中加以執行緒安全, will/did 之類輔助處理的 block 操作.

非同步獲取資料

- (void)objectForKey:(NSString *)key block:(TMMemoryCacheObjectBlock)block {
    NSDate *now = [[NSDate alloc] init];
    
    if (!key || !block)
        return;

    __weak TMMemoryCache *weakSelf = self;

    // 1.非同步載入儲存資料
    dispatch_async(_queue, ^{
        TMMemoryCache *strongSelf = weakSelf;
        if (!strongSelf)
            return;
        
        // 2.根據 key 找到value
        id object = [strongSelf->_dictionary objectForKey:key];

        if (object) {
            __weak TMMemoryCache *weakSelf = strongSelf;
            // 3.也用柵欄保護寫操作, 保證在寫的時候沒有執行緒在訪問共享資源
            dispatch_barrier_async(strongSelf->_queue, ^{
                TMMemoryCache *strongSelf = weakSelf;
                if (strongSelf)
                    // 4.更新資料的最後操作時間(當前時間)
                    [strongSelf->_dates setObject:now forKey:key];
            });
        }

        // 5.回撥
        block(strongSelf, key, object);
    });
}
複製程式碼

根據程式碼中註釋可知,除了拿到 key 值對應的 value, 還更新了此資料最後操作時間, 這有什麼用呢? 其實是為了記錄資料最後的操作時間, 後面會根據這個最後操作時間來刪除資料等一系列根據時間排序的操作.最後一步是回撥, 我們可以看到, TMMemoryCache所有的讀寫和回撥操作都放在同一個併發佇列中,這就為以後效能下降和死鎖埋下伏筆.

同步獲取資料

- (id)objectForKey:(NSString *)key {
    if (!key)
        return nil;

    __block id objectForKey = nil;

    // 採用訊號量強制轉化成同步操作
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);

    [self objectForKey:key block:^(TMMemoryCache *cache, NSString *key, id object) {
        objectForKey = object;
        dispatch_semaphore_signal(semaphore);
    }];

    dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);

    return objectForKey;
}
複製程式碼

同步獲取資料也是通過 dispatch_semaphore_t 訊號量的方式,把非同步獲取資料的操作強制轉成同步獲取, 跟同步儲存資料的原理相同.

同步/非同步的從記憶體中刪除指定 key 的物件,或者全部物件.

刪除操作也不例外:

- (void)removeObjectForKey:(NSString *)key block:(TMMemoryCacheObjectBlock)block {
    if (!key)
        return;

    __weak TMMemoryCache *weakSelf = self;

    // 1."柵欄"方法,保證執行緒安全
    dispatch_barrier_async(_queue, ^{
        TMMemoryCache *strongSelf = weakSelf;
        if (!strongSelf)
            return;

        // 2.根據 key 刪除 value
        [strongSelf removeObjectAndExecuteBlocksForKey:key];

        if (block) {
            __weak TMMemoryCache *weakSelf = strongSelf;
            
            // 3.完成後回撥
            dispatch_async(strongSelf->_queue, ^{
                TMMemoryCache *strongSelf = weakSelf;
                if (strongSelf)
                    block(strongSelf, key, nil);
            });
        }
    });
}

// private API
- (void)removeObjectAndExecuteBlocksForKey:(NSString *)key {
    id object = [_dictionary objectForKey:key];
    NSNumber *cost = [_costs objectForKey:key];

    if (_willRemoveObjectBlock)
        _willRemoveObjectBlock(self, key, object);

    if (cost)
        _totalCost -= [cost unsignedIntegerValue];

    // 刪除所有跟此資料相關的快取: value, date, cost
    [_dictionary removeObjectForKey:key];
    [_dates removeObjectForKey:key];
    [_costs removeObjectForKey:key];

    if (_didRemoveObjectBlock)
        _didRemoveObjectBlock(self, key, nil);
}
複製程式碼

需要注意的是 - (void)removeObjectAndExecuteBlocksForKey 是共用私有方法, 刪除跟 key 相關的所有快取, 後面的刪除操作還會用到此方法.

設定記憶體快取使用上限

TMMemoryCache 提供costLimit屬性來設定記憶體快取使用上限, 這個也是 NSCache 不具備的功能,來看一下跟此屬性相關的方法以及實現,程式碼中有詳細解釋:

// getter
- (NSUInteger)costLimit {
    __block NSUInteger costLimit = 0;

    // 要想通過函式返回值傳遞回去,那麼必須同步執行,所以使用dispatch_sync同步獲取記憶體使用上限
    dispatch_sync(_queue, ^{
        costLimit = _costLimit;
    });

    return costLimit;
}

// setter
- (void)setCostLimit:(NSUInteger)costLimit {
    __weak TMMemoryCache *weakSelf = self;

    // "柵欄"方法保護寫操作
    dispatch_barrier_async(_queue, ^{
        TMMemoryCache *strongSelf = weakSelf;
        if (!strongSelf)
            return;

        // 設定記憶體上限
        strongSelf->_costLimit = costLimit;

        if (costLimit > 0)
            // 根據時間排序來削減記憶體快取,以達到設定的記憶體快取上限的目的
            [strongSelf trimToCostLimitByDate:costLimit];
    });
}

- (void)trimToCostLimitByDate:(NSUInteger)limit {
    if (_totalCost <= limit)
        return;

    // 按照時間的升序來排列 key
    NSArray *keysSortedByDate = [_dates keysSortedByValueUsingSelector:@selector(compare:)];

    // oldest objects first
    for (NSString *key in keysSortedByDate) {
        [self removeObjectAndExecuteBlocksForKey:key];

        if (_totalCost <= limit)
            break;
    }
}
複製程式碼

- (void)trimToCostLimitByDate:(NSUInteger)limit 方法的作用:

  1. 如果目前已使用的記憶體大小小於需要設定的記憶體上線,則不刪除資料,否則刪除`最老`的資料,讓已使用的記憶體大小不超過設定的記憶體上限.
  2. 按照儲存的資料最近操作的最後時間進行升序排序,即最近操作的資料對應的 key 排最後.
  3. 如果已經超過記憶體上限, 則根據 key 值刪除資料, 先刪除操作時間較早的資料.

從這裡就會恍然大悟, 之前設定的 _date 陣列終於派上用場了,如果需要刪除資料則按照時間的先後順序來刪除,也算是一種優先順序策略吧.

設定記憶體快取過期時間

TMMemoryCache 提供ageLimit屬性來設定快取過期時間,根據上面costLimit屬性可以猜想一下ageLimit是怎麼實現的,既然是要設定快取過期時間, 那麼我設定快取過期時間 ageLimit = 10 10秒鐘,說明距離當前時間之前的10秒的資料已經過期, 需要刪除掉; 再過10秒又要當前時間刪除之前10秒存的資料,我們知道刪除只需要找到 key 就行,所以就必須通過_date字典找到過期的 key, 再刪除資料.由此可知需要一個定時器,每過10秒刪除一次,完成一個定時任務.
上面只是我們的猜想,來看看程式碼是不是這麼實現的呢?我們只需看核心的操作方法

- (void)trimToAgeLimitRecursively {
    if (_ageLimit == 0.0)
        return;

    // 說明距離現在 ageLimit 秒的快取應該被清除掉了
    NSDate *date = [[NSDate alloc] initWithTimeIntervalSinceNow:-_ageLimit];
    [self trimMemoryToDate:date];
    
    __weak TMMemoryCache *weakSelf = self;
    
    // 延遲 ageLimit 秒, 又非同步的清除快取
    dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_ageLimit * NSEC_PER_SEC));
    dispatch_after(time, _queue, ^(void){
        TMMemoryCache *strongSelf = weakSelf;
        if (!strongSelf)
            return;
        
        __weak TMMemoryCache *weakSelf = strongSelf;
        
        dispatch_barrier_async(strongSelf->_queue, ^{
            TMMemoryCache *strongSelf = weakSelf;
            [strongSelf trimToAgeLimitRecursively];
        });
    });
}
複製程式碼

上面的程式碼驗證了我們的猜想,但是在不斷的建立定時器,不斷的在並行佇列中使用dispatch_barrier_async柵欄方法提交遞迴 block, 天啦嚕…如果設定的 ageLimit 很小,可想而知效能消耗會非常大!

記憶體警告或退到後臺清空快取

記憶體警告和退到後臺需要監聽系統通知,UIApplicationDidReceiveMemoryWarningNotificationUIApplicationDidEnterBackgroundNotification, 然後執行清除操作方法removeAllObjects,只不過在相應的位置執行對應的 will/did 之類的 block 操作.

根據時間或快取大小來清空指定時間段或快取範圍的資料

這兩類方法主要是為了更加靈活的使用 TMMemoryCache,指定一個時間或者記憶體大小,會自動刪除時間點之前和大於指定記憶體大小的資料.
相關 API:

// 清空 date 之前的資料
- (void)trimToDate:(NSDate *)date block:(TMMemoryCacheBlock)block;
// 清空資料,讓已使用記憶體大小為cost 
- (void)trimToCost:(NSUInteger)cost block:(TMMemoryCacheBlock)block;
複製程式碼

刪除指定時間點有兩點注意:

  • 如果指定的時間點為 [NSDate distantPast] 表示最早能表示的時間,說明清空全部資料.
  • 如果不是最早時間,把_date中的 key 按照升序排序,再遍歷排序後的 key 陣列,判斷跟指定時間的關係,如果比指定時間更早則刪除, 即刪除指定時間節點之前的資料.
- (void)trimMemoryToDate:(NSDate *)trimDate {
    // 字典中存放的順序不是按照順序存放的, 所以按照一定格式排序, 根據 value 升序的排 key 值順序, 也就是說根據時間的升序來排 key, 陣列中第一個值是最早的時間的值.
    NSArray *keysSortedByDate = [_dates keysSortedByValueUsingSelector:@selector(compare:)];
    
    for (NSString *key in keysSortedByDate) { // oldest objects first
        NSDate *accessDate = [_dates objectForKey:key];
        if (!accessDate)
            continue;
        
        // 找出每個時間的然後跟要刪除的時間點進行比較, 如果比刪除時間早則刪除
        if ([accessDate compare:trimDate] == NSOrderedAscending) { // older than trim date
            [self removeObjectAndExecuteBlocksForKey:key];
        } else {
            break;
        }
    }
}
複製程式碼

總結

記憶體快取是很簡單的, 核心就是 key-value 的形式儲存資料進字典,再輔助設定記憶體上限,快取時間,各類 will/did block 操作, 最重要的是要實現執行緒安全.

歡迎大家斧正!

相關文章