iOS部分缓存框架比较

1,910 阅读9分钟

前言

最近打算空闲时间开始做各种优化了, 在此之前, 把之前梳理过的缓存框架, 做一遍对比,方便以后记录.然后做一期应用重签名的记录.

上面这件事做完之后, 因为Swift的不断优化API的稳定, 以及全新的性能体验, 打算开始建一个专题使用Swift刷算法每天一题.也算是Swift的开始学习.(PS:其实以前2.3版本的时候做过半年, 但是因为每次系统升级, Swift999的报错太过于浪费时间, 项目没法稳定运行, 后面就做的少了. )

之前看了SDWebImage的Cache, 但是没有记录, 这次就一并拿过来进行比较.

我打算从几个方便进行对比NSCache, YYCache, PINCache, SDImageCache: (这里的NSCache是很早的, 并不是代表现在的源码, 现在的接口来看应该已经使用链表了)

  • 线程和锁
  • 各自的复杂度
  • 各自的淘汰策略
  • 框架设计的不同

SD的建议大家看一看SDImageCachesManager这个类, 这是操作cache的类里面添加了cache的任务, 操作的时候使用的是_unfair_lock, 我只是看了缓存部分其他没看, 所以在这里我只讨论SDImageCache及其内部.

正文

内存

NSCache: 使用的是

  • _objects = [NSMapTable strongToStrongObjectsMapTable]. _objects用于查询
  • _accesses = [NSMutableArray new]; _accesses用于缓存策略 内存中有两份, 默认是执行缓存策略的.

YYCache:

YYCache分为内存缓存和磁盘缓存两个类分别进行不同的功能存储.

  • YYMemoryCache
    • 内存缓存使用_lru = [_YYLinkedMap new];使用字典和双向链表解决.内存全都在_YYLinkedMap_dic中. 链表只是通过节点的_prev, _next属性链接, 属性修饰符为__unsafe_unretained所以不占内存.

YYCache.png

  • YYDiskCache 数据库中存入key, inline_data, filename, last_access_time等.
    • 当数据value >20kb的时候, filename会有值(MD5对key加密后的值), 在存储的时候会写入文件.
    • value <= 20kb的时候, 数据会以二进制流的形势存入inline_data

PINCache:

  • PINCaching:这个协议里面定义了PIN的接口. 都会实现该协议.
  • PINMemoryCache:
    • 内存中使用了5个字典去存储同一个key的不同信息.
  • PINDiskCache
    • _metadata是一个可变字典, 会从磁盘中读取一份数据到_metadata中. 磁盘缓存这种的操作_metadata和磁盘数据都会同步更新.(这里没有数据, 只有key对应数据的一个大小, 访问时间等信息)

SDImageCache:

  • SDMemoryCache
//strong-weak的管理
self.weakCache = [[NSMapTable alloc] initWithKeyOptions:NSPointerFunctionsStrongMemory valueOptions:NSPointerFunctionsWeakMemory capacity:0];

//OS_UNFAIR_LOCK
#define SD_LOCK_INIT(lock) if (@available(iOS 10, tvOS 10, watchOS 3, macOS 10.12, *)) lock = OS_UNFAIR_LOCK_INIT; \

也就是说, 在内存缓存中, 并没有真正的持有存进来的对象.(UIImage)

PS:SDMemoryCache继承NSCache, 所以所有的操作都是从父类拿, 子类做一层容错判断

  • SDDiskCache中 直接将data写入文件, 文件名字使用了MD5对Key加密得到了16个字节, 对这16个字节进行拼接得到文件名. 数据在文件中存在一份.(NSData)

线程和锁

NSCache: 由于是内存缓存的系统类, 就和其他的数组, 字典设计一样, 系统在进行这方面设计的时候, 是不会设计一个线程安全的缓存类, 这样会影响效率和性能. 性能是永恒的话题.

YYCache:

  • YYMemoryCache:

    • 正常的增删改查使用了pthread_mutex_t互斥锁.
    • YYMemoryCacheGetReleaseQueue全局低优先级队列, 用于控制对象在哪个线程释放.
    • _queue: 是一个串行队列, 在内存缓存中所有的裁剪策略触发的时候, 进行异步调用.
      • 在队列中, 使用pthread_mutex_lock互斥锁进行线程保护_lru, 对_lru进行操作的时候, 保护线程安全.
  • YYDiskCache:

    • 在对数据进行操作的时候使用dispatch_semaphore_t处理线程安全.
    • _queue异步队列, 异步调用. 在磁盘缓存数据进行block回调的时候使用, 正常使用磁盘不需要回调, 只会用信号量保证操作安全.

