問題
一般一個 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,是非執行緒安全的,多執行緒讀寫時會有問題:
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 場景:
1 2 3 4 5 6 7 8 9 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 層整個替換掉這個物件,但這時上層扔持有著舊的物件,並不會自動把物件更新過來:
所以怎樣讓上層更新資料呢?有兩種方式,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 介面不友好,資料不可變需要配合單向資料流之類的規則或框架才會變得好用,可以按需選擇合適的方案。