GCD: очереди, QoS и главный поток
Три вопроса про GCD, которые задают почти на каждом собеседовании.
Очередь — это абстракция порядка выполнения, а не поток. Вы кладёте блок в очередь, а GCD сам берёт поток из общего пула и возвращает его обратно.
«Чем serial-очередь отличается от concurrent?»
Что на самом деле проверяют
Интервьюер хочет услышать не определение из документации, а понимание модели: что очередь гарантирует по порядку и кто вообще управляет потоками. Кандидат, который путает очередь и поток, дальше обязательно поплывёт на вопросах про deadlock и thread explosion.
Развёрнутый ответ
Serial-очередь запускает следующую задачу только после завершения предыдущей. Отсюда два гарантированных свойства: строгий порядок (FIFO) и взаимное исключение — две её задачи никогда не выполняются одновременно, а значит очередь работает вместо мьютекса.
Concurrent-очередь тоже забирает задачи в порядке FIFO, но стартует их параллельно. Порядок старта сохраняется, порядок завершения — нет.
let serial = DispatchQueue(label: "io.codechick.serial")
let concurrent = DispatchQueue(label: "io.codechick.concurrent",
attributes: .concurrent)
for i in 1...3 { serial.async { work(i) } } // 1 -> 2 -> 3, строго
for i in 1...3 { concurrent.async { work(i) } } // стартуют вместе
DispatchQueue.main — тоже serial-очередь, но особая: она намертво привязана к главному потоку и обслуживается его run loop. Именно поэтому «выполнить на main queue» и «выполнить на главном потоке» — одно и то же.
Барьеры: дешёвый reader-writer
Флаг .barrier на своей concurrent-очереди временно превращает её в serial: барьерный блок ждёт завершения всех ранее поставленных задач и не пускает следующие. Классика — кэш: читаем параллельно, пишем эксклюзивно.
final class Cache {
private var storage: [String: Data] = [:]
private let queue = DispatchQueue(label: "cache", attributes: .concurrent)
func value(for key: String) -> Data? { queue.sync { storage[key] } }
func set(_ data: Data, for key: String) {
queue.async(flags: .barrier) { self.storage[key] = data }
}
}
«Что будет, если обновить UI не на main thread?»
Что на самом деле проверяют
Знаете ли вы, что UIKit не потокобезопасен, и понимаете ли почему — а не просто заучили правило.
Развёрнутый ответ
UIKit почти не использует блокировок: он рассчитан на один поток. Изменения свойств вью попадают в CATransaction, которая неявно открывается и коммитится на каждом витке run loop главного потока. Правки из фонового потока ложатся в чужую или вообще не закоммиченную транзакцию. Симптомы плавающие и оттого противные:
- случайные крэши в глубине CoreAnimation, не воспроизводимые локально;
- «мигающий» UI: изменение применяется с задержкой в кадр или теряется совсем, layout рассыпается;
- в отладке — ассерт Main Thread Checker вида «UIView.setNeedsLayout must be used from main thread only».
Отсюда приём «тяжёлое — в фон, UI — на main»:
DispatchQueue.global(qos: .userInitiated).async {
let items = parse(rawData) // тяжёлый разбор в фоне
DispatchQueue.main.async {
self.items = items // публикация результата
self.tableView.reloadData()
}
}
Дождаться нескольких независимых загрузок помогает DispatchGroup: enter()/leave() на каждую задачу и notify(queue: .main) в конце.
let group = DispatchGroup()
group.enter(); api.loadProfile { profile = $0; group.leave() }
group.enter(); api.loadFeed { feed = $0; group.leave() }
group.notify(queue: .main) { render(profile, feed) }
«Что такое sync и почему DispatchQueue.main.sync из главного потока — это deadlock?»
Что на самом деле проверяют
Понимание блокирующей семантики и умение объяснить взаимную блокировку словами.
Развёрнутый ответ
async кладёт блок в очередь и сразу возвращает управление. sync кладёт блок и блокирует вызывающий поток до его завершения.
Подставим main. Вызов DispatchQueue.main.sync с главного потока означает: поток встал и ждёт, пока блок выполнится на главной очереди. Но эту очередь обслуживает тот же самый поток, который сейчас заблокирован и не вернулся в run loop. Блок никогда не стартует — deadlock из одного участника.
// на главном потоке
DispatchQueue.main.sync { print("никогда") } // мгновенный deadlock
// безопасный вариант
if Thread.isMainThread { render() }
else { DispatchQueue.main.async { render() } }
QoS, priority inversion и thread explosion
DispatchQueue.global(qos:) даёт пять уровней: .userInteractive (кадр анимации), .userInitiated (пользователь ждёт результат сейчас), .default, .utility (прогресс-бар, секунды и минуты) и .background (синхронизация, индексация — экономит батарею). QoS — не «сделай быстрее», а подсказка планировщику, как делить CPU, I/O и таймеры.
Priority inversion — когда высокоприоритетная задача ждёт ресурс, занятый низкоприоритетной, и исполняется с её скоростью. GCD умеет временно поднимать приоритет «виновника», но полагаться на это не стоит.
Thread explosion — вторая беда. Каждый блокирующийся блок (sync, семафор, синхронный сетевой вызов) занимает поток целиком, GCD поднимает новые потоки, и пул упирается в лимит около 64: память под стеки съедена, процессор занят переключением контекста. Сотня DispatchSemaphore.wait() в concurrent-очереди кладёт приложение на ровном месте.
Типичные ошибки кандидатов
- Говорят «concurrent-очередь создаёт потоки» — их создаёт GCD, очередь лишь описывает политику.
- Считают
mainconcurrent-очередью, «потому что на нём много всего крутится». - Объясняют deadlock фразой «main занят», не упоминая, что sync блокирует именно вызывающий поток.
- Оборачивают UI-код в
DispatchQueue.main.sync«чтобы наверняка» и ставят всему.userInteractive, считая QoS ускорителем. - Пытаются применить
.barrierкDispatchQueue.global()— на глобальных очередях он не работает.
Как ответить кратко
Serial-очередь выполняет задачи по одной и даёт порядок плюс взаимное исключение, concurrent стартует их параллельно и гарантирует только порядок старта. Очередь — это не поток: потоки выдаёт пул GCD.
UIKit не потокобезопасен, отрисовка идёт через CATransaction на run loop главного потока. Обновление UI из фона даёт плавающие крэши, потерянные кадры и ассерт Main Thread Checker. Считаем в фоне, публикуем через main.async.
sync блокирует вызывающий поток до конца блока. С главного потока main.sync — deadlock: поток ждёт блок, выполнить который может только он сам.