【轉】數據存儲——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 接口不友好,數據不可變須要配合單向數據流之類的規則或框架纔會變得好用,能夠按需選擇合適的方案。

相關文章
相關標籤/搜索