Pull to refresh

Concurrency в Swift 3 и 4. Operation и OperationQueue

Reading time 31 min
Views 79K



Если вы хотите добиться UI отзывчивости вашего iOS приложения, выполняя такие затратные по времени куски кода, как загрузка данных из сети или обработка изображений, то вам нужно использовать продвинутые паттерны, связанные с многопоточностью (сoncurrency), иначе работа вашего пользовательского интерфейса (UI) начнет сильно замедляться и даже может привести к полной его «заморозке». Вам нужно убрать ресурсо-затратные задачи с main thread (главного потока), который отвечает за выполнение кода, отображающего ваш пользовательский интерфейс (UI).

В текущей версии Swift 3 и ближайшей Swift 4 (осень 2017) это можно сделать двумя способами, которые пока не связаны с встроенными языковыми конструкциями Swift, начало реализации которых будет только в Swift 5 (конец 2018).

Один из них использует GCD (Grand Central Dispatch) и ему посвящена предыдущая статья. В этой статье мы покажем, как достичь отзывчивости UI в iOS приложениях с помощью таких абстрактных понятий, как операция Operation и очередь операций OperationQueue. Мы также покажем в чем различие этих двух подходов и какой из них в каких ситуациях лучше использовать.

Код для этой статьи можно посмотреть на Github.

Что такое Operation? Хорошее определение Operation дано в NSHipster:
Operation представляет собой законченную задачу и является абстрактным классом, который предоставляет вам потока-безопасную структуру для моделирования состояния операции, ее приоритета, зависимостей (dependencies) от других Operations и управления этой операцией.

Основные понятия. Operation


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


Полноценная операция Operation может быть сконструирована с помощью  BlockOperation инициализатора. Она запускается на выполнение с помощью своего собственного метода start ():


Если вы хотите получить что-то повторно используемое типа асинхронной версии синхронной функции, вам необходимо создать пользовательский subclass класса Operation и получить его экземпляр:


Операция FilterOperation получения размытого изображения с помощью соответствующего фильтра определена как пользовательский subclass класса Operation. Вы видите, что у пользовательского класса могут быть как входные, так и выходные свойства, а также другие вспомогательные функции. Для размещения функциональной части операции мы переопределили (override) метод main ().

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

У Operation есть машина состояний (state mashine), которая представляет собой «жизненный цикл» операции Operation:



Возможные состояния операции Operation: pending (отложенная), ready (готова к выполнению), executing (выполняется), finished (закончена) и cancelled (уничтожена).

Когда вы создаете операцию Operation и размещаете ее на OperationQueue, то устанавливаете операцию в состояние pending (отложенная). Спустя некоторое время она принимает состояние ready (готова к выполнению), и в любой момент может быть отправлена  на OperationQueue для выполнение, перейдя в состояние executing (выполняется), которое может длится от миллисекунд до нескольких минут или дольше. После завершения операция Operation переходит в финальное состояние finished (закончена).  В любой точке этого простого «жизненного» цикла операция Operation может быть уничтожена и перейдет в состояние cancelled (уничтожена).

API класса Operation отражает этот «жизненный цикл» операции и представлен ниже:


Мы можем запустить операцию Operation на выполнение с помощью метода start(), но чаще всего мы будем добавлять операцию на очередь операций  OperationQueue, и эта очередь автоматически будет запускать операцию. При этом надо помнить, что отдельная операция Operation, запущенная с помощью start(), выполняется СИНХРОННО на текущем потоке. Для того, чтобы ее запустить за пределами текущего потока нужно воспользоваться либо OperationQueue, либо DispatchQueue.

Текущее состояние операции Operation в любой точке приложения можно отслеживать с помощью булевских свойств : isReady, isExecuting, isFinished, isCancelled c помощью механизмов KVO (key-value observation), так как сама операция может выполняться на любом потоке, а информация может нам понадобиться скорее всего на главном потоке (main thread) или на любом другом потоке, отличным от того, на котором выполняется сама операция.

Если мы хотим добавить функциональности операции Operation, мы должны создать subclass Operation. В простейшем случае в этом subclass нам нужно переопределить метод main() класса Operation. Сам класс Operation автоматически управляет изменением состояния операции, но в более сложных случаях, представленных ниже, нам придется это делать вручную.

Мы можем снабдить операцию завершающим замыканием completionBlock, которое выполняется после завершения операции, а также «качеством обслуживания» qualityOfService, которое влияет на приоритет выполнения операции на OperationQueue.

Как мы видим, класс Operation имеет метод cancel(), однако использование этого метода только устанавливает свойство isCancelled в true, а что семантически означает «удаление» операции можно определить только при создании subclass Operation.  Например, в случае загрузки данных из сети можно определить cancel() как отключение операции от сетевого взаимодействия.

Основные понятия. OperationQueue


Вместо того, чтобы самостоятельно запускать операции, мы будем управлять ими с помощью очереди операций OperationQueue. Очередь операций OperationQueue можно рассматривать как высоко-приоритетную «обертку»  DispatchQueue, наделенную дополнительной функциональностью: возможностью уничтожения выполняемых операций, выполнения зависимых операций и т.д.  

Давайте посмотрим на API класса OperationQueue:



Здесь мы видим простейший инициализатор очереди операций OperationQueue () и два свойства класса: current и main, задающие текущую очередь операций OperationQueue.current и main queueOperationQueue.main, которую используют для обновления пользовательского интерфейса (UI) аналогично DispatchQueue.main в GCD. Очень важное свойство maxConcurrentOperationCount задает количество одновременно выполняемых операции на этой очереди и, задавая его равным 1, мы устанавливаем последовательную (serial) очередь операций.


По умолчанию значение свойства maxConcurrentOperationCount  устанавливается равным Default, что означает максимально возможное число одновременно выполняемых операций:



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

