【转】数据存储——APP 缓存数据线程安全问题探讨

http://blog.cnbang.net/tech/3262/git

问题

通常一个 iOS APP 作的事就是:请求数据->保存数据->展现数据,通常用 Sqlite 做为持久存储层,保存从网络拉取的数据,下次读取能够直接从 Sqlite DB 读取。咱们先忽略从网络请求数据这一环节,假设数据已经保存在 DB 里,那咱们要作的事就是,ViewController 从 DB 取数据,再传给 view 渲染:
cache1github

这是最简单的状况,随着程序变复杂,多个 ViewController 都要向 DB 取数据,ViewController自己也会由于数据变化从新去 DB 取数据,会有两个问题:数据库

  • 数据每次有变更,ViewController 都要从新去DB读取,作 IO 操做。
  • 多个 ViewController 之间可能会共用数据,例如同一份数据,原本在 Controller1 已经从 DB 取出来了,在 Controller2 要使用得从新去 DB 读取,浪费 IO。

cache2

对这里作优化,天然会想到在 DB 和 VC 层之间再加一层 cache,把从 DB 读取出来的数据 cache 在内存里,下次来取一样的数据就不须要再去磁盘读取 DB 了。数组

cache3

几乎全部的数据库框架都作了这个事情,包括微信读书开源的 GYDataCenter,CoreData,Realm 等。但这样作会致使一个问题,就是数据的线程安全问题。缓存

按上面的设计,Cache层会有一个集合,持有从DB读取的数据。安全

cache4

除了 VC 层,其余层也会从cache取数据,例如网络层。上层拿到的数据都是对 cache 层这里数据的引用:微信

cache5

可能还会在网络层子线程,或其余一些用于预加载的子线程使用到,若是某个时候一条子线程对这个 Book1 对象的属性进行修改,同时主线程在读这个对象的属性,就会 crash,由于通常咱们为了性能会把对象属性设为nonatomic,是非线程安全的,多线程读写时会有问题:网络

1
2
3
4
5
6
7
8
//Network
WRBook *book = [WRCache bookWithId:@“10000”];
book.fav = YES ;   //子线程在写
[book save];
 
//VC1
WRBook *book = [WRCache bookWithId:@“10000”];
self .view.title = book.title;   //主线程在读

能够经过这个测试看到 crash 场景:多线程

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
@interface TestMultiThread : NSObject
@property ( nonatomic ) NSArray *arr;
@end
 
@implementation TestMultiThread
@end
 
TestMultiThread *obj = [[TestMultiThread alloc] init];
for ( int i = 0; i < 100000; i ++) {
     dispatch_async(dispatch_get_global_queue(0, 0), ^{
         id a = obj.arr;
     });
     dispatch_async(dispatch_get_global_queue(0, 0), ^{
         obj.arr = [ NSArray arrayWithObject: @"b" ];
     });
}

解决方案

对这种状况,通常有三种解决方案:框架

1. 加锁

既然这个对象的属性是非线程安全的,那加锁让它变成线程安全就好了。能够给每一个对象自定义一个锁,也能够直接用 OC 里支持的属性指示符 atomic:

1
@property (atomic) NSArray *arr;

这样就不用担忧多线程同时读写的问题了。但在APP里大规模使用锁极可能会致使出现各类不可预测的问题,锁竞争,优先级反转,死锁等,会让整个APP复杂性增大,问题难以排查,并非一个好的解决方案。

2. 分线程cache

另外一种方案是一条线程建立一个 cache,每条线程只对这条线程对应的 cache 进行读写,这样就没有线程安全问题了。CoreData 和 Realm 都是这种作法,但这个方案有两个缺点:

  • a.使用者须要知道当前代码在哪条线程执行。
  • b.多条线程里的 cache 数据须要同步。

CoreData 在不一样线程要建立本身的 NSManagedObjectContext,这个 context 里维护了本身的 cache,若是某条子线程没有建立 NSManagedObjectContext,要读取数据就须要经过 performBlockAndWait: 等接口跑到其余线程去读取。若是多个 context 须要同步 cache 数据,就要调用它的 merge 方法,或者经过 parent-children context 层级结构去作。这致使它多线程使用起来很麻烦,API 友好度极低。

Realm 作得好一点,会在线程 runloop 开始执行时自动去同步数据,但若是线程没有 runloop 就须要手动去调 Realm.refresh() 同步。使用者仍是须要明确知道代码在哪条线程执行,避免在多线程之间传递对象。

3.数据不可变

