Статьи

Основные данные и Swift: параллелизм

Если вы разрабатываете маленькое или простое приложение, вы, вероятно, не увидите преимущества выполнения операций с основными данными в фоновом режиме. Однако что произойдет, если вы импортировали сотни или тысячи записей в основной поток при первом запуске приложения? Последствия могут быть драматичными. Например, ваше приложение может быть убито сторожевым таймером Apple из-за слишком долгого запуска.

В этой статье мы рассмотрим опасности, возникающие при использовании Core Data в нескольких потоках, и исследуем несколько решений для решения проблемы.

При работе с Core Data важно всегда помнить, что Core Data не является поточно-ориентированным. Базовые данные ожидают запуска в одном потоке. Это не означает, что каждая операция Core Data должна выполняться в главном потоке, что верно для UIKit, но это означает, что вам нужно помнить, какие операции выполняются с какими потоками. Это также означает, что вам нужно быть осторожным, как изменения из одного потока распространяются на другие потоки.

Работа с базовыми данными в нескольких потоках на самом деле очень проста с теоретической точки зрения. NSManagedObject , NSManagedObjectContext и NSPersistentStoreCoordinator не являются поточно- NSPersistentStoreCoordinator . Доступ к экземплярам этих классов возможен только из потока, в котором они были созданы. Как вы можете себе представить, это становится немного сложнее на практике.

Мы уже знаем, что NSManagedObject не является потокобезопасным, но как получить доступ к записи из разных потоков? Экземпляр NSManagedObject имеет свойство objectID которое возвращает экземпляр класса NSManagedObjectID . Класс NSManagedObjectID является потокобезопасным, и экземпляр этого класса содержит всю информацию, которая необходима контексту управляемого объекта для извлечения соответствующего управляемого объекта.

1
2
// Object ID Managed Object
let objectID = managedObject.objectID

В следующем фрагменте кода мы запрашиваем контекст управляемого объекта для управляемого объекта, который соответствует objectID . objectWithID(_:) и existingObjectWithID(_:) возвращают локальную версию — локальную для текущего потока — соответствующего управляемого объекта.

01
02
03
04
05
06
07
08
09
10
11
// Fetch Managed Object
let managedObject = managedObjectContext.objectWithID(objectID)
 
// OR
 
do {
    let managedObject = try managedObjectContext.existingObjectWithID(objectID)
} catch {
    let fetchError = error as NSError
    print(«\(fetchError), \(fetchError.userInfo)»)
}

Основное правило, которое нужно запомнить, — не передавать экземпляр NSManagedObject из одного потока в другой. Вместо этого передайте objectID управляемого объекта и запросите контекст управляемого объекта потока для локальной версии записи.

Поскольку класс NSManagedObjectContext не является потокобезопасным, мы могли бы создать контекст управляемого объекта для каждого потока, взаимодействующего с базовыми данными. Эта стратегия часто упоминается как ограничение потока .

Распространенным подходом является сохранение контекста управляемого объекта в словаре потока, словаре для хранения данных, специфичных для потока. Посмотрите на следующий пример, чтобы увидеть, как это работает на практике.

1
2
3
// Add Object to Thread Dictionary
let currentThread = NSThread.currentThread()
currentThread.threadDictionary.setObject(managedObjectContext, forKey: «managedObjectContext»)

Не так давно Apple рекомендовала такой подход. Даже при том, что это работает хорошо, есть еще один и лучший вариант, который Apple рекомендует сегодня. Мы рассмотрим этот вариант через несколько минут.

Как насчет постоянного координатора магазина? Вам нужно создать отдельный постоянный координатор хранилища для каждого потока. Хотя это возможно и является одной из стратегий, которые Apple рекомендовала, в этом нет необходимости.

