Как стать автором
Обновить

Параллельное программирование в Swift: Operations

Время на прочтение 10 мин
Количество просмотров 11K
Автор оригинала: Jan Olbrich
В параллельном программировании в Swift: Основы Я представил множество низкоуровневых способов для управления параллелизмом в Swift. Первоначальная идея состояла в том, чтобы собрать все различные подходы, которые мы можем использовать в iOS в одном месте. Но при написании этой статьи я осознал, что их слишком много, чтобы перечислить в одной статье. Поэтому я решил сократить методы более высокого уровня.

image

Я упомянул Operations в одной из моих статей, но давайте рассмотрим их более внимательно.

OperationQueue


image

Напомним: Operation — это высокоуровневая абстракция Cocoa над GCD. Если быть более точным, то это абстракция над dispatch_queue_t. Она использует тот же принцип, что и очереди, в которые вы можете добавлять задачи. В случае OperationQueue этими задачами являются операции. При выполнении операции нам нужно знать о потоке, в котором она запускается. Если необходимо обновить пользовательский интерфейс, разработчику следует использовать MainOperationQueue.

OperationQueue.main

В противном случае мы можем использовать приватную очередь.

let operationQueue: OperationQueue = OperationQueue()

Отличие от dispatch_queue_t — это возможность установить одновременно максимальное количество операций для запуска.

let operationQueue: OperationQueue = OperationQueue()
operationQueue.maxConcurrentOperationCount = 1

Operations


OperationQueue представляет собой абстракцию высокого уровня dispatch_queue_t, а сами операции рассматриваются как абстракции верхнего уровня dispatch блоков. Но есть некоторые отличия. Например, операция может выполняться в течении нескольких минут, или дольше, а блок работает в течении нескольких миллисекунд. Поскольку Operations являются классами, мы можем использовать их для инкапсуляции нашей бизнес-логики. Таким образом, нам нужно будет заменить несколько небольших операций для изменения основных компонентов (например, нашего уровня базы данных).

Жизненный цикл


image

За период своего существования Operations проходит через разные этапы. При добавлении в очередь она находится в состоянии ожидания. В этом состоянии она ожидает своих условий. Как только все они будут выполнены, Operations переходит в состояние готовности, и в случае наличия открытого слота она начнет выполняться. Выполнив всю свою работу, Operations войдет в состояние Finished, и будет удалена из OperationQueue. В каждом состоянии (кроме Завершенного) Operation может быть отменена.

Отмена


Отмена Operation довольно проста. В зависимости от операции отмена может иметь совершенно разные значения. Например, при запуске сетевого запроса отмена может привести к остановке этого запроса. При импорте данных это может означать отказ от транзакции. Ответственность за назначение этого значения лежит на вас.

Итак, как отменить Operation? Вы просто вызываете метод .cancel (). Это приведет к изменению свойства isCancelled. Это все, что сделает iOS для вас. От вас зависит, как реагировать на эту отмену операции и как вести себя дальше.

let op = DemoOperation()
OperationQueue.addOperations([op], waitUntilFinished: false)

op.cancel()

Имейте в виду, что отмена операции приводит к отмене всех ее условий и немедленному запуску, чтобы как можно скорее войти в состояние Finished. Переход в состояние Finished — это единственный путь, чтобы удалить операцию из очереди.

Если вы забыли проверить на отмену операции, вы можете увидеть, что они выполняются, даже если вы их отменили. Также имейте в виду, что это восприимчиво к «race condition». Нажатие кнопки и установка отметки занимает несколько микросекунд. В течение этого времени операция может завершиться и отметка об отмене операции не будет иметь никакого эффекта.

Готовность


Готовность описывается только одним Boolean значением. Это означает, что операция готова к выполнению, и она ждет очереди своего запуска. В последовательной очереди сначала выполняется операция, которая входит в состояние «Готовности», хотя она и может находиться на позиции 9 в очереди. Если в состояние готовности вошли одновременно несколько операций, будет выполнена приоритизация их выполнения. Operation войдет в состояние готовности только после завершения всех ее зависимостей.

