PINCache源码解读

github

前言

很早以前PINCache是为了处理TMCache的线程问题而出现的, 所以今天想了解一下PINCache的线程处理.

前段时间看了NSCache和YYCache, 今天来读一下PINCache的代码. 大致看了一眼, PINCache和YY暴露的接口都差不多, 只不过PINCache是把存储的数据, 每一对键值对全部用文件保存起来了, 关于各种内初处理机制也都实现了. 今天在把PINOperationGroup和PINOperationQueue两个类过一遍, 大致记录一下, 看看作者怎么使用GCD实现NSOperation的一些功能的.

PINCache目录结构

PINCache 遵循了两个协议

PINCaching协议主要作用就是对外暴露接口, 内存和硬盘都实现了对应的接口, 所以放在一个协议里面统一一下.

PINCacheObjectSubscripting协议, 里面有两个必须要实现的方法主要是为了存取方便.

  • (nullable id)objectForKeyedSubscript:(NSString *)key;

  • 说白了就是为了id object = cache[@”key”];这样取值的时候实现的方法.

  • (void)setObject:(nullable id)object forKeyedSubscript:(NSString *)key;

  • 这个就是为了cache[@”key”] = object;存值的时候

上面这种知识点平常还是比较容易忽略的, 一般知道了就不会在出错了, 打一下断点看一下就知道了.看下面两图就清楚了.

截屏2021-05-26 上午9.18.58.png

截屏2021-05-26 上午9.19.30.png

PINCaching
PINCacheObjectSubscripting

上面讲过了, 就是PINCache中很重要的两个协议, 所有主要的类都遵循了.

PINCacheMacros

一部分Runtime的宏, 想了解的就去看一下 github. 想深入了解的就去看llvm的官方文档, 编译原理什么的, 前端后端dyld很多环境变量的配置,系统配置都是在其中. 大部分也都可以在xcode中找到对应的配置项.

PINDiskCache

磁盘缓存的类, 在下面了解

PINMemoryCache

内存缓存的类, 在下面了解

PINCache接口设计

属性

1.同步检查磁盘队列上的字节数
@property (readonly) NSUInteger diskByteCount;
2.磁盘缓存
@property (readonly) PINDiskCache *diskCache;
3.内存缓存
@property (readonly) PINMemoryCache *memoryCache;
4.单例
@property (class, strong, readonly) PINCache *sharedCache;

复制代码

构造方法

- (instancetype)initWithName:(nonnull NSString *)name;
- (instancetype)initWithName:(nonnull NSString *)name rootPath:(nonnull NSString *)rootPath;
.......忽略中间的构造方法
- (instancetype)initWithName:(nonnull NSString *)name
                    rootPath:(nonnull NSString *)rootPath
                  serializer:(nullable PINDiskCacheSerializerBlock)serializer
                deserializer:(nullable PINDiskCacheDeserializerBlock)deserializer
                  keyEncoder:(nullable PINDiskCacheKeyEncoderBlock)keyEncoder
                  keyDecoder:(nullable PINDiskCacheKeyDecoderBlock)keyDecoder
                    ttlCache:(BOOL)ttlCache NS_DESIGNATED_INITIALIZER;
复制代码

不推荐使用的方法

PINCache (Deprecated)中的所有方法都不推荐使用.
复制代码

官方demo使用的方法

全部都在PINCaching协议里面
复制代码

PINMemoryCache

首先是遵循PINCaching, PINCacheObjectSubscripting两大协议.

PINMemoryCache和接口设计和之前的YY差别还挺大的.

我把下面这三个号称为 缓存三大属性. 总容量, 容量限制, 时间限制. 
基本上所有的框架里面都必备的, 都是为了做内存的移除策略必备的属性.
@property (readonly) NSUInteger totalCost;
@property (assign) NSUInteger costLimit;
@property (assign) NSTimeInterval ageLimit;

这个属性大致是意思就是说, 如果为YES则对象的生命周期就是跟着ageLimit走的, -setObject:forKey:withAgeLimit
会重写limit, 但是必须大于0. 期间访问属性之类的操作都不会改变对象的生命周期.(其他框架没有这个设计)
@property (nonatomic, readonly, getter=isTTLCache) BOOL ttlCache;

看名字就知道意思
@property (assign) BOOL removeAllObjectsOnMemoryWarning;
@property (assign) BOOL removeAllObjectsOnEnteringBackground;

接下来就是一堆的block, 忽略

生命周期看一下, 也是一个单例, 供外部使用的, 内部并未使用
@property (class, strong, readonly) PINMemoryCache *sharedCache;

接下来几十一堆构造方法, 加上内存异步裁剪的策略方法, 其中用到了PINOperationQueue作为入参.等会直接看内部实现.

在下面就是一堆不推荐使用的方法, 不想看可以直接忽略.
复制代码

实现文件中, 有

@interface PINMemoryCache ()
@property (copy, nonatomic) NSString *name;
@property (strong, nonatomic) PINOperationQueue *operationQueue;
@property (assign, nonatomic) pthread_mutex_t mutex;
@property (strong, nonatomic) NSMutableDictionary *dictionary;
@property (strong, nonatomic) NSMutableDictionary *createdDates;
@property (strong, nonatomic) NSMutableDictionary *accessDates;
@property (strong, nonatomic) NSMutableDictionary *costs;
@property (strong, nonatomic) NSMutableDictionary *ageLimits;
@end

@implementation PINMemoryCache

@synthesize name = _name;
@synthesize ageLimit = _ageLimit;
@synthesize costLimit = _costLimit;
@synthesize totalCost = _totalCost;
@synthesize ttlCache = _ttlCache;
复制代码

