Pull to refresh

Многопоточность (concurrency) в Swift 3. GCD и Dispatch Queues

Reading time28 min
Views332K
Надо сказать, что многопоточность (сoncurrency) в iOS всегда входит в вопросы, задаваемые на интервью разработчикам iOS приложений, а также в число топ ошибок, которые делают программисты при разработке iOS приложений. Поэтому так важно владеть этим инструментом в совершенстве.
Итак, у вас есть приложение, оно работает на main thread (главном потоке), который отвечает за выполнение кода, отображающего ваш пользовательский интерфейс (UI). Как только вы начинаете добавлять к вашему приложению такие «затратные по времени» куски кода, как загрузка данных из сети или обработка изображений на main thread (главном потоке), то работа вашего UI начинает сильно замедляться и даже может привести к полному его «замораживанию».



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

Процессор в каждый заданный момент времени может выполнять одну из ваших задач и для нее выделяется соответствующий поток (thread).
В случае одноядерного процессора (iPhone и iPad), многопоточность ( сoncurrency) достигается многократными кратковременными переключениями между «потоками» (threads), на которых выполняются задачи (tasks), создавая достоверное представление об одновременном выполнении задач на одноядерном процессоре. На многоядерном процессоре (Mac) многопоточность достигается тем, что каждому «потоку», связанному с задачей, предоставляется свое собственное ядро для запуска задач. Обе эти технологии используют общее понятие многопоточности (сoncurrency).

Своеобразной платой за введение многопоточности в вашем приложениии является трудность обеспечения безопасного выполнения кода на различных потоках (thread safety). Как только мы позволяем задачам (tasks) работать параллельно, появляются проблемы, связанные с тем, что разные задачи (tasks) захотят получить доступ к одним и тем же ресурсам, например, захотят изменять одну и ту же переменную в разных потоках, или захотят получить доступ к ресурсам, которые уже заблокированы другими задачами. Это может привести к разрушению самих ресурсов, используемых задачами на других потоках.

В iOS программировании многопоточность предоставляется разработчикам в виде нескольких инструментов: Thread, Grand Central Dispatch (сокращенно GCD) и Operation — и используется с целью увеличения производительности и отзывчивости пользовательского интерфейса. Мы не будем рассматривать Thread, так как это низкоуровневый механизм, а сосредоточимся на GCD в этой статье и Operation (объектно-ориентированном API, построенном поверх GCD) в дальнейшей публикации.

Надо сказать, что до появления Swift 3 столь мощный фреймворк, как Grand Central Dispatch (GCD), имел API, основанное на языке С, которое на первый взгляд кажется просто книгой заклинаний, и не сразу понятно, как мобилизовать его возможности для выполнения полезных пользовательских задач.
В Swift 3 все кардинально изменилось. GCD получил новый, полностью Swift-подобный синтаксис, который очень легко использовать. Если вы хотя бы немного знакомы со старым API GCD, то весь новый синтаксис покажется вам просто легкой прогулкой; если нет — то вам просто придется изучить еще один обычный раздел программирования на iOS. Новый фреймворк GCD работает в Swift 3 на всех Apple устройствах, начиная от Apple Watch, включая все iOS приборы, и кончая Apple TV и Mac.

Еще одна хорошая новость состоит в том, что начиная с Xcode 8, можно использовать для изучения GCD и Operation такой мощный и наглядный инструмент, как Playgroud. В Xcode 8 появился новый вспомогательный класс PlaygroudPage, у которого есть функция, позволяющая Playgroud жить неограниченное время. В этом случае очередь DispatchQueue может работать до тех пор, пока работа не закончится. Это особенно важно для сетевых запросов. Для того, чтобы использовать класс PlaygroudPage, вам нужно импортировать модуль PlaygroudSupport. Этот модуль также позволяет получить доступ к циклу выполнения (run loop), отображать «живой» UI, а также выполнять асинхронные операции на Playgroud. Ниже мы увидим, как выглядит эта настройка в работе. Эта новая возможность Playground в Xcode 8 делает изучение многопоточности в Swift 3 очень простым и наглядным.

Для лучшего понимания многопоточности (concurrency), Apple ввела некоторые абстрактные понятия, с которыми оперируют оба инструмента — GCD и Operation. Основным понятием является очередь (queue). Поэтому, когда мы говорим о многопоточности в iOS с точки зрения разработчика iOS приложений, мы говорим об очередях (queues). Очереди (queues) — это обычные очереди, в которые выстраиваются люди, чтобы купить, например, билет в кинотеатр, но в нашем случае в очередь выстраиваются замыкания (closure — анонимные блоки кода). Система просто выполняет их согласно очереди, “выдергивая” следующего по очереди и запуская его на выполнение в соответствующем этой очереди потоке. Очереди (queues) следуют FIFO паттерну (First In, First Out), это означает, что тот, кто первым был поставлен в очередь, будет первым направлен на выполнение. У вас может быть множество очередей (queues) и система “выдергивает” замыкания по одному из каждой очереди и запускает их на выполнение в их собственных потоках. Таким образом, вы получаете многопоточность.

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

Последовательные (serial) и параллельные (concurrent) очереди