咱们的问题是多线程同时读写致使,那若是只读不写,是否是就没有问题了?数据不可变指的就是一个数据对象生成后,对象里的属性值不会再发生改变,不容许像上述例子那样 book.fav = YES 直接设置,若一个对象属性值变了,那就新建一个对象,直接整个替换掉这个旧的对象:

1
2
3
4
5
6
7
8
9
//WRCache
@implementation WRCache
+( void ) updateBookWithId:( NSString *)bookId params:( NSDictionary *)params
{
     [WRDBCenter updateBookWithId:@“10000” params:{@“fav”: @( YES )}]; //更新DB数据
     WRBook *book = [WRDBCenter readBookWithId:bookId]; //从新从DB读取,新对象
     [ self .cache setObject:book forKey:bookId];  //整个替换cache里的对象
}
@end
1
2
3
4
self .book = [WRCache bookWithId:@“10000”];
// book.fav = YES;   //不这样写
[WRCache updateBookWithId:@“10000” params:{@“fav”: @( YES )}]; //在cache里整个更新
self .book = [WRCache bookWithId:@“10000”];   //从新读取对象

这样就不会再有线程安全问题,一旦属性有修改,就整个数据从新从DB读取,这些对象的属性都不会再有写操做,而多线程同时读是没问题的。

但这种方案有个缺陷,就是数据修改后,会在 cache 层整个替换掉这个对象,但这时上层扔持有着旧的对象,并不会自动把对象更新过来:

cache6

因此怎样让上层更新数据呢?有两种方式,push 和 pull。

a. push

push 的方式就是 cache 层把更新 push 给上层,cache对整个对象更新替换掉时,发送广播通知上层,这里发通知的粒度能够按需求斟酌,上层监听本身关心的通知,若是发现本身持有的对象更新了,就要更新本身的数据,但这里的更新数据也是件挺麻烦的事。

举个例子,读书有一个想法列表WRReviewController,存着一个数组 reviews,保存着想法 review 数据对象,数组里的每个 review 会持有这个这个想法对应的一本书,也就是 review.book 持有一个 WRBook 数据对象。而后这时 cache 层通知这个 WRReviewController,某个 book 对象有属性变了,这时这个 WRReviewController 要怎样处理呢?有两个选择:

  • 遍历 reviews 数组,再遍历每个 review 里的 book 对象,若是更新的是这个 book 对象,就把这个 book 对象替换更新。
  • 什么都无论,只要有数据更新的通知过来,全部数据都从新往 cache 层读一遍,从新组装数据,界面所有刷新。

第一种是精细化的作法,优势是不影响性能,缺点是蛋疼,工做量增多,还容易漏更新,须要清楚知道当前模块持有了哪些数据,有哪些须要更新。第二种是粗犷的作法,优势是省事省心,所有大刷一遍就好了,缺点是在一些复杂页面须要组装数据,会对性能形成较大影响。

b. pull

另外一种 pull 的方式是指上层在特定时机本身去判断数据有没有更新。

首先全部数据对象都会有一个属性,暂时命名为 dirty,在 cache 层更新替换数据对象前,先把旧对象的 dirty 属性设为 YES,表示这个旧对象已经从 cache 里被抛弃了,属于脏数据,须要更新。而后上层在合适的时候自行去判断本身持有的对象的 dirty 属性是否为 YES,如果则从新在 cache 里取最新数据。

实际上这样作发生了多线程读写 dirty 属性,是有线程安全问题的,但由于 dirty 属性读取不频繁,能够直接给这个属性的读写加锁,不会像对全部属性加锁那样引起各类问题,解决对这个 dirty 属性读写的线程安全问题。

这里主要的问题是上层应该在什么时机去 pull 数据更新。能够在每次界面显示 -viewWillAppear 或用户操做后去检查,例如用户点个赞,就能够触发一次检查,去更新赞的数据,在这两个地方作检查已经能够解决90%的问题,剩下的就是同个界面联动的问题,例如 iPad 邮件左右两栏两个 controller,右边详情点个收藏,左边列表收藏图标也要高亮,这种状况能够作特殊处理,也能够结合上面 push 的方式去作通知。

push 和 pull 两种是能够结合在一块儿用的,pull 的方式弥补了 push 后数据所有从新读取大刷致使的性能低下问题,push 弥补了 pull 更新时机的问题,实际使用中配合一些事先制定的规则或框架一块儿使用效果更佳。

总结

对于 APP 缓存数据线程安全问题,分线程 cache 和数据不可变是比较常见的解决方案,都有着不一样的实现代价,分线程 cache 接口不友好,数据不可变须要配合单向数据流之类的规则或框架才会变得好用,能够按需选择合适的方案。

相关文章
相关标签/搜索