Thread представляет собой обёртку Foundation над POSIX-потоком: вы сами его создаёте, запускаете и отвечаете за его жизнь. В приложениях так делают редко, потому что GCD и Swift Concurrency держат пул переиспользуемых потоков и сами решают, сколько их запускать; это дешевле, и в этом сложнее ошибиться.
Цена: каждому потоку нужен свой стек и ресурсы ядра, а создание потока занимает время. Пул переиспользует потоки.
Очередь не поток: вы отправляете работу в очередь, а поток для неё выбирает GCD; соседние блоки одной serial-очереди могут выполниться на разных потоках. К одному потоку привязана только главная очередь.
Когда всё же нужен: долгоживущий поток со своим RunLoop для старого API, которому он необходим, или поток с нестандартным размером стека.
iOSMiddle
GCD и потоки
Очереди, QoS, DispatchGroup и семафоры, deadlock на main.sync, Operation, связь с async/await
Закрыто вопросов
0 / 18
- 0Повторить
- 2Часто спрашивают
Вопросы
На фоновом: сессия вызывает completion handler на своей delegate-очереди, а у URLSession.shared это приватная serial OperationQueue, а не главная очередь. Данные разбирайте там же, а на главную очередь переходите через DispatchQueue.main.async только для работы с UI.
Почему главный поток: вью UIKit и SwiftUI не потокобезопасны и изолированы в @MainActor. Изменение вью с другого потока портит состояние лейаута или приводит к крэшу, причём иногда, а не стабильно.
Main Thread Checker: включён по умолчанию в debug-сборках; останавливается с UI API called on a background thread и показывает вызов, который это сделал.
async/await: try await URLSession.shared.data(from: url) в @MainActor-методе продолжается на главном акторе, переход не нужен; само ожидание сети главный поток не блокирует.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { … } планирует замыкание, но обычное замыкание отменить нельзя. Оберните работу в DispatchWorkItem, запланируйте его и вызовите у него cancel(), когда он больше не нужен.
**Что делает cancel()**: ещё не начавшийся item пропускается; уже выполняющийся продолжает работу, если сам не проверяет isCancelled.
Debounce: на каждое нажатие клавиши отменяйте предыдущий item и планируйте новый, тогда поиск запустит только последнее нажатие (код ниже).
**Захватывайте [weak self]**: очередь держит замыкание до запуска, и сильный захват удерживает закрытый экран в памяти всю задержку.
Thread Sanitizer (TSan) представляет собой инструмент времени выполнения, который находит гонки данных (data race): два потока обращаются к одной памяти без синхронизации, и хотя бы один из них пишет. Main Thread Checker сообщает только о вызовах UIKit и AppKit не с главного потока.
Как включить: Edit Scheme → Run → Diagnostics → Thread Sanitizer. Работает в симуляторе и на Mac, но не на реальном iPhone, и не совместим с Address Sanitizer.
Только то, что произошло: TSan видит обращения, которые реально случились во время запуска. Гонка на пути, по которому прогон не прошёл, останется незамеченной, поэтому нагружайте код множеством параллельных вызовов.
Main Thread Checker: включён по умолчанию в debug-сборках, почти не замедляет приложение и работает и на устройстве.
Он выведет B true, C true, D false, E true, F и в самом конце A true. sync выполняет блок на вызывающем потоке, здесь на главном, даже если очередь другая; async отдаёт блок рабочему потоку; main.async ждёт, пока run() вернёт управление.
**sync занимает ваш поток**: вызывающий поток всё равно заблокирован, поэтому GCD выполняет блок на нём, а не будит рабочий поток, и B и C печатают true. Взаимное исключение очереди сохраняется: dispatchPrecondition(condition: .onQueue(serial)) внутри B проходит.
Исключение: DispatchQueue.main.sync из фонового потока выполняется на главном потоке, потому что работа главной очереди обязана идти на нём.
D выполняется на рабочем потоке: async возвращает управление сразу, а GCD разбирает serial-очередь на потоке из пула.
E ждёт D: очередь работает по FIFO, поэтому sync блокируется, пока D не закончится, и выполняет E на главном потоке. Вывод одинаков при каждом запуске.
A печатается последним: главная очередь занята выполнением run(), поэтому блок ждёт конца текущего прохода run loop.
Вывод: очередь не равна потоку. sync даёт взаимное исключение и результат, но не параллелизм, а медленный sync-блок, вызванный с главного потока, замораживает UI.
Блок, отправленный с .barrier, ждёт, пока завершатся все блоки, отправленные в очередь раньше, выполняется один и только потом пропускает следующие блоки. Чтения идут через обычный sync и выполняются параллельно, а записи идут через barrier и выполняются эксклюзивно.
Только на вашей собственной concurrent-очереди: на DispatchQueue.global() флаг игнорируется, и запись идёт параллельно с чтениями: гонка данных. На serial-очереди он ничего не меняет, потому что там каждый блок и так выполняется один.
**async(flags: .barrier) для записи**: пишущий не ждёт, а sync-чтение, отправленное после него, всё равно увидит новое значение, потому что начнётся только после barrier.
**sync(flags: .barrier)**: когда вызывающему нужен результат записи, например removeValue, возвращающий старое значение.
Когда это окупается: много чтений и редкие записи. Для крошечных критических секций лок дешевле перехода на очередь, а при частых записях barrier всё равно всё сериализует.
Реентерабельность: sync на ту же очередь изнутри barrier-блока приводит к крэшу, как любой sync на очередь, на которой вы уже находитесь.
image(for:orInsert:) выполняется внутри queue.sync и вызывает image(for:), который делает queue.sync на той же serial-очереди. Очередь ждёт саму себя: deadlock, который libdispatch обнаруживает и превращает в крэш.
Почему deadlock: serial-очередь выполняет один блок за раз. Внутренний sync-блок встаёт в очередь за внешним, а внешний заблокирован в ожидании внутреннего.
Крэш: BUG IN CLIENT OF LIBDISPATCH: dispatch_sync called on queue already owned by current thread. Если два потока ждут друг друга через две очереди, лок или семафор, сообщения нет: оба просто зависают.
Исправление: код, который уже выполняется на очереди, обращается к storage напрямую. Распространённое правило: queue.sync вызывают только публичные методы, а общие приватные хелперы никогда не блокируют.
Побочный эффект: make() теперь выполняется на очереди кэша и блокирует всех остальных читателей, пока создаётся картинка. Для дорогой работы создавайте значение снаружи и вставляйте вторым sync, смирившись с тем, что два вызывающих могут создать его дважды.
Та же ловушка прячется в колбэках делегатов и наблюдателях, которые обращаются к объекту, пока он находится внутри своей очереди.
На пути ошибки колбэк выходит через guard до group.leave(), поэтому счётчик группы никогда не доходит до нуля и notify не срабатывает. Достаточно одного упавшего запроса из двадцати, и экран ждёт вечно, без крэша и без логов.
Правило: ровно один leave() на каждый enter(), на любом пути. defer { group.leave() } первой строкой колбэка делает это свойством структуры кода.
**Лишний leave()** наоборот приводит к крэшу с Unbalanced call to dispatch_group_leave(), например, если колбэк срабатывает дважды.
Порядок: images.append собирает результаты в порядке завершения, а не в порядке ids. Запись в ячейку по индексу сохраняет исходный порядок.
Безопасно только по контракту: добавления в массив не гонка данных лишь потому, что API вызывает колбэк на главной очереди. Если колбэки приходят на произвольных очередях, массиву нужен лок или serial-очередь.
**async(group:) не замена**: queue.async(group: group) { api.fetchAvatar(…) } покидает группу, когда возвращается замыкание, ещё до завершения запроса, и notify срабатывает сразу.
В async-коде withTaskGroup даёт тот же параллельный запуск со встроенными результатами, ошибками и отменой.
Timer.scheduledTimer добавляет таймер в run loop текущего потока, а здесь это рабочий поток GCD, чей run loop никто не запускает. Таймер создаётся, лежит в этом run loop и не срабатывает никогда: ни ошибки, ни лога.
Таймеру нужен работающий run loop: run loop главного потока работает всегда. Рабочий поток получает объект run loop при первом обращении, но GCD его не запускает, а сам поток возвращается в пул, как только блок закончился.
Почему баг проскакивает: если вызвать тот же код на главном потоке, например по нажатию кнопки, он работает; ломается он, только когда об ошибке сообщают в очереди загрузки.
Та же ловушка: perform(_:with:afterDelay:) и любой API, который планирует работу в «текущем run loop», если вызвать его из фоновой очереди.
Неверные исправления: RunLoop.current.run() внутри блока занимает очередь и поток из пула, пока таймер не сработает. Таймер на RunLoop.main сработает, но retry() тогда выполнится на главном потоке, вне queue, и устроит гонку с остальным состоянием.
Исправление: таймер, который принадлежит очереди: queue.asyncAfter с DispatchWorkItem, который можно отменить, или DispatchSource.makeTimerSource(queue:) для повторяющегося таймера.
concurrentPerform(iterations:execute:) выполняет замыкание N раз параллельно примерно на стольких потоках, сколько ядер, и возвращается после завершения всех итераций. Цикл с global().async отправляет N независимых блоков без лимита и без способа их дождаться.
Нужное число потоков: итерации распределяются примерно по одному потоку на доступное ядро, а вызывающий поток выполняет часть из них сам; thread explosion не возникает.
Синхронный вызов: он блокирует вызывающего, поэтому долгую работу запускайте с фоновой очереди и никогда с главного потока.
Делите на крупные куски: у каждой итерации есть накладные расходы на планирование, поэтому отдавайте каждой итерации тысячи элементов, а не один.
Только для CPU-задач: итерация, которая ждёт сеть или диск, держит рабочий поток и ничего не выигрывает от параллельности.
Общий результат: пишите в отдельные ячейки на итерацию или сливайте под локом один раз на кусок, как в примере, а не на каждый элемент.
В Swift Concurrency то же самое без блокировки вызывающего делает withTaskGroup с одной дочерней задачей на кусок.
На iOS 18+ берите Mutex, на iOS 16–17 используйте OSAllocatedUnfairLock: оба владеют защищаемым состоянием и очень быстры. NSLock остаётся классическим запасным вариантом, NSRecursiveLock нужен для кода, который повторно входит в тот же лок, а голый os_unfair_lock в свойстве Swift не гарантирует адрес.
**NSLock**: объект-мьютекс: lock() и unlock() с defer или withLock { }. Повторный захват на том же потоке даёт deadlock, а отпустить лок может только захвативший его поток.
**NSRecursiveLock: тот же поток может захватить его повторно, например когда метод под локом вызывает другой метод под локом. Если он понадобился, обычно стоит перестроить блокировки.
os_unfair_lock**: C-структура, которая должна оставаться по одному адресу в памяти. Хранимое свойство Swift этого не гарантирует: &lock может указывать на временную копию (обычно это работает, но язык этого не обещает), и два потока могут заблокировать разные копии. OSAllocatedUnfairLock сам размещает его в куче.
Unfair-локи передают приоритет: система знает владельца и поднимает его приоритет, пока ждёт поток с более высоким QoS; семафор в роли лока так не умеет.
Правила для всех: крошечные критические секции, никаких await и вызовов неизвестного кода под локом, а несколько локов всегда берутся в одном фиксированном порядке.
Только static let: глобальные переменные и статические хранимые свойства инициализируются лениво ровно один раз, даже если к ним одновременно впервые обращаются много потоков. lazy var, добавление в общий Array и count += 1 превращаются в гонки данных, если это делают два потока одновременно.
**static let и глобальные переменные**: рантайм защищает первое обращение через swift_once (dispatch_once на платформах Apple), поэтому static let shared = Manager() даёт безопасный синглтон.
**lazy var: без синхронизации: два потока могут оба увидеть пустое значение, оба выполнить инициализатор и оба записать. Инициализируйте его заранее или защищайте тем же локом, что и остальное состояние.
Коллекции**: Array, Dictionary и String устроены как значения с copy-on-write. Передать копию в другой поток и менять её там можно; если же менять одну и ту же переменную из двух потоков, её хранилище портится, часто с крэшем внутри append.
Только чтение: значение, которое никто не меняет, могут читать сколько угодно потоков.
**count += 1**: это чтение, сложение и запись; два потока теряют инкременты. Нужен лок, актор или Atomic из модуля Synchronization (iOS 18+).
atomic-свойства в Objective-C делают атомарным только отдельный геттер или сеттер, а не последовательность «прочитать, изменить, записать».
b.addDependency(a) не даёт b стать готовой, пока не завершится a, даже если они в разных очередях. Отменённая операция всё равно завершается, поэтому зависимые от неё стартуют; каждая операция должна сама проверять, не были ли отменены или не упали ли её зависимости.
Готовность, а не успех: зависимость считается выполненной, когда другая операция завершилась: успешно, с ошибкой или после отмены. Результаты передавайте через свойства зависимости и проверяйте их в main().
Сначала зависимости: добавляйте их до того, как кладёте операции в очередь. Цикл (a зависит от b, b от a) означает, что ни одна не запустится, и без всяких сообщений.
**cancelAllOperations()** выставляет isCancelled всем операциям в очереди и выполняющимся; выполняющаяся остановится, только когда её код проверит флаг.
**maxConcurrentOperationCount = 1** делает очередь последовательной, но порядок всё равно определяется готовностью и queuePriority, а не порядком addOperation. Если порядок важен, используйте зависимости.
Дождаться всех: addBarrierBlock (iOS 13+) выполняется после всех операций, добавленных до него; waitUntilAllOperationsAreFinished() блокирует поток, и на главном ему не место.
Когда API просит очередь, когда работа блокирует поток и её нельзя сделать асинхронной, а также для таймеров и источников событий. Общее состояние и обычную фоновую работу отдают акторам и задачам; GCD остаётся на границах.
API, которые принимают очередь: AVCaptureVideoDataOutput.setSampleBufferDelegate(_:queue:), CBCentralManager(delegate:queue:) и NWConnection.start(queue:) присылают колбэки на переданную очередь.
Блокирующие вызовы: синхронная C-библиотека, sqlite3_step, большой синхронный файловый ввод-вывод: выполняйте их на отдельной serial-очереди и возвращайтесь через withCheckedContinuation, чтобы они не занимали cooperative pool.
Источники событий: у DispatchSource.makeTimerSource и источников событий файловой системы или сигналов нет встроенного async-аналога; оборачивайте их в AsyncStream.
Параллельные циклы на CPU: concurrentPerform по-прежнему простой и правильно масштабируемый способ разбить большое вычисление.
Актор на конкретной очереди: актор может использовать DispatchSerialQueue как custom executor (iOS 17+), если его состояние должно жить на очереди, на которую фреймворк уже присылает колбэки.
Не для нового кода, который защищает состояние или переходит к UI: актор, Mutex и @MainActor проверяет компилятор, а очередь не проверяет.
Переопределите start() вместо main(), сами отдавайте isExecuting и isFinished и отправляйте KVO-уведомления для обоих при каждом изменении. Очередь узнаёт о завершении операции только из KVO-изменения isFinished; без него операция остаётся в очереди навсегда.
Что ломается без KVO
Слоты maxConcurrentOperationCount остаются занятыми, и после N зависших операций в очереди больше ничего не выполняется.
Зависимые операции так и не становятся готовыми, а completionBlock не вызывается.
waitUntilAllOperationsAreFinished() блокирует навсегда.
Правила
Не вызывайте super.start(): стандартная реализация вызывает main() и помечает операцию завершённой сразу после возврата из него, ещё до прихода вашего колбэка.
Отменённая операция всё равно должна перейти в finished: проверьте isCancelled в start() и вызовите finish(). Переопределите cancel(), чтобы заодно отменить нижележащий URLSessionTask.
finish() вызывается ровно один раз, на любом пути, включая ошибки.
Защищайте флаги локом: start(), сетевой колбэк и cancel() выполняются на разных потоках.
isAsynchronous важен, только если вы сами вызываете start(); очередь всегда вызывает start() на отдельном потоке.
По умолчанию async захватывает QoS отправляющего потока, и на очереди без собственного QoS блок выполняется с ним; очередь со своим QoS использует свой. Инверсия приоритетов (работа с высоким QoS ждёт работу с низким) решается автоматически, только если система знает, кого именно ждут.
Распространение QoS
С главного потока userInteractive передаётся как userInitiated, чтобы фоновая работа, запущенная из UI, не конкурировала с самим UI.
DispatchWorkItem, созданный с .enforceQoS, использует свой QoS, если он выше, чем у очереди.
Решается автоматически
Serial-очередь: если блок userInitiated попал в очередь за блоками .background, GCD выполняет очередь с более высоким QoS, пока этот блок не отработает.
**sync с потока с высоким QoS поднимает QoS целевой очереди на время вызова.
Локи, знающие владельца** (Mutex, OSAllocatedUnfairLock), одалживают владельцу приоритет ждущего; см. вопрос про локи.
Не решается
У DispatchSemaphore и DispatchGroup.wait() нет владельца, поэтому главный поток, ждущий .background-производителя, ждёт столько, сколько планировщик его морит. Thread Performance Checker сообщает об этом во время выполнения.
Исправление: давайте работе QoS того, кто её ждёт, и предпочитайте колбэки ожиданию.
Независимые serial-очереди могут работать одновременно, каждая на своём потоке, и получить новый поток, даже когда все ядра заняты. Если направить их в одну serial target-очередь на подсистему, вся группа будет выполнять не больше одного блока за раз и не плодить потоки.
Почему очереди превращаются в потоки
Serial-очередь дешёвая, но не её работа: если в 50 очередях таблиц лежит по блоку, GCD может выполнять все 50 одновременно на 50 рабочих потоках.
Если эти блоки ждут ввод-вывод или лок, GCD добавляет ещё потоков: thread explosion, за который платят памятью под стеки и переключениями контекста.
Target-очереди
DispatchQueue(label:target:) заставляет очередь передавать свои блоки в target. С serial target всё дерево выполняет не больше одного блока за раз, а каждая очередь сохраняет свой порядок FIFO.
Очереди с общей serial target-очередью никогда не выполняются параллельно, и это может заменить лок между ними.
Правила
Задавайте target при создании очереди или создайте её с .initiallyInactive, вызовите setTarget(queue:) и затем activate(). Не меняйте target у уже активной очереди.
Держите небольшое фиксированное число иерархий, по одной на подсистему (база данных, сеть, обработка картинок), как Apple рекомендует в «Modernizing Grand Central Dispatch Usage» (WWDC17).
В Swift Concurrency эта идея встроена: акторы не владеют потоками, а делят cooperative pool.
Во время выполнения оба варианта идут на главном потоке: executor главного актора и есть главная dispatch-очередь. Разница в компиляторе и во времени: @MainActor проверяется статически и из главного актора выполняется сразу, а main.async всегда ставит работу в очередь.
Особый случай в компиляторе: замыкание, переданное напрямую в DispatchQueue.main.async или asyncAfter, считается @MainActor, поэтому работа с UI внутри него компилируется без замечаний. Если сохранить очередь в переменную, то же замыкание станет обычным @Sendable-замыканием, на которое компилятор выдаёт предупреждение.
Всегда отложенно: main.async из кода на главном потоке выполнится после того, как текущая работа вернёт управление, поэтому его использовали как хак «на следующем проходе run loop». Прямой вызов @MainActor-метода из кода на главном акторе выполняется сразу, так что замена одного на другое может переставить обновления UI местами.
Чего нет у GCD: нет результата для await, ошибок, отмены, приоритета задачи и task-local значений; у Task { @MainActor in } всё это есть.
**Нет аналога sync**: DispatchQueue.main.sync с главного потока приводит к крэшу; в синхронном коде, который точно выполняется на главном потоке, MainActor.assumeIsolated { } даёт изоляцию без перехода (см. тему про strict concurrency).
Миграция: внутри @MainActor-типов удаляйте обёртки DispatchQueue.main.async, а не конвертируйте их; в nonisolated-колбэках GCD-API оставьте main.async или перейдите на Task { @MainActor in }.