Очереди (queues) могут быть “serial” (последовательными), когда задача (замыкание), которая находится на вершине очереди, “вытягивается” iOS и работает до тех пор, пока не закончится, затем вытягивается следующий элемент из очереди и т.д. Это serial queue или последовательная очередь. Очереди (queues) могут быть “concurrent” (многопоточными), когда система “вытягивает” замыкание, находящееся на вершине очереди, и запускает ее на выполнение в определенном потоке. Если у системы еще есть ресурсы, то она берет следующий элемент из очереди и запускает его на выполнение в другом потоке в то время, пока первая функция еще работает. И так система может вытянуть целый ряд функций. Для того, чтобы не путать общее понятие многопоточности с "concurrent queues" (многопоточными очередями), мы будем называть "concurrent queue" параллельной очередью, имея ввиду порядок выполнения заданий на ней по отношению друг к другу, не вдаваясь в техническую реализацию этой параллельности.



Мы видим, что на serial (последовательной) очереди завершение замыканий происходит строго в том порядке, в каком они поступали на выполнение, в то время как на concurrent (параллельной) очереди задания заканчиваются непредсказуемым образом. Кроме того, вы видите, что общее время выполнения определенной группы заданий на serial очереди значительно превосходит время выполнения той же группы заданий на concurrent очереди. На serial (последовательной) очереди в любой текущий момент времени выполняется только одно задание, а на concurrent(параллельной) очереди число заданий в любой текущий момент времени может меняться.

Синхронное и асинхронное выполнение заданий


Как только очередь (queue) создана, задание на ней можно разместить с помощью двух функций: sync — синхронное выполнение по отношению к текущей очереди и async — асинхронное выполнение по отношению к текущей очереди.

Синхронная функция sync возвращает управление на текущую очередь только после полного завершения задания, тем самым блокируя текущую очередь:



Асинхронная функция async, в противоположность функции sync, возвращает управление на текущую очередь немедленно после запуска задания на выполнение в другой очереди, не ожидая его завершения. Таким образом, асинхронная функция async не блокирует выполнение заданий на текущей очереди:



«Другой очередью» может оказаться в случае асинхронного выполнения как последовательная (serial) очередь:



так и параллельная (concurrent) очередь:



Задача разработчика состоит только в выборе очереди и добавлении задания (как правило, замыкания) в эту очередь синхронно с помощью функции sync или асинхронно с помощью функции async, дальше работает исключительно iOS.

Возвращаясь к задаче, представленной в самом начале этого поста, мы переключим выполнение задания получения данных из сети «Data from Network» на другую очередь:



После получения данных Data из сети на другой очереди Dispatch Queue, мы посылаем их обратно на Main thread.



Когда мы получаем данные Data из сети на другой очереди DispatchQueue, Main thread — свободна и обслуживает все события, которые происходят на UI. Давайте посмотрим, как выглядит реальный код для этого случая:



Для выполнения загрузки данных по URL-адресу imageURL, что может занять значительное время и заблокировать Main queue, мы АСИНХРОННО переключаем выполнение этого ресурса-емкого задания на глобальную параллельную очередь с качеством обслуживания qos, равным .utility (более подробно об этом чуть позже):

  let imageURL: URL = URL(string: "http://www.planetware.com/photos-large/F/france-paris-eiffel-tower.jpg")!
    let queue = DispatchQueue.global(qos: .utility)
    queue.async{
        if let data = try? Data(contentsOf: imageURL){
            DispatchQueue.main.async {
                image.image = UIImage(data: data)
                 print("Show image data")
            }
            print("Did download  image data")
        }
    }


После получения данных data мы вновь возвращаемся на Main queue, чтобы обновить наш UI элемент image1.image с помощью этих данных.
Вы видите, как просто выполнить цепочку переключений на другую очередь, чтобы «увести» выполнение «затратных» заданий с Main queue, а затем опять на нее вернуться. Код находится на EnvironmentPlayground.playground на Github.

Заметьте, что переключение затратных заданий с Main queue на другой поток всегда АСИНХРОННО.
Нужно быть очень внимательным с методом sync для очередей, потому что «текущий поток» вынужден ждать окончания выполнения задания на другой очереди. НИКОГДА НЕ вызывайте метод sync на Main queue, потому что это приведет к deadlock вашего приложения! (об этом ниже)

Глобальные очереди


Помимо пользовательских очередей, которые нужно специально создавать, система iOS предоставляет в распоряжение разработчика готовые (out-of-the-box) глобальные очереди (queues). Их 5:

1.) последовательная очередь Main queue, в которой происходят все операции с пользовательским интерфейсом (UI):
let main = DispatchQueue.main

Если вы хотите выполнить функцию или замыкание, которые что-то делают с пользовательским интерфейсом (UI), с UIButton или с UI-чем-нибудь, вы должны поместить эту функцию или замыкание на Main queue. Эта очередь имеет наивысший приоритет среди глобальных очередей.

2.) 4 фоновых concurrent (параллельных) глобальных очереди с разным качеством обслуживания qos и, конечно, разными приоритетами:
// наивысший приоритет
let userInteractiveQueue = DispatchQueue.global(qos: .userInteractive)

let userInitiatedQueue = DispatchQueue.global(qos: .userInitiated)

let utilityQueue = DispatchQueue.global(qos: .utility)

// самый низкий приоритет
let backgroundQueue = DispatchQueue.global(.background) 

// по умолчанию 
let defaultQueue = DispatchQueue.global()

Каждую из этих очередей Apple наградила абстрактным «качеством обслуживания» qos (сокращение для Quality of Service), и мы должны решить, каким оно должно быть для наших заданий.