上面看到这个@synthesize感觉还是有必要说一下, 因为以前我和一个很好同事开发的时候, 在协议里面写了一个block, 然后我们发现没有set和get的方法, 但是也不知道这个东西, 我们就在对应遵守协议的类里面, 每次都是重写的set和get还得设置一个对应的block去接收. 想想也挺搞笑的.

@synthesize和@dynamic一个是自动合成set,get, 一个是防止生成set, get.

需要注意的地方就是@synthesize使用的时候, 不能同时重写set和get方法, 如果同时重写了的话, 就不会生成成员变量了. 不设置别名就会自己生成一个一模一样的名字成员变量, 默认都是@synthesize name = _name, _name就是自动生成的成员变量.

主要逻辑

通过_dictionary存储对应的key和value, _createdDates创建日期, _accessDates访问日期, _costs内存大小, _ageLimits存储时间限制, 每个字典存储的都是同一个key对应的不同属性.
看一下

  • (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost ageLimit:(NSTimeInterval)ageLimit;方法
  1. ageLimit小于0, 或者ttl缓存策略为YES并且ageLimit大于0的情况下, 内存缓存才生效.
  2. 内存缓存中的锁用的是pthread的互斥锁, pthread_mutex_init(&_mutex, NULL);
  3. willAddObjectBlock, didAddObjectBlock先取出. willblock回调, 然后上锁, 进行_dictionary, 日期, 时间, 访问等存值, 设置totalcost等等,解锁. 五个不同的字典, 存储同样的key对应的不同信息.
  4. 解锁完毕之后, 回调didblock.
  5. 最后进行判断, costLimit>0就进行缓存裁剪.

注意: costLimit默认为0, 需要自己设置才会生效和其他框架基本一致, 当totalCost小于等于costLimit的时候是不进行裁剪的.

- (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost ageLimit:(NSTimeInterval)ageLimit
{
    NSAssert(ageLimit <= 0.0 || (ageLimit > 0.0 && _ttlCache), @"ttlCache must be set to YES if setting an object-level age limit.");

    if (!key || !object)
        return;
    
    [self lock];
        PINCacheObjectBlock willAddObjectBlock = _willAddObjectBlock;
        PINCacheObjectBlock didAddObjectBlock = _didAddObjectBlock;
        NSUInteger costLimit = _costLimit;
    [self unlock];
    
    if (willAddObjectBlock)
        willAddObjectBlock(self, key, object);
    
    [self lock];
        NSNumber* oldCost = _costs[key];
        if (oldCost)
            _totalCost -= [oldCost unsignedIntegerValue];

        NSDate *now = [NSDate date];
        _dictionary[key] = object;
        _createdDates[key] = now;
        _accessDates[key] = now;
        _costs[key] = @(cost);

        if (ageLimit > 0.0) {
            _ageLimits[key] = @(ageLimit);
        } else {
            [_ageLimits removeObjectForKey:key];
        }

        _totalCost += cost;
    [self unlock];
    
    if (didAddObjectBlock)
        didAddObjectBlock(self, key, object);
    
    if (costLimit > 0)
        [self trimToCostByDate:costLimit];
}
复制代码

移除细节

- (void)trimToCostLimitByDate:(NSUInteger)limit
{
    if (self.isTTLCache) {
        [self removeExpiredObjects];
    }

    NSUInteger totalCost = 0;
    
    [self lock];
        totalCost = _totalCost;
        NSArray *keysSortedByAccessDate = [_accessDates keysSortedByValueUsingSelector:@selector(compare:)];
    [self unlock];
    
    if (totalCost <= limit)
        return;

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

        [self lock];
            totalCost = _totalCost;
        [self unlock];
        if (totalCost <= limit)
            break;
    }
}
复制代码

NSArray *keysSortedByAccessDate = [_accessDates keysSortedByValueUsingSelector:@selector(compare:)]; 移除策略中有这样一行代码, 这个就是OC中的一个比较器NSComparator, 集合类包括string都继承compare的方法, 类似于block类型的, 返回值决定了升序降序, 经常使用数组自带的block遍历方法的就是使用的这个. 这里的调用也可以进行学习. 比较器一般使用都是创建好, 传入对应的类型使用的.

PINMemoryCache的异步策略

全都是PINOperationQueue对象完成的. 可以暂时认为是串行队列, 异步任务这样调用.后面读到PINOperationQueue在仔细查看.

内存缓存总结

  1. 内存缓存使用的是五个不同的字典存储同一个key的value和属性状态,缓存策略基本和之前一致, 增删改查都是根据key进行修改都是常量级.
  2. 根据limitcost, agelimit等执行对应的缓存策略. 默认的ageLimit, costLimit都是0不移除.
  3. 移除策略都是根据NSComparator的compare方法进行排序, 然后移除, 所以复杂度默认为nlogn.
  4. 增加了一个ttl的值根据age移除的属性默认为NO.开启之后生命周期全部由ageLimit管理.
  5. 内存缓存中的锁使用的是互斥锁pthread_mutex_
  6. 内存缓存中的异步操作使用的是PINOperationQueue, 目前默认为是串行队列异步任务先, 后面看完这个类就清楚了. 现在只需要了解异步方法就是,实例对象一个block内部调用同步方法即可.
  7. 还有一个内存警告是否全部移除属性removeAllObjectsOnMemoryWarning默认为YES, 内存进入后台是否全部移除属性_removeAllObjectsOnEnteringBackground默认为YES.
  8. 剩下具体的就是其中的一些细节了, 一些不常用的方法的记录, 没有很多不好理解的东西.

PINDiskCache

同样也是必须遵循PINCaching, PINCacheObjectSubscripting协议的. 硬盘缓存分了三类,初始化接口, 异步接口, 同步接口.

PINDiskCache接口设计

初始化接口的设计, 一共9个入参,加上隐藏入参self, _cmd一共11个参数. 超过8个参数会在对应的栈空间后开辟一片额外的内存存储, 不是寄存器存储, 很多框架平常大部分的接口设计都是保持在6个入参以内会好那么一丢丢.

//最终的初始化
- (instancetype)initWithName:(nonnull NSString *)name
                      prefix:(nonnull NSString *)prefix
                    rootPath:(nonnull NSString *)rootPath
                  serializer:(nullable PINDiskCacheSerializerBlock)serializer
                deserializer:(nullable PINDiskCacheDeserializerBlock)deserializer
                  keyEncoder:(nullable PINDiskCacheKeyEncoderBlock)keyEncoder
                  keyDecoder:(nullable PINDiskCacheKeyDecoderBlock)keyDecoder
              operationQueue:(nonnull PINOperationQueue *)operationQueue
                    ttlCache:(BOOL)ttlCache NS_DESIGNATED_INITIALIZER;

1.name: 文件的名字
2.prefix: 文件名字的前缀
3.rootPath: 根目录
4.serializer: 序列化数据进行本地存储,OC数据本地化需要进行序列化
5.deserializer: 就是取值是使用的
6.keyEncoder: 加密方式(并不是真正的加解密, 只是类似于url的字符转化)
7.keyDecoder: 解密方式(同上)
8.operationQueue: PIN自己设计的队列, 内部封装GCD下面将
9.ttlCache: 和内存缓存的一样, 只根据ageLimit处理生命周期

每个参数对应的默认值,都在每一个构造方法里面, 每一层有不同的设计, 可以自行查看.
复制代码

Asynchronous Methods 异步方法

列出来了几个, 基本上就是自定义的贯穿全局的一个_queue在block中调用一下同步的方法, 然后将整个任务扔进执行列表执行, 万变不离其宗.后面写到PINOperationQueue就清楚了, 现在只了解同步实现

- (void)setObjectAsync:(id <NSCoding>)object forKey:(NSString *)key withCost:(NSUInteger)cost ageLimit:(NSTimeInterval)ageLimit completion:(nullable PINCacheObjectBlock)block;

- (void)removeObjectForKeyAsync:(NSString *)key completion:(nullable PINDiskCacheObjectBlock)block;


复制代码

Synchronous Methods 同步方法

说简单一点, 就是同步里面的所有方法, 异步的调用都是queue异步执行操作, 回调, 串行方法内部有加锁, 操作, 解锁的数据安全处理.大概可以简单的这么理解一下.

- (void)synchronouslyLockFileAccessWhileExecutingBlock:(PIN_NOESCAPE PINCacheBlock)block;
//查
- (nullable id <NSCoding>)objectForKey:(NSString *)key;
//获取key的文件路径
- (nullable NSURL *)fileURLForKey:(nullable NSString *)key;
//增
- (void)setObject:(nullable id <NSCoding>)object forKey:(NSString *)key;
//根据大小裁剪
- (void)trimToSize:(NSUInteger)byteCount;
//根据日期裁剪
- (void)trimToSizeByDate:(NSUInteger)byteCount;
//遍历数据
- (void)enumerateObjectsWithBlock:(PIN_NOESCAPE PINDiskCacheFileURLEnumerationBlock)block;

复制代码

关于接口的定义, 主要是是学习一个设计思维, 单单看并没有什么很特别的.

PINDiskCache一些默认属性,参数解析

初始化方法的参数初始化的值, 以下均是默认设置, 代码并不是初始化方法内部代码. 写的都是初始化的核心代码, 不代表初始化的对象类型.

- (instancetype)initWithName:(NSString *)name
                      prefix:(NSString *)prefix
                    rootPath:(NSString *)rootPath
                  serializer:(PINDiskCacheSerializerBlock)serializer
                deserializer:(PINDiskCacheDeserializerBlock)deserializer
                  keyEncoder:(PINDiskCacheKeyEncoderBlock)keyEncoder
                  keyDecoder:(PINDiskCacheKeyDecoderBlock)keyDecoder
              operationQueue:(PINOperationQueue *)operationQueue
                    ttlCache:(BOOL)ttlCache {
                    
     //文件名前面文件夹的名字的后缀
     name = @"PINDiskCacheShared";
     //文件名前面文件夹的名字的前缀
     prefix = @"com.pinterest.PINDiskCache";
     //文件路径
     rootPath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) objectAtIndex:0];
     //序列化
     serializer = [NSKeyedArchiver archivedDataWithRootObject:object requiringSecureCoding:NO error:&error];
     //反序列化
     _deserialize = [NSKeyedUnarchiver unarchiveObjectWithData:data];
     //加密, 实际上是字符串的合法解析替换成百分号.keyEncode
     [decodedKey stringByAddingPercentEncodingWithAllowedCharacters:[[NSCharacterSet characterSetWithCharactersInString:@".:/%"] invertedSet]];
     //解密, 字符串去掉对应的百分号编码.defaultKeyDecoder
     [encodedKey stringByRemovingPercentEncoding];
     
     //字节数量, initializeDiskProperties方法里面初始化了为文件的字节大小
     _byteCount = 0;
     // 50 MB by default. 默认50m的内存限制
        _byteLimit = 50 * 1024 * 1024;
     // 30 days by default. 默认30天的时间限制
        _ageLimit = 60 * 60 * 24 * 30;
     
     //元数据. 默认是个空字典
     _metadata
     //文件路径, 默认是rootPath+_prefix+name
     _cacheURL
     
     // 这两个条件锁. 代码里面初始化后参数都是yes, 所以不执行下面的条件锁.忽略
     // 注释是这样写的while this may not be true if success is false, 
     // it's better than deadlocking later.
     // 大致意思就是如果成功是假的, 这也可能不是正确的, 但是这个东西要比死锁好的多
     _diskWritableCondition
     _diskStateKnownCondition
     

}
复制代码

