Grand Central Dispatch — разбираемся раз и навсегда: Часть 1
Освой Grand Central Dispatch (GCD) в Swift (iOS): изучаем взаимодействие потоков и очередей, синхронное и асинхронное выполнение, качество обслуживания (QoS) и deadlock через практические упражнения. В первой части - терминология, во второй - разбор реальных задач
68 открытий500 показов

Привет! Меня зовут Кирилл Фомин, я iOS-разработчик.
В этой статье мы разберемся со Swift Grand Central Dispatch (GCD) раз и навсегда. Несмотря на то, что GCD может казаться устаревшим в условиях существования Swift Modern Concurrency, код с использованием этого фреймворка будет встречаться еще долгие годы как в проде, так и на собеседованиях.
Сегодня сосредоточимся только на фундаментальном понимании GCD. Мы в деталях рассмотрим самые ключевые концепции в многопоточности, включая взаимодействие потоков и очередей. С осознанием этих основ легко будет самостоятельно разобраться с другими темами: DispatchGroup
, DispatchBarrier
, семафоры, мьютексы и так далее.
Текст будет полезен как новичкам, так и опытным iOS-разработчикам. Я постараюсь объяснять все максимально понятным языком, избегая обилия терминов, но не упуская критичные для понимания моменты.
P.s. Это первая часть материала. В следующей рассмотрим реальные задачи, которые пригодятся вам на собеседованиях
Основные понятия: поток, многопоточность, GCD, задача, очередь
Поток — фактически, контейнер, в который кладется и выполняется какой-то набор инструкций для системы. На самом деле, весь исполняемый код исполняется в каком-то потоке. Различают main (основной, главный) и worker потоки.
Многопоточность — возможность системы выполнять несколько потоков параллельно (одновременно).
Grand Central Dispatch (GCD) — фреймворк для удобной работы с потоками (использования преимуществ многопоточности). Основные примитивы — задача, очередь. GCD — инструмент, позволяющий легко писать параллельно исполняющийся код. Простой пример: вынос тяжелых вычислений в отдельный поток, чтобы не мешать обновлению UI на основном (main) потоке.
Задача — набор инструкций, группируемых разработчиком. Тут важно понять, что разработчик сам решает, какой код будет относиться к конкретной задаче:
Очередь (queue) — основной примитив GCD. Это место, куда разработчик помещает задачи для выполнения. Очередь берет на себя задачу по распределению по потокам (каждая очередь имеет доступ к thread pool — пулу потоков системы).
Фактически, очереди позволяют сфокусироваться на распределении задач по очередям, вместо ручного управления потоками. Когда задача ставится в очередь, она будет выполнена в свободном потоке, часто отличном от того, из которого была поставлена.
Типы очередей
Основная очередь (main
) — выполняется только в основном потоке. Она последовательная (об этом чуть позже).
Глобальные очереди (global
) — 5 очередей (на каждый уровень приоритетности), предоставляемых системой. Они параллельные.
Пользовательские очереди (custom) — создаются пользователем. Разработчик сам выбирает один из 5 приоритетов и тип: последовательная или параллельная (по умолчанию — последовательные).
* здесь создаем пользовательскую параллельную очередь, используя атрибут .
concurrent
.
Приоритеты очередей
Quality of Service (QoS, дословно — качество обслуживания) — система приоритетов очередей. Чем выше приоритет очереди, в которой находится задача, тем больше ресурсов на нее будет выделено. Всего есть 5 QoS:
.userInteractive
Самый высокий приоритет. Используется для задач, требующих немедленного выполнения, но не подходящих для исполнения в основном потоке.
Например, в приложении, позволяющем ретушировать изображения в реальном времени, нужно мгновенно рассчитывать результат ретуши. Но если делать это в main потоке, затруднится обновление интерфейса и обработка жестов пользователя, которые всегда происходят в главном потоке (например, когда пользователь ведет пальцем по области, которую нужно отретушировать, и приложение должно сразу «под пальцем» показывать результат). Таким образом, мы получаем результат настолько быстро, насколько это возможно не в основном потоке.
.userInitiated
Приоритет для задач, требующих быстрой обратной связи, но не настолько критичной, как интерактивные задачи. Обычно используется тогда, когда пользователь понимает, что задача выполнится не мгновенно и придется подождать. Примером может послужить запрос на сервер.
.default
Стандартный приоритет. Присваивается, если разработчик не указал QoS при создании очереди — когда нет специфичных требований к задаче, и приоритет не может быть определен из контекста (например, если вызвать задачу из очереди с приоритетом .userInitiated, она унаследует приоритет и также будет выполняться в очереди .
userInitiated
).
.utility
Приоритет для задач, не требующих обратной связи для пользователя, но необходимых для работы приложения. Например, синхронизация данных с сервером или запись автосохранения на диск.
.background
Для задач с самым низким приоритетом. Пример — очистка кэша.
Последовательные и параллельные очереди
Все очереди делятся на 2 типа: последовательные (serial
) и параллельные (concurrent
).
Последовательные (serial
) очереди — когда следующая задача начинает выполняться только после окончания текущей.
Параллельные (concurrent
) очереди — когда следующая задача начинает выполнение сразу при выделении ресурсов, вне зависимости от статуса завершения предыдущих. Важно обратить внимание, что тут гарантируется только то, что задача, которая была поставлена на выполнение раньше, начнет свое выполнение раньше, но порядок окончания у нее — любой.
Способы выполнения задач
Важно заметить, что сейчас речь пойдет про способы выполнения задач относительно вызывающего потока. То есть, способ вызова отвечает за развитие событий в потоке, из которого мы добавляем задачу в очередь.
Асинхронно (async)
Вызов, при котором вызывающий поток не блокируется (то есть не ждет выполнения задачи, которую он добавляет в очередь).
В этом примере мы асинхронно добавляем задачу на print
из главного потока (так как мы пишем этот код, не находясь ни в какой очереди, стандартно он исполняется в главном потоке) в основную очередь. Ставим print(“A”)
в основную очередь и сразу же идем дальше, не ожидая ее исполнения — выполняем print(“B”)
.
Поскольку главный поток занят выполнением текущего кода, а задачи из main очереди могут выполняться только в главном потоке, сначала завершается текущая задача — print(“B”)
, а только потом, после освобождения главного потока, выполняется задача из основной очереди — print(“A”)
. Таким образом, вывод — BA.
Логика выполнения:
1. Добавляем задачу в глобальную очередь с приоритетом .default из основного потока асинхронно — в вызывающем потоке сразу идем дальше и вызываем indicateLoading()
.
2. Через какой-то промежуток времени система выделяет ресурсы для исполнения задачи в глобальной очереди и начинает ее выполнение в свободном потоке из thread pool. Вызывается метод updateData()
.
3. Задача с updateInterface()
добавляется в основную очередь асинхронно — в вызывающем потоке не ждем ее исполнения и идем дальше.
4. Когда добавляем задачи асинхронно, не можем точно сказать, когда на них будут выделены ресурсы. В конкретном случае неизвестно, что произойдет раньше: выполнение updateInterface()
в основном потоке или Logger.log(.success)
в одном из worker потоков из thread pool (как не можем и в шагах 1-2: первым выполнится indicateLoading()
в основном или updateData()
в worker). Основной поток хоть и не стоит без дела (в нем выполняется обновление интерфейса, обработка жестов и другие подкапотные задачи), но он работает всегда и на него выделено максимальное количество системных ресурсов. С другой стороны, выделение ресурсов для выполнения в workerthread может произойти почти мгновенно.
Заметьте, что в этом видео глобальная очередь выполняет свои задачи в каких-то свободных worker потоках.
Синхронно (sync)
Вызов, при котором вызывающий поток останавливается и ожидает выполнения задачи, которую он добавил в очередь.
Здесь мы из worker потока, на котором выполнялась задача из глобальной очереди, синхронно ставим на выполнение увеличение баланса в пользовательской очереди. Текущий поток блокируется и ждет выполнения поставленной в очередь задачи. Таким образом, вывод баланса произойдет только после того, как завершится задача в пользовательской очереди на его увеличение.
Заметьте, что в этой анимации пользовательская очередь выполняет свои задачи в каких-то свободных worker потоках.
Deadlock
Deadlock возникает, когда поток бесконечно ждет сам себя или другой поток для продолжения работы. Классический пример — вызов DispatchQueue.main.sync {} из главного потока.
В этом случае:
- Главный поток выполняет
printing()
. DispatchQueue.main.sync { print("B") }
пытается выполнить код синхронно, но main-очередь может выполняться только в главном потоке.- Главный поток уже заблокирован и ждет завершения
print("B")
, но эта задача не может начаться, так как поток занят.
В итоге возникает deadlock — выполнение программы зависает.
Заметьте, как print(“B”)
из основной очереди не может выполниться, поскольку задача из main очереди может выполняться только на main потоке, но он заблокирован.
Освоение этих базовых концепций необходимо для создания производительных и стабильных приложений, а также для предотвращения распространенных ошибок по типу deadlock.
Надеюсь, Вы нашли для себя что-то полезное в этой статье. Если какие-то вещи остались непонятными, я могу бесплатно помочь с разбором: telegram @kfamyn.
68 открытий500 показов