YYCache 原始碼解析(二):磁碟快取的設計與快取元件設計思路
上一篇講解了 YYCache 的使用方法,架構與記憶體快取的設計。這一篇講解磁碟快取的設計與快取元件的設計思路。
一. YYDiskCache
YYDiskCache 負責處理容量大,相對低速的磁碟快取。執行緒安全,支援非同步操作。作為 YYCache 的第二級快取,它與第一級快取YYMemoryCache
的相同點是:
-
都具有查詢,寫入,讀取,刪除快取的介面。
-
不直接操作快取,也是間接地通過另一個類(YYKVStorage)來操作快取。
-
它使用
LRU
演算法來清理快取。 -
支援按
cost
,count
和age
這三個維度來清理不符合標準的快取。
它與YYMemoryCache不同點是:
-
根據快取資料的大小來採取不同的形式的快取:
-
資料庫 sqlite: 針對小容量快取,快取的 data 和元資料都儲存在資料庫裡。
-
檔案+資料庫的形式: 針對大容量快取,快取的 data 寫在檔案系統裡,其元資料儲存在資料庫裡。
-
除了 cost,count 和 age 三個維度之外,還添加了一個磁碟容量的維度。
這裡需要說明的是:
對於上面的第一條:我看原始碼的時候只看出來有這兩種快取形式,但是從內部的快取 type 列舉來看,其實是分為三種的:
typedef NS_ENUM(NSUInteger, YYKVStorageType) { YYKVStorageTypeFile = 0, YYKVStorageTypeSQLite = 1, YYKVStorageTypeMixed = 2, };
也就是說我只找到了第二,第三種快取形式,而第一種純粹的檔案儲存(YYKVStorageTypeFile
)形式的實現我沒有找到:當 type 為
YYKVStorageTypeFile
和 YYKVStorageTypeMixed
的時候的快取實現都是一致的:都是講 data 存在檔案裡,將元資料放在資料庫裡面。
在 YYDiskCache 的初始化方法裡,沒有發現正確的將快取型別設定為 YYKVStorageTypeFile 的方法:
//YYDiskCache.m - (instancetype)init { @throw [NSException exceptionWithName:@"YYDiskCache init error" reason:@"YYDiskCache must be initialized with a path. Use 'initWithPath:' or 'initWithPath:inlineThreshold:' instead." userInfo:nil]; return [self initWithPath:@"" inlineThreshold:0]; } - (instancetype)initWithPath:(NSString *)path { return [self initWithPath:path inlineThreshold:1024 * 20]; // 20KB } - (instancetype)initWithPath:(NSString *)path inlineThreshold:(NSUInteger)threshold { ... YYKVStorageType type; if (threshold == 0) { type = YYKVStorageTypeFile; } else if (threshold == NSUIntegerMax) { type = YYKVStorageTypeSQLite; } else { type = YYKVStorageTypeMixed; } ... }
從上面的程式碼可以看出來,當給指定初始化方法initWithPath:inlineThreshold:
的第二個引數傳入 0 的時候,快取型別才是 YYKVStorageTypeFile。而且比較常用的初始化方法 initWithPath:
的實現裡,是將 20kb 傳入了指定初始化方法裡,結果就是將 type 設定成了 YYKVStorageTypeMixed。
而且我也想不出如果只有檔案形式的快取的話,其元資料如何儲存。如果有讀者知道的話,麻煩告知一下,非常感謝了~~
在本文暫時對於上面提到的”檔案+資料庫的形式”在下文統一說成檔案快取了。
在介面的設計上,YYDiskCache 與 YYMemoryCache 是高度一致的,只不過因為有些時候大檔案的訪問可能會比較耗時,所以框架作者在保留了與 YYMemoryCache 一樣的介面的基礎上,還在原來的基礎上添加了 block 回撥,避免阻塞執行緒。來看一下 YYDiskCache 的介面(省略了註釋):
//YYDiskCache.h - (BOOL)containsObjectForKey:(NSString *)key; - (void)containsObjectForKey:(NSString *)key withBlock:(void(^)(NSString *key, BOOL contains))block; - (nullable id<NSCoding>)objectForKey:(NSString *)key; - (void)objectForKey:(NSString *)key withBlock:(void(^)(NSString *key, id<NSCoding> _Nullable object))block; - (void)setObject:(nullable id<NSCoding>)object forKey:(NSString *)key; - (void)setObject:(nullable id<NSCoding>)object forKey:(NSString *)key withBlock:(void(^)(void))block; - (void)removeObjectForKey:(NSString *)key; - (void)removeObjectForKey:(NSString *)key withBlock:(void(^)(NSString *key))block; - (void)removeAllObjects; - (void)removeAllObjectsWithBlock:(void(^)(void))block; - (void)removeAllObjectsWithProgressBlock:(nullable void(^)(int removedCount, int totalCount))progress endBlock:(nullable void(^)(BOOL error))end; - (NSInteger)totalCount; - (void)totalCountWithBlock:(void(^)(NSInteger totalCount))block; - (NSInteger)totalCost; - (void)totalCostWithBlock:(void(^)(NSInteger totalCost))block; #pragma mark - Trim - (void)trimToCount:(NSUInteger)count; - (void)trimToCount:(NSUInteger)count withBlock:(void(^)(void))block; - (void)trimToCost:(NSUInteger)cost; - (void)trimToCost:(NSUInteger)cost withBlock:(void(^)(void))block; - (void)trimToAge:(NSTimeInterval)age; - (void)trimToAge:(NSTimeInterval)age withBlock:(void(^)(void))block;
從上面的介面程式碼可以看出,YYDiskCache 與 YYMemoryCache 在介面設計上是非常相似的。但是,YYDiskCache 有一個非常重要的屬性,它作為用 sqlite 做快取還是用檔案做快取的分水嶺 :
//YYDiskCache.h @property (readonly) NSUInteger inlineThreshold;
這個屬性的預設值是20480byte
,也就是 20kb。即是說,如果快取資料的長度大於這個值,就使用檔案儲存;如果小於這個值,就是用 sqlite 儲存。來看一下這個屬性是如何使用的:
首先我們會在 YYDiskCache 的指定初始化方法裡看到這個屬性:
//YYDiskCache.m - (instancetype)initWithPath:(NSString *)path inlineThreshold:(NSUInteger)threshold { ... _inlineThreshold = threshold; ... }
在這裡將_inlineThreshold
賦值,也是唯一一次的賦值。然後在寫入快取的操作裡判斷寫入快取的大小是否大於這個臨界值,如果是,則使用檔案快取:
//YYDiskCache.m - (void)setObject:(id<NSCoding>)object forKey:(NSString *)key { ... NSString *filename = nil; if (_kv.type != YYKVStorageTypeSQLite) { //如果長度大臨界值,則生成檔名稱,使得filename不為nil if (value.length > _inlineThreshold) { filename = [self _filenameForKey:key]; } } Lock(); //在該方法內部判斷filename是否為nil,如果是,則使用sqlite進行快取;如果不是,則使用檔案快取 [_kv saveItemWithKey:key value:value filename:filename extendedData:extendedData]; Unlock(); }
現在我們知道了 YYDiskCache 相對於 YYMemoryCache 最大的不同之處是快取型別的不同。
細心的朋友會發現上面這個寫入快取的方法 (saveItemWithKey:value:filename:extendedData:
)實際上是屬於 _kv
的。這個 _kv 就是上面提到的 YYKVStorage 的例項,它在 YYDiskCache 的初始化方法裡被賦值:
//YYDiskCache.m - (instancetype)initWithPath:(NSString *)path inlineThreshold:(NSUInteger)threshold { ... YYKVStorage *kv = [[YYKVStorage alloc] initWithPath:path type:type]; if (!kv) return nil; _kv = kv; ... }
同樣地,再舉其他兩個介面為例,內部也是呼叫了 _kv 的方法:
- (BOOL)containsObjectForKey:(NSString *)key { if (!key) return NO; Lock(); BOOL contains = [_kv itemExistsForKey:key]; Unlock(); return contains; } - (void)removeObjectForKey:(NSString *)key { if (!key) return; Lock(); [_kv removeItemForKey:key]; Unlock(); }
所以是時候來看一下 YYKVStorage 的介面和實現了:
YYKVStorage
YYKVStorage 例項負責儲存和管理所有磁碟快取。和 YYMemoryCache 裡面的_YYLinkedMap
將快取封裝成節點類 _YYLinkedMapNode
類似,YYKVStorage 也將某個單獨的磁碟快取封裝成了一個類,這個類就是 YYKVStorageItem
,它儲存了某個快取所對應的一些資訊(key、 value、檔名、大小等等):
//YYKVStorageItem.h @interface YYKVStorageItem : NSObject @property (nonatomic, strong) NSString *key; //鍵 @property (nonatomic, strong) NSData *value; //值 @property (nullable, nonatomic, strong) NSString *filename; //檔名 @property (nonatomic) int size; //值的大小,單位是byte @property (nonatomic) int modTime; //修改時間戳 @property (nonatomic) int accessTime; //最後訪問的時間戳 @property (nullable, nonatomic, strong) NSData *extendedData; //extended data @end
既然在這裡將快取封裝成了 YYKVStorageItem 例項,那麼作為快取的管理者,YYKVStorage 就必然有操作 YYKVStorageItem 的介面了 :
//YYKVStorage.h //寫入某個item - (BOOL)saveItem:(YYKVStorageItem *)item; //寫入某個鍵值對,值為NSData物件 - (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value; //寫入某個鍵值對,包括檔名以及data資訊 - (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value filename:(nullable NSString *)filename extendedData:(nullable NSData *)extendedData; #pragma mark - Remove Items //移除某個鍵的item - (BOOL)removeItemForKey:(NSString *)key; //移除多個鍵的item - (BOOL)removeItemForKeys:(NSArray<NSString *> *)keys; //移除大於引數size的item - (BOOL)removeItemsLargerThanSize:(int)size; //移除時間早於引數時間的item - (BOOL)removeItemsEarlierThanTime:(int)time; //移除item,使得快取總容量小於引數size - (BOOL)removeItemsToFitSize:(int)maxSize; //移除item,使得快取數量小於引數size - (BOOL)removeItemsToFitCount:(int)maxCount; //移除所有的item - (BOOL)removeAllItems; //移除所有的item,附帶進度與結束block - (void)removeAllItemsWithProgressBlock:(nullable void(^)(int removedCount, int totalCount))progress endBlock:(nullable void(^)(BOOL error))end; #pragma mark - Get Items //讀取引數key對應的item - (nullable YYKVStorageItem *)getItemForKey:(NSString *)key; //讀取引數key對應的data - (nullable NSData *)getItemValueForKey:(NSString *)key; //讀取引數陣列對應的item陣列 - (nullable NSArray<YYKVStorageItem *> *)getItemForKeys:(NSArray<NSString *> *)keys; //讀取引數陣列對應的item字典 - (nullable NSDictionary<NSString *, NSData *> *)getItemValueForKeys:(NSArray<NSString *> *)keys;
大家最關心的應該是寫入快取的介面是如何實現的,下面重點講一下寫入快取的介面:
//寫入某個item - (BOOL)saveItem:(YYKVStorageItem *)item; //寫入某個鍵值對,值為NSData物件 - (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value; //寫入某個鍵值對,包括檔名以及data資訊 - (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value filename:(nullable NSString *)filename extendedData:(nullable NSData *)extendedData;
這三個介面都比較類似,上面的兩個方法都會呼叫最下面引數最多的方法。在詳細講解寫入快取的程式碼之前,我先講一下寫入快取的大致邏輯,有助於讓大家理解整個 YYDiskCache 寫入快取的流程:
-
首先判斷傳入的 key 和 value 是否符合要求,如果不符合要求,則立即返回 NO,快取失敗。
-
再判斷是否
type==YYKVStorageTypeFile
並且檔名為空字串(或nil):如果是,則立即返回 NO,快取失敗。 -
判斷
filename
是否為空字串: -
如果不為空:寫入檔案,並將快取的 key,等資訊寫入資料庫,但是不將 key 對應的 data 寫入資料庫。
-
如果為空:
-
如果快取型別為 YYKVStorageTypeSQLite:將快取檔案刪除
-
如果快取型別不為 YYKVStorageTypeSQLite:則將快取的 key 和對應的 data 等其他資訊存入資料庫。
- (BOOL)saveItem:(YYKVStorageItem *)item { return [self saveItemWithKey:item.key value:item.value filename:item.filename extendedData:item.extendedData]; } - (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value { return [self saveItemWithKey:key value:value filename:nil extendedData:nil]; } - (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value filename:(NSString *)filename extendedData:(NSData *)extendedData { if (key.length == 0 || value.length == 0) return NO; if (_type == YYKVStorageTypeFile && filename.length == 0) { return NO; } if (filename.length) { //如果檔名不為空字串,說明要進行檔案快取 if (![self _fileWriteWithName:filename data:value]) { return NO; } //寫入元資料 if (![self _dbSaveWithKey:key value:value fileName:filename extendedData:extendedData]) { //如果快取資訊儲存失敗,則刪除對應的檔案 [self _fileDeleteWithName:filename]; return NO; } return YES; } else { //如果檔名為空字串,說明不要進行檔案快取 if (_type != YYKVStorageTypeSQLite) { //如果快取型別不是資料庫快取,則查找出相應的檔名並刪除 NSString *filename = [self _dbGetFilenameWithKey:key]; if (filename) { [self _fileDeleteWithName:filename]; } } // 快取型別是資料庫快取,把元資料和value寫入資料庫 return [self _dbSaveWithKey:key value:value fileName:nil extendedData:extendedData]; } }
從上面的程式碼可以看出,在底層寫入快取的方法是_dbSaveWithKey:value:fileName:extendedData:
,這個方法使用了兩次:
-
在以檔案(和資料庫)儲存快取時
-
在以資料庫儲存快取時
不過雖然呼叫了兩次,我們可以從傳入的引數是有差別的:第二次 filename 傳了 nil。那麼我們來看一下_dbSaveWithKey:value:fileName:extendedData:
內部是如何區分有無 filename 的情況的:
-
當 filename 為空時,說明在外部沒有寫入該快取的檔案:則把 data 寫入資料庫裡
當 filename 不為空時,說明在外部有寫入該快取的檔案:則不把 data 也寫入了資料庫裡
下面結合程式碼看一下:
//資料庫儲存 - (BOOL)_dbSaveWithKey:(NSString *)key value:(NSData *)value fileName:(NSString *)fileName extendedData:(NSData *)extendedData { //sql語句 NSString *sql = @"insert or replace into manifest (key, filename, size, inline_data, modification_time, last_access_time, extended_data) values (?1, ?2, ?3, ?4, ?5, ?6, ?7);"; sqlite3_stmt *stmt = [self _dbPrepareStmt:sql]; if (!stmt) return NO; int timestamp = (int)time(NULL); //key sqlite3_bind_text(stmt, 1, key.UTF8String, -1, NULL); //filename sqlite3_bind_text(stmt, 2, fileName.UTF8String, -1, NULL); //size sqlite3_bind_int(stmt, 3, (int)value.length); //inline_data if (fileName.length == 0) { //如果檔名長度==0,則將value存入資料庫 sqlite3_bind_blob(stmt, 4, value.bytes, (int)value.length, 0); } else { //如果檔名長度不為0,則不將value存入資料庫 sqlite3_bind_blob(stmt, 4, NULL, 0, 0); } //modification_time sqlite3_bind_int(stmt, 5, timestamp); //last_access_time sqlite3_bind_int(stmt, 6, timestamp); //extended_data sqlite3_bind_blob(stmt, 7, extendedData.bytes, (int)extendedData.length, 0); int result = sqlite3_step(stmt); if (result != SQLITE_DONE) { if (_errorLogsEnabled) NSLog(@"%s line:%d sqlite insert error (%d): %s", __FUNCTION__, __LINE__, result, sqlite3_errmsg(_db)); return NO; } return YES; }
框架作者用資料庫的一條記錄來儲存關於某個快取的所有信息。
而且資料庫的第四個欄位是儲存快取對應的 data 的,從上面的程式碼可以看出當 filename 為空和不為空的時候的處理的差別。
上面的sqlite3_stmt
可以看作是一個已經把 sql 語句解析了的、用 sqlite 自己標記記錄的內部資料結構。
而sqlite3_bind_text
和 sqlite3_bind_int
是繫結函式,可以看作是將變數插入到欄位的操作。
OK,現在看完了寫入快取,我們再來看一下獲取快取的操作:
//YYKVSorage.m - (YYKVStorageItem *)getItemForKey:(NSString *)key { if (key.length == 0) return nil; YYKVStorageItem *item = [self _dbGetItemWithKey:key excludeInlineData:NO]; if (item) { //更新記憶體訪問的時間 [self _dbUpdateAccessTimeWithKey:key]; if (item.filename) { //如果有檔名,則嘗試獲取檔案資料 item.value = [self _fileReadWithName:item.filename]; //如果此時獲取檔案資料失敗,則刪除對應的item if (!item.value) { [self _dbDeleteItemWithKey:key]; item = nil; } } } return item; }
從上面這段程式碼我們可以看到獲取 YYKVStorageItem 的例項的方法是_dbGetItemWithKey:excludeInlineData:
我們來看一下它的實現:
-
首先根據查詢 key 的 sql 語句生成 stmt
-
然後將傳入的 key 與該 stmt 進行繫結
-
最後通過這個 stmt 來查找出與該 key 對應的有關該快取的其他資料並生成 item。
來看一下程式碼:
- (YYKVStorageItem *)_dbGetItemWithKey:(NSString *)key excludeInlineData:(BOOL)excludeInlineData { NSString *sql = excludeInlineData ? @"select key, filename, size, modification_time, last_access_time, extended_data from manifest where key = ?1;" : @"select key, filename, size, inline_data, modification_time, last_access_time, extended_data from manifest where key = ?1;"; sqlite3_stmt *stmt = [self _dbPrepareStmt:sql]; if (!stmt) return nil; sqlite3_bind_text(stmt, 1, key.UTF8String, -1, NULL); YYKVStorageItem *item = nil; int result = sqlite3_step(stmt); if (result == SQLITE_ROW) { //傳入stmt來生成YYKVStorageItem例項 item = [self _dbGetItemFromStmt:stmt excludeInlineData:excludeInlineData]; } else { if (result != SQLITE_DONE) { if (_errorLogsEnabled) NSLog(@"%s line:%d sqlite query error (%d): %s", __FUNCTION__, __LINE__, result, sqlite3_errmsg(_db)); } } return item; }
我們可以看到最終生成 YYKVStorageItem 例項的是通過_dbGetItemFromStmt:excludeInlineData:
來實現的:
- (YYKVStorageItem *)_dbGetItemFromStmt:(sqlite3_stmt *)stmt excludeInlineData:(BOOL)excludeInlineData { //提取資料 int i = 0; char *key = (char *)sqlite3_column_text(stmt, i++); char *filename = (char *)sqlite3_column_text(stmt, i++); int size = sqlite3_column_int(stmt, i++); //判斷excludeInlineData const void *inline_data = excludeInlineData ? NULL : sqlite3_column_blob(stmt, i); int inline_data_bytes = excludeInlineData ? 0 : sqlite3_column_bytes(stmt, i++); int modification_time = sqlite3_column_int(stmt, i++); int last_access_time = sqlite3_column_int(stmt, i++); const void *extended_data = sqlite3_column_blob(stmt, i); int extended_data_bytes = sqlite3_column_bytes(stmt, i++); //將資料賦給item的屬性 YYKVStorageItem *item = [YYKVStorageItem new]; if (key) item.key = [NSString stringWithUTF8String:key]; if (filename && *filename != 0) item.filename = [NSString stringWithUTF8String:filename]; item.size = size; if (inline_data_bytes > 0 && inline_data) item.value = [NSData dataWithBytes:inline_data length:inline_data_bytes]; item.modTime = modification_time; item.accessTime = last_access_time; if (extended_data_bytes > 0 && extended_data) item.extendedData = [NSData dataWithBytes:extended_data length:extended_data_bytes]; return item; }
上面這段程式碼分為兩個部分:
-
獲取資料庫裡每一個欄位對應的資料
-
將資料賦給 YYKVStorageItem 的例項
需要注意的是:
-
字串型別需要使用
stringWithUTF8String:
來轉成 NSString 型別。 -
這裡面會判斷
excludeInlineData:
-
如果為 TRUE,就提取存入的 data 資料
-
如果為 FALSE,就不提取
二. 快取元件的設計思路
保證執行緒安全的方案
我相信對於某個設計來說,它的產生一定是基於某種個特定問題下的某個場景的
由上文可以看出:
-
YYMemoryCache 使用了
pthread_mutex
執行緒鎖(互斥鎖)來確保執行緒安全 -
YYDiskCache 則選擇了更適合它的
dispatch_semaphore
。
記憶體快取操作的互斥鎖
在 YYMemoryCache 中,是使用互斥鎖來保證執行緒安全的。
首先在 YYMemoryCache 的初始化方法中得到了互斥鎖,並在它的所有接口裡都加入了互斥鎖來保證執行緒安全,包括 setter,getter 方法和快取操作的實現。舉幾個例子:
- (NSUInteger)totalCost { pthread_mutex_lock(&_lock); NSUInteger totalCost = _lru->_totalCost; pthread_mutex_unlock(&_lock); return totalCost; } - (void)setReleaseOnMainThread:(BOOL)releaseOnMainThread { pthread_mutex_lock(&_lock); _lru->_releaseOnMainThread = releaseOnMainThread; pthread_mutex_unlock(&_lock); } - (BOOL)containsObjectForKey:(id)key { if (!key) return NO; pthread_mutex_lock(&_lock); BOOL contains = CFDictionaryContainsKey(_lru->_dic, (__bridge const void *)(key)); pthread_mutex_unlock(&_lock); return contains; } - (id)objectForKey:(id)key { if (!key) return nil; pthread_mutex_lock(&_lock); _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key)); if (node) { //如果節點存在,則更新它的時間資訊(最後一次訪問的時間) node->_time = CACurrentMediaTime(); [_lru bringNodeToHead:node]; } pthread_mutex_unlock(&_lock); return node ? node->_value : nil; }
而且需要在 dealloc 方法中銷燬這個鎖頭:
- (void)dealloc { ... //銷燬互斥鎖 pthread_mutex_destroy(&_lock); }
磁碟快取使用訊號量來代替鎖
框架作者採用了訊號量的方式來給。
首先在初始化的時候例項化了一個訊號量:
- (instancetype)initWithPath:(NSString *)path inlineThreshold:(NSUInteger)threshold { ... _lock = dispatch_semaphore_create(1); _queue = dispatch_queue_create("com.ibireme.cache.disk", DISPATCH_QUEUE_CONCURRENT); ...
然後使用了巨集來代替加鎖解鎖的程式碼:
#define Lock() dispatch_semaphore_wait(self->_lock, DISPATCH_TIME_FOREVER) #define Unlock() dispatch_semaphore_signal(self->_lock)
簡單說一下訊號量:
dispatch_semaphore 是 GCD 用來同步的一種方式,與他相關的共有三個函式,分別是
-
dispatch_semaphore_create
:定義訊號量 -
dispatch_semaphore_signal
:使訊號量+1 -
dispatch_semaphore_wait
:使訊號量-1
當訊號量為 0 時,就會做等待處理,這是其他執行緒如果訪問的話就會讓其等待。所以如果訊號量在最開始的的時候被設定為1,那麼就可以實現“鎖”的功能:
-
執行某段程式碼之前,執行 dispatch_semaphore_wait 函式,讓訊號量 -1 變為 0,執行這段程式碼。
-
此時如果其他執行緒過來訪問這段程式碼,就要讓其等待。
-
當這段程式碼在當前執行緒結束以後,執行 dispatch_semaphore_signal 函式,令訊號量再次 +1,那麼如果有正在等待的執行緒就可以訪問了。
需要注意的是:如果有多個執行緒等待,那麼後來訊號量恢復以後訪問的順序就是執行緒遇到 dispatch_semaphore_wait 的順序。
這也就是訊號量和互斥鎖的一個區別:互斥量用於執行緒的互斥,訊號線用於執行緒的同步。
-
互斥:是指某一資源同時只允許一個訪問者對其進行訪問,具有唯一性和排它性。但互斥無法限制訪問者對資源的訪問順序,即訪問是無序的。
-
同步:是指在互斥的基礎上(大多數情況),通過其它機制實現訪問者對資源的有序訪問。在大多數情況下,同步已經實現了互斥,特別是所有寫入資源的情況必定是互斥的。也就是說使用訊號量可以使多個執行緒有序訪問某個資源。
那麼問題來了:為什麼記憶體快取使用的是互斥鎖(pthread_mutex),而磁碟快取使用的就是訊號量(dispatch_semaphore)呢?
答案在框架作者的文章 YYCache 設計思路里可以找到:
為什麼記憶體快取使用互斥鎖(pthread_mutex)?
框架作者在最初使用的是自旋鎖(OSSpinLock)作為記憶體快取的執行緒鎖,但是後來得知其不夠安全,所以退而求其次,使用了pthread_mutex。
為什麼磁碟快取使用的是訊號量(dispatch_semaphore)?
dispatch_semaphore
是訊號量,但當訊號總量設為 1 時也可以當作鎖來。在沒有等待情況出現時,它的效能比 pthread_mutex 還要高,但一旦有等待情況出現時,效能就會下降許多。相對於 OSSpinLock 來說,它的優勢在於等待時不會消耗 CPU 資源。對磁碟快取來說,它比較合適。
因為 YYDiskCache 在寫入比較大的快取時,可能會有比較長的等待時間,而 dispatch_semaphore 在這個時候是不消耗CPU資源的,所以比較適合。
提高快取效能的幾個嘗試
選擇合適的執行緒鎖
可以參考上一部分 YYMemoryCache 和 YYDiskCache 使用的不同的鎖以及原因。
選擇合適的資料結構
在 YYMemoryCache 中,作者選擇了雙向連結串列來儲存這些快取節點。那麼可以思考一下,為什麼要用雙向連結串列而不是單向連結串列或是陣列呢?
-
為什麼不選擇單向連結串列:單鏈表的節點只知道它後面的節點(只有指向後一節點的指標),而不知道前面的。所以如果想移動其中一個節點的話,其前後的節點不好做銜接。
-
為什麼不選擇陣列:陣列中元素在記憶體的排列是連續的,對於定址操作非常便利;但是對於插入,刪除操作很不方便,需要整體移動,移動的元素個數越多,代價越大。而連結串列恰恰相反,因為其節點的關聯僅僅是靠指標,所以對於插入和刪除操作會很便利,而定址操作缺比較費時。由於在LRU策略中會有非常多的移動,插入和刪除節點的操作,所以使用雙向連結串列是比較有優勢的。
選擇合適的執行緒來操作不同的任務
無論快取的自動清理和釋放,作者預設把這些任務放到子執行緒去做:
看一下釋放所有記憶體快取的操作:
- (void)removeAll { //將開銷,快取數量置為0 _totalCost = 0; _totalCount = 0; //將連結串列的頭尾節點置空 _head = nil; _tail = nil; if (CFDictionaryGetCount(_dic) > 0) { CFMutableDictionaryRef holder = _dic; _dic = CFDictionaryCreateMutable(CFAllocatorGetDefault(), 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); //是否在子執行緒操作 if (_releaseAsynchronously) { dispatch_queue_t queue = _releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue(); dispatch_async(queue, ^{ CFRelease(holder); // hold and release in specified queue }); } else if (_releaseOnMainThread && !pthread_main_np()) { dispatch_async(dispatch_get_main_queue(), ^{ CFRelease(holder); // hold and release in specified queue }); } else { CFRelease(holder); } } }
這裡的YYMemoryCacheGetReleaseQueue()
使用了行內函數,返回了低優先順序的併發佇列。
//行內函數,返回優先順序最低的全域性併發佇列 static inline dispatch_queue_t YYMemoryCacheGetReleaseQueue() { return dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0); }
選擇底層的類
同樣是字典實現,但是作者使用了更底層且快速的 CFDictionary 而沒有用 NSDictionary 來實現。
其他知識點
禁用原生初始化方法並標明新定義的指定初始化方法
YYCache 有 4 個供外部呼叫的初始化介面,無論是物件方法還是類方法都需要傳入一個字串(名稱或路徑)。
而兩個原生的初始化方法被框架作者禁掉了:
- (instancetype)init UNAVAILABLE_ATTRIBUTE; + (instancetype)new UNAVAILABLE_ATTRIBUTE;
如果使用者使用了上面兩個初始化方法就會在編譯期報錯。
而剩下的四個可以使用的初始化方法中,有一個是指定初始化方法,被作者用NS_DESIGNATED_INITIALIZER
標記了。
- (nullable instancetype)initWithName:(NSString *)name; - (nullable instancetype)initWithPath:(NSString *)path NS_DESIGNATED_INITIALIZER; + (nullable instancetype)cacheWithName:(NSString *)name; + (nullable instancetype)cacheWithPath:(NSString *)path;
指定初始化方法就是所有可使用的初始化方法都必須呼叫的方法。更詳細的介紹可以參考我的下面兩篇文章:
-
iOS 程式碼規範中講解“類”的這一部分。
*《Effective objc》乾貨三部曲(三):技巧篇中的第16條。
非同步釋放物件的技巧
為了非同步將某個物件釋放掉,可以通過在 GCD 的 block 裡面給它發個訊息來實現。這個技巧在該框架中很常見,舉一個刪除一個記憶體快取的例子:
首先將這個快取的 node 類取出,然後非同步將其釋放掉。
- (void)removeObjectForKey:(id)key { if (!key) return; pthread_mutex_lock(&_lock); _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)(key)); if (node) { [_lru removeNode:node]; if (_lru->_releaseAsynchronously) { dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue(); dispatch_async(queue, ^{ [node class]; //hold and release in queue }); } else if (_lru->_releaseOnMainThread && !pthread_main_np()) { dispatch_async(dispatch_get_main_queue(), ^{ [node class]; //hold and release in queue }); } } pthread_mutex_unlock(&_lock); }
為了釋放掉這個 node 物件,在一個非同步執行的(主佇列或自定義佇列裡) block 裡給其傳送了 class 這個訊息。不需要糾結這個訊息具體是什麼,他的目的是為了避免編譯錯誤,因為我們無法在 block 裡面硬生生地將某個物件寫進去。
其實關於上面這一點我自己也有點拿不準,希望理解得比較透徹的同學能在下面留個言~ ^^
記憶體警告和進入後臺的監聽
YYCache 預設在收到記憶體警告和進入後臺時,自動清除所有記憶體快取。所以在 YYMemoryCache 的初始化方法裡,我們可以看到這兩個監聽的動作:
//YYMemoryCache.m - (instancetype)init{ ... //監聽app生命週期 [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_appDidReceiveMemoryWarningNotification) name:UIApplicationDidReceiveMemoryWarningNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_appDidEnterBackgroundNotification) name:UIApplicationDidEnterBackgroundNotification object:nil]; ... }
然後實現監聽到訊息後的處理方法:
//記憶體警告時,刪除所有記憶體快取 - (void)_appDidReceiveMemoryWarningNotification { if (self.didReceiveMemoryWarningBlock) { self.didReceiveMemoryWarningBlock(self); } if (self.shouldRemoveAllObjectsOnMemoryWarning) { [self removeAllObjects]; } } //進入後臺時,刪除所有記憶體快取 - (void)_appDidEnterBackgroundNotification { if (self.didEnterBackgroundBlock) { self.didEnterBackgroundBlock(self); } if (self.shouldRemoveAllObjectsWhenEnteringBackground) { [self removeAllObjects]; } }
判斷標頭檔案的匯入
#if __has_include(<YYCache/YYCache.h>) #import <YYCache/YYMemoryCache.h> #import <YYCache/YYDiskCache.h> #import <YYCache/YYKVStorage.h> #elif __has_include(<YYWebImage/YYCache.h>) #import <YYWebImage/YYMemoryCache.h> #import <YYWebImage/YYDiskCache.h> #import <YYWebImage/YYKVStorage.h> #else #import "YYMemoryCache.h" #import "YYDiskCache.h" #import "YYKVStorage.h" #endif
在這裡作者使用__has_include
來檢查 Frameworks 是否引入某個類。
因為 YYWebImage 已經整合 YYCache,所以如果匯入過 YYWebImage 的話就無需重再匯入 YYCache了。
最後的話
通過看該元件的原始碼,我收穫的不僅有快取設計的思路,還有:
-
雙向連結串列的概念以及相關操作
-
資料庫的使用
-
互斥鎖,訊號量的使用
-
實現執行緒安全的方案
-
變數,方法的命名以及介面的設計
相信讀過這篇文章的你也會有一些收穫~ 如果能趁熱打鐵,下載一個 YYCache 原始碼看就更好啦~
推薦閱讀
ofollow,noindex"> YYCache 原始碼解析(一):使用方法,架構與記憶體快取的設計