APP 緩存數據線程安全問題探討

szly666 8年前發布 | 5K 次閱讀 線程 iOS開發 移動開發

問題

一般一個 iOS APP 做的事就是:請求數據->保存數據->展示數據,一般用 Sqlite 作為持久存儲層,保存從網絡拉取的數據,下次讀取可以直接從 Sqlite DB 讀取。我們先忽略從網絡請求數據這一環節,假設數據已經保存在 DB 里,那我們要做的事就是,ViewController 從 DB 取數據,再傳給 view 渲染:

這是最簡單的情況,隨著程序變復雜,多個 ViewController 都要向 DB 取數據,ViewController本身也會因為數據變化重新去 DB 取數據,會有兩個問題:

  • 數據每次有變動,ViewController 都要重新去DB讀取,做 IO 操作。
  • 多個 ViewController 之間可能會共用數據,例如同一份數據,本來在 Controller1 已經從 DB 取出來了,在 Controller2 要使用得重新去 DB 讀取,浪費 IO。

對這里做優化,自然會想到在 DB 和 VC 層之間再加一層 cache,把從 DB 讀取出來的數據 cache 在內存里,下次來取同樣的數據就不需要再去磁盤讀取 DB 了。

幾乎所有的數據庫框架都做了這個事情,包括微信讀書開源的 GYDataCenter,CoreData,Realm 等。但這樣做會導致一個問題,就是數據的線程安全問題。

按上面的設計,Cache層會有一個集合,持有從DB讀取的數據。

除了 VC 層,其他層也會從cache取數據,例如網絡層。上層拿到的數據都是對 cache 層這里數據的引用:

可能還會在網絡層子線程,或其他一些用于預加載的子線程使用到,如果某個時候一條子線程對這個 Book1 對象的屬性進行修改,同時主線程在讀這個對象的屬性,就會 crash,因為一般我們為了性能會把對象屬性設為nonatomic,是非線程安全的,多線程讀寫時會有問題:

//Network
WRBook *book = [WRCache bookWithId:@“10000”];
book.fav = YES;   //子線程在寫
[book save];

//VC1 WRBook book = [WRCache bookWithId:@“10000”]; self.view.title = book.title; //主線程在讀</pre>

可以通過這個測試看到 crash 場景:

@interface TestMultiThread : NSObject
@property (nonatomic) NSArray arr;
@end

@implementation TestMultiThread @end

TestMultiThread obj = [[TestMultiThread alloc] init]; for (int i = 0; i < 1000; i ++) { dispatch_async(dispatch_get_global_queue(0, 0), ^{ NSLog(@"%@", obj.arr); }); } for (int i = 0; i < 1000; i ++) { dispatch_async(dispatch_get_global_queue(0, 0), ^{ obj.arr = [NSArray arrayWithObject:@“b"]; }); }</pre>

解決方案

對這種情況,一般有三種解決方案:

1. 加鎖

既然這個對象的屬性是非線程安全的,那加鎖讓它變成線程安全就行了。可以給每個對象自定義一個鎖,也可以直接用 OC 里支持的屬性指示符 atomic:

@property (atomic) NSArray arr;</pre> 
  

這樣就不用擔心多線程同時讀寫的問題了。但在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 直接設置,若一個對象屬性值變了,那就新建一個對象,直接整個替換掉這個舊的對象:

//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
self.book = [WRCache bookWithId:@“10000”];
// book.fav = YES;   //不這樣寫
[WRCache updateBookWithId:@“10000” params:{@“fav”: @(YES)}]; //在cache里整個更新
self.book = [WRCache bookWithId:@“10000”];   //重新讀取對象

這樣就不會再有線程安全問題,一旦屬性有修改,就整個數據重新從DB讀取,這些對象的屬性都不會再有寫操作,而多線程同時讀是沒問題的。

但這種方案有個缺陷,就是數據修改后,會在 cache 層整個替換掉這個對象,但這時上層扔持有著舊的對象,并不會自動把對象更新過來:

所以怎樣讓上層更新數據呢?有兩種方式,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 接口不友好,數據不可變需要配合單向數據流之類的規則或框架才會變得好用,可以按需選擇合適的方案。

 

來自:http://blog.cnbang.net/tech/3262/

 

 本文由用戶 szly666 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!