内容简介:上一篇讲解了 YYCache 的使用方法,架构与内存缓存的设计。这一篇讲解磁盘缓存的设计与缓存组件的设计思路。YYDiskCache 负责处理容量大,相对低速的磁盘缓存。线程安全,支持异步操作。作为 YYCache 的第二级缓存,它与第一级缓存它与YYMemoryCache不同点是:
上一篇讲解了 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 源码看就更好啦~
推荐阅读
以上所述就是小编给大家介绍的《YYCache 源码解析(二):磁盘缓存的设计与缓存组件设计思路》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Django 1.0 Template Development
Scott Newman / Packt / 2008 / 24.99
Django is a high-level Python web application framework designed to support the rapid development of dynamic websites, web applications, and web services. Getting the most out of its template system a......一起来看看 《Django 1.0 Template Development》 这本书的介绍吧!