Ниже представлены различные qos и объясняется, для чего они предназначены:

  • .userInteractive — для заданий, которые взаимодействуют с пользователем в данный момент и занимают очень мало времени: анимация, выполняются мгновенно; пользователь не хочет этого делать на Main queue, однако это должно быть сделано по возможности быстро, так как пользователь взаимодействует со мной прямо сейчас. Можно представить ситуацию, когда пользователь водит пальцем по экрану, а вам необходимо просчитать что-то, связанное с интенсивной обработкой изображения, и вы размещаете расчет в этой очереди. Пользователь продолжает водить пальцем по экрану, он не сразу видит результат, результат немного отстает от положения пальца на экране, так как расчеты требуют некоторого времени, но по крайней мере Main queue все еще “слушает” наши пальцы и реагирует на них. Эта очередь имеет очень высокий приоритет, но ниже, чем у Main queue.
  • .userInitiated — для заданий, которые инициируются пользователем и требуют обратной связи, но это не внутри интерактивного события, пользователь ждет обратной связи, чтобы продолжить взаимодействие; может занять несколько секунд; имеет высокий приоритет, но ниже, чем у предыдущей очереди,
  • .utility — для заданий, которые требуют некоторого времени для выполнения и не требуют немедленной обратной связи, например, загрузка данных или очистка некоторой базы данных. Делается что-то, о чем пользователь не просит, но это необходимо для данного приложения. Задание может занять от несколько секунд до нескольких минут; приоритет ниже, чем у предыдущей очереди,
  • .background — для заданий, не связанных с визуализацией и не критичных ко времени исполнения; например, backups или синхронизация с web — сервисом. Это то, что обычно запускается в фоновом режиме, происходит только тогда, когда никто не хочет никакого обслуживания. Просто фоновая задача, которая занимает значительное время от минут до часов; имеет наиболее низкий приоритет среди всех глобальных очередей.

Есть еще Глобальная параллельная (concurrency) очередь по умолчанию .default, которая сообщает об отсутствие информации о «качестве обслуживания» qos. Она создается с помощью оператора:
DispatchQueue.global()

Если удается определить qos информацию из других источников, то используется она, если нет, то используется qos между .userInitiated и .utility.

Важно понимать, что все эти глобальные очереди являются СИСТЕМНЫМИ глобальными очередями и наши задания — не единственные задания в этой очереди! Также важно знать, что все глобальные очереди, кроме одной, являются concurrent (параллельными) очередями.

Особенная Глобальная последовательная очередь для пользовательского интерфейса — Main queue


Apple обеспечивает нас единственной ГЛОБАЛЬНОЙ serial (ПОСЛЕДОВАТЕЛЬНОЙ) очередью — это упомянутая выше Main queue. На этой очереди нежелательно выполнять ресурсоёмкие операции (например, загрузку данных из сети), не относящиеся с изменению UI, чтобы не «замораживать» UI на время выполнения этой операции и сохранить отзывчивость пользовательского интерфейса на действия пользователя в любой момент времени, например, на жесты.



Настоятельно рекомендуется «уводить» такие ресурсоёмкие операции на другие потоки или очереди:



Есть и еще одно жесткое требование — ТОЛЬКО на Main queue мы можем изменять UI элементы.

Это потому, что мы хотим, чтобы Main queue была не только “отзывчивой” на действия с UI (да, это основная причина), но мы хотим также, чтобы пользовательский интерфейс был защищен от “разлаживания” в многопоточной среде, то есть реакция на действия пользователя выполнялась бы строго последовательно в упорядоченной манере. Если мы разрешим нашим элементам UI выполнять свои действия в различных очередях, то может случиться, что рисование будет происходить с разной скоростью, и действия будет пересекаться, что приведет к полной непредсказуемости на экране. Мы используем Main queue как своего рода “точку синхронизации”, в которую возвращается каждый, кто хочет “рисовать” на экране.

Проблемы многопоточности


Как только мы позволяем задачам (tasks) работать параллельно, появляются проблемы, связанные с тем, что разные задачи захотят получить доступ к одним и тем же ресурсам.
Основных проблемы три:

  • cостояние гонки (race condition) — ошибка проектирования многопоточной системы или приложения, при которой работа системы или приложения зависит от того, в каком порядке выполняются части кода
  • инверсия приоритетов (priority inversion)
  • взаимная блокировка (deadlock) — ситуация в многопоточной системе, при которой несколько потоков находятся в состоянии бесконечного ожидания ресурсов, занятых самими этими потоками


Состояние гонки (race condition)


Мы можем воспроизвести простейший случай race condition, если будем изменять переменную value асинхронно на private очереди, а показывать value на текущем потоке:



У нас есть обычная переменная value и обычная функция changeValue для ее изменения, причем умышленно мы сделали с помощью оператора sleep(1) так, что изменение переменной value требует значительного времени. Если мы будем запускать функцию changeValue АСИНХРОННО с помощью async, то прежде, чем дойдет дело до размещения измененного значения в переменной value, на текущем потоке переменная value может быть переустановлена в другое значение, это и есть race condition. Этому коду соответствует печать в виде:



и диаграмма, на которой наглядно видно явление под названием "race condition":



Давайте заменим метод async на sync:



И печать, и результат изменились:

<img

и диаграмма, на которой отсутствует явление под названием "race condition":



