PINCache
第二篇是关于最经典的KV缓存解决方案PINCache
客户端的存储
在正式讲解PINCache
前,先来谈谈客户端什么时候需要做存储。
总所周知,现在的APP越来越依赖网络,在离开网络的情况下,绝大多数的APP都成了摆设。然而,优秀的APP已经有自己的一套设计,避免在无网或者弱网的情况下完全失去作用。例如微信这样的强网络要求的APP,在失去网络的情况依然能够浏览本地缓存的聊天信息和朋友圈记录。优秀的APP,应该根据自己的业务需求,选择合适的存储方案。对于一般体量的APP来说,利用KV形式的存储是最好不过的选择。
KV,即Key-Value形式的存储。通常客户端的信息是由后端给出的json解析的,使用Key-Value正是方便了这种格式。
PINCache
PINCache
是脱胎于TMCache
,解决了TMCache
中的线程死锁问题。PINCache
中依然设计了memoryCache
和diskCache
两种,分别解决了临时存储和长期持久化的问题。
在PINCache
的github主页上,提出在iOS上如果没有特别的情况,尽可能使用diskCache
,原因是每次iOS应用启动,都会造成memoryCache
中的内容被清空。在下面,会对比下内存缓存和磁盘缓存的优缺点,以及PINCache
是如何实现的,
文件结构
从图上可以看到,PINCache
的实现并不复杂,除去PINCache
本身实现对外的方法外,总共只有八个实现文件。
PINMemoryCache
PINMemoryCache
为实现内存缓存的类。什么是内存缓存,我们可以理解为通过一个NSMutableDictionary
来临时存储对象。实现一个内存缓存难度不大,通常需要完成以下几个事情:
- 选择合适的存储结构
- 选择和实现对应的读写方法
- 选择和实现淘汰方法
- 处理系统内存警告
了解了以上步骤,我们来看看PINMemoryCache
是怎么解决上面的问题
- 选择
NSMutableDictionary
作为存储结构 - 采用
NSMutableDictionary
的读写方法 - 通过两个
NSMutableDictionary
分别记录date
和cost
,实现时间和内容的管理。 - 收到内存警告后,清空内存中所有的对象,清空缓存。
看上去不难?在笔者看来,实现内存缓存的难度主要是2,3点。在这里,PINMemoryCache
主要考虑的是如何解决多线程安全的问题,以及缓存淘汰问题。
多线程安全
由于缓存的特殊性,读写必须考虑到多线程安全的问题。如果一个线程改变了缓存中的某个值,而别的线正好需要这个值,处理不好轻则造成数据出错,重则造成程序崩溃。
在PINMemoryCache
中,使用了pthread_mutex_t
来保证线程安全。
pthread_mutex_t
为互斥锁,当锁上的时候,其他线程就无法访问被锁上的资源
使用pthread_mutex_t
需要注意几点。
必须在dealloc
方法中主动释放
1 |
|
单独使用pthread_mutex_t
容易造成死锁,同时也坑你造成效率不高,可以通过实现对应的递归锁来避免
1 | pthread_mutexattr_t attr; |
在完成锁的初始化后,就需要考虑什么时候要加锁解锁了
为了避免data race的情况,在这里,PINMemoryCache
选择了对所有读写操作都进行加锁。同时,采用副本的形式避免读取的数据被篡改。
1 | - (__nullable id)objectForKey:(NSString *)key |
通过这两个读写方法,我们发现,PINMemoryCache
对任何涉及属性操作的地方都进行了锁操作,避免了data race的情况。
缓存淘汰
缓存淘汰,也就是第三点。如果保证缓存中数据有效性,保证缓存搞笑的利用是所有缓存应该考虑的问题。通常来讲,我们应该从缓存利用率上来着手,只保留常被使用的数据,淘汰长期不需要的数据。LRU就会常用的一种缓存淘汰算法。在这里,PINMemoryCache
通过时间和空间来实现的LRU算法。
在上面贴出的读写中,读的时候,更新date
中key对应的时间。在写的时候,记录或更新key对应时间。
当触发缓存删除操作的时候,优先删除占用空间大和使用时间靠前的的内容。
至此,PINMemoryCache
的内容基本就分析完了。内存缓存的实现,只要考虑清楚上面列的四个内容,我认为就不难做到,剩下的就是实现细节和性能的考量了。
PINDiskCache
PINDiskCache
实现的磁盘缓存。我们知道,磁盘的读取速度要远小于内存读取。大量的磁盘I/O会降低程序的运行效率。虽然如此,面对大体量的数据以及需要长期存储的数据,我们只能考虑存储在磁盘上。同时,由于iOS系统的特殊性,选择存储在磁盘上往往是我们的第一选择。
和memory cache
不同,磁盘I/O需要考虑的问题要更多一点,除了上面再memory cache
讲到的几点外,还需要考虑一下几点。
- 实现方式。磁盘存储一般分为三种:文件读写,数据库,mmap文件内存映射。
PINDiskCache
使用的是文件读写的方式 - 存储位置。iOS文件系统不同位置建议存储的内容不同。是放在cache文件夹中还是temp文件中取决于开发者对存储文件的态度。
- 文件系统的细节。避免和其他文件产生冲突,必须考虑拥有自身的标记。
- 数据序列化
看上去磁盘缓存的实现的确要难上很多问题,在内存缓存的基础上依然要考虑这么多问题。
PINDiskCache
在这四个问题上,充分考虑了oc语言已经iOS已经MAC OS系统的特性。首先采用文件读写的方式,依然考虑了数据的特性。文件读写的方式非常适合用于存储数据规模小的情况,同时实现方便,性能稳定也是选择的原因之一。但是使用文件存储也有不小的弱点:方便扩展、没有元数据、难以实现较好的淘汰算法、数据统计缓慢。
2,3,4点将在下面实现细节中会讲到。
实现细节
线程安全
线程安全依然是绕不过去的一个坎,在PINDiskCache
里,除了需要对付一般的data race,还需要处理磁盘I/O带来的问题。
在这里,PINDiskCache
使用的是NSConditionLock
条件锁。在代码中有如下定义:
1 | typedef NS_ENUM(NSUInteger, PINDiskCacheCondition) { |
可见,条件锁中并不涉及到具体的实例对象,而是一个常量来作为锁的条件。在这里,个人认为直接用递归锁差别也不是很大,不知道为何这里选择使用了条件锁。(或许有一天知道了会回来补。
在使用中,所有对成员变量操作的地方都进行的锁操作。(实际上有些地方是不需要用的)
读写
磁盘读写如果不注意极其容易造成死锁。
写:
1 | - (void)setObject:(id <NSCoding>)object forKey:(NSString *)key fileURL:(NSURL **)outFileURL |
读:
1 | - (__nullable id <NSCoding>)objectForKey:(NSString *)key fileURL:(NSURL **)outFileURL |
在序列化和反序列化的地方解锁,之后再上锁,目的是避免死锁问题(序列化失败等)。
在读写之后,改变文件的最后修改时间,作为一个参考依据。
在写入的时候,先尝试将数据写入到磁盘,然后读取写入的文件的信息,比较文件的大小是否和写入的数据大小相同
异步操作
在PINDiskCache
中大量使用了异步操作。比如读取文件后修改文件时间:
1 | - (void)asynchronouslySetFileModificationDate:(NSDate *)date forURL:(NSURL *)fileURL |
这里用的operationQueue
就是在下面要讲到PINCache
自己实现的一套异步方案
淘汰方法
PINDiskCache
仍然根据文件大小和最近修改时间来作为淘汰的依据。
PINOperationQueue
PINOperationQueue
是PINCache
实现的一个并发控制方案。实现了最大并发数,取消,优先级管理。在PINDiskCache
中大量使用了PINOperationQueue
来达到并发行为的控制。
实现细节
PINOperationQueue
是对外公开的操作,主要是通过下面方法添加操作
1 | - (id <PINOperationReference>)addOperation:(PINOperationBlock)operation |
其中dataCoallescingBlock
和coalescingData
提供了联合数据处理的能力,例如
1 | - (void)trimToDate:(NSDate *)trimDate block:(PINDiskCacheBlock)block |
这个方法中,DiskCache
会去清除指定时间的数据,在这里,添加到PINOperationQueue
的时候,将trimDate
作为联合数据传入。这里的联合数据,可以认为是block之外的一个负载。
初始化
通过初始化可以得知,PINOperationQueue
是怎么实现上述功能的。
1 | pthread_mutex_t _lock; |
通过PINOperationQueue
的成员变量,可以得知:
- 通过
dispatch_semaphore_t
来实现并发数控制。dispatch_semaphore_t
是信号量 - 通过
NSMutableOrderedSet
实现了优先级和顺序管理。NSMutableOrderedSet
是具有排序功能的集合类。 - 通过
NSMapTable
实现了绑定id和操作。NSMapTable
类似NSdictionry
,区别在于NSMapTable
是可变的,同时键值可以为弱引用,当任何一项为nil的时候将这一项移除。同时,NSMapTable
可以存储任意指针,通过指针来进行相等性和散列校验
初始化代码如下:
1 | - (instancetype)initWithMaxConcurrentOperations:(NSUInteger)maxConcurrentOperations concurrentQueue:(dispatch_queue_t)concurrentQueue |
在这里,实现了一个递归锁。递归锁允许在未释放锁的时候反复的上锁,当解锁次数和等于上锁的次数的时候才PINOperationQueue
可能在一个多次加锁。
并发
PINOperationQueue
最大特色就是实现了并发管理操作,提供cancel,wait, Priority的功能,通过实现来看看怎么做到的。
- (void)scheduleNextOperations:(BOOL)onlyCheckSerial
方法为派发operation的地方私有方法。
scheduleNextOperations
执行的分为两块
1 | [self lock]; |
在这里,locked_nextOperationByQueue
从_queuedOperations
取出第一个operation,然后异步派发到_serialQueue
中执行。可以看出,这里本质是串行取出operation然后再派发到_serialQueue
异步执行。
1 | dispatch_async(_semaphoreQueue, ^{ |
在这里,实现了并发,从locked_nextOperationByPriority
方法中根据优先级获得一个operation,异步派发到_concurrentQueue
中执行。这里通过信号量来管理并发数,当信号量为0的时候就会一直等待。
locked_nextOperationByPriority
中从_highPriorityOperations
取,取不到的情况下就从下一个优先级的set中取。
看懂了scheduleNextOperations
,再看locked_cancelOperation
就不难了。
1 | - (BOOL)locked_cancelOperation:(id <PINOperationReference>)operationReference |
取消操作,主要根据operationRef来取出操作,然后从所在的优先级队列中删除对应的operation。注意到,如果这个operation已经被派发到执行队列上了,就无法取消了。
PINOperationGroup
是在PINOperationQueue
之上再实现的包装。
PINOperationQueue
和PINOperationGroup
可以看做是PINCache
使用的GCD实现一套与NSOperation相似的功能。通过学习阅读这一块,可以加深对GCD异步处理的理解。
尾声
PINCache
是这个系列的第二篇,个人感觉收获很大,对整个key-value缓存实现的一个思路有了更清楚的认识。
下一篇,打算看看YYCache
,主要关注点是如何实现缓存淘汰算法和优化性能的。