Класс NSPersistentStoreCoordinator был разработан для поддержки нескольких контекстов управляемых объектов, даже если эти контексты управляемых объектов создавались в разных потоках. Поскольку класс NSManagedObjectContext блокирует координатор постоянного хранилища при доступе к нему, несколько контекстов управляемого объекта могут использовать один и тот же координатор постоянного хранилища, даже если эти контексты управляемого объекта живут в разных потоках. Это делает многопоточную настройку Core Data намного более управляемой и менее сложной.

Итак, мы узнали, что вам нужно несколько контекстов управляемых объектов, если вы выполняете операции Core Data в нескольких потоках. Однако предостережение заключается в том, что контексты управляемого объекта не знают о существовании друг друга. Изменения, вносимые в управляемый объект в одном контексте управляемого объекта, не распространяются автоматически в другие контексты управляемого объекта. Как мы решаем эту проблему?

Apple рекомендует использовать две популярные стратегии: уведомления и контексты управляемых объектов «родитель-потомок». Давайте посмотрим на каждую стратегию и выясним их плюсы и минусы.

Сценарий, который мы возьмем в качестве примера, это подкласс NSOperation который выполняет работу в фоновом режиме и получает доступ к базовым данным в фоновом потоке операции. Этот пример покажет вам различия и преимущества каждой стратегии.

Ранее в этой серии я познакомил вас с классом NSFetchedResultsController и вы узнали, что контекст управляемого объекта NSFetchedResultsController три типа уведомлений:

  • NSManagedObjectContextObjectsDidChangeNotification : это уведомление публикуется, когда один из управляемых объектов контекста управляемого объекта изменился.
  • NSManagedObjectContextWillSaveNotification : это уведомление публикуется до того, как контекст управляемого объекта выполняет операцию сохранения.
  • NSManagedObjectContextDidSaveNotification : это уведомление публикуется после того, как контекст управляемого объекта выполняет операцию сохранения.

Когда контекст управляемого объекта сохраняет свои изменения в постоянном хранилище, через координатор постоянного хранилища, другие контексты управляемого объекта могут захотеть узнать об этих изменениях. Это очень легко сделать, и еще проще включить или объединить изменения в другой контекст управляемого объекта. Давайте поговорим о коде.

Мы создаем не параллельную операцию, которая выполняет некоторую работу в фоновом режиме и требует доступа к базовым данным. Вот как может выглядеть реализация подкласса NSOperation .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import UIKit
import CoreData
 
class Operation: NSOperation {
 
    let mainManagedObjectContext: NSManagedObjectContext
    var privateManagedObjectContext: NSManagedObjectContext!
     
    init(managedObjectContext: NSManagedObjectContext) {
        mainManagedObjectContext = managedObjectContext
         
        super.init()
    }
     
    override func main() {
        // Initialize Managed Object Context
        privateManagedObjectContext = NSManagedObjectContext(concurrencyType: .PrivateQueueConcurrencyType)
         
        // Configure Managed Object Context
        privateManagedObjectContext.persistentStoreCoordinator = mainManagedObjectContext.persistentStoreCoordinator
         
        // Add Observer
        let notificationCenter = NSNotificationCenter.defaultCenter()
        notificationCenter.addObserver(self, selector: «managedObjectContextDidSave:», name: NSManagedObjectContextDidSaveNotification, object: privateManagedObjectContext)
         
        // Do Some Work
        // …
         
        if privateManagedObjectContext.hasChanges {
            do {
                try privateManagedObjectContext.save()
            } catch {
                // Error Handling
                // …
            }
        }
    }
 
}

Есть несколько важных деталей, которые необходимо уточнить. Мы инициализируем контекст закрытого управляемого объекта и устанавливаем его свойство координатора постоянного хранилища, используя объект mainManagedObjectContext . Это прекрасно, потому что мы не mainManagedObjectContext к mainManagedObjectContext , мы только запрашиваем его для ссылки на постоянный координатор хранилища приложения. Мы не нарушаем правило ограничения потока.

