Итак, у вас есть приложение, оно работает на
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://ift.tt/29JL7Q3")!
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
— для заданий, которые инициируются пользователем и требуют обратной связи, но это не внутри интерактивного события, пользователь ждет обратной связи, чтобы продолжить взаимодействие; может занять несколько секунд; имеет высокий приоритет, но ниже, чем у предыдущей очереди,.utulity
— для заданий, которые требуют некоторого времени для выполнения и не требуют немедленной обратной связи, например, загрузка данных или очистка некоторой базы данных. Делается что-то, о чем пользователь не просит, но это необходимо для данного приложения. Задание может занять от несколько секунд до нескольких минут; приоритет ниже, чем у предыдущей очереди,.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
) queues
— userInteractiveQueue
, 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
:
- Эйфелева башня
- Венеция
- Шотландский замок
- Спутник «Кассини» — загружается из сети значительно дольше остальных
- Эйфелева башня
- Венеция
- Шотландский замок
- Арктика
Если мы запустим приложение и начнем прокручивать достаточно быстро вниз с тем, чтобы увидеть все 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 2016-17 (основанных на iOS 10 и Swift 3), которые будут размещены на iTunes в конце января 2017 — начале февраля 2017, ибо там предполагается значительный объем многопоточного программирования.
Ссылки:
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
Комментарии (0)