APP 緩存數據線程安全問題,多個線程同時對同一資源進行讀寫問題

在開發中,我們經常使用到多線程。使用多線程訪問同一數據的時候,可能一不小心就crash。如下:

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

@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 直接設置,若一個對象屬性值變了,那就新建一個對象,直接整個替換掉這個舊的對象:

//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 更新時機的問題,實際使用中配合一些事先制定的規則或框架一起使用效果更佳。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章