Core Data:多執行緒大量資料同步

發表於2015-11-09

前言:本文是我幾個月前的這篇《iOS 面試基礎題目》 其中的一個問題的回答,這幾天整理部落格,更新內容,自覺有能力回答這個問題了。這篇單獨拿出來首先是因為這個問題很不錯,值得單獨寫一篇;其次為了便於檢索,因為簡書目前不支援標籤,只能通過文集來分類,有點不方便,折騰個優美的基於 Github 的部落格又嫌麻煩,暫時還是在這裡寫吧,等有精力了遷移;最後是因為這個回答寫得太長了,原本打算寫個大綱型的,但由於回答是定位於基礎,所以加了很多基礎知識的介紹和補充,這樣一來,原文就更長了。我這裡的很多文章都寫得太長,羅裡吧嗦的,我已經難以忍受,但目前我還沒太多能力進行精簡。

老實說,當時寫的東西大部分只是搬運而已,是我的部落格裡很水的一篇,但卻是我這麼多文章裡最受歡迎的一篇。能說明什麼,大家都很水唄,水不要緊,日拱一卒,共勉。

第一步:搭建 Core Data 多執行緒環境

這個問題首先要解決的是搭建 Core Data 多執行緒環境。Core Data 對併發模式的支援非常完備,NSManagedObjectContext 的指定初始化方法中就指定了併發模式:

有三種模式:

1.NSConfinementConcurrencyType
這種模式是用於向後相容的,使用這種模式時你應該保證只能在建立的執行緒裡使用 context,然而這不容易得到保證。關於此模式的最新訊息是 iOS 9 中它將被廢棄,不推薦使用。

2.NSPrivateQueueConcurrencyType
在一個私有佇列中建立並管理 context。

3.NSMainQueueConcurrencyType
其實這種模式與第2種模式比較相似,只不過 context 與主佇列繫結,同時也因此與應用的 event loop 緊密相連。當 context 與 UI 更新相關的話就使用這種模式。

從 iOS 9 開始就剩下後面兩種模式了,那麼搭建多執行緒 Core Data 環境的方案一般如下,建立一個 NSMainQueueConcurrencyType 的 context 用於響應 UI 事件,其他涉及大量資料操作可能會阻塞 UI 的,就使用 NSPrivateQueueConcurrencyType 的 context。

但需要注意的是「Core Data 使用執行緒或者序列化的佇列來保護 managed objects 和 managed object context,因此 context 假設它的預設擁有者是它初始化時分配的執行緒或佇列,你不能在某個執行緒中初始化一個 context 後傳遞給另外一個執行緒來使用它。」這段蹩腳的話是我從NSManagedObjectContext的文件翻譯來的,意思就是說 managed object context 並非執行緒安全的,你不能隨便地開啟一個後臺執行緒訪問 managed object context 進行資料操作就管這叫支援多執行緒了,那麼應該怎麼做呢?官方文件《Using a Private Queue to Support Concurrency》為我們做了示範,在 private queue 的 context 中進行操作時,應該使用以下方法:

要在不同執行緒中使用 managed object context 時,不需要我們建立後臺執行緒然後訪問 managed object context 進行操作,而是交給 context 自身繫結的私有佇列去處理,我們只需要在上述兩個方法的 Block 中執行操作即可。事實上,你也可以在其他執行緒中來使用 context,但是要保證以上兩個方法。而且,在 NSMainQueueConcurrencyType 的 context 中也應該使用這種方法執行操作,這樣可以確保 context 本身在主執行緒中進行操作。

題外話,在構建多執行緒 context 時,經常會出現這樣的局面:Multi-contexts vs Concurrency。前者可能有更加複雜的情況,在 iOS 5之後,context 可以指定父 context,persistent store coordinator 不再是其與 persistent store 聯絡的唯一選擇。需要注意的是,子 context 的 fetch 和 save 操作都會交給父 context 來完成,對於子 context 的 save 操作,只會到達上一層的父 context 裡,只有父 context 執行了 save 操作,子 context 中的變化才會提交到 persistent store 儲存。這種子 context 適合在後臺執行長時間操作,比如在後臺裡在子 context 裡匯入大量資料,在主執行緒的父 context 裡更新進度。另外一種是平行的多 context。Concurrency 的特性也是在 iOS 5 後開始支援,這個特性減少了平行的多 context 的需求。關於這個話題,可以看這篇文章:《Concurrent Core Data Stacks – Performance Shootout》