Зависимости


Это одна из действительно огромных особенностей операций. Мы можем создавать задачи, в которых, указывается, что другие задачи должны выполняться первыми, прежде чем они сами могут быть выполнены. В то же время есть задачи, которые могут выполняться параллельно с другими задачами, но являются зависимыми от последующих действий. Это можно сделать, вызвав .addDependency()

operation2.addDependency(operation1) //execute operation1 before operation2

Любая операция, которая имеет зависимости, по умолчанию будет иметь состояние ready после завершения всех ее зависимостей. Тем не менее, вам решать, как действовать после отмены зависимости.

Это позволяет нам строго упорядочить наши операции.

Я не думаю, что это очень легко читать, поэтому давайте создадим собственный оператор (==>) для создания зависимостей. Таким образом, мы можем указать порядок выполнения операций слева направо.

precedencegroup OperationChaining {
    associativity: left
}
infix operator ==> : OperationChaining

@discardableResult
func ==><T: Operation>(lhs: T, rhs: T) -> T {
    rhs.addDependency(lhs)
    return rhs
}

operation1 ==> operation2 ==> operation3 // Execute in order 1 to 3

Зависимости могут находиться в разных OperationQueues. В то же время они могут создать неожиданное поведение при блокировке. Например, пользовательский интерфейс может работать с замедлением, поскольку обновление зависит от операции в фоновом режиме и блокирует другие операции. Помните о цикличных зависимостях. Это происходит, если операция A зависит от работы B и B зависит от A. Таким образом, они обе ожидают выполнения друг друга, поэтому вы получите deadlock.

Выполнено


После выполнения Operation войдет в состояние «Готово» и выполнит свой completion блок ровно один раз. Completion блок может быть установлен следующим образом:

let op1 = Operation()

op1.completionBlock = {
    print("done")
}

Практический пример


Давайте создадим простую структуру для операций со всеми этими принципами. Операции имеют довольно много сложных понятий. Вместо создания примера, который слишком сложный, давайте просто напечатаем «Hello world» и попробуем включить большинство из них. Пример будет содержать асинхронное выполнение, зависимости и несколько операций, рассматриваемых, как одна. Давайте погрузимся в создание примера!

AsyncOperation


Сначала мы создадим Operation для создания асинхронных задач. Таким образом, мы можем создавать подклассы и любые асинхронные задачи.

import Foundation

class AsyncOperation: Operation {
    override var isAsynchronous: Bool {
        return true
    }
    
    var _isFinished: Bool = false
    
    override var isFinished: Bool {
        set {
            willChangeValue(forKey: "isFinished")
            _isFinished = newValue
            didChangeValue(forKey: "isFinished")
        }
        
        get {
            return _isFinished
        }
    }

    var _isExecuting: Bool = false
    
    override var isExecuting: Bool {
        set {
            willChangeValue(forKey: "isExecuting")
            _isExecuting = newValue
            didChangeValue(forKey: "isExecuting")
        }
        
        get {
            return _isExecuting
        }
    }
    
    func execute() {
    }
    
    override func start() {
        isExecuting = true
        execute()
        isExecuting = false
        isFinished = true
    }
}

Это выглядит довольно некрасиво. Как видите, мы должны переопределить isFinished и isExecuting. Кроме того, изменения в них должны соответствовать требованиям KVO, в противном случае OperationQueue не сможет наблюдать за состоянием наших операций. В нашем методе start() мы управляем состоянием нашей операции от запуска выполнения до входа в состояние Finished(Завершено). Мы создали метод execute(). Это будет метод, который необходимо реализовать нашим подклассам.

TextOperation


import Foundation

class TextOperation: AsyncOperation {
    let text: String
    
    init(text: String) {
        self.text = text
    }
    
    override func execute() {
        print(text)
    }
}

В этом случае нам просто нужно передать текст, который мы хотим распечатать в init(), и переопределить execute().

GroupOperation


GroupOperation будет нашей реализацией для объединения нескольких операций в одну.

import Foundation