Мы видим, что хотя нужно быть очень внимательным с методом sync для очередей, потому что «текущий поток» вынужден ждать окончания выполнения задания на другой очереди, метод sync оказывается очень полезным для того, чтобы избежать race conditions. Код для имитации явления "race condition" можно посмотреть на firstPlayground.playground на Github. Позже мы покажем настоящие "race condition" при формировании строки из символов, получаемых на разных потоках. Будет также предложен элегантный способ формирования строки с использованием «барьеров», который позволит избежать"race conditions" и сделать формируемую строку потокобезопасной.

Инверсия приоритетов (priority inversion)


С блокировкой ресурсов тесно связано понятие инверсии приоритетов:



Допустим в системе существуют две задачи с низким (А) и высоким (Б) приоритетом. В момент времени T1 задача (А) блокирует ресурс и начинает его обслуживать. В момент времени T2 задача (Б) вытесняет низкоприоритетную задачу (А) и пытается завладеть ресурсом в момент времени T3. Но так как ресурс заблокирован, задача (Б) переводится в ожидание, а задача (А) продолжает выполнение. В момент времени Т4 задача (А) завершает обслуживание ресурса и разблокирует его. Так как ресурс ожидает задача (Б), она тут же начинает выполнение.
Временной промежуток (T4-T3) называют ограниченной инверсией приоритетов. В этом промежутке наблюдается логическое несоответствие с правилами планирования — задача с более высоким приоритетом находится в ожидании в то время как низкоприоритетная задача выполняется.

Но это еще не самое страшное. Допустим в системе работают три задачи: низкоприоритетная (А), со средними приоритетом (Б) и высокоприоритетная (В):



Если ресурс заблокирован задачей (А), а он требуется задаче (В), то наблюдается та же ситуация — высокоприоритетная задача блокируется. Но допустим, что задача (Б) вытеснила (А), после того как (В) ушла в ожидание ресурса. Задача (Б) ничего не знает о конфликте, поэтому может выполняться сколь угодно долго на промежутке времени (T5-T4). Кроме того, помимо (Б) в системе могут быть и другие задачи, с приоритетами больше (А), но меньше (Б). Поэтому длительность периода (T6-T3) в общем случае неопределена. Такую ситуацию называют неограниченной инверсией приоритетов.

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

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

Взаимная блокировка (deadlock)


Взаимная блокировка — это аварийное состояние системы, которое может возникать при вложенности блокировок ресурсов. Допустим в системе существуют две задачи с низким (А) и высоким (Б) приоритетом, которые используют два ресурса — X и Y:



В момент времени T1 задача (А) блокирует ресурс X. Затем в момент времени T2 задачу (А) вытесняет более приоритетная задача (Б), которая в момент времени T3 блокирует ресурс Y. Если задача (Б) попытается заблокировать ресурс X (T4) не освободив ресурс Y, то она будет переведена в состояние ожидания, а выполнение задачи (А) будет продолжено. Если в момент времени T5 задача (А) попытается заблокировать ресурс Y, не освободив X, возникнет состояние взаимной блокировки — ни одна из задач (А) и (Б) не сможет получить управление.

Взаимная блокировка возможна только тогда, когда в системе используется зависимый (вложенный) многопоточный доступ к ресурсам. Взаимной блокировки можно избежать, если не использовать вложенность, или если ресурс использует протокол увеличения приоритета.
Если мы в задаче, представленной в начале поста, после получения данных из сети в фоновой очереди, попытаемся использовать для возвращения на main queue метод sync, то мы мы получим взаимную блокировку (deadock).

НИКОГДА НЕ вызывайте метод sync на main queue, потому что это приведет к взаимной блокировке (deadlock) вашего приложения!

Экспериментальная среда


Для экспериментов мы будем использовать Playground, настроенную на бесконечное время работы c помощью модуль PlaygroundSupport и класса PlaygroudPage, чтобы мы смогли завершиться все задачи, помещенные в очереди и получить доступ к main queue. Мы можем остановить ожидание какого-то события на Playground c помощью команды PlaygroundPage.current.finishExecution().
Есть еще одна крутая возможность на Playground — возможность взаимодействия с «живым» UI с помощью команды
PlaygroundPage.liveView = viewController

и Ассистента Редактора (Assistant Editor). Если, например, вы создаете viewController, то для того, чтобы увидеть ваш viewController, вам достаточно настроить Playground на неограниченное выполнение кода и включить Ассистента Редактора (Assistant Editor). Придется закомментировать команду PlaygroundPage.current.finishExecution() и останавливать Playground вручную.



Playground c кодом шаблона экспериментальной среды имеет имя EnvironmentPlayground.playground и находится на Github.

1. Первый эксперимент. Глобальные очереди и задания


Начнем с простых экспериментов. Определим также ряд глобальных очередей: одну последовательную mainQueue — это main queue, и четыре параллельные (concurrent) queuesuserInteractiveQueue, userQueue, utilityQueue и backgroundQueue. Можно задать concurrent queue по умолчанию — defautQueue:



В качестве задания task выберем печать любых десяти одинаковых символов и приоритета текущей очереди. Еще одно задание taskHIGH, которое будет печатать один символ, мы будем запускать с высоким приоритетом:



2. Второй эксперимент будет касаться СИНХРОННОСТИ и АСИНХРОННОСТИ на глобальных очередях


Как только вы получили глобальную очередь, например, userQueue, вы можете выполнять задания на ней либо СИНХРОННО, используя метод sync, либо АСИНХРОННО, используя метод async.



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

В случае же асинхронного async выполнения, мы видим, что задания

стартуют, не дожидаясь завершения заданий