_globalInstances: 一个NSMapTable对象StrongToWeak, 一个磁盘缓存的path, 对应一个YYDiskCache的对象. 对于_globalInstances的操作也是由dispatch_semaphore_t管理的.

PINCache:

  • _operationQueue:PINOperationQueue对象, 贯穿全局

PINOperationQueue

  • _concurrentQueue 异步队列, 执行优先级任务以及额外任务的队列
  • _serialQueue 串行队列, 主要执行任务的队列
  • _semaphoreQueue 串行队列, 主要是控制最大任务并发量的队列.
  • _group 线程组, 主要是为了实现任务阻塞的功能. 使用了递归锁pthread_mutexattr_t, 锁主要是添加任务移除任务使用的, 这一部分内容其实很多, 这里不多细述. 之前的文章也详细写过 PIN
  • PINMemoryCache

    • 同步接口: 同步情况下使用了pthread_mutex_t互斥锁保证线程安全.
    • 异步接口: 添加任务进_operationQueue
  • PINDiskCache

    • 同步接口: 同步情况下使用了pthread_mutex_t互斥锁保证线程安全.
    • 异步接口: 添加任务进_operationQueue

SDImageCache:

  • ioQueue一条串行队列, 增删改查异步调用.
  • SDMemoryCache, SDDiskCache内部没有额外的线程处理.

SDMemoryCache使用了OS_UNFAIR_LOCK, 去保护线程安全.

复杂度

NSCache: 下面是执行缓存策略的复杂度分析, 不执行缓存策略默认就相当于哈希表的增删改查均为O(1)

  • 直接添加, 复杂度O(1)
  • 删除数组中的元素, 在添加.O(n)
  • 查询到之后, 先删除, 然后赋值添加. O(n)
  • 先查询, 查到了之后, 执行缓存策略, 先移除后添加. O(n)

YYCache:

  • 直接添加到_dic, 插入链表头部 复杂度O(1)
  • 删除字典中的元素, 删除链表中的元素, 由于是直接处理删除node的前后节点的指针,所以复杂度为O(1).
  • 从_dic中取出node, node插入链表头部, 然后赋值添加. O(1)
  • 字典中拿出node, 然后链表将node插入头节点 O(1)

PINCache:

  • 直接添加到_dic, 插入链表头部 复杂度O(1)
  • 删除字典中的元素, 删除链表中的元素, 由于是直接处理删除node的前后节点的指针,所以复杂度为O(1).
  • 从_dic中取出node, node插入链表头部, 然后赋值添加. O(1)
  • 字典中拿出node, 然后链表将node插入头节点 O(1)

SDImageCache:

  • 直接加入weak, 加入磁盘.O(1)
  • 内存中直接移除, 磁盘中根据文件名直接移除.O(1).
  • 没有修改, 直接就是覆盖, 和增是一个调用. O(1)
  • 内存中直接查, 磁盘中直接根据文件名查. O(1)

淘汰策略

NSCache: 使用比较器NSEnumerator *e = [_accesses objectEnumerator];

    1. 进行遍历获取元素, 然后遍历数组, 从前往后获取到最久不用的数据.
    1. 拿到数据, 根据访问频率进行判断是否加入移除队列.
    1. 移除的数据量足够放下元素时, 结束移除策略.

YYCache:

这里要说一下YYCache作者使用递归的方式, 去做了定时清理缓存的一个做法, 就是递归调用方法内部延迟5秒继续递归. 全局队列设置了一个低优先级.

不同的是内存中是串行队列, 磁盘中是异步队列

  • YYMemoryCache: 双向链表, 由于是头插法, 所以每次都是移除尾结点即可, 移除节点的时候, 需要拿到key从_dic众移除, 并且修改前面节点的_next指针.LRU

    • 异步执行的时候, 也是在串行队列中和增删改查在同一个队列.
  • YYDiskCache: 数据库中使用last_access_time字段, order排序进行移除.实现LRU

    • 多线程移除的时候, 是在磁盘的一个并发队列进行异步执行

PINCache:

  • PINMemoryCache:
    • 这里其实是做了排序的, 还是粘贴出来看一下
- (void)trimToCostLimit:(NSUInteger)limit
{
//删除很多代码, 留下核心了解一下.
    NSArray *keysSortedByCost = [_costs keysSortedByValueUsingSelector:@selector(compare:)];
    for (NSString *key in [keysSortedByCost reverseObjectEnumerator]) { 
    //....
    }
}
    • 从不同的字典中, 根据字典的value进行排序, 然后进行移除. 比如时间, 消耗, 限制时间等.
  • PINDiskCache:

    • _metadata中取出obj拿到key, 根据对象的lastModifiedDate或者其他属性进行排序, 记录keys. 然后根据keys中的值先移除本地文件, 在移除_metadata中的数据.
    • 异步进行策略的时候, 也是加入队列, 优先级设置最低, 然后根据统一的任务执行流程处理.

