APP 快取資料執行緒安全問題探討

發表於2016-12-17

問題

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

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

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

12cache2-700x347

對這裡做優化,自然會想到在 DB 和 VC 層之間再加一層 cache,把從 DB 讀取出來的資料 cache 在記憶體裡,下次來取同樣的資料就不需要再去磁碟讀取 DB 了。

13cache3-700x241

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

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

14cache4

除了 VC 層,其他層也會從cache取資料,例如網路層。上層拿到的資料都是對 cache 層這裡資料的引用:

15cache5-700x436

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

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

解決方案

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

1. 加鎖

既然這個物件的屬性是非執行緒安全的,那加鎖讓它變成執行緒安全就行了。可以給每個物件自定義一個鎖,也可以直接用 OC 裡支援的屬性指示符 atomic:

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

這樣就不會再有執行緒安全問題,一旦屬性有修改,就整個資料重新從DB讀取,這些物件的屬性都不會再有寫操作,而多執行緒同時讀是沒問題的。

但這種方案有個缺陷,就是資料修改後,會在 cache 層整個替換掉這個物件,但這時上層扔持有著舊的物件,並不會自動把物件更新過來:

16cache6-700x840

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

相關文章