条件锁_diskWritableCondition, _diskStateKnownCondition和diskWritable, diskStateKnown两个参数默认都是YES配合使用. 默认都是不开启的, 代码中除了在_locked_createCacheDirectory创建文件方法中调用了pthread_cond_broadcast激活条件, 在其他的任何地方都没有signal, 等同于信号量的概念. 加锁之后如果条件锁开启了, 要等待条件锁的信号才可以继续执行. 目前代码里面均未使用所以直接过掉即可.

PINDiskCache的裁剪策略

  • (void)trimDiskToSize:(NSUInteger)trimByteCount;

里面使用的还是比较器, 代码如下.

- (void)trimDiskToSize:(NSUInteger)trimByteCount
{
    NSMutableArray *keysToRemove = nil;
    
    //lockForWriting相当于互斥锁加锁的操作
    [self lockForWriting];
    //如果当前的所含有的容量大于trimByteCount裁剪的要求数量, 进行裁剪
        if (_byteCount > trimByteCount) {
            keysToRemove = [[NSMutableArray alloc] init];
            
            //根据对象的size进行对keys进行排序, 升序的
            NSArray *keysSortedBySize = [_metadata keysSortedByValueUsingComparator:^NSComparisonResult(PINDiskCacheMetadata * _Nonnull obj1, PINDiskCacheMetadata * _Nonnull obj2) {
                return [obj1.size compare:obj2.size];
            }];
            
            //生成的对象, 根据对应的size从大到小排列删除. 因为数组调用了reverseObjectEnumerator, 反转了. 
            //其实在上面直接return [obj2.size compare:obj1.size];可以直接生成降序数组
            NSUInteger bytesSaved = 0;
            for (NSString *key in [keysSortedBySize reverseObjectEnumerator]) { // largest objects first
                [keysToRemove addObject:key];
                NSNumber *byteSize = _metadata[key].size;
                if (byteSize) {
                    bytesSaved += [byteSize unsignedIntegerValue];
                }
                if (_byteCount - bytesSaved <= trimByteCount) {
                    break;
                }
            }
        }
    [self unlock];
    
    for (NSString *key in keysToRemove) {
        [self removeFileAndExecuteBlocksForKey:key];
    }
}
复制代码