第二步:資料的同步操作

總的來說,在多 context 環境的下,context 的生命週期裡有兩個階段需要處理資料同步的問題。當某個 context 裡的狀態發生了變化並執行儲存來更新 persistent store後,對於其他 context 來說有兩個選擇:1. persistent store 更新後,此時其他 context 與 persistent store 進行同步;2. persistent store 更新後,其他 context 並不立即同步,而在自身進行儲存時與 persistent store 進行同步,兩者有差異時需要解決衝突問題。前者採取的是「一處修改處處同步」的策略,所有的 context 中同步為一個版本,後者採取的是「多版本共存協商處理」的策略。以下討論都基於多個 context,假設應用配置了兩個 managed object context,mainContext,在主執行緒執行 ,另外一個 backgroundContext 用於後臺處理。

多 context 單資料版本

在 context 中執行儲存時,應用並不會主動告知其他 context。那麼如何在多個 context 間進行通訊呢?Core Data 提供了通知機制,context 執行儲存時,會發出以下通知:

1.NSManagedObjectContextWillSaveNotification
Managed object context 即將執行儲存操作時發出此通知,沒有附帶任何資訊。

2.NSManagedObjectContextDidSaveNotification
Managed object context 的儲存操作完成之後由該 context 自動發出此通知,包含了所有的新增、更新和刪除的物件的資訊。注意在通知裡的 managed objects 只能在 context 所在的執行緒裡使用。由於 context 也只能在自身的執行緒裡執行操作,所以沒法直接使用通知裡的 managed objects,這時候應該通過 managed object 的 objectID 以及 context 的objectWithID(_ objectID: ) 來獲取。

合併其他 context 的資料可以通過mergeChangesFromContextDidSaveNotification(_ notification:)來完成。在這個方法裡,context 會更新發出通知的 context 裡變化的任何同樣的物件,引入新增的物件並處於 faults 狀態,並刪除在發出通知的 context 裡已經刪除的物件。

程式碼示例,在 backgroundContext 中編輯後使 mainContext 與之同步:

多 context 多資料版本

在這種方案下,backgroundContext 並不針對 mainContext 的儲存做出反應。在 mainContext 和 backgroundContext 中 fetch 了同類的 managed objects,兩個 context 都發生了變化並且變化不一樣,此時讓 backgroundContext 與 mainContext 前後腳分別執行儲存的話,就會發生衝突導致後者儲存失敗。

在 managed object context 中執行 fetch 操作時,會對 persistent store 裡的狀態進行快照,當 context 執行儲存時,會使用快照與 persistent store 進行對比,如果狀態不一致,說明 persistent store 在其他地方被更改了,而這個變化並不是當前 context 造成的,這樣就造成了當前 context 狀態的不連續,此時儲存就會產生衝突。這裡需要介紹 managed object context 的屬性mergePolicy,這個屬性指定了 context 的合併策略,決定了儲存時合併資料發生衝突時如何應對,該屬性有以下幾種值:

1.NSErrorMergePolicy
預設策略,有衝突時儲存失敗,persistent store 和 context 都維持原樣,並返回錯誤資訊,是唯一反饋錯誤資訊的合併策略。

2.NSMergeByPropertyStoreTrumpMergePolicy
當 persistent store 和 context 裡的版本有衝突,persistent store 裡的版本有優先權, context 裡使用 persistent store 裡的版本替換。

3.NSMergeByPropertyObjectTrumpMergePolicy

與上面相反,context 裡的版本有優先權,persistent store 裡使用 context 裡的版本替換。

4.NSOverwriteMergePolicy
用 context 裡的版本強制覆蓋 persistent store 裡的版本。

5.NSRollbackMergePolicy
放棄 context 中的所有變化並使用 persistent store 中的版本進行替換。

除了預設的 NSErrorMergePolicy在發生衝突時返回錯誤等待下一步處理外,其他的合併策略直接根據自身的規則來處理合並衝突,因此在選擇時要謹慎處理。從上面的解釋來看,似乎NSMergeByPropertyStoreTrumpMergePolicyNSRollbackMergePolicy沒什麼區別,NSMergeByPropertyObjectTrumpMergePolicyNSOverwriteMergePolicy也沒有什麼區別。區別在於怎麼對待被覆蓋的一方中沒有衝突的變化(解釋見此處)NSMergeByPropertyStoreTrumpMergePolicyNSMergeByPropertyObjectTrumpMergePolicy採取的是區域性替換,前者 context 中沒有衝突的變化不會受到影響,後者 persistent store 中沒有衝突的變化不受影響;NSOverwriteMergePolicyNSRollbackMergePolicy 採取的是全域性替換,persistent store 和 context 中只有一方的狀態得以保留。