class GroupOperation: AsyncOperation {
    let queue = OperationQueue()
    var operations: [AsyncOperation] = []
    
    override func execute() {
        print("group started")
        queue.addOperations(operations, waitUntilFinished: true)
        print("group done")
    }
}

Как видите, мы создаем массив, в котором наши подклассы будут добавлять свои операции. Затем во время выполнения мы просто добавляем операции в нашу приватную очередь. Таким образом мы гарантируем, что они будут выполнены в определенном порядке. Вызов метода addOperations ([Operation], waitUntilFinished: true) приводит к блокировке очереди до тех пор, пока не будут выполнены дополнительные операции. После этого GroupOperation изменит свое состояние на Finish.

HelloWorld Operation


Просто создайте собственные операции, установите зависимости и добавьте их в массив. Вот и все.

import Foundation

class HelloWorldOperation: GroupOperation {
    override init() {
        super.init()
        
        let op = TextOperation(text: "Hello")
        let op2 = TextOperation(text: "World")

        op2.addDependency(op)
        
        operations = [op2, op]
    }
}

Operation Observer


Итак, как мы узнаем, что операция завершена? Как один из способов, можно добавить competionBlock. Другой способ — зарегистрировать OperationObserver. Это класс, который подписывается на keyPath через KVO. Он наблюдает за всем, до тех пор, пока он совместим с KVO.

Давайте в нашем маленьком Фреймворке напечатаем «done», как только закончится HelloWorldOperation:

import Foundation

class OperationObserver: NSObject {
    init(operation: AsyncOperation) {
        super.init()
        operation.addObserver(self, forKeyPath: "finished", options: .new, context: nil)
    }
    
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        guard let key = keyPath else {
            return
        }
        
        switch key {
        case "finished":
            print("done")
        default:
            print("doing")
        }
    }
}

Передача данных


Для «Hello World!» не имеет смысла передавать данные, но давайте быстро рассмотрим этот случай. Самый простой способ — использовать BlockOperations. Используя их, мы можем установить свойства для следующей операции, которой необходимы данные. Не забудьте установить зависимость, иначе операция может не выполниться вовремя ;)

let op1 = Operation1()
let op2 = Operation2()

let adapter = BlockOperation() { [unowned op1, unowned op2] in
    op2.data = op1.data
}

adapter.addDependency(op1)
op2.addDependency(adapter)

queue.addOperations([op1, op2, adapter], waitUntilFinished: true)

Обработка ошибок


Еще одна вещь, которую мы не рассматриваем сейчас, — обработка ошибок. По правде говоря, я еще не нашел хороший способ сделать это. Один из вариантов заключается в том, Чтобы добавить вызов метода finished(withErrors:) и дать возможность каждой асинхронной операций вызывать его вместо AsyncOperation, обрабатывая его в start(). Таким образом, мы можем проверить наличие ошибок, и добавить их в массив. Допустим, у нас есть операция A, которая зависит от операции B. Внезапно операция B заканчивается ошибкой. И в этом случае Operation A может проверить этот массив и прервать его выполнение. В зависимости от требований, Вы можете добавить дополнительные ошибки.

Это может выглядеть так:

class GroupOperation: AsyncOperation {
    let queue = OperationQueue()
    var operations: [AsyncOperation] = []
    var errors: [Error] = []
    
    override func execute() {
        print("group started")
        queue.addOperations(operations, waitUntilFinished: true)
        print("group done")
    }
  
    func finish(withError errors: [Error]) {
        self.errors += errors
    }
}

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

Но, как всегда, существует много способов, и это только один из них. Вы также можете использовать observer для наблюдения за значением ошибки.

Неважно, как вы это делаете. Просто убедитесь, что ваша операция будет удаляться после завершения выполнения. Например: Если вы напишете в контексте CoreData, и что-то пойдет не так, вам необходимо очистить этот контекст. В противном случае у вас может быть неопределенное состояние.

UI Operations