裁剪的核心就是size, 根据字典对象的size属性进行从大到小的排序. 从而进行递归移除, 直到满足内存要求. 复杂度的话, 要看比较器的复杂度, 因为是系统的实现, 但是比较大小排序基本上就算是系统的实现基本上也是nlogn的复杂度了.

根据日期裁剪, 其实和size一样, 就是使用lastModifiedDate参数进行比较, 结果就是日期从远到近形成一个数组. 每次都移除最久未使用的数据.方法如下

  • (void)trimDiskToSizeByDate:(NSUInteger)trimByteCount
- (void)trimDiskToSizeByDate:(NSUInteger)trimByteCount
{
    if (self.isTTLCache) {
        [self removeExpiredObjects];
    }

    NSMutableArray *keysToRemove = nil;
  
    [self lockForWriting];
        if (_byteCount > trimByteCount) {
            keysToRemove = [[NSMutableArray alloc] init];
            
            // last modified represents last access.
            NSArray *keysSortedByLastModifiedDate = [_metadata keysSortedByValueUsingComparator:^NSComparisonResult(PINDiskCacheMetadata * _Nonnull obj1, PINDiskCacheMetadata * _Nonnull obj2) {
                return [obj1.lastModifiedDate compare:obj2.lastModifiedDate];
            }];
            
            NSUInteger bytesSaved = 0;
            // objects accessed last first.
            for (NSString *key in keysSortedByLastModifiedDate) {
                [keysToRemove addObject:key];
                NSNumber *byteSize = _metadata[key].size;
                if (byteSize) {
                    bytesSaved += [byteSize unsignedIntegerValue];
                }
                if (_byteCount - bytesSaved <= trimByteCount) {
                    break;
                }
            }
        }
    [self unlock];
    
    for (NSString *key in keysToRemove) {
        [self removeFileAndExecuteBlocksForKey:key];
    }
}
复制代码

在移除策略执行之后, 并没有进行byteSize和metaData的处理, 处理都放在下面这个参数了. 并且操作了空value的block.

  • (BOOL)removeFileAndExecuteBlocksForKey:(NSString *)key
- (BOOL)removeFileAndExecuteBlocksForKey:(NSString *)key
{
    NSURL *fileURL = [self encodedFileURLForKey:key];
    if (!fileURL) {
        return NO;
    }
    // 一直锁定到可以写入, 一旦可以写入就可以一直写入
    // We only need to lock until writable at the top because once writable, always writable
    [self lockForWriting];
        if (![[NSFileManager defaultManager] fileExistsAtPath:[fileURL path]]) {
            [self unlock];
            return NO;
        }
    
        PINDiskCacheObjectBlock willRemoveObjectBlock = _willRemoveObjectBlock;
        if (willRemoveObjectBlock) {
            [self unlock];
            willRemoveObjectBlock(self, key, nil);
            [self lock];
        }
        
    //移动到一个垃圾文件夹下
        BOOL trashed = [PINDiskCache moveItemAtURLToTrash:fileURL];
        if (!trashed) {
            [self unlock];
            return NO;
        }
    //清空文件夹
        [PINDiskCache emptyTrash];
    //更新byteCount, 元数据
        NSNumber *byteSize = _metadata[key].size;
        if (byteSize)
            self.byteCount = _byteCount - [byteSize unsignedIntegerValue]; // atomic
        
        [_metadata removeObjectForKey:key];
    
        PINDiskCacheObjectBlock didRemoveObjectBlock = _didRemoveObjectBlock;
        if (didRemoveObjectBlock) {
            [self unlock];
            _didRemoveObjectBlock(self, key, nil);
            [self lock];
        }
    
    [self unlock];
    
    return YES;
}
复制代码

