Привязывайте платформенное решение к lifecycle, состоянию UI, обратной связи, доступности и цене поддержки для команды.
Вопросы и ответы
11 подробных ответов
01Чем serial очередь отличается от concurrent, что такое main и global — и почему очередь это не поток?
junior
Короткий ответ: Очередь — абстракция «список задач», а не поток: 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 |
невидимая работа: синк, индексация |
- Очередь ≠ поток — serial очередь не владеет потоком: её задачи могут выполняться на разных потоках пула, но никогда одновременно.
- QoS решает планирование — системе QoS говорит, сколько CPU и энергии дать:
.userInteractive— вне очереди,.backgroundможет подождать.
⚠️ Частая ошибка: «создал очередь — создал поток». Нет: тысячи очередей обслуживаются десятком потоков пула. А вот заблокировать много потоков через sync-ожидания — путь к thread explosion.
02Что произойдёт при вызове DispatchQueue.main.sync с мейн-треда?
junior
Короткий ответ: Дедлок и краш. sync блокирует текущий поток до выполнения блока, а блок ждёт своей очереди на main — serial очереди, чей единственный поток мы только что заблокировали. Никто никого не дождётся.
Подробно:
// уже выполняемся на мейн-треде:
DispatchQueue.main.sync {
print("сюда не дойдём")
}
// 💥 EXC_BAD_INSTRUCTION — дедлок
- Механика —
sync= «поставь блок в очередь и жди завершения». Main — serial: следующий блок начнётся только после текущего. Но текущий (наш код) заблокирован ожиданием → взаимное ожидание навсегда. - С фонового потока — легально —
DispatchQueue.main.sync { ... }из global очереди корректен: блокируется фоновый поток, мейн свободен и выполнит блок. Так иногда синхронно читают UI-состояние, хотяasyncпочти всегда уместнее. - Диагностика вместо догадок —
dispatchPrecondition(condition: .onQueue(.main))проверяет очередь явно, без «sync на всякий случай».
⚠️ Частая ошибка: хелпер «если не мейн — sync на мейн, иначе выполнить сразу», который однажды вызывают из кода, уже стоящего в sync-цепочке. Правило общее: sync на текущую serial очередь — всегда дедлок, main — лишь самый частый случай.
03Что напечатает этот код: async и sync на main и global очередях?
middle
Короткий ответ: 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— обычный синхронный вызов.main.async { 2 }— блок встаёт в очередь main и ждёт: у мейна сейчас выполняется текущий код.global().sync— мейн блокируется, блок выполняется сразу (как правило прямо на мейн-треде: GCD не переключает поток без необходимости). Печатается3,main.async { 4 }— снова только в очередь, затем5.6— продолжение после возврата из sync.main.async { 7 }— третий блок в очередь main.- Текущий код завершился — main разбирает накопленное в порядке FIFO:
2,4,7.
⚠️ Частая ошибка: ответить «2 сразу после 1». async на ту же очередь никогда не выполняется немедленно, даже если очередь «свободна»: сначала обязан завершиться текущий блок на ней.
04Четыре потока инкрементируют общий счётчик по 100 раз, а печатается меньше 400. Почему, и как это чинится?
middle
Короткий ответ: 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 — и каждый запуск по-разному
Способы починить:
- Serial очередь — весь доступ через
queue.sync { counter += 1 }. - 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 } }
}
}
- NSLock / os_unfair_lock — минимум накладных расходов.
- Actor — решение уровня языка: компилятор сам не пустит к состоянию мимо изоляции.
Ловить гонки — Thread Sanitizer (схема → Diagnostics → TSan).
⚠️ Частая ошибка: верить, что такой @Atomic чинит counter += 1. Это отдельный get и отдельный set — между ними вклинится другой поток. Атомарной должна быть вся операция read-modify-write, а не свойство по кусочкам.
05Как дождаться завершения N асинхронных операций: DispatchGroup и семафоры?
middle
Короткий ответ: 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)
}
}
- Симметрия enter/leave — ровно один
leave()на каждыйenter();deferспасает от раннего return в completion. Лишний leave — краш, недостающий —notifyне случится никогда. - notify против wait —
notifyпланирует блок и возвращается сразу;wait()останавливает текущий поток (терпимо на фоновом, лучше с таймаутом).
⚠️ Частая ошибка: group.wait() или sem.wait() на мейне «чтобы дождаться сети». Если completion тоже приходит на main — дедлок; если нет — просто фриз UI на всё время ожидания. На мейне — только notify.
06GCD против OperationQueue: когда Operation стоит своих накладных расходов?
middle
Короткий ответ: OperationQueue — надстройка над GCD, превращающая задачу в объект: отмена, зависимости, наблюдаемые состояния, лимит параллелизма. Берите её, когда задачами нужно управлять после запуска; для fire-and-forget достаточно GCD.
Подробно:
| GCD | OperationQueue | |
|---|---|---|
| Отмена | DispatchWorkItem.cancel() — лишь флаг до старта |
cancel() / cancelAllOperations() + проверка isCancelled |
| Зависимости | вручную: group, семафоры | addDependency(_:), даже между разными очередями |
| Состояния | ✗ | KVO: isReady → isExecuting → isFinished |
| Лимит параллелизма | напрямую нет | maxConcurrentOperationCount |
| Приоритеты | QoS очереди/блока | qualityOfService + queuePriority |
| Переиспользование | замыкания | подклассы Operation, тестируемые отдельно |
- Отмена везде кооперативна — и в Operation длинный цикл обязан сам периодически проверять
isCancelledи выходить. - Классический follow-up — асинхронная Operation (обёртка над сетевым вызовом) требует ручного конечного автомата: переопределить
isAsynchronous, самому слать KVO-уведомления дляisExecuting/isFinished— иначе очередь сочтёт операцию завершённой сразу послеmain().
⚠️ Частая ошибка: сравнивать их по скорости. Внутри OperationQueue — тот же GCD; вопрос не «что быстрее», а нужен ли задаче жизненный цикл: отмена, зависимости, наблюдаемость.
07Что даёт «структурность» в structured concurrency по сравнению с GCD?
middle
Короткий ответ: Дерево задач. Дочерние задачи (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) }
}
- Отмена — каскадом — отменили родителя, флаг получили все дети. Но отмена кооперативна: сама собой никакая работа не останавливается, длинный код обязан проверять
try Task.checkCancellation(). - Ошибки — вверх по дереву — упавший ребёнок отменяет братьев, ошибка всплывает в
awaitродителя. - Контраст —
Task.detachedи GCD-блок живут сами по себе: отмену, приоритет и ожидание завершения вы прокидываете руками.
⚠️ Частая ошибка: ждать, что отмена «убьёт» задачу. Cancel лишь взводит isCancelled; задача, которая не проверяет флаг, спокойно доработает до конца.
08Task {} против Task.detached {}: что наследуется и когда detached оправдан?
senior
Короткий ответ: 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 | нет |
- Правильный способ снять работу с мейна —
nonisolated-метод или отдельный актор, а не detached: сохраняются приоритет и task-locals. - Когда detached честен — работа, независимая от контекста запуска: логирование, прогрев кэша, запуск из синхронного кода без актора.
⚠️ Частая ошибка: лечить «Task блокирует UI», рассыпая detached по коду. Теряются приоритет (риск инверсии) и MainActor-гарантии — а хватило бы nonisolated func.
09Что изолируют акторы, что такое reentrancy и зачем нужен MainActor?
middle
Короткий ответ: Актор сериализует доступ к своему мутабельному состоянию: снаружи обращения идут через 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
}
}
- Изоляция — гонки на данных актора исключены компилятором, а не дисциплиной команды.
- Reentrancy — защита от дедлоков ценой интерливинга: инварианты, прочитанные до
await, после него не гарантированы. Классическое лечение дубль-загрузки — словарь in-flightTask-ов: повторный вызов ждёт уже запущенную задачу. nonisolated— для членов, не трогающих состояние (чистые вычисления,Hashable): вызываются безawait.
⚠️ Частая ошибка: «актор — это мьютекс вокруг всего метода». Нет: эксклюзив держится только между точками приостановки. Критическая секция актора заканчивается на первом же await.
10Что на самом деле проверяет компилятор про Sendable в строгом режиме Swift 6?
senior
Короткий ответ: 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] = [:]
}
- Sendable автоматически — структуры и enum из Sendable-полей, акторы (по определению),
finalклассы, где все поляletSendable-типов. - Не Sendable — классы с
var, замыкания, захватившие мутабельное состояние. - Честные фиксы ошибок строгого режима — перевести модель на value-тип; спрятать состояние в актор; UI-модели пометить
@MainActor;@unchecked Sendable— только когда синхронизация реально написана (лок внутри).
⚠️ Частая ошибка: глушить ошибки, рассыпая @unchecked Sendable. Это не фикс, а расписка «гонки здесь мои»: компилятор перестаёт проверять ровно то место, где гонка и жила.
11Почему Timer не срабатывает на фоновом потоке, и при чём здесь RunLoop?
senior
Короткий ответ: 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
- RunLoop — цикл «жди событие → обработай → спи», обслуживающий таймеры, perform-селекторы и часть делегатов старых API (
NSStream, legacyURLConnection). - Современные замены —
DispatchSourceTimer,Task.sleepв цикле,AsyncTimerSequence— им RunLoop не нужен. - Второй классический вопрос — режимы —
scheduledTimerвешает таймер в.defaultmode, а во время скролла мейновский RunLoop уходит в.tracking: таймер замирает. Лечение:RunLoop.main.add(timer, forMode: .common).
⚠️ Частая ошибка: «починить» фоновый таймер вызовом RunLoop.current.run() — и навсегда занять поток циклом без условия выхода. Если работа периодическая — DispatchSourceTimer проще и безопаснее.
Источники
Источники и редакционная политика
Материалы RecallDeck сопоставлены с официальной документацией и открытыми публикациями компаний, когда первичный источник доступен. Мы не связаны с упомянутыми работодателями, не публикуем конфиденциальные задания и не продаём места в подборках. Формат найма может меняться — уточняйте его у рекрутера.
От чтения к воспроизведению
Отрепетируйте полный цикл интервью.
RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.