, и приоритет глобальной очереди userQueue выше приоритета выполнения кода на Playground. Следовательно, задания на userQueue выполняются чаще.

3. Третий эксперимент. Private последовательные очереди


Помимо глобальный очередей мы можем создавать пользовательские Private очереди с помощью инициализатора класса DispatchQueue:



Единственное, что необходимо указать при создании пользовательской очереди, — это уникальная метка label, которую Apple рекомендует задавать в виде инверсной DNS нотации (“com.bestkora.mySerial”), именно под таким именем будет видна эта очередь в отладчике. Тем не менее, это необязательно, и вы можете использовать любую строку, лишь бы она оставалась уникальной. Если вы не задаете больше никаких других аргументов кроме label при инициализации Private очереди, то по умолчанию создается последовательная (.serial) очередь. Есть и другие аргументы, которые можно задать при инициализации очереди, и о них мы поговорим чуть позже.
Смотрим, как работает пользовательская Private последовательная очередь mySerialQueue при использовании sync и async методов:



В случае синхронного sync мы видим ту же ситуацию, что и в эксперименте 3 -тип очереди не имеет значения, потому что в качестве оптимизации функция sync может запустить замыкание на текущем потоке. Именно это мы и видим.

Что произойдет, если мы используем async метод и позволим последовательной очереди mySerialQueue выполнить задания

асинхронно по отношению к текущей очереди? В этом случае выполнение программы не останавливается и не ожидает, пока завершится это задание в очереди mySerialQueue; управление немедленно перейдет к выполнению заданий

и будет исполнять их в одно и то же время, что и задания


4. Четвертый эксперимент будет касаться приоритетов QoS последовательных очередей


Давайте назначим нашей Private последовательной очереди serialPriorityQueue качество обслуживания qos, равное .userInitiated, и поставим асинхронно в эту очередь сначала задания

а потом

Этот эксперимент убедит нас в том, что наша новая очередь serialPriorityQueue действительно является последовательной, и несмотря на использование async метода, задания выполняются последовательно друг за другом в порядке поступления:



Таким образом, для многопоточного выполнения кода недостаточно использовать метод async, нужно иметь много потоков либо за счет разных очередей, либо за счет того, что сама очередь является параллельной (.concurrent). Ниже в эксперименте 5 с параллельными (.concurrent) очередями мы увидим аналогичный эксперимент с Private параллельной (.concurrent) очередью workerQueue, но там будет совсем другая картина, когда мы будем помещать в эту очередь те же самые задания.

Давайте используем последовательные Private очереди с разными приоритетами для асинхронной постановки в эту очереди сначала заданий

, а потом заданий


очередь serialPriorityQueue1 c qos .userInitiated
очередь serialPriorityQueue2 c qos .background



Здесь происходит многопоточное выполнение заданий, и задания чаще исполняются на очереди serialPriorityQueue1, имеющей более приоритетное качество обслуживания qos: .userIniatated.

Вы можете задержать выполнение заданий на любой очереди DispatchQueue на заданное время, например, на now() + 0.1 с помощью функции asyncAfter и еще изменить при этом качество обслуживания qos:



5. Пятый эксперимент будет касаться Private параллельных (concurrent) очередей


Для того, чтобы инициализировать Private параллельную (.concurrent) очередь достаточно указать при инициализации Private очереди значение аргумента attributes равное .concurrent. Если вы не указываете этот аргумент, то Private очередь будет последовательной (.serial). Аргумент qos также не требуется и может быть пропущен без всяких проблем.

Давайте назначим нашей параллельной очереди workerQueue качество обслуживания qos, равное .userInitiated, и поставим асинхронно в эту очередь сначала задания

, а потом

Наша новая параллельная очередь workerQueue действительно является параллельной, и задания в ней выполняются одновременно, хотя все, что мы сделали по сравнению со четвертым экспериментом (одна последовательная очередь serialPriorityQueue), это задали аргумент attributes равном .concurrent:



Картина совершенно другая по сравнению с одной последовательной очередью. Если там все задания выполняются строго в том порядке, в котором они поступают на выполнение, то для нашей параллельной (многопоточной) очереди workerQueue, которая может «расщепляться» на несколько потоков, задания действительно выполняются параллельно: некоторые задания с символом

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

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

очередь workerQueue1 c qos .userInitiated
очередь workerQueue2 c qos .background



Здесь такая же картина, как и с разными последовательными Private очередями во втором эксперименте. Мы видим, что задания чаще исполняются на очереди workerQueue1, имеющей более высокий приоритет.

Можно создавать очереди с отложенным выполнением с помощью аргумента attributes, а затем активировать выполнение заданий на ней в любое подходящее время c помощью метода activate():



6. Шестой эксперимент связан с использованием DispatchWorkItem объектов


Если вы хотите иметь дополнительные возможности по управлению выполнением различных заданий на Dispatch очередях, то можно создать DispatchWorkItem, для которого можно задать качество обслуживания qos, и оно будет воздействовать на его выполнение:



Задавая флаг [.enforceQoS] при подготовке DispatchWorkItem, мы получаем более высокий приоритет для задания highPriorityItem перед остальными заданиями на той же очереди:



Это позволяет принудительно повышать приоритет выполнения конкретного задания на Dispatch Queue c определенным качеством обслуживания qos и, таким образом, бороться с явлением «инверсия приоритетов». Мы видим, что несмотря на то, что два задания highPriorityItem стартуют самыми последними, они выполняется в самом начале благодаря флагу [.enforceQoS] и повышению приоритета до .userInteractive. Кроме того, задание highPriorityItem может запускаться многократно на различных очередях.