磁盘缓存策略大概就这么多, 回头记录一篇不同缓存框架的移除策略的比较.

增删改查

都是一个元数据字典进行的操作, 找到key, key就有URl路径.set也是直接存, remove移除, 增删改查都是常量级的. 看看代码就可以了.

_metadata元数据, 存储的是key和对应的PINDiskCacheMetadata对象(主要是访问时间, size等属性), 真实的文件名是根据key进行百分号编码进行访问的.

磁盘缓存中的锁

使用的是互斥锁

@property (assign, nonatomic) pthread_mutex_t mutex;

- (void)lock
{
    __unused int result = pthread_mutex_lock(&_mutex);
    NSAssert(result == 0, @"Failed to lock PINDiskCache %@. Code: %d", self, result);
}

- (void)unlock
{
    __unused int result = pthread_mutex_unlock(&_mutex);
    NSAssert(result == 0, @"Failed to unlock PINDiskCache %@. Code: %d", self, result);
}

复制代码

使用中就是操作前先lock ,操作, 然后unlock. 需要注意的就是互斥锁不同于递归锁, 不可以连续上锁. 但是互斥锁可以搭配条件变量使用各个线程同步, 可以看看上面提到的不用关注的两个条件锁的使用.

磁盘缓存中的线程

sharedTrashQueue

比较值得注意的就是下面这个方法

+ (dispatch_queue_t)sharedTrashQueue
{
    static dispatch_queue_t trashQueue;
    static dispatch_once_t predicate;
    
    dispatch_once(&predicate, ^{
        NSString *queueName = [[NSString alloc] initWithFormat:@"%@.trash", PINDiskCachePrefix];
        trashQueue = dispatch_queue_create([queueName UTF8String], DISPATCH_QUEUE_SERIAL);
        dispatch_set_target_queue(trashQueue, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0));
    });
    
    return trashQueue;
}
复制代码

里面用到了这个GCD函数dispatch_set_target_queue, 设置了一个串行队列. 基本上使用有两种:

  1. dispatch_set_target_queue(q1, targetQueue);以目标队列的优先级执行
  2. 在目标队列中执行其他队列
dispatch_queue_t q1 = dispatch_queue_create("q1", DISPATCH_QUEUE_SERIAL);
    dispatch_queue_t q2 = dispatch_queue_create("q2", DISPATCH_QUEUE_SERIAL);
    dispatch_queue_t q3 = dispatch_queue_create("q3", DISPATCH_QUEUE_SERIAL);
    
    dispatch_queue_t targetQueue = dispatch_queue_create("serial", DISPATCH_QUEUE_SERIAL);
    
    //重点代码
    dispatch_set_target_queue(q1, targetQueue);
    dispatch_set_target_queue(q2, targetQueue);
    dispatch_set_target_queue(q3, targetQueue);
    

    dispatch_async(q1, ^{
        NSLog(@"1");
    });

    dispatch_async(q2, ^{
        NSLog(@"2");
    });

    dispatch_async(q3, ^{
        NSLog(@"3");
    });

打印123是有序的, 正常是无序的
复制代码

sharedTrashQueue是一个同步队列, 设置的全局队列的DISPATCH_QUEUE_PRIORITY_BACKGROUND优先级, 优先级非常低.
是结合sharedLock和sharedTrashURL一起使用的.

sharedLock就是NSLock, 本质也是封装的pthread_mutex, 使用的是互斥锁类型. sharedTrashURL是tmp文件夹下的. 主要是用于垃圾清理文件移动到tmp文件后自动清理.

用到不常用的一些类

NSProcessInfo

从名字来看, 就是一个用于获取进程的相关信息的类.是一个单例,获取进程信息, 获取环境变量, 获取活跃状态的处理器数量等等. 做监测之类的都挺有用的, 详细的可以看官方文档. 知道就行, 用到了可以在进行详细的了解.

在tmp文件中创建了一个[[NSProcessInfo processInfo] globallyUniqueString];名字的垃圾文件, 在移除的时候先移动到tmp文件夹下, 然后移除掉对应的数据.

NSCharacterSet

NSCharacterSet是一个字符串处理的类, 非常好用, 可以多看看留意一下.
NSCharacterSet

NSString *encodedString = [decodedKey stringByAddingPercentEncodingWithAllowedCharacters:[[NSCharacterSet characterSetWithCharactersInString:@".:/%"] invertedSet]];

大致就是对标记的字符进行百分号编码转换 还是对标记之外的, 多用用就清楚了
原文: @"7:sjf7.8sf990s//.:12356756576";
正向文字: %37:%73%6A%66%37.%38%73%66%39%39%30%73//.:%31%32%33%35%36%37%35%36%35%37%36
invertedSet文字: 7%3Asjf7%2E8sf990s%2F%2F%2E%3A12356756576

按照PINDiskCache来说, 就是原来的文件名字是com.abc, 经过转化以为, 实际上encrypt之后的文件名就是com%2Eabc, 然后每次取的时候先获取到这个文件名进行解密得到的实际存储的文件名还是com.abc, 大概就是这个样子.对@".:/%"这里面的符号进行百分号化. 

PINDiskCache是对文件名做了NSURL的一个百分号转化的方式做的加解密.
yycache在进行存文件的时候, 是对文件名做了md5的加密, 获取到了128位长度的加密文本作为文件名.
复制代码

