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

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

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

11 мин чтения11 подробных ответовОбновлено
Главная мысль

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

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

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

Проверка по первоисточникам опубликована для 3 из 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 против wait — notify планирует блок и возвращается сразу; 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: isReady → isExecuting → isFinished
Лимит параллелизма напрямую нет maxConcurrentOperationCount
Приоритеты QoS очереди/блока qualityOfService + queuePriority
Переиспользование замыкания подклассы Operation, тестируемые отдельно
  1. Отмена везде кооперативна — и в Operation длинный цикл обязан сам периодически проверять isCancelled и выходить.
  2. Классический follow-up — асинхронная Operation (обёртка над сетевым вызовом) требует ручного конечного автомата: переопределить isAsynchronous, самому слать KVO-уведомления для isExecuting/isFinished — иначе очередь сочтёт операцию завершённой сразу после main().

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

07

Когда ошибка дочерней задачи отменяет соседние задачи в Swift?

Короткий ответ: В withThrowingTaskGroup сама ошибка дочерней задачи не отменяет соседей. Ошибка наблюдается через next() или обход результатов. Если она выходит из тела группы наружу, группа отменяет остальных детей и ждёт их завершения перед возвратом. Отмена кооперативна.

Подробно:

struct Failure: Error {}

try await withThrowingTaskGroup(of: Void.self) { group in
    group.addTask { throw Failure() }
    // Здесь ошибка ребёнка наблюдается и выходит из тела группы.
    try await group.next()
}

Если тело перехватит ошибку и продолжит работу, соседние задачи не отменятся автоматически. Если результат упавшего ребёнка вообще не прочитать, группа может завершиться без выбрасывания его ошибки. Вызывайте group.cancelAll(), если ваша политика требует отмены оставшейся работы.

Структурность отдельно гарантирует время жизни: группа не вернётся, пока её дети выполняются. Отмена родителя распространяется вниз по дереву, но дети должны сотрудничать — например, вызывать Task.checkCancellation() или использовать точки приостановки с поддержкой отмены.

У async let ошибка наблюдается при ожидании значения с try. При выходе из области оставшиеся дети отменяются и ожидаются; ненаблюдённая ошибка ребёнка сама по себе не выбрасывается из родителя.

Важно: Отмена не означает принудительное завершение. Ребёнок, игнорирующий отмену, может задержать возврат всей группы.

Источник: Apple: throwing task groups и обработка ошибок.

08

Как вынести тяжёлую async-работу с MainActor в Swift 6.2?

Короткий ответ: Пометьте async-функцию @concurrent, чтобы явно выполнять её вне актора вызывающего кода. В Swift 6.2 с настройкой NonisolatedNonsendingByDefault обычная nonisolated async-функция остаётся на акторе вызывающего кода и не гарантирует вынос тяжёлого парсинга с MainActor.

Подробно:

import Foundation

struct Item: Decodable, Sendable { let id: Int }

@concurrent
nonisolated func parseOffMain(_ data: Data) async throws -> [Item] {
    try JSONDecoder().decode([Item].self, from: data)
}

@MainActor
func loadItems(_ data: Data) async throws -> [Item] {
    try await parseOffMain(data)
}

Нужен Swift 6.2+. @concurrent выбирает generic executor, а не конкретный поток. Вызывающий код ожидает результат, не выполняя синхронный парсинг на своём акторе. Значения, пересекающие границы изоляции, должны проходить применимые concurrency-проверки.

При прежнем поведении Swift, когда NonisolatedNonsendingByDefault выключен, nonisolated async переключается на generic executor. При включённой настройке для явного переключения используйте @concurrent; отдельный актор подходит работнику с собственным изменяемым состоянием.

Task {}, созданный в MainActor-изолированном коде, наследует эту изоляцию. Обёртка вокруг синхронного парсинга сама по себе не переносит работу с MainActor. Task.detached неструктурирован и требует явного управления отменой и временем жизни; это не универсальное исправление.

Источник: Swift SE-0461: изоляция async-функций.

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 означает, что значение допускает безопасное использование между доменами изоляции. Swift 6 проверяет такие переходы и изоляцию изменяемого состояния. Не каждый перенос не-Sendable значения запрещён: анализ регионов изоляции может доказать безопасную передачу, если исходный домен больше не обращается к значению.

Подробно:

struct User: Sendable { let id: Int }

final class Token: Sendable {
    let value: String
    init(value: String) { self.value = value }
}

final class Packet { var value = 0 }
actor Inbox { func accept(_ packet: Packet) {} }
func transfer(to inbox: Inbox) async {
    let packet = Packet()
    await inbox.accept(packet) // безопасная передача отдельного региона
    // Не используем packet повторно в исходном домене.
}
  1. Проверяемое соответствие — у struct/enum хранимые значения должны быть Sendable. Неявное соответствие выводится лишь в предусмотренных языком случаях; публичному API часто требуется явное объявление. Обычный final класс с неизменяемыми Sendable-полями может явно объявить Sendable, но не получает его автоматически только из-за let.
  2. Изоляция — акторы и типы с глобальным актором имеют особые гарантии. @MainActor означает изоляцию на главном акторе, а не разрешение обращаться к состоянию отовсюду.
  3. Передача владения — анализ регионов и параметр sending позволяют безопасные передачи некоторых не-Sendable значений. Нельзя оставлять конфликтующие обращения в исходном домене.
  4. @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 возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.

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

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

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

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

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

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