Важно инициализировать контекст закрытого управляемого объекта в методе main() операции, поскольку этот метод выполняется в фоновом потоке, в котором выполняется операция. Разве мы не можем инициализировать контекст управляемого объекта в методе операции init(managedObjectContext:) ? Ответ — нет. Метод init(managedObjectContext:) операции выполняется в потоке, в котором инициализирован экземпляр Operation , который, скорее всего, является основным потоком. Это противоречит цели частного контекста управляемого объекта.

В методе main() Operation мы добавляем экземпляр Operation в качестве наблюдателя любых уведомлений NSManagedObjectContextDidSaveNotification публикуемых контекстом закрытого управляемого объекта.

Затем мы выполняем работу, для которой была создана операция, и сохраняем изменения контекста частного управляемого объекта, который вызовет уведомление NSManagedObjectContextDidSaveNotification . Давайте посмотрим, что происходит в методе managedObjectContextDidSave(_:) .

1
2
3
4
5
6
7
// MARK: —
// MARK: Notification Handling
func managedObjectContextDidSave(notification: NSNotification) {
    dispatch_async(dispatch_get_main_queue()) { () -> Void in
        self.mainManagedObjectContext.mergeChangesFromContextDidSaveNotification(notification)
    }
}

Как видите, его реализация короткая и простая. Мы вызываем mergeChangesFromContextDidSaveNotification(_:) для основного контекста управляемого объекта, передавая объект уведомления. Как я упоминал ранее, уведомление содержит изменения, вставки, обновления и удаления контекста управляемого объекта, который опубликовал уведомление.

Это ключ для вызова этого метода в потоке, в котором был создан основной контекст управляемого объекта, в основном потоке. Вот почему мы отправляем этот вызов в очередь основного потока. Чтобы сделать это проще и прозрачнее, вы можете использовать performBlock(_:) или performBlockAndWait(_:) чтобы обеспечить слияние изменений в очереди контекста управляемого объекта. Подробнее об этих методах мы поговорим позже в этой статье.

1
2
3
4
5
6
7
// MARK: —
// MARK: Notification Handling
func managedObjectContextDidSave(notification: NSNotification) {
    mainManagedObjectContext.performBlock { () -> Void in
        self.mainManagedObjectContext.mergeChangesFromContextDidSaveNotification(notification)
    }
}

Использование класса Operation для использования так же просто, как инициализация экземпляра, передача контекста управляемого объекта и добавление операции в очередь операций.

1
2
3
4
5
// Initialize Import Operation
let operation = Operation(managedObjectContext: managedObjectContext)
 
// Add to Operation Queue
operationQueue.addOperation(operation)

Начиная с iOS 6, есть еще лучшая, более элегантная стратегия. Давайте вернемся к классу Operation и используем контексты родительского / дочернего управляемого объекта. Концепция контекста управляемого объекта родитель / потомок проста, но мощна. Позвольте мне объяснить, как это работает.

Дочерний контекст управляемого объекта зависит от его родительского контекста управляемого объекта для сохранения его изменений в соответствующем постоянном хранилище. Фактически, дочерний контекст управляемого объекта не имеет доступа к постоянному координатору хранилища. Всякий раз, когда сохраняется контекст дочернего управляемого объекта, содержащиеся в нем изменения передаются в контекст родительского управляемого объекта. Нет необходимости использовать уведомления для ручного объединения изменений в основной или родительский контекст управляемого объекта.

Еще одним преимуществом является производительность. Поскольку у дочернего контекста управляемого объекта нет доступа к постоянному координатору хранилища, изменения не передаются последнему при сохранении контекста дочернего управляемого объекта. Вместо этого изменения помещаются в контекст родительского управляемого объекта, что делает его грязным. Изменения не передаются автоматически постоянному координатору хранилища.

Использование контекстов управляемого объекта ParentChild

Контексты управляемого объекта могут быть вложенными. Дочерний контекст управляемого объекта может иметь собственный дочерний контекст управляемого объекта. Применяются те же правила. Однако важно помнить, что изменения, передаваемые в контекст родительского управляемого объекта, не переносятся в другие контексты дочернего управляемого объекта. Если дочерний элемент A передает свои изменения своему родителю, то дочерний элемент B не знает об этих изменениях.