磁盘缓存总结

  1. 磁盘缓存的增删改查都是根据字典进行的, 复杂度都是常量级.
  2. 磁盘缓存的裁剪策略都是根据cost, lastAccesstime, ageLimit的属性进行排序, 然后循环移除直到满足条件. 因为要排序进行处理, 所以复杂度为nlogn
  3. 磁盘缓存的锁就是pthread的互斥锁, 项目中写了两个条件锁但并未使用.
  4. 移除数据的时候, 是先把数据移动到tmp文件下面的一个根据NSProcessInfo获取类似于全局唯一标识符的文件下. 然后用一个串行低优先级的队列, 加上NSLock的锁进行数据移除的操作.
  5. 文章中有很多很好的细节, 可以仔细看一看

PINOperation的目录结构

PINOperationQueue和PINOperationGroup是作者对于GCD的一些封装, 没看PINCache之前我还以为是对于原生的NSOperation的一些封装, 恕我无知了. 比较重要的就是PINOperationQueue, group个人觉得和gcd本身的差别不大, 读完再说.

对于GCD本身不熟悉的人来说, 想读PINOperation估计要难受了…

PINOperationGroup

看了一遍PINOperationGroup的调用和内部实现, 总体来说任务加进去以后还是加进了_operationQueue的对象里面作为一个operation执行的, 感觉和正常的使用线程组的区别并不是很大.

PINGroupOperationReference自定义的一个协议继承NSObject的协议, 内部定义了一个NSNumber的分类.

NSNumber的继承连是NSNumber>NSValue>NSObject, 本身就已经实现了NSObject的协议, 直接用NSNumber使用效果来说基本一样, 但是没有什么扩展性.

@interface NSNumber (PINGroupOperationQueue) <PINGroupOperationReference>
@end
复制代码

外部调用

- (void)setObjectAsync:(nonnull id)object forKey:(nonnull NSString *)key withCost:(NSUInteger)cost ageLimit:(NSTimeInterval)ageLimit completion:(nullable PINCacheObjectBlock)block
{
    if (!key || !object)
        return;
    
    PINOperationGroup *group = [PINOperationGroup asyncOperationGroupWithQueue:_operationQueue];
    [group addOperation:^{
        [self->_memoryCache setObject:object forKey:key withCost:cost ageLimit:ageLimit];
    }];
    [group addOperation:^{
        [self->_diskCache setObject:object forKey:key withAgeLimit:ageLimit];
    }];
  
    if (block) {
        [group setCompletion:^{
            block(self, key, object);
        }];
    }
    
    [group start];
}
复制代码

感觉虽然代码少, 但是在没看完自定义queue的情况下, 容易被绕进去.上图
PINCacheGroup.png

主要就是看外部调用的逻辑

核心就是

  1. _operations进组
  2. 取出任务
  3. 包装任务
  4. 任务扔进贯穿全剧的_operationQueue执行
  5. 具体的出组和任务取消可自己查看代码

PINOperationQueue(重点)

需要先看里面的几个重要的东西:

  1. PINOperationReference是一个协议, 遵循< NSObject >协议的一个协议.其实都是NSNumber对象, 上面提到过了, 一样的东西.
  2. PINOperation在PINOperationQueue.m文件内部的定义. PINOperation的block对象和completions对象就是存储的需要执行的任务代码块.(看一下PINOperation对象, 类似于一个节点的一样的存在, 就是对任务和一些额外的信息的一些封装)

PINOperation
相当于对一个任务的封装, 大致了解一下结构

  1. block 需要执行的任务
  2. reference 任务的计数
  3. priority 任务的优先级
  4. identifier 任务的标识
  5. data 任务的id类型入参
  6. _completions 一个任务重可能执行很多的block, 全部都加在这里面, 调用的时候都是先执行block, 然后循环执行_completions中的任务, 默认的话其实用不到.

首先看一下PINCache中默认的调用:

- (instancetype)initWithName:(NSString *)name
                    rootPath:(NSString *)rootPath
                  serializer:(PINDiskCacheSerializerBlock)serializer
                deserializer:(PINDiskCacheDeserializerBlock)deserializer
                  keyEncoder:(PINDiskCacheKeyEncoderBlock)keyEncoder
                  keyDecoder:(PINDiskCacheKeyDecoderBlock)keyDecoder
                    ttlCache:(BOOL)ttlCache
{
    if (!name)
        return nil;
    
    if (self = [super init]) {
        _name = [name copy];
      
        //10 may actually be a bit high, but currently much of our threads are blocked on empyting the trash. Until we can resolve that, lets bump this up.
        //重点
        _operationQueue = [[PINOperationQueue alloc] initWithMaxConcurrentOperations:10];
        _diskCache = [[PINDiskCache alloc] initWithName:_name
                                                 prefix:PINDiskCachePrefix
                                               rootPath:rootPath
                                             serializer:serializer
                                           deserializer:deserializer
                                             keyEncoder:keyEncoder
                                             keyDecoder:keyDecoder
                                         operationQueue:_operationQueue
                                               ttlCache:ttlCache];
        _memoryCache = [[PINMemoryCache alloc] initWithName:_name operationQueue:_operationQueue ttlCache:ttlCache];
    }
    return self;
}

复制代码

代码里面创建的是10的最大并发量, 注释写的是可能并发量有点高, 但是由于清空垃圾的时候线程被堵塞, 大概意思就是为了解决这个问题才这样写的.

核心

PINCache由于上面这段代码可知, 所有的异步安全以及实现的基础就是在于一个贯穿于整个项目的_operationQueue对象.

PINCache, PINMemoryCache, PINDiskCache三个使用的是同一个_operationQueue.
包括PINOperationGroup在使用的时候传入的也是统一的queue, 所以就保证了所有的任务在整个PINCache里面全部都是由同一个对象处理和操作的.

入手

  1. initWithMaxConcurrentOperations构造方法入手, 查看其中的属性, 变量之类的了解这个类的结构.
  2. 从- (id )scheduleOperation:(dispatch_block_t)block withPriority:(PINOperationQueuePriority)priority; 外部调用的都是调用的此方法, 所以从这个方法入手.
  • 根据构造方法探入