SDImageCache:

  • 继承自NSCache.

框架设计

NSCache: 就是内存缓存设计, 在一定程度上官方建议使用NSCache代替NSDictionary. 但是API的暴露以及接口设计是得很多缓存框架应该都是在这基础之上进行设计的.

YYCache: 从接口设计, 到内存, 磁盘, 数据库, 每个类的任务分明且相互独立又完美实现了其功能. 非常非常非常适合学习的框架.

PINCache: 用协议暴露接口, 对外暴露聚合, 内部低耦合. 总体来说由于数据全部都是存磁盘, 加上需要根据字典value排序进行策略执行, 所以在性能上是注定了不会特别好. (对于小数据不友好, 对于大数据性能会高于其他)

  • 所以在一定范围内大小的数据, 使用PINCache存储效率可能要高于其他框架.
  • 同样实现了增删改查都是常量级

SDImageCache: 跨平台的框架, 关于cache的设计满足了自己的需求. 更适合在其内部使用. 使用了KVO监听一些costLimit等属性的设置.

学习到的点

NSCache:

可能是很早之前的设计, 总体感觉或者内存设计中规中矩的感觉. 像NSMutableArray一样, 系统不会去设计线程安全的, 有需求的可以自己在处理的时候使用锁去解决. 或者自己设计一个县城安全的字典啊, 数组啊, 甚至NSCache去玩玩.

大部分的线程安全思路, 就是使用同步队列, 把任务同步或者队列+barr函数. 或者递归锁也可以. 网上有很多资料可以自行查看,

YYCache:

高内聚, 低耦合, 每个类在接口设计中都能实现对应的功能, 独立又有同性.

  • 可以多看看测试demo, 不知道怎么测试性能的, 建议看一下.
  • 对于控制移除对象的移除线程来说, 我之前看到的时候学习到了. 看起来很简单, 但是非常不引人注目, 并不是那么容易想象的.
  • 不同的场景对于不同锁的测试, 比如作者就在内存中选择了互斥锁, 磁盘中选择了信号量.

PINCache:

  • PINOperationQueue这个类对于线程的封装和处理.
  • 自己写类似于Cache是时候, setObject:forKeyedSubscript:``objectForKeyedSubscript这两个方法是我们简便写法必须要实现的. 就是 dic[@"key"] = value这种写法的.

SDImageCache:

  • 首先SDImageCache里面是使用同一个SDImageCacheConfig相当于各种信息的配置类进行传递, 有点类似于PINCache的哪个全局的queue, 在一开始就定义好, 然后传入.
  • 属性的memoryCache以及diskCache都是id<Protocol>, 有别与其他框架, 可以更好的保护内部数据. 并不是特性的类作为成员变量去控制. 所以才外部操作cache的时候, 可以避免直接调用特定类.
  • SD框架的图片编码解码是很好的资料 , 自己做存储的时候完全可以直接拿过来用.
  • 由于我只看了cache的一小部分, 这里就不多赘述, 以免误导到大家.

补充os_unfair_lock

os_unfair_lock.png 上图所示, 在线程执行耗时操作的时候, 进入到了内核代码, 系统调用了, 虽然不知道系统调用了啥, 但是从名字来看应该也是忙等.

网上老是看到有人说线程会挂起线程等待?

  • 因为不常用, 所以没怎么关注, iOS10以后替代了OSSpinLock.

  • 既然是代替自旋锁, 那么本质就是强资源.

  • 既然设计初衷就是这样, 一个替代的锁应该不会违背理念, 如果要挂起线程释放CPU资源等到信号处理, 那么互斥锁完全可以使用为什么还要做这个, 所以挂起线程这个说法我并不认同.

文档也没有特别说明, 不得而知, 其实内核态和用户态切换的时候就会造成大量的性能消耗, 不公平锁在等待的时候会进入内核系统调用. 原理应该和自旋锁一样, 只是解决了优先级反转的问题, 一样会不断的访问资源直到获取到.

可能要看一些其他系统的锁, 找一些灵感, 在去思考探究真正的流程是什么样的.

代码在libsystem_pthread.dylib中, 是一个系统动态库, 没法继续往下搞了. 回头在找找资料看看.

总结

这些知识看着简单, 但是实际上在输出和真正编写的时候才是最为困难的. 所以很多事都是看着简单而已, 只要你没动手尝试, 未真正做过一件事不要轻易说这很简单. 建议用行动证明, 或许真的不简单, 只是想象很简单...