Создание контекста управляемого дочернего объекта лишь немного отличается от того, что мы видели до сих пор. Мы инициализируем дочерний контекст управляемого объекта, вызывая init(concurrencyType:) . Тип параллелизма, который принимает инициализатор, определяет модель потоков контекста управляемого объекта. Давайте посмотрим на каждый тип параллелизма.

  • MainQueueConcurrencyType : контекст управляемого объекта доступен только из основного потока. Исключение выдается, если вы пытаетесь получить к нему доступ из любого другого потока.
  • PrivateQueueConcurrencyType : при создании контекста управляемого объекта с типом параллелизма PrivateQueueConcurrencyType контекст управляемого объекта связывается с частной очередью, и к нему можно получить доступ только из этой частной очереди.
  • ConfinementConcurrencyType : это тип параллелизма, который соответствует концепции ограничения потока, которую мы исследовали ранее. Если вы создаете контекст управляемого объекта с помощью init() , типом параллелизма этого контекста управляемого объекта является ConfinementConcurrencyType . Apple отказалась от этого типа параллелизма с iOS 9. Это также означает, что init() устарела с iOS 9.

Существует два ключевых метода, которые были добавлены в платформу Core Data, когда Apple представила контексты родительского / дочернего управляемого объекта: performBlock(_:) и performBlockAndWait(_:) . Оба метода сделают вашу жизнь намного проще. Когда вы вызываете performBlock(_:) в контексте управляемого объекта и передаете блок кода для выполнения, Core Data гарантирует, что блок выполняется в правильном потоке. В случае типа параллелизма PrivateQueueConcurrencyType это означает, что блок выполняется в частной очереди этого контекста управляемого объекта.

Разница между performBlock(_:) и performBlockAndWait(_:) проста. Метод performBlock(_:) не блокирует текущий поток. Он принимает блок, планирует его выполнение в правильной очереди и продолжает выполнение следующего оператора.

Однако performBlockAndWait(_:) блокируется. Поток, из которого performBlockAndWait(_:) ожидает завершения блока, переданного методу, перед выполнением следующего оператора. Преимущество заключается в том, что вложенные вызовы performBlockAndWait(_:) выполняются по порядку.

Чтобы закончить эту статью, я хотел бы провести рефакторинг класса Operation чтобы использовать преимущества контекста управляемого объекта родитель / потомок. Вы быстро заметите, что это значительно упрощает NSOperation подкласс NSOperation . Метод main() меняется совсем немного. Посмотрите на его обновленную реализацию ниже.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
override func main() {
    // Initialize Managed Object Context
    privateManagedObjectContext = NSManagedObjectContext(concurrencyType: .PrivateQueueConcurrencyType)
     
    // Configure Managed Object Context
    privateManagedObjectContext.parentContext = mainManagedObjectContext
     
    // Do Some Work
    // …
     
    if privateManagedObjectContext.hasChanges {
        do {
            try privateManagedObjectContext.save()
        } catch {
            // Error Handling
            // …
        }
    }
}

Вот и все. Основной контекст управляемого объекта является родителем частного контекста управляемого объекта. Обратите внимание, что мы не устанавливаем свойство persistentStoreCoordinator контекста частного управляемого объекта и не добавляем операцию в качестве наблюдателя для уведомлений NSManagedObjectContextDidSaveNotification . Когда закрытый контекст управляемого объекта сохраняется, изменения автоматически передаются в родительский контекст управляемого объекта. Базовые данные гарантируют, что это происходит в правильном потоке. Это зависит от основного контекста управляемого объекта, контекста родительского управляемого объекта, чтобы передать изменения в постоянный координатор хранилища.

Параллелизм нелегко понять или реализовать, но наивно думать, что вы никогда не столкнетесь с ситуацией, когда вам нужно выполнять операции с базовыми данными в фоновом потоке.