Если мы уберем флаг [.enforceQoS]:



то задания highPriorityItem будут брать то качество обслуживание qos, которое установлено для очереди, на которой они запускаются:



Но все равно они попадают в самое начало соответствующих очередей. Код для всех этих экспериментов находится на firstPlayground.playground на Github.

У класса DispatchWorkItem есть свойство isCancelled и ряд методов:



Несмотря на присутствие метода cancel() для DispatchWorkItem GCD все еще не позволяет удалять замыкания, которые уже стартовали на определенной очереди. Что мы можем в настоящее время — это пометить DispatchWorkItem как «удаленную» с помощью метода cancel(). Если вызов метода cancel() происходит перед тем, как DispatchWorkItem будет поставлена в очередь с помощью метода async, то DispatchWorkItem не будет выполняться. Одна из причин, почему иногда необходимо использовать механизм Operation, а не GCD, состоит как раз в том, что GCD не умеет удалять замыкания, которые стартовали на определенной очереди.

Можно использовать класс DispatchWorkItem и его метод notify (queue:, execute:), а также метод экземпляра класса DispatchQueue

async(execute workItem: DispatchWorkItem)

для решения задачи, приведенной в самом начале поста — загрузки изображения из сети:



Мы формируем синхронное задание в виде экземпляра workItem класса DispatchWorlItem, состоящее в получение данных data из «сети» по заданному imageURL адресу. Выполняем асинхронно задание workItem на параллельной глобальной очереди queue с качеством обслуживания qos: .utility с помощью функции
queue.async(execute: workItem)


С помощью функции
workItem.notify(queue: DispatchQueue.main) {
                          if let imageData = data {
                                eiffelImage.image = UIImage(data: imageData)}
}

мы ждем уведомление об окончании загрузки данных в data. Как только это произошло, мы обновляем изображение элемента UI eiffelImage:



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

Паттерн 1. Варианты кода для загрузки изображения из сети


У нас есть две синхронные задачи:
получение данных из сети
let data = try? Data(contentsOf: imageURL)

и обновление на основе данных data пользовательского интерфейса (UI)
eiffelImage.image = UIImage(data: data)

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

Это можно сделать либо классическим способом:



либо с помощью готового асинхронного API, используя URLSession:



либо с помощью DispatchWorlItem:



Наконец, мы можем всегда сами «завернуть» нашу синхронную задачку в асинхронную «оболочку» и выполнить ее:



Код для этого паттерна находится на LoadImage.playground на Github.

Паттерн 2. Особенности загрузка изображений из сети для Table View и Collection View с помощью GCD


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



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



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



Загрузка изображения производится обычным классическим способом. Моделью для класса ImageTableViewController является массив из 8 URLs:

  1. Эйфелева башня
  2. Венеция
  3. Шотландский замок
  4. Спутник «Кассини» — загружается из сети значительно дольше остальных
  5. Эйфелева башня
  6. Венеция
  7. Шотландский замок
  8. Арктика

Если мы запустим приложение и начнем прокручивать достаточно быстро вниз с тем, чтобы увидеть все 8 изображений, то мы обнаружим, что Спутник «Кассини» так и не загрузится до тех пор, пока мы покинем экран. Очевидно, что ему требуется значительно больше времени для загрузки, чем всем остальным.



Зато прокрутив до конца и увидев в самой последней ячейки «Арктику», мы вдруг обнаружим, что спустя некоторое очень небольшое время она будет заменена на Спутник «Кассини»:



Это неправильное функционирование такого простого приложения. В чем же дело? Дело в том, что ячейки в таблицы являются повторно-используемыми благодаря методу dequeueReusableCell. Каждый раз, когда ячейка (новая или повторноиспользуемая) попадает а экран, запускается асинхронно загрузка изображения из сети (в это время крутится «колесико»), как только загрузка выполнена и изображение получено, происходит обновление UI этой ячейки. Но мы не ждем загрузки изображения, мы продолжаем прокручивать таблицу и ячейка («Кассини») уходит с экрана, так и не обновив свой UI. Однако снизу должно появится новое изображение и эта же ячейка, ушедшая с экрана, будет использована повторно, но уже для другого изображения (" Арктика"), которое быстро загрузится и обновит UI. В это время вернется запущенная в этой ячейки ранее загрузка «Кассини» и обновит экран, что неправильно.Это происходит потому, что мы запускаем разные вещи, работающие с сетью в разных потоках. Они возвращаются в разное время:



Как мы можем исправить ситуацию? В пределах механизма GCD мы не можем отменить загрузку изображения ушедшей с экрана ячейки, но мы можем, когда приходят наши imageData из сети, проверить URL, который вызвал загрузку этих данных, url и сравнить его с тем, который пользователь хочет иметь в этой ячейки в данный момент, imageURL:



Теперь все будет работать правильно. Таким образом, многопоточное программирование требует нестандартного воображения. Дело в том, что некоторые вещи в многопоточном программировании осуществляются в другом порядке, чем написан код. Приложение GCDTableViewController находится на Github.

Паттерн 3. Использование групп DispatchGroup


Если у вас есть несколько задач, которые нужно выполнить асинхронно, и дождаться их полного завершения, то применяется группа DispatchGroup, которую очень легко создать:

let imageGroup = DispatchGroup()


Допустим, нам нужно загрузить «из сети» 4 различных изображения:



Метод queue.async(group: imageGroup) позволяет добавить в группу любое задание (синхронное), исполняемое на любой очереди queue:



Мы создаем группу imageGroup и помещаем в эту группу с помощью метода async (group: imageGroup) два задания для асинхронной загрузки изображений в глобальную параллельную очередь DispatchQueue.global() и два задания асинхронной загрузки изображений в глобальную параллельную очередь DispatchQueue.global(qos:.userInitiated) с качеством обслуживания .userInitiated. Важно, что в одну и ту же группу можно добавлять задачи, функционирующие на разных очередях. Когда все задачи в группе будут выполнены, вызывается функция notify — это своего рода блок обратного вызова на всю группу, который и размещает все изображения на экране одновременно:



Группа содержит потоко-безопасный внутренний счетчик, который автоматически увеличивается при добавлении задания в группу с помощью метода async (group: imageGroup). Когда какое-то задание выполняется, то счетчик уменьшается на единицу и нам гарантируют, что блок обратного вызова будет вызван после завершения всех долговременных операций. Эксперименты с формированием группы синхронных операций представлены на Playground GroupSyncTasks.playground на Github.

Если в вашей группе есть не только синхронные операции, но и асинхронные, то потокобезопасным счетчиком можно управлять вручную: метод enter() увеличивает счетчик, а метод leave() уменьшает. Размещение асинхронных операций в группе мы будем изучать с помощью Playground GroupAsyncTasks.playground на Github. Мы будем размещать асинхронные задания в группу и отображать в верхней части экрана.



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



Возможно размещение в группе смешанных операций: синхронных и асинхронных:



Результат будет тот же.

Паттерн 4. Поточно-безопасные (thread-safe) переменные. Очереди изоляции


Вернемся к к нашим первым экспериментам с очередями GCD в Swift 3 и попробуем сохранить хронологическую (вертикальную) последовательность выполнения заданий в строке, и тем самым представить пользователю результат выполнения заданий на разных очередях в горизонтальном виде:



Скажу сразу, что я использовала для накопления результатов как обычную НЕпоточно-безопасную в Swift 3 строку usualString: String, так и поточно-безопасную (thread-safe) строку safeString: ThreadSafeString:

var safeString = ThreadSafeString("")
var usualString = ""


Цель данного раздела состоит в том, чтобы показать, как должна быть устроена поточно-безопасная строка в Swift 3, поэтому об этом немного позже.

Все эксперименты с поточно-безопасной строкой будут происходить на Playground GCDPlayground.playground в Github.

Я немного изменю задания с целью накопления информации в обоих строках usualString и safeString:



В Swift любая переменная, декларируемая с ключевым словом let является константой, а следовательно, и поточно-безопасной (thread-safe). Декларация переменной с ключевым словом var делает переменную изменяемой (mutable) и непотокобезопасной (thread-safe) до тех пор, пока она не будет сконструирована специальным образом. Если два потока начнут изменять одновременно один и тот же блок памяти, то может произойти повреждение этого блока памяти. Кроме того, если читать какую-то переменную на одном потоке в то время, когда идет обновление ее значения на другом потоке, то вы рискуете считать «старое значение», то есть имеет место состояние гонки (race condition).

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

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

К счастью, GCD предоставляет нам элегантный способ решения с помощью барьеров (barrier) и очередей изоляции:



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

Давайте посмотрим, как будет выглядеть потокобезопасный класс ThreadSafeString:



Функция isolationQueue.sync отправит замыкание «чтения» {result = self.internalString} в нашу очередь изоляции isolationQueue и будет дожидаться окончания, перед тем как вернуть результат выполнения result. После этого у нас будет результат чтения. Если не делать вызов синхронным, тогда потребуется введение блока обратного вызова. Благодаря тому, что очередь isolationQueue параллельная (.concurrent), такие синхронные чтения могут выполняться по несколько штук одновременно.

Функция isolationQueue.async (flags: .barrier) отправит замыкание «записи», «добавления» или «инициализации» в очередь изоляции isolationQueue. Функция async означает, что управление будет возвращено до того, как замыкание «записи», «добавления» или «инициализации» фактически выполниться. Барьерная часть (flags: .barrier) означает, что замыкание не будет выполнено до тех пор, пока каждое замыкание в очереди не закончит свое выполнение. Другие замыкания будут размещены после барьерного и выполняться после того, как выполнится барьерное.
Результаты экспериментов с DispatchQueues, представленные поточно-безопасной (thread-safe) строкой safeString: ThreadSafeString и обычной строкой usualString: String, находятся на с Playground GCDPlayground.playground на Github.

Давайте посмотрим эти результаты.

1. Функция sync на Глобальной параллельной очереди DispatchQueue.global(qos: .userInitiated) по отношению к Playground:



Результаты на обычной НЕпоточно-безопасной строке usualString, СОВПАДАЮТ с результатами на поточно-безопасной строке safeString.

2. Функция async на Глобальной параллельной очереди DispatchQueue.global(qos: .userInitiated) по отношению к Playground:



Результаты на обычной НЕпоточно-безопасной строке usualString, НЕ СОВПАДАЮТ с результатами на поточно-безопасной строке safeString.

3. Функция sync на Private последовательной очереди DispatchQueue (label: "com.bestkora.mySerial") по отношению к Playground:



Результаты на обычной НЕ поточно-безопасной строке usualString, СОВПАДАЮТ с результатами на поточно-безопасной строке safeString.