Очередь операций  OperationQueue выполняет размещенные на ней операции согласно их приоритету qualityOfService, «готовности» (свойство isReady установлено в true) и зависимостям ( dependencies) от других операций. Если все эти характеристики равны, то операции отправляются на «выполнение» в том порядке, в котором они были поставлены в очередь. Если какая-то операция размещена в какой-то очереди операций, то она не может быть поставлена еще раз в любую из этих очередей. Если операция была выполнена, она не может быть выполнена повторно ни на какой из очередей операции, операция — одноразовая вещь, поэтому имеет смысл создавать subclasses класса Operation и использовать их, если необходимо, для повторного получения экземпляра  этой операции.

Вы можете послать сообщение cancel()  всем находящимся в очереди операциям с помощью метода cancellAllOperations (), например, если приложение «уходит» в фоновый (background) режим. С помощью метода waitUntilAllOperationsAreFinished() вы можете блокировать текущий поток до тех пор, пока не будут завершены все операции на этой очереди операций. Но НИКОГДА НЕ делайте этого на main queue. Если вам действительно нужно что-то сделать только после завершения всех операций, то создайте private последовательную очередь операций (serial queue) и ожидайте там завершения ваших операций.

Очередь операций OperationQueue ведет себя как DispatchGroup. Вы можете добавлять на OperationQueue операции с различными qualityOfService, и они будут запускаться в соответствии с их приоритетом. Вы можете также установить qualityOfService на более высоком уровне — для очереди операций в целом, но это значение будет заново переопределено значением   qualityOfService для отдельной операции.

По умолчанию qualityOfService для OperationQueue -это .background.

Вы можете также остановить выполнение операций на OperationQueue путем задания свойства isSuspended в true. Выполняемые операции на этой очереди будут продолжаться, но вновь добавляемые не будут отправляться на выполнение до тех пор, пока вы не измените значение свойства isSuspended на false. По умолчанию значение свойства isSuspended - false.

Давайте проведем некоторые эксперименты с операциями Operation и очередью операций OperationQueue на Playground.

Эксперимент 1. Создание OperationQueue и добавление замыканий


Код можно посмотреть на OperationQueue.playground на Github.

Создаем пустую очередь printerQueue:


Добавляем операции в виде замыканий на очередь printerQueue:


Операции стартуют асинхронно по отношению к текущему потоку, как только мы добавили их на printerQueue, и они находятся в состоянии ready. Время выполнения всех операций оцениваем с помощью метода waitUntilAllOperationsAreFinished(), который «синхронно» по отношению к текущему потоку ждет окончания операций. На main queue в приложении лучше этого не делать, но на нашей Playground ничего не происходит с UI и мы можем себе это позволить. Общее время выполнения всех 7 операций составляет чуть больше 2 секунд и соответствует времени выполнения оператора sleep(2), следовательно, printerQueue запускает все 7 операции одновременно на многих потоках. 

Давайте изменим свойство очереди  printerQueue, и установим свойство maxConcurrentOperationCount в 2:



Мы видим, что требуется очень короткое время, чтобы поместить все операции на printerQueue, и чуть больше 8 секунд для выполнения всех операций, так как они стартуют парами и последняя 7-ая операция стартует одна в четвертой «паре».

Теперь добавим еще одну операцию concatenationOperation с повышенным qualityOfService, равным .userInitiated:



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

Давайте превратим очередь printerQueue в последовательную (serial), установив свойство maxConcurrentOperationCount в 1:



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

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



применяется пользовательская операция фильтрации FilterOperation, знакомая нам из предыдущего раздела:



Создадим очередь filterQueue для выполнения операций фильтрации и последовательную (serial) очередь appendQueue для потоко-безопасного добавления отфильтрованного изображения к массиву. Дело в том, что множество операций фильтрации будут одновременно обращаться к разделяемому (shared) ресурсу — массиву filteredImages - для добавления своего элемента. Помните? Мы использовали последовательную (serial) DispatchQueue очередь для изменения разделяемых (shared) ресурсов? Здесь то же самое, но будет выполняться для Operation. Конечно, последовательная (serialDispatchQueue очередь будет более эффективна, но мы покажем создание и использование последовательной (serial) OperatioQueue очереди.

Swift массивы являются value type и копируются при записи (copy on write), поэтому вам не стоит беспокоиться о многопоточных изменениях, но в форумах есть сообщения о том, есть с этим проблемы, особенно, если массив является свойством объекта типа class. Поэтому мы показываем способ сохранить многопоточную безопасность (thread safe) массива при добавлении в него новых элементов в разных потоках.



Затем создаем операцию фильтрации для каждого изображения и добавляем ее на filterQueue для асинхронного выполнения. После того, как фильтрация выполнена, мы добавляем полученное изображение к массиву filteredImages и делаем это в completionBlock, где используем другую очередь операций —  appendQueue. У completionBlock нет входных параметров и он ничего не возвращает.

Дожидаемся выполнения всех операций фильтрации и проверяем отфильтрованный массив изображений:



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



Асинхронная операция


Код можно посмотреть на AsyncOperations.playground на Github.

До сих пор мы использовали операции для СИНХРОННЫХ заданий, то есть функций, которые используют текущий поток  и не возвращаются до тех пор, пока не выполнят свое задание целиком. АСИНХРОННЫЕ задания (функции) ведут себя совершенно по-другому: они немедленно возвращают управление на текущем потоке, а выполняют свое задание на другом потоке, и дают вам знать, что задание  выполнено, вызывая замыкание completionHandler на другом потоке. Классическим примером является URLSession:



Мы можем «завернуть» функциональность URLSession в операцию Operation, но нам придется вручную управлять состояниями операции.

Для создания пользовательских операций для СИНХРОННЫХ функций нам нужно было только переопределить метод операции main(). Если мы точно также поступим с АСИНХРОННОЙ функцией, то в main() она немедленно вернет управление текущему потоку и «уйдет» работать на другой поток, а мы окажемся в конце метода main() и OperationQueue тут же «выкинет» нашу операцию из очереди, так и не завершив нашу АСИНХРОННУЮ функцию. Такова логика работы OperationQueue.

У АСИНХРОННОЙ ОПЕРАЦИИ совсем другая логика работы .



Если операция «готова» ( isReady = true), операционная очередь OperationQueue вызывает метод start(), в котором мы должны установить  операцию в состояние «выполняется» (isExecuting = true) и вызвать метод main(), который в свою очередь вызовет АСИНХРОННУЮ функцию. АСИНХРОННАЯ функция что-то выполняет на ДРУГОМ потоке, но свойство isExecuting должно оставаться равным true, даже если она ничего не выполняет на ТЕКУЩЕМ потоке, а только представляет задание, которое выполняется на другом потоке. Когда АСИНХРОННАЯ функция вызывает completionHandler, что свидетельствует об окончании АСИНХРОННОЙ функции, мы должны установить в completionHandler свойство isFinished в true, а свойство isExecuting - в false.

Следовательно, для АСИНХРОННОЙ операции нам придется переопределить (override) больше, чем просто метод main(). Нам нужно переопределить следующие методы и свойства:



Давайте создадим абстрактный пользовательский класс AsyncOperaton, наследуемый от Operation и пригодный для выполнения любой АСИНХРОННОЙ операции. Его абстрактность заключается в том, что у него не будет метода main() для выполнения асинхронной операции. Вот его схема:



Если вы будете использовать АСИНХРОННУЮ операцию самостоятельно, без OperationQueue, то вам нужно переопределить свойство isAsynchronous и вернуть true. Нам нужно переопределить метод start(), чтобы реально стартовать АСИНХРОННУЮ функцию и сохранить свойство isExecuting равным true. Нам нужно также научиться управлять свойствами, определяющими состояние операции: isReady, isExecuting, isFinished. Эти свойства использует очередь операций OperationQueue для отслеживания состояний операций и организации выполнения зависимых операций.

Когда вы определяете зависимости (dependencies) между операциями, то это означает, что одна операция должна закончиться прежде, чем другая операция начнется, поэтому очереди операций OperationQueue очень важно знать, когда операция заканчивается. Для СИНХРОННОЙ операции это не является проблемой, потому что СИНХРОННАЯ операция заканчивается, когда заканчивается СИНХРОННАЯ функция. Но АСИНХРОННАЯ функция заканчивается за пределами текущего потока, поэтому нам нужен какой-то способ, чтобы сказать  очереди операций OperationQueue о действительном окончании АСИНХРОННОЙ функции. Если вспомним GCD, то при добавлении АСИНХРОННОЙ функции в группу, мы четко обозначали с помощью методов enter() и leave() начало и конец АСИНХРОННОЙ функции. Но в случае операции Operation ситуация гораздо сложнее, так как у операции есть состояния: isReadyisExecutingisFinished, isCancelled и т.д.. При добавлении АСИНХРОННОЙ функции в операцию Operation, мы должны управлять этими состояниями вручную. Для того, чтобы облегчить эту работу, мы и создали специальный абстрактный пользовательский subclass  класса Operation с именем AsyncOperaton, главной задачей которого является управление изменением состояния операции.  Для своей собственной АСИНХРОННОЙ функции вы создадите subclass класса AsyncOperaton, определив там только main (), из которого вызовите свою АСИНХРОННОЙ функции.  И уже эту новую операцию будете добавлять на OperatonQueue.

Но проблема заключается в том, что я не могу написать, например, isExecuting = true, потому что все свойства, связанные с состоянием операции: isReadyisExecutingisFinished, являются readonly ({get} ), и мы не можем устанавливать их на прямую. Мы можем только сделать что-то, что заставит свойство isExecuting вернуть true, сообщив при этом системе, что состояние АСИНХРОННОЙ операции AsyncOperaton изменилось.

Класс Operation использует KVO (key-value observation) механизм и методы willChangeValueForKe и didChangeValueForKey для уведомления об изменении свойств состояния типа isReadyisExecutingisFinished.

Поэтому для удобства управления состояниями операции мы создадим новый тип данных — собственное  перечисление enum State для представления состояний АСИНХРОННОЙ операции собственными вариантами: ready, executing, finished.


Перечисление State содержит также  fileprivate свойство с именем keyPath, которое мы будем использовать в качестве переключателя для KVO уведомлений. Свойство keyPath, является вычисляемым и складывается из строки "is", соединенной с rawValue, которым является наименование элемента перечисления State с заглавной буквы.

Затем мы определяем в абстрактном классе AsyncOperaton переменную var state типа State для представления текущего состояния операции, по умолчанию это значение равно ready. При каждом изменении переменной state нам необходимо переключать KVO уведомления. Мы будем это делать с помощью Наблюдателей willSet {} и didSet {} свойства state:


Перед тем, как переключить состояние операции state, например, с executing на finished, нам нужно послать KVO уведомления willChangeValue о предстоящем изменении обоих операций: нового состояния newValue (finished) и текущего состояния state (executing). После того, как переключения состояния state произошло, мы посылаем KVO уведомления didChangeValue для keyPath обоих состояний: oldValue ( executing) и state (finished).

Это заставит систему прочитать новые значения «родных» переменных состояния операции  isReadyisExecutingisFinished. Поэтому в АСИНХРОННОЙ операции AsyncOperation нам нужно переопределить «родные» переменные состояния операции isReady, isExecutingisFinished с использованием нового свойства state как вычисляемые переменные, возвращающие правильные значения. Мы сделаем это в расширении extension класса AsyncOperation:


В некоторый момент времени свойство операцииisReady становится равным true, и мы должны использовать свойствоisReady для superclass, которое «воспринимает» зависимости (dependencies) от других операций. Комбинируя нашу собственную логику со свойством isReady для superclass, мы можем быть уверены, что операция действительно «готова». Заметьте, что если переменная state равна .finished, то свойство superclass isFinished равно true, а свойство isExecutingfalse. Мы переопределим также свойство isAsynchronous, вернув true, и два метода: start() и cancell().

В методе start() мы проверяем, уничтожена ли операция, то есть свойство isCancelled равно true. Если это так, то мы должны установить новое значение для переменной state.finished. Если операция не уничтожена, мы вызываем main(). Помните, что в main() находится АСИНХРОННАЯ функция, и она немедленно возвращает управление, так что нам следует вручную установить состояние операции stat равное .executing. Когда АСИНХРОННАЯ функция вернет completionHanller,  в нем мы должны установить состояние операции .finished. Очень важно помнить, что в случае АСИНХРОННАЯ функции мы не можем использовать в методе start() вызов аналогичного метода для superclasssuper.start(), так как это будет означать синхронный запуск функции main(), а нам нужно совершенно противоположное. 
В методе cancell() мы также должны установить состояние операции  .finished.

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


В качестве примера возьмем функцию асинхронного медленного (внутри есть sleep (1)) сложения двух чисел:


Используя AsyncOperation в качестве superclass, мы должны переопределить main () и не забыть установить состояние операции state в .finished в callback:


callback возвращает результат result, который мы присваиваем свойству операции self.result и устанавливаем состояние операции state в .finished, что информирует  очередь операций о завершении асинхронного сложения чисел и о том, что больше не нужно работать с этой операцией.

Давайте используем нашу SumOperation для получения массива суммы пар чисел:


Для каждой пары чисел мы создаем операцию SumOperation и размещаем ее в очереди операций additionQueue обычным способом, Мы видим, что порядок выполнения асинхронных операций немного отличается от порядка пар чисел в массиве. Это говорит о многопоточном выполнении наших асинхронных операций.

Второй пример связан с асинхронной загрузкой изображения по заданному URL с помощью URLSession:



Создаем АСИНХРОННУЮ операция ImageLoadOperation, которая, как и в прошлый раз, является subclass класса AsyncOperation. Для операции ImageLoadOperation, как и в прошлый раз, переопределяем main () и не забываем установить состояние операции state в .finished в completion:



Создаем операцию operationLoad, получаем изображение operationLoad.outputImage и отображаем на view:



Код можно посмотреть на AsyncOperations.playground на Github.

Зависимости (dependencies)


Код можно посмотреть на LoadAndFilter.playground на Github.

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



На рисунке вы видите две операции: первая загружает изображение из сети, а вторая осуществляет «затуманивание» верхней и нижней частей изображения — фильтрацию.
Обе эти операции очень хорошо работают друг с другом: «загрузчик» изображения передает напрямую свои данные «фильтру».

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



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

Более гибкое решение связано с выполнением «цепочки» операций с передачей изображения из первой операции во вторую. Мы можем определить, что «фильтр» зависит от «загрузчика», и тогда очередь операций OperationQueue будет знать, что «фильтр» устанавливается в состояние ready только после окончания работы «загрузчика». Это действительно замечательная «способность» очереди операций OperationQueue. Вы можете создать очень сложный граф «зависимостей» и тем самым заставить OperationQueue осуществлять автоматически запуск операций так, как вам нужно. API класса Operation, поддерживающего работу с «зависимостями» (dependencies) - очень простое, но обнаруживает при работе фантастическую мощность.



Вы можете добавлять и убирать «зависимость» (dependency), а также получать список «зависимостей» dependencies, добавленных для этой операции. Ниже мы будем использовать список dependencies для получения входного изображения зависимой операции фильтрации.

Когда вы создаете зависимости, то есть большая вероятность получить deadlock (взаимную блокировку):



Появление замкнутых циклов в графе «зависимостей» ведет к возникновению deadlock и нет универсального способа их устранения, кроме как выявления их путем визуального анализа.
Следующая проблема, связанная с «зависимостями» операций состоит в том, как передавать данные по «цепочке зависимостей»? Например, в рассмотренном выше примере, когда вначале работает «загрузчик», а затем «фильтр», для которого входным изображением является выходное изображение «загрузчика»:



Как нам этого добиться? Создаем протокол ImagePass, который будет поставлять нам нужные данные, в нашем случае UIImage?:



Загрузчик" — уже знакомый нам класс ImageLoadOperation операции загрузки изображения из сети. На входе у него задается URL изображения в виде строки urlString, а на выходе — само изображение outputImage.



Класс ImageLoadOperation «подтверждает» протокол ImagePass и возвращает в качестве  свойства протокола image выходное загруженное изображение outputImage.

В свою очередь операция  «фильтрации» — класс FilterOperation - в случае отсутствия  входное изображение _inputImage анализирует свои «зависимости» dependencies и интересуется только теми, которые «подтвердили» протокол ImagePass. Он выбирает первую же такую зависимую операцию и извлекает оттуда свой inputImage:



У «фильтра» на входе — входное изображение inputImage, а на выходе — отфильтрованное с помощью функции filterImage (image:) изображение outputImage. Это обычная синхронная операция, поэтому нам нужно только переопределись main().

Мы хотим заставить эти две операции работать вместе, так, чтобы «фильтр» использовал в качестве входного изображение inputImage выходное изображение операции «загрузка». Для этого мы инициализируем операцию фильтрации filter со значение nil:




Код находится на LoadAndFilter.playground на Github.

Уничтожение операций на OperationQueue


Код находится на Cancellation.playground на Github.

Мы рассмотрим еще одну замечательную возможность очереди OperationQueue — возможность уничтожения операций.

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



Вы можете подумать, что вызов метода cancel() мгновенно приведет к остановке операции, но это не так. Метод cancel() только устанавливает свойство isCancelled операции в true. Если операция еще не стартовала, то по умолчанию метод start() не позволит операции выполняться и установит ее свойство isFinished в true. Если вы переопределяете (override) метод start(), то вы должны сохранить способность вашего start() предотвращать запуск операции, если свойство операции isCancelled установлено в true. И если вы посмотрите на абстрактный класс AsyncOperation, то увидите, что мы именно так и сделали.

Далее в методе main() операции, особенно перед тем, как выполнять что-то медленное или ресурсо-затратное, нужно тестировать свойство isCancelled на предмет того, а не уничтожена ли уже операция. И если операция уничтожена и это показывает значение true свойства isCancelled, то вы должны САМИ провести необходимые действия по остановке операции. Если операция Operation выполняется локально, например, преобразование изображения, то вы можете остановить операцию. Если  операция связана с обращением в сеть, как например, загрузка изображения с сервера, то вы не можете остановить такую операцию до тех пор, пока сервер не вернет вам результат.

Вам нужно добавить «логику» между различными «шагами» операции по проверке того, а стоит ли продолжать выполнять эту операцию или установить операцию в состояние isFinished равно true.

API класса Operation, предназначенное для удаления операции очень простое и состоит всего из двух позиций:


Вы вызываете метод cancel() — он устанавливает свойство isCancelled операции в true. Важно отметить, что когда вызывается метод cancel(), то свойства isExecuting и isFinished также изменяются на false и true соответственно.


Для операции совершенно нормально быть уничтоженной (isCancelled = true) и не закончится (isFinished = true). Свойство isCancelled сообщает операции, что она должна остановиться, а свойство isFinished сообщает системе, что операция уже остановлена. 

Наша абстрактная АСИНХРОННАЯ операция AsyncOperation переопределяет метод cancel() таким образом, что устанавливает состояние операции state в .finished, а такое изменение приводит к изменению свойств операции isFinished и isExecuting:


Очередь OperationQueue может уничтожить все операции:


На примере некоторых пользовательских операций давайте посмотрим, как можно добиться правильной реакции операции на вызов метода cancel().

Код находится на Cancellation.playground на Github.

Операция ArraySumOperation имеет на входе массив inputArray кортежей, состоящих из пары целых чисел и формирует массив outputArray их сумм на выходе:



Для каждой пары чисел мы используем функцию «медленного» сложения slowAdd, представленную в папке Source на Cancellation.playground, и добавляем в выходной массив outputArray.

Задаем входной массив чисел, формируем операцию sumOperation, добавляем ее в очередь операций queue и запускаем таймер, который позволит нам в дальнейшем регулировать время, спустя которое мы сможем проверить реакцию операции sumOperation на вызов метода cancel(). Кроме того, у операции есть completionBlock, в котором мы останавливаем таймер, показываем на Playground outputArray  и завершаем работу на Playground:



Итак, на выполнение операции sumOperation уходит чуть более 5 секунд. Теперь попытаемся уничтожить эту операцию, спустя 2 секунды после начала, вызвав метод cancel ():



Мы получили неожиданный результат — операция sumOperation выполнилась полностью, никакого уничтожения операции не произошло. В чем же дело? А дело в том, что метод cancel () только устанавливает свойство isCancelled в true, а действия, необходимые для удаления операции ложатся на самого разработчика операции. Мы должны отреагировать на то, что свойство isCancelled установилось в true. Мы будем в цикле перед каждым добавлением суммы в выходной массив проверять, а не уничтожена ли операция. И если уничтожена, то мы прерываем цикл:



Давайте повторно запустим Playground:



Мы остановились немного позже, чем через 2 секунды и успели получить 2 суммы, а когда собирались получить третью сумму, то получили сигнал об уничтожении операции и остановили дальнейшее получение сумм. На этом примере наглядно видно, как получить реакцию пользовательской операции на команду cancel ().

Давайте рассмотрим еще одну операцию AnotherArraySumOperation, которая отличается тем, что используется другая функция slowAddArray для получения выходного массива цикл по массиву кортежей:



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



На входе функции slowAddArray массив input пар целых чисел, кроме того у нее есть аргумент progress, представляющий собой Optional функцию, забирающую в качестве аргумента изменяющуюся глубину обработки массива

Double(results.count) / Double(input.count

и возвращающую Bool. Этот Bool и определяет продолжение обработки массива.

В методе main() операции AnotherArraySumOperation (предыдущий рисунок) мы передали функции slowAddArray массив inputArray, а аргумент progress оформили в виде «хвостового» замыкания, в котором использовали свойство progress для печати. Свойство progress является Double, так что мы умножили его на 100 и получили % окончания обработки массива и завершения операции. Затем мы возвращаем реакцию на уничтожение операции, что является сигналом на продолжение или прерывание обработки массива. Реакция представляет собой инверсию свойства isCancelled.

Заменим предыдущую операцию SumOperation на новую операцию AnotherArraySumOperation:



Прервав операцию через 2 секунды, мы получили тот же результат — нам удалось обработать только 2 элемент массива из 5-ти, то есть 40%, перед тем, как операция была уничтожена.

Установим задержку прерывания операции 4 секунды:



Обработано 4 элемента массива из 5-ти, то есть 80%, перед тем, как операция была уничтожена.

Очень важно убедиться, что индивидуальные операции реагируют на свойство isCancelled и, следовательно, могут быть уничтожены.

Но в дополнение к уничтожению отдельных операций с помощью метода cancel (), вы можете уничтожить все стартовавшие операции на очереди операций OprationQueue с помощью метода cancellAllOperations. Это особенно полезно, если у вас есть набор операций, работающих на единую цель. Эта цель может заключаться в параллельном запуске множества независимые операции или представлять собой граф зависимых операций, исполняемых одна за другой. Рассмотрим оба этих случая.

Паттерн 1. Работа с группой независимых операций


Код находится на CancellationGroup.playground на Github.

Поставим задачу достигнуть того же результата, что и операция ArraySumOperation, представленная в предыдущем разделе. Эта операция берет массив кортежей (Int, Int), и, используя функцию медленного сложения slowAdd(), создает массив сумм чисел, составляющих кортеж. Цикл по составляющим входного массива скрыт внутри ArraySumOperation. Давайте создадим группу отдельных очень простых операций типа SumOperation. Операция SumOperation складывает два числа из входной пары inputPair с помощью функции медленного сложения slowAdd() и возвращает результат output:



Создадим самый обычный класс GroupAdd, который управляет private очередью операций queue и множеством операций SumOperation для того, чтобы посчитать сумму всех пар во входном массиве и разместить в выходном массиве outputArray кортежи (Int, Int, Int ), состоящие из исходных данных и результата:



При инициализации экземпляра класса GroupAdd задается входный массив input пар чисел, из которых формируются операции типа SumOperation.  В completionBlock каждой операции производится добавление результата в выходной массив outputArray, которое выполняется на отдельной private последовательной очереди операций appendOperation, чтобы избежать race condition.

Класс имеет все присущие  операции Operation методы: start(), cancel (), wait (), поэтому мы вправе рассматривать его как «комплексную операцию».

Создаем экземпляр класса GroupAdd, подавая на вход массив пар чисел:



Стартуем groupAdd, ожидаем 1 секунду и используем метод cancel () для удаления всех операций суммирования из очереди операций. В результате после завершения всех операций (используем wait(), который НЕЛЬЗЯ использовать на main queue, но можно на Playground), получаем укороченный выходной массив:



Результат можно посмотреть на Playground CancelletionGroup.playground на Github.

Паттерн 2. Работаем с группой зависимых операций


Код находится на CancellationFourImages.playground на Github.

В качестве группы зависимых операций рассмотрим уже знакомую нам группу взаимосвязанных операций по загрузке изображения из сети, его фильтрации и модификации UI. Попробуем оформить эту последовательность в отдельный класс ImageProvider, который будет управлять этими операциями на OperationQueue с помощью методов start (), wait () и cancel ().

У нас будет две абстрактные операции (то есть те, у которых  нет реализации метода main()). Одна — это уже знакомая нам АСИНХРОННАЯ операция AsyncOperation, а другая — операция ImageTakeOperation извлечения входное изображение inputImage из зависимостей dependecies.

На основе AsyncOperation создадим операцию загрузки изображения из «сети» по заданному URL адресу:



Эта операция подтверждает протокол ImagePass для передачи полученного изображения outputImage далее по цепочке операций.

Абстрактная операция ImageTakeOperation извлекает входное изображение inputImage из зависимостей dependecies, если оно не задано при инициализации этой операции, и позволяет «забрать» выходное изображение с помощью уже знакомого нам протокола ImagePass, используемого для передачи изображений в цепочке последовательных операций:



Абстрактный класс ImageTakeOperation очень удобно использовать в качестве superclass для создания операции, участвующей в цепочке зависимых операций. Например, для операции фильтрации Filter:



Или для операции «состаривания» изображения в стиле «хипстер» PostProcessImageOperation:



Или для операции «выбрасывания» входного изображения во внешнюю среду с помощью замыкания ImageOutputOperation:



Теперь займемся классом ImageProvider. Создадим самый обычный класс ImageProvider, который управляет private очередью операций operationQueue и последовательностью операций dataLoadfilter и output для того, чтобы загрузить изображение по заданному URL, отфильтровать его и передать в замыкание completion:



Класс ImageProvider имеет все присущие операции Operation методы: start()cancel ()wait (), поэтому мы вправе рассматривать его как «комплексную операцию».

Создаем 4 экземпляра класса ImageProvider:



Стартуем загрузку изображений:



Ждем завершения операций и получаем 4 изображения:



Длительность всех операций чуть больше 10 секунд.

Стартуем загрузку изображений, ожидаем 6 секунду и используем метод cancel () для удаления всех операций. В результате получаем загрузку лишь трех изображений — 1-го, 3-го и 4-го:



Результат можно посмотреть на Playground CancelletionFourImages.playground на Github.

Паттерн 3. Работаем с TableViewController и CollectionViewController


Код проекта находится в папке OperationTableViewController на Github.

Очень часто таблицы в iOS приложениях содержат изображения, получение которых требует обращения к серверу, а иногда и дополнительных действий с полученным изображением типа «фильтрации», о котором упоминалось в предыдущем разделе. Все это занимает значительное время и для гладкого прокручивания таблицы все манипуляции с изображениями должны выполняться асинхронно за пределами main queue. Давайте рассмотрим применение представленного в предыдущем разделе класса ImageProvider, который выполняет уже знакомую нам группу взаимосвязанных операций по загрузке изображения из сети, его фильтрации и модификации UI.

Рассмотрим в качестве примера очень простое приложение, состоящее всего из одного Image Table View Controller, у которого ячейки таблицы содержат только изображения, загружаемые из интернета и индикатор активности, показывающий процесс загрузки:



Вот как выглядит класс ImageTableViewController, обслуживающий экранный фрагмент Image Table View Controller:



Моделью для класса ImageTableViewController является массив из 8 URLs:

  1. Эйфелева башня
  2. Венеция - загружается и фильтруется значительно дольше остальных
  3. Шотландский замок
  4. Арктика -02
  5. Эйфелева башня
  6. Арктика -16
  7. Арктика -15
  8. Арктика -12




Класс ImageTableViewCell для ячейки таблицы, в которую загружается изображение, имеет вид:



Public API этого класса — строка imageURLString, содержащая URL адрес изображения. Но если мы зададим imageURLString не равным nil,  то загрузки изображения не будет, начнет работать только индикатор в виде «вращающего колесика». Но если у нас уже есть каким-то образом загруженное и обработанное изображение image, то вызывая метод updateImageViewWithImage, мы покажем его в этой ячейке на экране с помощью легкой анимации. В этом классе есть индикатор активности spinner, который стартует, если присвоить imageURLString значение в методе tableView( _ : cellForRowAt:).

Загрузку изображения будем производить в методах делегата UITableViewDelegate, отвечающих за взаимодействие ячейки UITableViewCell с таблицей UITableView:

  • таблица запрашивает ячейку для показа на экране — tableView( _ : cellForRowAt:),
  • таблица готова показать ячейку на экране — tableView( _ : willDisplay:forRowAt:),
  • таблица убрала ячейку с экрана — tableView( _ : didEndDisplaying:forRowAt:),




Запрос на асинхронную загрузку изображения с помощью класса ImageProvider будет выведен за пределы метода tableView( _ : cellForRowAt:), чтобы максимально «облегчить» этот метод. Он разместится  в методе tableView( _ : willDisplay:forRowAt:) делегата, который готовит ячейку к тому чтобы стать видимой. Другой метод tableView( _ : didEndDisplaying:forRowAt:) делегата будет использован для того, чтобы уничтожить любой запрос на загрузку изображения, который не будет завершен к тому моменту, когда ячейка покинет экран. Это достаточно общий подход и может быть использован в любом приложении, работающим с TableView. Это улучшит производительность прокрутки таблицы.

Но сначала вернемся к классу ImageProvider, который будет использоваться в этом приложении. В отличие от варианта класса ImageProvider, который был использован в предыдущем разделе на Playground, будем использовать его упрощенную форму. А именно, нам нет необходимости при инициализации экземпляра класса ImageProvider останавливать (isSuspended = true) очередь операций, а затем специально стартовать экземпляра класса ImageProvider с помощью метода start() — мы сразу запускаем цепь зависимых операций при инициализации и задаем waitUntilFinished равным false, так как это не Playground, а приложение, и мы не можем использовать синхронный метод wait():



Таким образом, в классе ImageProvider у нас есть инициализатор, на вход которого мы должны подать строку imageURLString с URL адресом изображения и  замыканием completion, которое generic способом возвращает изображение типа UIImage? тому, кто создал этот экземпляр класса ImageProvider, вместо того, чтобы использовать вычисляемое свойство image. Входное замыкание completion имеет сигнатуру (UIImage?)  -> (), то есть берет изображение UIImage? и ничего не возвращает. Оно может быть использовано для возвращение в UITableViewController.

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

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

Возвращаемся к ImageTableViewController.

Вместо того, чтобы загружать изображение в методе tableView( _  tableView:, cellForRowAt indexPath:), мы будем это делать в другом методе делегата —  tableView( _  tableView: , willDisplay cell:, forRowAt indexPath:), а затем мы  удалим изображения в методе tableView( _  tableView: , didEndDisplaying cell:, forRowAt indexPath:)

Давайте создадим расширение extension для этих двух методов. Начнем с метода  tableView( _  tableView: , willDisplay cell:, forRowAt indexPath:).



Также как и в методе tableView( _  tableView:, cellForRowAt indexPath:), у нас будет ячейка cell и indexPath. Сначала выполняем стандартную процедуру проверки того, что ячейка имеет тип ImageTableViewCell. Затем создаем imageProvider со строкой imageURLs [ (indexPath as NSIndexPath).row ] в качестве URL адреса изображения и замыканием, которое оформлено как «хвостовое» замыкание. В замыкании мы получаем изображение image, которое необходимо показать в этой ячейки таблицы. Это UI, и мы должны использовать для его обновления main queue, потому что если вы попытаетесь сделать обновление UI на фоновой очереди (background queue), то это не будет работать, а вы будете удивляться, почему это не появляется изображение. У нас есть свойство main класса OperationQueue для main queue, и все, что нам нужно сделать, — это вызвать метод updateImageViewWithImage( image ) на main queue, который обновит нужную нам ячейку UITableViewCell.

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

Идем в самое начало класса ImageTableViewController и добавляем новое свойство c именем imageProviders :



Свойство imageProviders представляет собой множество объектов типа imageProvider, которое вначале пусто.

Давайте посмотрим на нижнюю часть файла ImageProvider.swift. Вы увидите там уже существующее расширение extension класса ImageProvider, которое подтверждает протокол Hashable, необходимый для множеств Set:



Мы получаем вычисляемое свойство hashValue и оператор сравнения на равенство ==. И теперь можем устанавливать и сравнивать экземпляры объектов ImageProvider. Возвращаемся к  ImageTableViewController. Теперь мы можем отслеживать экземпляры объектов ImageProvider и добавлять их в множество imageProviders, которые задействованы на данный момент времени:



Давайте пройдем по этому коду, чтобы посмотреть шаг за шагом, что происходит. Это метод делегата tableView, который вызывается непосредственно перед тем, как ячейка появится на экране. В этот момент мы создаем ImageProvider, который асинхронно загружает, фильтрует изображение и возвращает результирующее изображение image в completionHandler. Мы используем image для обновления UIImageView на main queue. Затем мы запоминаем ImageProvider в множестве imageProviders для того, чтобы мы могли уничтожить его позже. Мы будем это делать в следующем методе делегата tableView с именем tableView( _  tableView:, didEndDisplaying cell:, forRowAt indexPath:), который вызывается сразу же после того, как ячейка уйдет с экрана. Именно здесь нам нужно уничтожить все операции этого ImageProvider:



Для этого мы находим ImageProvider для этой ячейки cell и используем метод cancel () этого ImageProvider, который удаляет все операции этого провайдера, а затем удаляем и сам провайдер из моего множества imageProviders. Как всегда мы сначала выполняем стандартную процедуру проверки того, что ячейка имеет тип ImageTableViewCell, а затем находим все провайдеры, у которых та же самая строка ImageURLString, что и для данной ячейки. Проходим по всем этим провайдерам и удаляем их, а затем убираем их из множества imageProviders. Это все, что нам необходимо сделать. 

Давайте запустим приложение.



Вы видите, что работают индикаторы активности во время загрузки изображений и прокрутка теперь работает очень быстро без всякой задержки. Изображения пустые до тех пор, пока они не загрузятся, а затем происходит их анимация. Прекрасно.
Код проекта находится в папке OperationTableViewController на Github.

Сравнение  операции Operation и очереди операций OperationQueue с GCD


GCD и Operations имеют очень много сходных возможностей, но в таблице представлены их отличия.



DispatchGroup и OperationQueue могут обрабатывать событие, связанное с полным завершение всех заданий, но вы должны быть очень внимательны при запуске метода waitUntilAllOperationsAreFinished для очереди OperationQueue, которая ни в коем случае НЕ ДОЛЖНА быть main queue.

Что касается зависимостей (dependecies), то все, что вы можете сделать на GCD, это реализовать цепочку заданий на private последовательной (serial) DispatchQueue. Зато это самая сильная сторона OperationQueue. Зависимости ( dependecies) на OperationQueue могут быть более сложными, чем просто цепочки, и операции могут выполняться на разных очередях OperationQueue.

Вы можете использовать барьеры в GCD для решения проблемы «писателей» и «читателей», если последовательная (serial) очередь DispatchQueue не подходит. Соответствующее решение этой проблемы с помощью OperationQueue очень запутанное и требует flags и очень специальных зависимостей.

В GCD вы можете удалять только DispatchWorkItems. Операции Operations можно удалять с помощью их собственного метода cancel() или все операции сразу на OperationQueue. Можно удалять замыкания в BlockOperation.

Как GCD, так и Operations могут выполнить СИНХРОННУЮ функцию АСИНХРОННО. При этом операция Operation снабжает нас объектно-ориентированной моделью для инкапсуляции всех данных для этой повторно используемой функции, включая реализацию subclasses Operation. Но часто для более простых задач, не обремененных сложными зависимостями, удобнее использовать более «легкие» методы GCD, чем создавать операцию Operation. Кроме того Dispatch блоки в GCD требуют меньше времени для выполнения: от наносекунд до миллисекунд, а операция Operation обычно требует от нескольких миллисекунд до минут.

Заключение


В этой статье мы рассмотрели следующие вопросы, касающиеся операции и Operation и очереди операций  OperationQueue:



1. Операция Operation может инкапсулировать задачу и данные в одном объекте, который имеет «жизненный цикл» и свойства, отображающие его состояния.

2. BlockOperation представляет собой объектно-ориентированную «обертку» вокруг DispatchQueue.global(), которая позволяет вам отслеживать выполнение группы замыканий вместо потери контроля над этой группой на DispatchQueue.global(). BlockOperation удобно использовать для простых операций как альтернативу GCD, если вы в своем приложении уже используете Operations и не хотите мешать их с DispatchQueue.

3. Основной свой потенциал операции Operation раскрывают, если они запускаются на OperationQueue. Как только вы подготовили операцию Operation, вы передаете ее на OperationQueue, которая управляет порядком выполнения всех операций, по существу являясь очень простой моделью, которая скрывая сложность многопоточного программирования. OperationQueue похожа на DispatchGroup, для которой вы можете перемешивать операции с различным уровнем qualityOfService и ожидать, пока все операции закончатся. Но вы должны быть очень внимательны, когда вызываете этот sync метод.

4. Для включения АСИНХРОННЫХ функций, в операцию Operation, мы должны сделать что-то специальное, чтобы точно фиксировать ее завершение. Мы должны управлять АСИНХРОННОЙ операцией AsyncOperation вручную с помощью KVO.

5. Непревзойденной возможностью операций Operation является то, что вы можете их комбинировать в цепочки операций для получения сложного графа зависимостей (dependencies). Это означает, что вы можете очень легко определить операцию Operation, которая не может стартовать до тех пор, пока одна или несколько других операций Operation не завершатся.  В статье показано, как можно использовать протокол protocol для передачи данных между операциями Operation для графа зависимостей (dependencies). Но вы должны исследовать свой граф зависимостей  с целью избежания циклов, которые могут вызвать deadLock (неразрешимую взаимную блокировку), особенно, если есть зависимости между операциями на различных OperationQueue

6. Как только вы передали операцию Operation на очередь операций OperationQueue, вы потеряли контроль над этой операцией, ибо теперь очередь OperationQueue сама составляет расписание запуска операций на выполнение и управляет их выполнением. Тем не менее, вы можете использовать метод cancel () для того, чтобы предотвратить запуск операции. В статье показано, как нужно учитывать свойство isCancelled при конструировании операции Operation и как можно удалить абсолютно все операции на очереди операций OperationQueue.

7. В заключении показана разработка приложения, отражающего реальный сценарий, когда нужно прокручивать таблицу с  изображениями, полученными из интернета и требующими дополнительных действий типа «фильтрации» или «состаривания». Все это занимает значительное время и для гладкого прокручивания таблицы все манипуляции с изображениями должны выполняться асинхронно за пределами main queue. В этом приложении мы использовали широкий спектр приемов работы с операцией Operation, в частности, с АСИНХРОННОЙ ОПЕРАЦИЕЙ AsyncOperation и ее зависимостями (dependencies), которые позволили нам добиться значительного улучшения нашего UI.

Эта статья вместе с предыдущей дают вам полное представление о многопоточной обработке в Swift 3 и 4 на iOS, которая существует в настоящее время. Теперь вы можете принять полноправное участие в обсуждении будущих возможностей многопоточной обработки в Swift, которая закладывается именно сейчас, когда для версии Swift 5 приоритетным направлением помимо ABI стабильности объявлена многопоточность (concurrency). Версия Swift 5 предполагает лишь начало работы над полностью новой моделью многопоточности, реализация которой будет продолжаться в последующих версиях. Уже поступают предложения о будущей модели многопоточности в Swift 5. Так что «включайте моторы!» и вперед.

За эволюцией Swift можно смотреть теперь здесь.

Ссылки


WWDC 2015.Advanced NSOperations (session 226).
Having fun with NSOperations in iOS
NSOperation and NSOperationQueue Tutorial in Swift
iOS Concurrency with GCD and Operations
CONCURRENCY IN IOS
Concurrency in Swift: One possible approach
Tags:
Hubs:
+10
Comments 9
Comments Comments 9

Articles