回到本節開始的場景,mainContext 和 backgroundContext 中的版本不一致,會產生合併衝突,解決方案有以下兩種選擇:

1.不管 mainContext 中是否發生改變,與 backgroundContext 中狀態同步;

又或者,mainContext 的合併策略採用NSMergeByPropertyStoreTrumpMergePolicyNSRollbackMergePolicy,這樣就省去了 reset 操作。實際上,採用這種方案不如上一個策略來得方便。

2.不管其他 context 中發生什麼變化,當前 context 進行儲存時直接覆蓋 persistent store 裡的版本,這種方案下 context 的合併策略需要採用NSOverwriteMergePolicyTypeNSMergeByPropertyObjectTrumpMergePolicy,而且執行儲存時不會返回錯誤,不需要後續的處理。

小結

同步多個 context 是個比較複雜的事情,需要根據具體的需要來設定 context 的合併策略以及選擇同步的時機,不僅僅限於以上的兩種策略,融合兩種策略也可以,當然那樣可能會大大增加複雜度,更容易導致 Bug。另外,還有一種使用 child context 的方法,就是將其他 context 作為 context 的 parentContext,這種方法沒有研究,自己有興趣可以試試。

1.同步問題第一原則:不要跨執行緒使用 managed object,而應該通過其對應的 objectID,在其他執行緒裡的 context 裡來獲取物件。

2.NSManagedObjectContext的合併方法mergeChangesFromContextDidSaveNotification(_ notification:)可以替完全複製另一個 context 的狀態;如果你不想完全複製,可以使用更精確的方法refreshAllObjects(),這是 iOS 9 中推出的新方法;或者手動處理,當然,不推薦這麼做。

3.利用NSMergePolicy來處理同步相對而言危險一點,你得明確知道你在做什麼。

最後一站:大量資料操作

從上面的內容可以得知,在多執行緒環境下同步資料基本上不需要我們手動去處理 managed objects 的同步,因此處理大量資料的同步,關注的重點更多在於記憶體佔用和效能。寫程式碼要記住以下幾點:

1.涉及大量資料的操作儘量要放在後臺執行緒裡處理,防止阻塞主執行緒;對於多 context 的結構,可以參考這篇文章《Concurrent Core Data Stacks – Performance Shootout》,作者通過驗證,證明了「設定一個 persistent store coordinator 和兩個獨立的 contexts 被證明了是在後臺處理 Core Data 的好方法」。

2.能夠保持 faults 狀態的 managed objects 儘量不要觸發 fire,降低記憶體佔用,同時也能提升響應速度。

3.fetch 大量資料時注意技巧,可以通過利用 predicate 來篩選實際需要的資料,限制 fetch 的總數量,設定合適的批量獲取數量來降低 IO 的頻次,這些需要在實際環境中尋找平衡點。

4.儘量讓 context 中的 entity 類別少一些,降低對同步的需求。
(從 iOS 8 開始,Core Data 在效能方面有了較大的提升,儘量合理利用。)

5.使用非同步請求 Asynchronous Fetch,儘管可以將 fetch 大量資料的操作放在後臺執行緒裡,但是這樣依然會阻塞那個執行緒,使用非同步請求,則依然可以在後臺執行緒裡進行其他操作,並且還有方便的進度提示和取消功能。

6.使用批量更新 Batch Update,有效降低記憶體佔用並大幅提升儲存的速度。以往在NSManagedObjectContext中進行儲存時,只能將其中包含的變化進行儲存,而 Batch Update 則是直接對 persistent store 進行更新而不需要將 managed objects 讀入記憶體,可以大幅降低記憶體佔用而且更新速度提升不少。但需要注意的是,使用批量更新並不會提醒 context,需要我們對 context 手動進行更新,而且沒有進行有效驗證,也需要開發者來保證有效性。

7.使用批量刪除 Batch Delete,與批量更新類似,直接對 persistent store 進行操作,效率非常高,也有著和批量更新類似的問題。

8.使用 iOS 9 新增的NSManagedObjectContext的新 API:refreshAllObjects(),該方法會對 context 中註冊的所有物件進行重新整理,還沒有儲存的變化也會得到保留,這樣就可以解放 6 和 7 中的手動更新工作。

相關文章