- (instancetype)initWithMaxConcurrentOperations:(NSUInteger)maxConcurrentOperations
{
    return [self initWithMaxConcurrentOperations:maxConcurrentOperations concurrentQueue:dispatch_queue_create("PINOperationQueue Concurrent Queue", DISPATCH_QUEUE_CONCURRENT)];
}

- (instancetype)initWithMaxConcurrentOperations:(NSUInteger)maxConcurrentOperations concurrentQueue:(dispatch_queue_t)concurrentQueue
{
    if (self = [super init]) {
        NSAssert(maxConcurrentOperations > 0, @"Max concurrent operations must be greater than 0.");
        _maxConcurrentOperations = maxConcurrentOperations;
        _operationReferenceCount = 0;
        
        pthread_mutexattr_t attr;
        pthread_mutexattr_init(&attr);
        //mutex must be recursive to allow scheduling of operations from within operations
        //必须是递归锁, 内部调度使用
        pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
        pthread_mutex_init(&_lock, &attr);
        
        _group = dispatch_group_create();
        
        _serialQueue = dispatch_queue_create("PINOperationQueue Serial Queue", DISPATCH_QUEUE_SERIAL);
        
        _concurrentQueue = concurrentQueue;
        
        //Create a queue with max - 1 because this plus the serial queue add up to max.
        _concurrentSemaphore = dispatch_semaphore_create(_maxConcurrentOperations - 1);
        _semaphoreQueue = dispatch_queue_create("PINOperationQueue Serial Semaphore Queue", DISPATCH_QUEUE_SERIAL);
        
        _queuedOperations = [[NSMutableOrderedSet alloc] init];
        _lowPriorityOperations = [[NSMutableOrderedSet alloc] init];
        _defaultPriorityOperations = [[NSMutableOrderedSet alloc] init];
        _highPriorityOperations = [[NSMutableOrderedSet alloc] init];
        
        _referenceToOperations = [NSMapTable weakToWeakObjectsMapTable];
        _identifierToOperations = [NSMapTable weakToWeakObjectsMapTable];
    }
    return self;
}
复制代码

//The number of active processing cores available on the computer. 当前电脑可用的,活跃的处理器数量
上面是Queue的初始化方法, 比较不一样的就是其他地方的互斥锁在这里_lock必须使用递归锁.

在最初看完这里的时候, 其实是有几点疑惑的.

    1. 为什么初始化线程的最大并发量设置为了10.
    1. 为什么这里要使用递归锁, 根据之前的判断, 如果只使用串行队列异步执行, 互斥锁更为合适而且够用了.
    1. 里面为什么要维护三个队列, 一个异步队列, 两个同步队列.

_group 线程组

里面为什么要维护线程组, 既然有了信号量, 其实来说线程组跟根本用不到. 所以我就搜一下dispatch_group_enter, dispatch_group_leave, dispatch_group_wait, dispatch_group_notify四个方法的使用发现.


enter的使用, 这有这一处
- (void)locked_addOperation:(PINOperation *)operation
{
    NSMutableOrderedSet *queue = [self operationQueueWithPriority:operation.priority];
    
    /*****enter****/dispatch_group_enter(_group);
    [queue addObject:operation];
    [_queuedOperations addObject:operation];
    [_referenceToOperations setObject:operation forKey:operation.reference];
    if (operation.identifier != nil) {
        [_identifierToOperations setObject:operation forKey:operation.identifier];
    }
}


wait的使用只有这一处
- (void)waitUntilAllOperationsAreFinished
{
    [self scheduleNextOperations:NO];
    dispatch_group_wait(_group, DISPATCH_TIME_FOREVER);
}

leave的使用
- (void)scheduleNextOperations:(BOOL)onlyCheckSerial
{
}

取消任务
- (BOOL)locked_cancelOperation:(id <PINOperationReference>)operationReference
{
    PINOperation *operation = [_referenceToOperations objectForKey:operationReference];
    BOOL success = [self locked_removeOperation:operation];
    if (success) {
        dispatch_group_leave(_group);
    }
    return success;
}

复制代码

由于上面的操作可发现, enter处理任务的添加, 任务执行完毕或者被删除leave, wait设置的时候之后的任务在添加的时候要等待前面的group执行完所有任务. 所以group的这个设计, 我认为就是为了waitUntilAllOperationsAreFinished这一个方法的实现.

_serialQueue 串行队列

只按照递归处理_queuedOperations中的一个一个任务

该队列只在一个地方使用了就是scheduleNextOperations方法中, 用于任务对象的block中任务执行. 实现方式为同步队列, 异步执行, 直白点说就是创建一条子线程专门处理每个任务的block属性的代码块顺序执行.

_semaphoreQueue 命名为信号量的同步队列

只按处理_semaphoreQueue中的, 信号量的signal和wait.

在setMaxConcurrentOperations方法和, scheduleNextOperations下半部分执行操作. 主要是为了设置信号量, 默认为10, 保证最多10条子线程任务同时运行. 单例创建的时候是根据活跃处理器数量创建的信号量, 最小应该>=2.

_concurrentQueue 唯一的异步队列

只根据优先级队列中优先级顺序, 取出优先级最高的任务之一进行处理.

整个包含semp的任务在同步队列_semaphoreQueue,中异步执行.

核心方法 scheduleNextOperations:

- (void)scheduleNextOperations:(BOOL)onlyCheckSerial
{
    [self lock];
    
    //get next available operation in order, ignoring priority and run it on the serial queue
    //获取下一个操作, 忽略优先级 并且在串行队列上运行. (这里不区分优先级)
    if (_serialQueueBusy == NO) {
        //获取下一个操作, _queuedOperations中的所有任务, 都是PINOperation对象
        PINOperation *operation = [self locked_nextOperationByQueue];
        //如果操作存在, 串行队列变忙, 串行队列中异步执行任务的block, 离开group组, 修改串行队列的状态为不忙, 递归调用.
        if (operation) {
            _serialQueueBusy = YES;
            dispatch_async(_serialQueue, ^{
                operation.block(operation.data);
                for (dispatch_block_t completion in operation.completions) {
                    completion();
                }
                dispatch_group_leave(self->_group);
                
                [self lock];
                self->_serialQueueBusy = NO;
                [self unlock];
                
                //see if there are any other operations, 如果还有其他的任务
                [self scheduleNextOperations:YES];
            });
        }
    }
    
    NSInteger maxConcurrentOperations = _maxConcurrentOperations;
    
    [self unlock];
    
    if (onlyCheckSerial) {
        return;
    }
    
    //if only one concurrent operation is set, let's just use the serial queue for executing it
    if (maxConcurrentOperations < 2) { //如果最大任务数量小于2, 返回
        return;
    }
    
    //串行队列添加异步任务, _concurrentSemaphore, 从优先级高, 默认, 低取出对应的队列进行执行
    dispatch_async(_semaphoreQueue, ^{
        dispatch_semaphore_wait(self->_concurrentSemaphore, DISPATCH_TIME_FOREVER);
        [self lock];
        PINOperation *operation = [self locked_nextOperationByPriority];
        [self unlock];
        
        if (operation) {
            dispatch_async(self->_concurrentQueue, ^{
                operation.block(operation.data);
                for (dispatch_block_t completion in operation.completions) {
                    completion();
                }
                dispatch_group_leave(self->_group);
                dispatch_semaphore_signal(self->_concurrentSemaphore);
            });
        } else {
            dispatch_semaphore_signal(self->_concurrentSemaphore);
        }
    });
}
复制代码

这段代码里面核心的逻辑就是递归实现每一条任务, 所以必须用递归锁, 不然会产生死锁.

(1)

  • 第一次执行的时候, onlyCheckSerial为NO, 取出_queuedOperations中的任务放入_serialQueue队列中, 实现任务, 然后依次递归取出任务调用.
  • 递归的时候onlyCheckSerial为YES, 不在执行下面代码, 仅仅重复上面的操作.

(2)

  • 第一次进入之后, 上半部分一次取出队列中的任务执行, 如果最大并发量小于2, 则不处理, 因为并发量小于2,上面已经存在一条线程处理了, 不需要在这样处理.
  • 接下来是_semaphoreQueue串行队列里面, 设置信号量数量的线程, 允许同时最大的并发量(初始化的时候已经减1, 因为上面有一条执行的),
  • 然后locked_nextOperationByPriority方法根据优先级, 取出对应优先级队列中的任务, 从高到低, 移除_queuedOperations中的任务.
  • 然后在同步队列的里面, 对让任务在异步队列_concurrentQueue中异步执行, 执行完毕.

队列总结

  1. 要搞明白每个队列设定的功能是要做什么.
  2. 核心的地方代码, scheduleNextOperations这个方法的整个逻辑. 对于锁的操作, 在对数据进行改动的时候都要进行锁的保护. 框架代码不多,但是很经典, 对于GCD的封装思维也很优秀, 值得学习. 代码就是这样, 看着简单, 写着难, 切勿眼高手低.

根据正常的调用走一遍代码逻辑

默认代码同步

最常用的方式:

[[PINCache sharedCache] setObject:@"123" forKey:@"name"];
我们看看到底内部是怎么走的;

  1. 首先[PINCache sharedCache]调用到了initWithName, 里面各种默认值设置, 最后走到初始化方法如下图;

截屏2021-05-30 下午1.13.09.png
里面有内存, 磁盘初始化的设置配置, 均和PINCache的一样.

2.初始化的设置完毕了之后, 看看set方法

截屏2021-05-30 下午1.11.51.png
默认的cost, agelimit都是0.

3._memoryCache上面的set方法也介绍过, 主要就是互斥锁, 加上内部五个字典进行的增删改查. 默认cost和agelimit以及TTLCahce都是0和NO, 所以除了发生内存警告和程序进入后台会移除掉内存中的值, 默认是内存中的数据是不会移除的.

4._diskache上面的Set方法也介绍过, 简单来说也使用互斥锁, _metadata数据进行key和自定义的PINDiskCacheMetadata对象的绑定, 数据写入然后根据key创建路径, 写入文件, 然后给_metadata设置对应的对象存储文件的一些访问时间等属性更新.

最后结果就是:

disk.png

异步

  • 关于异步的调用, 其实就是把所有的任务都加进_operationQueue队列中去执行. 执行的方式根据最大并发量处理:

    • 大于等于2: 先在一个串行队列钟执行排队的任务, 然后在信号量队列里面根据优先级取出任务在异步队列中异步执行, 用信号量控制并发数量.
    • 小于2: 只在一个串行队列中异步执行保持稳定.

锁: 不管同步还是异步, 全局都是使用的互斥锁, 队列中执行任务的之后使用的是互斥锁的递归锁.

总结

PINCache看完了, 整体感觉最厉害的地方就是对于GCD的封装, 怎么实现最大并发, 怎么取消之类的实现. 系统的NSOperation我使用的不多, 但是看完PIN的话, 应该是对于系统的都是类似的, 不然开源作者全靠自己想也挺难的. 最起码可以了解到, 比如已经开始的任务其实在开始之前已经被移除队列了, 所以就算想cancel, 也是cancel之后所有未执行的任务, 已经执行的就没办法控制了.

其实很多没必要写, 我也是尽量挑核心的部分去记录, 以后自己想看也不用花很多时间, 大概看一眼就能想起来就好了.

有些的错误, 不对的地方欢迎讨论.

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享