APP緩存數(shù)據(jù)線程安全問題探討
問題
一般一個 iOS APP 做的事就是:請求數(shù)據(jù)->保存數(shù)據(jù)->展示數(shù)據(jù),一般用 Sqlite 作為持久存儲層,保存從網(wǎng)絡(luò)拉取的數(shù)據(jù),下次讀取可以直接從 Sqlite DB 讀取。我們先忽略從網(wǎng)絡(luò)請求數(shù)據(jù)這一環(huán)節(jié),假設(shè)數(shù)據(jù)已經(jīng)保存在 DB 里,那我們要做的事就是,ViewController 從 DB 取數(shù)據(jù),再傳給 view 渲染:
這是最簡單的情況,隨著程序變復(fù)雜,多個 ViewController 都要向 DB 取數(shù)據(jù),ViewController本身也會因為數(shù)據(jù)變化重新去 DB 取數(shù)據(jù),會有兩個問題:
數(shù)據(jù)每次有變動,ViewController 都要重新去DB讀取,做 IO 操作。
多個 ViewController 之間可能會共用數(shù)據(jù),例如同一份數(shù)據(jù),本來在 Controller1 已經(jīng)從 DB 取出來了,在 Controller2 要使用得重新去 DB 讀取,浪費 IO。
對這里做優(yōu)化,自然會想到在 DB 和 VC 層之間再加一層 cache,把從 DB 讀取出來的數(shù)據(jù) cache 在內(nèi)存里,下次來取同樣的數(shù)據(jù)就不需要再去磁盤讀取 DB 了。
幾乎所有的數(shù)據(jù)庫框架都做了這個事情,包括微信讀書開源的 GYDataCenter,CoreData,Realm 等。但這樣做會導(dǎo)致一個問題,就是數(shù)據(jù)的線程安全問題。
按上面的設(shè)計,Cache層會有一個集合,持有從DB讀取的數(shù)據(jù)。
除了 VC 層,其他層也會從cache取數(shù)據(jù),例如網(wǎng)絡(luò)層。上層拿到的數(shù)據(jù)都是對 cache 層這里數(shù)據(jù)的引用:
可能還會在網(wǎng)絡(luò)層子線程,或其他一些用于預(yù)加載的子線程使用到,如果某個時候一條子線程對這個 Book1 對象的屬性進(jìn)行修改,同時主線程在讀這個對象的屬性,就會 crash,因為一般我們?yōu)榱诵阅軙褜ο髮傩栽O(shè)為nonatomic,是非線程安全的,多線程讀寫時會有問題:
- //Network
- WRBook *book = [WRCache bookWithId:@“10000”];
- book.fav = YES; //子線程在寫
- [book save];
- //VC1
- WRBook *book = [WRCache bookWithId:@“10000”];
- self.view.title = book.title; //主線程在讀
- 可以通過這個測試看到 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"];
- });
- }
解決方案
對這種情況,一般有三種解決方案:
1. 加鎖
既然這個對象的屬性是非線程安全的,那加鎖讓它變成線程安全就行了。可以給每個對象自定義一個鎖,也可以直接用 OC 里支持的屬性指示符 atomic:
- @property (atomic) NSArray *arr;
這樣就不用擔(dān)心多線程同時讀寫的問題了。但在APP里大規(guī)模使用鎖很可能會導(dǎo)致出現(xiàn)各種不可預(yù)測的問題,鎖競爭,優(yōu)先級反轉(zhuǎn),死鎖等,會讓整個APP復(fù)雜性增大,問題難以排查,并不是一個好的解決方案。
2. 分線程cache
另一種方案是一條線程創(chuàng)建一個 cache,每條線程只對這條線程對應(yīng)的 cache 進(jìn)行讀寫,這樣就沒有線程安全問題了。CoreData 和 Realm 都是這種做法,但這個方案有兩個缺點:
- a.使用者需要知道當(dāng)前代碼在哪條線程執(zhí)行。
- b.多條線程里的 cache 數(shù)據(jù)需要同步。
CoreData 在不同線程要創(chuàng)建自己的 NSManagedObjectContext,這個 context 里維護(hù)了自己的 cache,如果某條子線程沒有創(chuàng)建 NSManagedObjectContext,要讀取數(shù)據(jù)就需要通過 performBlockAndWait: 等接口跑到其他線程去讀取。如果多個 context 需要同步 cache 數(shù)據(jù),就要調(diào)用它的 merge 方法,或者通過 parent-children context 層級結(jié)構(gòu)去做。這導(dǎo)致它多線程使用起來很麻煩,API 友好度極低。
Realm 做得好一點,會在線程 runloop 開始執(zhí)行時自動去同步數(shù)據(jù),但如果線程沒有 runloop 就需要手動去調(diào) Realm.refresh() 同步。使用者還是需要明確知道代碼在哪條線程執(zhí)行,避免在多線程之間傳遞對象。
3.數(shù)據(jù)不可變
我們的問題是多線程同時讀寫導(dǎo)致,那如果只讀不寫,是不是就沒有問題了?數(shù)據(jù)不可變指的就是一個數(shù)據(jù)對象生成后,對象里的屬性值不會再發(fā)生改變,不允許像上述例子那樣 book.fav = YES 直接設(shè)置,若一個對象屬性值變了,那就新建一個對象,直接整個替換掉這個舊的對象:
- //WRCache
- @implementation WRCache
- +(void) updateBookWithId:(NSString *)bookId params:(NSDictionary *)params
- {
- [WRDBCenter updateBookWithId:@“10000” params:{@“fav”: @(YES)}]; //更新DB數(shù)據(jù)
- 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”]; //重新讀取對象
這樣就不會再有線程安全問題,一旦屬性有修改,就整個數(shù)據(jù)重新從DB讀取,這些對象的屬性都不會再有寫操作,而多線程同時讀是沒問題的。
但這種方案有個缺陷,就是數(shù)據(jù)修改后,會在 cache 層整個替換掉這個對象,但這時上層扔持有著舊的對象,并不會自動把對象更新過來:
所以怎樣讓上層更新數(shù)據(jù)呢?有兩種方式,push 和 pull。
a. push
push 的方式就是 cache 層把更新 push 給上層,cache對整個對象更新替換掉時,發(fā)送廣播通知上層,這里發(fā)通知的粒度可以按需求斟酌,上層監(jiān)聽自己關(guān)心的通知,如果發(fā)現(xiàn)自己持有的對象更新了,就要更新自己的數(shù)據(jù),但這里的更新數(shù)據(jù)也是件挺麻煩的事。
舉個例子,讀書有一個想法列表WRReviewController,存著一個數(shù)組 reviews,保存著想法 review 數(shù)據(jù)對象,數(shù)組里的每一個 review 會持有這個這個想法對應(yīng)的一本書,也就是 review.book 持有一個 WRBook 數(shù)據(jù)對象。然后這時 cache 層通知這個 WRReviewController,某個 book 對象有屬性變了,這時這個 WRReviewController 要怎樣處理呢?有兩個選擇:
- 遍歷 reviews 數(shù)組,再遍歷每一個 review 里的 book 對象,如果更新的是這個 book 對象,就把這個 book 對象替換更新。
- 什么都不管,只要有數(shù)據(jù)更新的通知過來,所有數(shù)據(jù)都重新往 cache 層讀一遍,重新組裝數(shù)據(jù),界面全部刷新。
第一種是精細(xì)化的做法,優(yōu)點是不影響性能,缺點是蛋疼,工作量增多,還容易漏更新,需要清楚知道當(dāng)前模塊持有了哪些數(shù)據(jù),有哪些需要更新。第二種是粗獷的做法,優(yōu)點是省事省心,全部大刷一遍就行了,缺點是在一些復(fù)雜頁面需要組裝數(shù)據(jù),會對性能造成較大影響。
b. pull
另一種 pull 的方式是指上層在特定時機自己去判斷數(shù)據(jù)有沒有更新。
首先所有數(shù)據(jù)對象都會有一個屬性,暫時命名為 dirty ,在 cache 層更新替換數(shù)據(jù)對象前,先把舊對象的 dirty 屬性設(shè)為 YES ,表示這個舊對象已經(jīng)從 cache 里被拋棄了,屬于臟數(shù)據(jù),需要更新。然后上層在合適的時候自行去判斷自己持有的對象的 dirty 屬性是否為 YES ,若是則重新在 cache 里取最新數(shù)據(jù)。
實際上這樣做發(fā)生了多線程讀寫 dirty 屬性,是有線程安全問題的,但因為 dirty 屬性讀取不頻繁,可以直接給這個屬性的讀寫加鎖,不會像對所有屬性加鎖那樣引發(fā)各種問題,解決對這個 dirty 屬性讀寫的線程安全問題。
這里主要的問題是上層應(yīng)該在什么時機去 pull 數(shù)據(jù)更新。可以在每次界面顯示 -viewWillAppear 或用戶操作后去檢查,例如用戶點個贊,就可以觸發(fā)一次檢查,去更新贊的數(shù)據(jù),在這兩個地方做檢查已經(jīng)可以解決90%的問題,剩下的就是同個界面聯(lián)動的問題,例如 iPad 郵件左右兩欄兩個 controller,右邊詳情點個收藏,左邊列表收藏圖標(biāo)也要高亮,這種情況可以做特殊處理,也可以結(jié)合上面 push 的方式去做通知。
push 和 pull 兩種是可以結(jié)合在一起用的,pull 的方式彌補了 push 后數(shù)據(jù)全部重新讀取大刷導(dǎo)致的性能低下問題,push 彌補了 pull 更新時機的問題,實際使用中配合一些事先制定的規(guī)則或框架一起使用效果更佳。
總結(jié)
對于 APP 緩存數(shù)據(jù)線程安全問題,分線程 cache 和數(shù)據(jù)不可變是比較常見的解決方案,都有著不同的實現(xiàn)代價,分線程 cache 接口不友好,數(shù)據(jù)不可變需要配合單向數(shù)據(jù)流之類的規(guī)則或框架才會變得好用,可以按需選擇合適的方案。