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, очередь лишь описывает политику.
  • Считают main concurrent-очередью, «потому что на нём много всего крутится».
  • Объясняют 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: поток ждёт блок, выполнить который может только он сам.

Проверьте себя
1. Что гарантирует concurrent-очередь GCD?
AПорядок старта задач, но не порядок их завершения
BИ порядок старта, и порядок завершения задач
CЧто каждой задаче достанется отдельный поток на всё время работы
DВзаимное исключение: две задачи очереди не выполняются одновременно
2. Почему вызов DispatchQueue.main.sync с главного потока приводит к deadlock?
AПотому что главная очередь concurrent и не принимает синхронные блоки
BПотому что sync блокирует вызывающий поток, а выполнить блок может только он же
CПотому что у главной очереди QoS .userInteractive, а он запрещает sync
DПотому что GCD запрещает вкладывать блоки друг в друга
3. Что такое thread explosion в GCD?
AКрэш при обновлении UI из фонового потока
BСитуация, когда задача с высоким QoS ждёт задачу с низким
CРост числа потоков пула из-за блокирующих ожиданий внутри задач
DАвтоматическое повышение QoS у заблокированной очереди