4. Функция async на Private последовательной очереди DispatchQueue (label: "com.bestkora.mySerial") по отношению к Playground:



Результаты на обычной НЕпоточно-безопасной строке usualString, НЕ СОВПАДАЮТ с результатами на поточно-безопасной строке safeString.

5. Функция async для заданий

и

на Private последовательной очереди DispatchQueue (label: "com.bestkora.mySerial", qos : .userInitiated):



Результаты на обычной НЕ поточно-безопасной строке usualString, СОВПАДАЮТ с результатами на поточно-безопасной строке safeString.

6. Функция async для заданий

и

на разных Private последовательных очередях DispatchQueue (label: "com.bestkora.mySerial", qos : .userInitiated) и DispatchQueue (label: "com.bestkora.mySerial", qos : .background):



Результаты на обычной НЕпоточно-безопасной строке usualString, НЕ СОВПАДАЮТ с результатами на поточно-безопасной строке safeString.

7. Функция async для заданий

и

на Private параллельной очереди DispatchQueue (label: "com.bestkora.mySerial", qos : .userInitiated, attributes: .concurrent):



Результаты на обычной НЕпоточно-безопасной строке usualString, НЕ СОВПАДАЮТ с результатами на поточно-безопасной строке safeString.

8. Функция async для заданий

и

на разных Private параллельных очередях с qos : .userInitiated и qos : .background:



Результаты на обычной НЕпоточно-безопасной строке usualString, НЕ СОВПАДАЮТ с результатами на поточно-безопасной строке safeString.

9. Функция asyncAfter (deadline: .now() + 0.0, qos: .userInteractive) c изменением приоритета:



Результаты на обычной НЕпоточно-безопасной строке usualString, НЕ СОВПАДАЮТ с результатами на поточно-безопасной строке safeString.

10. Функция asyncAfter (deadline: .now() + 0.1, qos: .userInteractive) c изменением приоритета:



Результаты на обычной НЕ поточно-безопасной строке usualString, НЕ СОВПАДАЮТ с результатами на поточно-безопасной строке safeString, так как задания

и

разнесены во времени.
Везде, где есть многопоточное выполнение заданий

и

происходит либо на разных очередях, либо на одной, но параллельной (.concurrent) очереди, мы наблюдаем несовпадение обычной строки usualString с поточно-безопасной строкой safeString.

Используя поточно-безопасную строку safeString мы можем взглянуть на свойства очередей и функций sync и async, так сказать, «с высоты птичьего полета», справа приводится время выполнения соответствующих заданий:



Если вы используете не Playground, а приложение, то в Xcode 8 есть возможность использовать Thread Sanitizer для определения race condition. Thread Sanitizer работает на этапе выполнения приложения. Запустить его можно путем редактирования Схемы (Scheme):



Вы видите обнаружение race condition для нашего примера. Код приложения Tsan находится на Github.

ЗАКЛЮЧЕНИЕ.

Мы рассмотрели некоторые примеры использования GCD для решения вопросов многопоточного программирования в Swift 3. Следующая статья будет посвящена вопросам использования Operations в практике многопоточного программирования на Swift 3.

P.S. В настоящее время GCD API доступно на всех платформах, и обеспечивает прекрасный способ создания многопоточных приложений. Но текущая версия Swift 3 не имеет никаких синтаксических конструкций для описания многопоточности. Команда разработчиков Swift планирует взяться за многопоточность более интенсивно и подготовить реальные изменения в синтаксисе многопоточности в версии Swift 5 (2018 г.), начав обсуждение Весной/Летом 2017 г., выпустив "manifesto" к Осени 2017г..

Крис Латнер на Дне Языков программирования в IBM рассказывал, что существующее многопоточное программирование с использование GCD API и async функции приводит к «пирамиде смерти» (pyramid of doom), в которой очень тяжело распознать без комментариев, какие данные/состояния «владеют» какими Dispatch Queue и соответствующими задачами, выполняемыми на этих очередях:



Одно из возможный направлений улучшения многопоточности — это использование Модели акторов (actor models). Каждый actor — это, фактически, DispatchQueue + Состояние, которым эта очередь управляет, + Операции, выполняемые на этой очереди:



Но это всего лишь одно из многих предложений. Предполагается рассмотреть actors, async/await, atomicity, memory models и другие связанные с этим темы. Многопоточность очень важна, так как она «открывает дверь» новым подходам как на клиенте, так и на сервере.

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

Эта статья может быть полезна тем, кто изучает iOS программирование на Swift c помощью стэнфордских курсов CS193p Winter 17 (основанных на iOS 10 и Swift 3), которые размещены на iTunes, ибо там значительный объем многопоточного программирования.

Ссылки


WWDC 2016. Concurrent Programming With GCD in Swift 3 (session 720)
WWDC 2016. Improving Existing Apps with Modern Best Practices (session 213)
WWDC 2015. Building Responsive and Efficient Apps with GCD.
Grand Central Dispatch (GCD) and Dispatch Queues in Swift 3
iOS Concurrency with GCD and Operations
The GCD Handbook
Поваренная книга GCD
Modernize libdispatch for Swift 3 naming conventions
GCD
GCD – Beta
CONCURRENCY IN IOS
www.uraimo.com/2017/05/07/all-about-concurrency-in-swift-1-the-present
All about concurrency in Swift — Part 1: The Present
Tags:
Hubs:
Total votes 24: ↑23 and ↓1+22
Comments14

Articles