Операции не ограничиваются элементами, которые вы не видите. Все, что вы делаете в приложении, может быть операцией (хотя я бы советовал вам так не поступать). Но есть некоторые вещи, которые легче видеть как Операции. Все, что является модальным, следует учитывать соответствующим образом. Давайте посмотрим на операцию, чтобы отобразить диалог:

import Foundation

class UIOperation: AsyncOperation {
    let viewController: UIViewcontroller!
  
    override func execute() {
        let alert = UIAlertController(title: "My Alert", message: @"This is an alert.", preferredStyle: .alert) 
        alert.addAction(UIAlertAction(title: "OK", style: .`default`, handler: { _ in 
              self.handleInput()
        }))
        viewController.present(alert, animated: true, completion: nil) 
    }
  
    func handleInput() {
        //do something and continue operation
    }
}

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

UI Operations


Операции не ограничиваются элементами, которые вы не видите. Все, что вы делаете в приложении, может быть операцией (хотя я бы советовал вам так не поступать). Но есть некоторые вещи, которые легче видеть как Операции. Все, что является модальным, следует учитывать соответствующим образом. Давайте посмотрим на операцию, чтобы отобразить диалог:

import Foundation

class UIOperation: AsyncOperation {
    let viewController: UIViewcontroller!
  
    override func execute() {
        let alert = UIAlertController(title: "My Alert", message: @"This is an alert.", preferredStyle: .alert) 
        alert.addAction(UIAlertAction(title: "OK", style: .`default`, handler: { _ in 
              self.handleInput()
        }))
        viewController.present(alert, animated: true, completion: nil) 
    }
  
    func handleInput() {
        //do something and continue operation
    }
}

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

Взаимное исключение


Учитывая то, что мы можем использовать Operations для Пользовательского Интерфейса, появляется иная задача. Представьте, что вы видите диалог об ошибке. Возможно, вы добавляете в очередь несколько операций, которые будут отображать ошибку, когда сеть недоступна. Это легко может привести к тому, что при появлении предупреждений все вышеупомянутые Operations могут привести к разрыву сетевого подключения. В результате у нас было бы несколько диалогов, которые отобразились бы одновременно, и мы не знаем какой является первым, а какой вторым. Поэтому нам придется сделать эти диалоги взаимоисключающими.

Несмотря на то, что сама идея сложная, ее довольно легко реализовать при помощи зависимостей. Просто создайте зависимость между этими диалогами, и все готово. Одной из проблем является отслеживание операции. Но это может быть разрешено с помощью операций именования, а затем наличием доступа к OperationQueue и поиска имени. Таким образом, вам не нужно держать ссылку.

let op1 = Operation()
op1.name = "Operation1"

OperationQueue.main.addOperations([op1], waitUntilFinished:false)
let operations = OperationQueue.main.operations

operations.map { op in
    if op.name == "Operation1" {
        op.cancel()
    }
}

Заключение



Operations – это хороший инструмент для параллелизма. Но не обманывайте себя, они сложнее, чем вы думаете. В настоящее время я поддерживаю проект, базирующийся на Operations, а некоторые его части являются весьма сложными и неудобными в работе. В частности, при обработке ошибок появляется масса неисправностей. Каждый раз, когда вы выполняете групповую операцию, и она выполняется некорректно, существует вероятность более, чем одной ошибки. Вам придется отфильтровать их для получения необходимых ошибок определенного вида, поэтому иногда ошибка сбивает с толку из-за подпрограмм отображения.

Другая проблема заключается в том, что вы перестаете думать о возможных параллельных идентичных проблемах. Я еще не говорил об этих деталях, но помню о GroupOperations с кодом обработки ошибок, приведенных выше. Они содержат ошибку, которая будет исправлена ​​в будущем посте.

Operations — хороший инструмент для управления параллелизмом. GCD все еще не упорядочены. Для небольших задач, таких, как переключения потоков или задач, которые необходимо выполнить как можно быстрее, вы, возможно, не захотите использовать операции. Идеальным решением для этого является GCD.
Теги:
Хабы:
+14
Комментарии 0
Комментарии Комментировать

Публикации

Истории

Работа

iOS разработчик
23 вакансии
Swift разработчик
38 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн