Перейти к содержанию
Мобильная разработка

11 вопросов по теме «iOS: GCD и Swift Concurrency» на собеседовании

В этом материале — 11 вопросов из русской колоды RecallDeck по теме «iOS: GCD и Swift Concurrency». Сначала сформулируйте короткий ответ сами, затем откройте подробный разбор и проверьте примеры, ограничения и отказные случаи.

11 мин чтения11 подробных ответовПроверено 24 августа 2026
Главная мысль

Привязывайте платформенное решение к lifecycle, состоянию UI, обратной связи, доступности и цене поддержки для команды.

Вопросы и ответы

11 подробных ответов

01

Чем serial очередь отличается от concurrent, что такое main и global — и почему очередь это не поток?

Короткий ответ: Очередь — абстракция «список задач», а не поток: GCD мультиплексирует очереди по общему пулу потоков. Serial выполняет задачи строго по одной в порядке FIFO, concurrent запускает несколько параллельно. Main — единственная очередь с контрактом «выполняюсь на мейн-треде»; global — системные concurrent очереди, по одной на класс QoS.

Подробно:

Очередь Тип Особенность
DispatchQueue.main serial привязана к мейн-треду; весь UI
DispatchQueue.global(qos:) concurrent системные, общие на приложение
DispatchQueue(label:) serial по умолчанию своя; attributes: .concurrent — параллельная
QoS Для чего
.userInteractive анимации, реакция на жест — «прямо сейчас»
.userInitiated пользователь ждёт результат (открытие экрана)
.utility долгая работа с прогрессом (загрузка файла)
.background невидимая работа: синк, индексация
  1. Очередь ≠ поток — serial очередь не владеет потоком: её задачи могут выполняться на разных потоках пула, но никогда одновременно.
  2. QoS решает планирование — системе QoS говорит, сколько CPU и энергии дать: .userInteractive — вне очереди, .background может подождать.

⚠️ Частая ошибка: «создал очередь — создал поток». Нет: тысячи очередей обслуживаются десятком потоков пула. А вот заблокировать много потоков через sync-ожидания — путь к thread explosion.

02

Что произойдёт при вызове DispatchQueue.main.sync с мейн-треда?

Короткий ответ: Дедлок и краш. sync блокирует текущий поток до выполнения блока, а блок ждёт своей очереди на main — serial очереди, чей единственный поток мы только что заблокировали. Никто никого не дождётся.

Подробно:

// уже выполняемся на мейн-треде:
DispatchQueue.main.sync {
    print("сюда не дойдём")
}
// 💥 EXC_BAD_INSTRUCTION — дедлок
  1. Механикаsync = «поставь блок в очередь и жди завершения». Main — serial: следующий блок начнётся только после текущего. Но текущий (наш код) заблокирован ожиданием → взаимное ожидание навсегда.
  2. С фонового потока — легальноDispatchQueue.main.sync { ... } из global очереди корректен: блокируется фоновый поток, мейн свободен и выполнит блок. Так иногда синхронно читают UI-состояние, хотя async почти всегда уместнее.
  3. Диагностика вместо догадокdispatchPrecondition(condition: .onQueue(.main)) проверяет очередь явно, без «sync на всякий случай».

⚠️ Частая ошибка: хелпер «если не мейн — sync на мейн, иначе выполнить сразу», который однажды вызывают из кода, уже стоящего в sync-цепочке. Правило общее: sync на текущую serial очередь — всегда дедлок, main — лишь самый частый случай.

03

Что напечатает этот код: async и sync на main и global очередях?

Короткий ответ: 1 3 5 6 2 4 7. Всё решают два правила: sync выполняет блок немедленно, блокируя текущий поток, а main.async лишь ставит блок в очередь — он не начнётся, пока текущий код на мейне не завершится.

Подробно:

// выполняется на мейн-треде
print("1")
DispatchQueue.main.async { print("2") }
DispatchQueue.global().sync {
    print("3")
    DispatchQueue.main.async { print("4") }
    print("5")
}
print("6")
DispatchQueue.main.async { print("7") }
  1. 1 — обычный синхронный вызов.
  2. main.async { 2 } — блок встаёт в очередь main и ждёт: у мейна сейчас выполняется текущий код.
  3. global().sync — мейн блокируется, блок выполняется сразу (как правило прямо на мейн-треде: GCD не переключает поток без необходимости). Печатается 3, main.async { 4 } — снова только в очередь, затем 5.
  4. 6 — продолжение после возврата из sync.
  5. main.async { 7 } — третий блок в очередь main.
  6. Текущий код завершился — main разбирает накопленное в порядке FIFO: 2, 4, 7.

⚠️ Частая ошибка: ответить «2 сразу после 1». async на ту же очередь никогда не выполняется немедленно, даже если очередь «свободна»: сначала обязан завершиться текущий блок на ней.

04

Четыре потока инкрементируют общий счётчик по 100 раз, а печатается меньше 400. Почему, и как это чинится?

Короткий ответ: counter += 1 — это чтение, инкремент и запись: три шага без атомарности. Потоки читают одно и то же старое значение и затирают записи друг друга — классическая гонка (data race). Чинится сериализацией доступа: serial очередь, барьер, лок или актор.

Подробно:

var counter = 0
DispatchQueue.concurrentPerform(iterations: 4) { _ in
    for _ in 0..<100 { counter += 1 }   // read-modify-write — не атомарно
}
print(counter)   // например, 317 — и каждый запуск по-разному

Способы починить:

  1. Serial очередь — весь доступ через queue.sync { counter += 1 }.
  2. Concurrent + барьер — чтения параллельно, запись эксклюзивно:
@propertyWrapper final class Atomic<Value> {
    private let queue = DispatchQueue(label: "atomic", attributes: .concurrent)
    private var value: Value
    init(wrappedValue: Value) { value = wrappedValue }
    var wrappedValue: Value {
        get { queue.sync { value } }
        set { queue.async(flags: .barrier) { self.value = newValue } }
    }
}
  1. NSLock / os_unfair_lock — минимум накладных расходов.
  2. Actor — решение уровня языка: компилятор сам не пустит к состоянию мимо изоляции.

Ловить гонки — Thread Sanitizer (схема → Diagnostics → TSan).

⚠️ Частая ошибка: верить, что такой @Atomic чинит counter += 1. Это отдельный get и отдельный set — между ними вклинится другой поток. Атомарной должна быть вся операция read-modify-write, а не свойство по кусочкам.

05

Как дождаться завершения N асинхронных операций: DispatchGroup и семафоры?

Короткий ответ: DispatchGroup: перед запуском операции — enter(), в completion — leave(), результат забираем через notify(queue:) без блокировки. DispatchSemaphore(value: N) — чтобы ограничить число одновременных операций. Блокирующий wait() на мейне — замороженный UI или дедлок.

Подробно:

let group = DispatchGroup()
for url in urls {
    group.enter()
    load(url) { result in
        defer { group.leave() }   // leave гарантирован на всех путях выхода
        store(result)
    }
}
group.notify(queue: .main) { updateUI() }   // не блокирует

// семафор: не больше 3 загрузок одновременно
let sem = DispatchSemaphore(value: 3)
for url in urls {
    worker.async {
        sem.wait()
        defer { sem.signal() }
        download(url)
    }
}
  1. Симметрия enter/leave — ровно один leave() на каждый enter(); defer спасает от раннего return в completion. Лишний leave — краш, недостающий — notify не случится никогда.
  2. notify против waitnotify планирует блок и возвращается сразу; wait() останавливает текущий поток (терпимо на фоновом, лучше с таймаутом).

⚠️ Частая ошибка: group.wait() или sem.wait() на мейне «чтобы дождаться сети». Если completion тоже приходит на main — дедлок; если нет — просто фриз UI на всё время ожидания. На мейне — только notify.

06

GCD против OperationQueue: когда Operation стоит своих накладных расходов?

Короткий ответ: OperationQueue — надстройка над GCD, превращающая задачу в объект: отмена, зависимости, наблюдаемые состояния, лимит параллелизма. Берите её, когда задачами нужно управлять после запуска; для fire-and-forget достаточно GCD.

Подробно:

GCD OperationQueue
Отмена DispatchWorkItem.cancel() — лишь флаг до старта cancel() / cancelAllOperations() + проверка isCancelled
Зависимости вручную: group, семафоры addDependency(_:), даже между разными очередями
Состояния KVO: isReadyisExecutingisFinished
Лимит параллелизма напрямую нет maxConcurrentOperationCount
Приоритеты QoS очереди/блока qualityOfService + queuePriority
Переиспользование замыкания подклассы Operation, тестируемые отдельно
  1. Отмена везде кооперативна — и в Operation длинный цикл обязан сам периодически проверять isCancelled и выходить.
  2. Классический follow-up — асинхронная Operation (обёртка над сетевым вызовом) требует ручного конечного автомата: переопределить isAsynchronous, самому слать KVO-уведомления для isExecuting/isFinished — иначе очередь сочтёт операцию завершённой сразу после main().

