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





















 
 
 



 
 
 
 