⚠️ Частая ошибка: сравнивать их по скорости. Внутри OperationQueue — тот же GCD; вопрос не «что быстрее», а нужен ли задаче жизненный цикл: отмена, зависимости, наблюдаемость.

07

Что даёт «структурность» в structured concurrency по сравнению с GCD?

Короткий ответ: Дерево задач. Дочерние задачи (async let, TaskGroup) наследуют приоритет и отмену родителя и не могут пережить его скоуп — компилятор гарантирует, что функция не вернётся, пока живы её дети. В GCD время жизни работы никак не привязано к коду, который её породил.

Подробно:

func loadDashboard() async throws -> Dashboard {
    async let user = fetchUser()   // дочерняя задача — стартует параллельно
    async let feed = fetchFeed()
    return try await Dashboard(user: user, feed: feed)
}   // выйти из функции, бросив детей, невозможно

try await withThrowingTaskGroup(of: Image.self) { group in
    for url in urls {
        group.addTask { try await download(url) }  // наследует приоритет и отмену
    }
    for try await image in group { save(image) }
}
  1. Отмена — каскадом — отменили родителя, флаг получили все дети. Но отмена кооперативна: сама собой никакая работа не останавливается, длинный код обязан проверять try Task.checkCancellation().
  2. Ошибки — вверх по дереву — упавший ребёнок отменяет братьев, ошибка всплывает в await родителя.
  3. КонтрастTask.detached и GCD-блок живут сами по себе: отмену, приоритет и ожидание завершения вы прокидываете руками.

⚠️ Частая ошибка: ждать, что отмена «убьёт» задачу. Cancel лишь взводит isCancelled; задача, которая не проверяет флаг, спокойно доработает до конца.

08

Task {} против Task.detached {}: что наследуется и когда detached оправдан?

Короткий ответ: Task {} наследует контекст актора, приоритет и task-local значения места создания; Task.detached не наследует ничего. Отсюда ловушка: Task {} внутри @MainActor-кода выполняется на MainActor — «фоновая» работа на самом деле блокирует UI. Detached — редкий инструмент для действительно независимой работы.

Подробно:

@MainActor final class ViewModel: ObservableObject {
    @Published var items: [Item] = []

    func reload() {
        Task {
            // унаследовал MainActor: парсинг идёт на мейне — UI виснет
            items = parseHugeJSON()
        }
    }

    nonisolated func parseOffMain() async -> [Item] {
        parseHugeJSON()   // без изоляции — уйдёт на кооперативный пул потоков
    }
}
Наследуется Task {} Task.detached {}
Актор-контекст
Приоритет ✗ (задаёте сами)
Task-local значения
Структурность нет — обе unstructured нет
  1. Правильный способ снять работу с мейнаnonisolated-метод или отдельный актор, а не detached: сохраняются приоритет и task-locals.
  2. Когда detached честен — работа, независимая от контекста запуска: логирование, прогрев кэша, запуск из синхронного кода без актора.

⚠️ Частая ошибка: лечить «Task блокирует UI», рассыпая detached по коду. Теряются приоритет (риск инверсии) и MainActor-гарантии — а хватило бы nonisolated func.

09

Что изолируют акторы, что такое reentrancy и зачем нужен MainActor?

Короткий ответ: Актор сериализует доступ к своему мутабельному состоянию: снаружи обращения идут через await, одновременно выполняется максимум один метод. Но акторы реентерабельны: на каждом await внутри метода актор может принять другой вызов — состояние после await нужно перепроверять. @MainActor — глобальный актор поверх мейн-треда, дом для UI-состояния.

Подробно:

actor ImageCache {
    private var cache: [URL: Image] = [:]

    func image(for url: URL) async throws -> Image {
        if let cached = cache[url] { return cached }
        // точка приостановки: пока качаем, актор выполняет другие вызовы
        let image = try await download(url)
        // сюда могли прийти раньше нас — без перепроверки скачаем дважды
        cache[url] = image
        return image
    }
}
  1. Изоляция — гонки на данных актора исключены компилятором, а не дисциплиной команды.
  2. Reentrancy — защита от дедлоков ценой интерливинга: инварианты, прочитанные до await, после него не гарантированы. Классическое лечение дубль-загрузки — словарь in-flight Task-ов: повторный вызов ждёт уже запущенную задачу.
  3. nonisolated — для членов, не трогающих состояние (чистые вычисления, Hashable): вызываются без await.

⚠️ Частая ошибка: «актор — это мьютекс вокруг всего метода». Нет: эксклюзив держится только между точками приостановки. Критическая секция актора заканчивается на первом же await.

10

Что на самом деле проверяет компилятор про Sendable в строгом режиме Swift 6?

Короткий ответ: Sendable — маркер «значение безопасно пересекать границу изоляции»: между акторами, в Task, между потоками. В строгом режиме компилятор проверяет каждое такое пересечение: не-Sendable значение, улетающее в другой домен изоляции, — ошибка компиляции. Потенциальная гонка ловится до запуска.

Подробно:

struct User: Sendable { let id: Int }        // ✓ авто: value-тип из Sendable-полей

final class Token: Sendable {                // ✓ final + только let-поля
    let value: String
    init(value: String) { self.value = value }
}

final class Cache: @unchecked Sendable {     // «поверь мне»: синхронизация — ваша забота
    private let lock = NSLock()
    private var storage: [String: Data] = [:]
}
  1. Sendable автоматически — структуры и enum из Sendable-полей, акторы (по определению), final классы, где все поля let Sendable-типов.
  2. Не Sendable — классы с var, замыкания, захватившие мутабельное состояние.
  3. Честные фиксы ошибок строгого режима — перевести модель на value-тип; спрятать состояние в актор; UI-модели пометить @MainActor; @unchecked Sendable — только когда синхронизация реально написана (лок внутри).

⚠️ Частая ошибка: глушить ошибки, рассыпая @unchecked Sendable. Это не фикс, а расписка «гонки здесь мои»: компилятор перестаёт проверять ровно то место, где гонка и жила.

11

Почему Timer не срабатывает на фоновом потоке, и при чём здесь RunLoop?

Короткий ответ: Timer — источник событий RunLoop, а не самостоятельный механизм: чтобы он сработал, на потоке должен крутиться RunLoop. У мейн-треда он запущен всегда; потоки GCD-пула его не крутят — таймер молча не сработает никогда.

Подробно:

// ❌ молча не работает: на потоке пула RunLoop не запущен
DispatchQueue.global().async {
    Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
        print("tick")   // никогда
    }
}

// ✓ без RunLoop: DispatchSourceTimer живёт на очереди
let timer = DispatchSource.makeTimerSource(queue: .global())
timer.schedule(deadline: .now() + 1, repeating: 1)
timer.setEventHandler { print("tick") }
timer.resume()   // и держите сильную ссылку на timer
  1. RunLoop — цикл «жди событие → обработай → спи», обслуживающий таймеры, perform-селекторы и часть делегатов старых API (NSStream, legacy URLConnection).
  2. Современные заменыDispatchSourceTimer, Task.sleep в цикле, AsyncTimerSequence — им RunLoop не нужен.
  3. Второй классический вопрос — режимыscheduledTimer вешает таймер в .default mode, а во время скролла мейновский RunLoop уходит в .tracking: таймер замирает. Лечение: RunLoop.main.add(timer, forMode: .common).

⚠️ Частая ошибка: «починить» фоновый таймер вызовом RunLoop.current.run() — и навсегда занять поток циклом без условия выхода. Если работа периодическая — DispatchSourceTimer проще и безопаснее.

Источники

Источники и редакционная политика

Материалы RecallDeck сопоставлены с официальной документацией и открытыми публикациями компаний, когда первичный источник доступен. Мы не связаны с упомянутыми работодателями, не публикуем конфиденциальные задания и не продаём места в подборках. Формат найма может меняться — уточняйте его у рекрутера.

От чтения к воспроизведению

Отрепетируйте полный цикл интервью.

RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.

Начать подготовку

Продолжить подготовку

Мобильная разработка8 мин

10 вопросов по теме «Android: Компоненты и жизненный цикл» на собеседовании

10 вопросов по теме «Android: Компоненты и жизненный цикл» с подробными ответами, примерами, компромиссами и частыми ошибками кандидатов на техническом интервью.

10 подробных ответов
Библиотека собеседований RecallDeck

Подробные русские ответы, разборы этапов найма и планы подготовки для российского IT-рынка.

RSS