Конкурентность означает, что несколько задач находятся в работе одновременно и могут чередоваться; параллельность означает, что они буквально выполняются в один момент на разных ядрах. Синхронный вызов заставляет вызывающего ждать результата, асинхронный даёт продолжить работу и отдаёт результат позже.
Конкурентность без параллельности: на одном ядре ОС делит время между потоками, поэтому и загрузка, и UI продвигаются, хотя в каждый момент работает только один из них.
Параллельность требует нескольких ядер; это один из способов выполнить конкурентную работу, а не её синоним.
Синхронно: вызов возвращается только когда работа сделана. Долгий синхронный вызов на главном потоке замораживает UI.
Асинхронно: вызов возвращается сразу, а результат приходит через колбэк, делегат или await. Асинхронно не значит «в фоне»: DispatchQueue.main.async асинхронен и всё равно выполняется на главном потоке.
iOSMiddle
Swift Concurrency
async/await, Task, акторы, structured concurrency, AsyncSequence
Закрыто вопросов
0 / 25
- 0Повторить
- 17Часто спрашивают
Вопросы
GCD (Grand Central Dispatch) представляет собой библиотеку Apple: вы отправляете замыкания в очереди, а система выполняет их на своём пуле потоков. Serial-очередь выполняет по одной задаче в порядке FIFO, concurrent запускает несколько сразу; async возвращается сразу, sync блокирует до конца замыкания.
Главная очередь: последовательная и привязана к главному потоку; сюда идут все обновления UIKit и SwiftUI.
Глобальные очереди: concurrent, по одной на каждый класс QoS: DispatchQueue.global(qos: .userInitiated).
Свои очереди: DispatchQueue(label:) по умолчанию последовательная; для concurrent передайте attributes: .concurrent.
Очередь не поток: serial-очередь гарантирует «по одной задаче за раз», а не один и тот же поток; GCD может выполнять соседние задачи на разных потоках. К одному потоку привязана только главная очередь.
sync в ту же serial-очередь, на которой вы уже выполняетесь, даёт deadlock: очередь ждёт сама себя.
async-функция может приостановиться на каждом await и продолжить позже, а пока она приостановлена, она не держит поток. По сравнению с completion handler’ами код читается сверху вниз, ошибки идут через try/catch, а компилятор гарантирует, что результат придёт ровно один раз.
Линейный поток: три последовательных шага занимают три строки, а не три вложенных замыкания.
Ошибки: async throws использует те же try / do / catch, что и синхронный код, вместо switch по Result в каждом колбэке.
Нет забытых колбэков: completion handler можно вызвать дважды или не вызвать вовсе; async-функция возвращается ровно один раз.
По сравнению с GCD: не нужно вручную прыгать через DispatchQueue.main.async: где выполняется код, решает изоляция актора (например, @MainActor), а после await код может продолжиться на другом потоке.
Структурная отмена: Task.cancel() распространяется на дочерние задачи; у completion handler’а такой связи «родитель/потомок» нет.
Из синхронного кода запускают Task { } и делают await внутри него; синхронная функция возвращается сразу и напрямую результат не получает. В обратную сторону ничего не нужно: async-функция может вызвать любую синхронную функцию, если этот вызов не блокирует надолго.
Изоляция наследуется: Task, созданный в @MainActor-коде, выполняет тело на главном акторе, поэтому может напрямую обновлять состояние UI.
Сохраняйте хэндл, если работу нужно уметь отменить: держите Task в свойстве и потом вызывайте cancel(). В SwiftUI лучше модификатор .task { }, который отменяет работу при исчезновении view.
Синхронно дождаться нельзя: корректного способа заблокироваться до готовности async-результата нет; семафор вокруг Task приводит к deadlock (см. вопрос про cooperative pool).
Долгая CPU-работа внутри async-функции держит поток всё это время; разбейте её на части или вынесите в @concurrent-функцию.
На главном потоке выведется A, C, и дальше всё встанет: main.sync ждёт, пока главная очередь выполнит D, а главная очередь занята выполнением самого test(), который ждёт; это deadlock, который libdispatch обычно превращает в краш.
B тоже не выведется: он стоит в главной очереди за заблокированным test().
Сообщение краша: dispatch_sync called on queue already owned by current thread; без этой проверки приложение просто зависло бы.
С фоновой очереди deadlock нет: сначала A, потом B и C в любом порядке (B выполнится на main, как только main освободится), затем D, затем E. B всегда раньше D, потому что главная очередь последовательная и B попал в неё первым.
Правило: никогда не делайте sync в ту serial-очередь, на которой уже выполняетесь. В Swift Concurrency аналогом служит await MainActor.run { }, который приостанавливает, а не блокирует, и так зависнуть не может.
QoS сообщает системе, насколько работа важна пользователю прямо сейчас, а система по нему выставляет приоритет потока, делит процессорное время и выбирает между производительными и энергоэффективными ядрами. Работа с низким QoS получает меньше CPU и под нагрузкой может откладываться надолго.
.userInteractive: главный поток, анимации, обработка событий; должно укладываться в кадр.
.userInitiated означает, что пользователь сам запустил и ждёт: открытие документа, загрузка следующего экрана.
.default используется, если ничего не указано; находится между .userInitiated и .utility.
.utility предназначен для долгой работы, о которой пользователь знает, часто с прогресс-баром: загрузки, импорт.
.background предназначен для работы, которую пользователь не видит: бэкапы, префетч, индексация. Её сильно ограничивают, особенно в режиме энергосбережения.
Когда работа с высоким QoS ждёт очередь с низким (инверсия приоритетов), GCD поднимает QoS очереди при sync-вызове или если в serial-очереди стоят задачи с высоким QoS. Приоритеты задач в Swift Concurrency ложатся на те же классы: .high соответствует .userInitiated, а .low соответствует .utility. Никогда не ставьте на .background то, чего ждёт пользователь; оно может ждать секунды или минуты.
DispatchGroup считает набор асинхронных задач и сообщает, когда все они закончились. Классический случай: запустить несколько запросов параллельно и обновить UI, когда вернулись все.
**enter() / leave()**: enter() перед каждой задачей и leave() ровно один раз в её completion, на любом пути. Если пропустить leave(), группа никогда не завершится; лишний вызов приводит к крашу Unbalanced call to dispatch_group_leave().
**notify(queue:): неблокирующий колбэк, когда счётчик дошёл до нуля. Предпочитайте его.
wait(timeout:)**: блокирует вызывающий поток; никогда на главном потоке и никогда внутри async-кода.
queue.async(group:) сам делает enter/leave, но покрывает только работу, синхронную внутри замыкания; для API на колбэках всё равно нужны enter/leave.
Результатам от нескольких задач нужна своя синхронизация (lock или serial-очередь).
В новом коде его заменяют async let или task group: с типизированными результатами, пробросом ошибок и отменой из коробки.
OperationQueue служит надстройкой над GCD: она оборачивает работу в объекты Operation и добавляет зависимости, лимит конкурентности, отмену и наблюдаемое состояние; GCD просто выполняет замыкания. Её выбирают, когда работа складывается в граф шагов, который нужно упорядочить, ограничить или отменить целиком.
Зависимости: b.addDependency(a) заставляет b ждать a, даже между разными очередями; в GCD аналога нет, кроме вложенных колбэков или групп.
Лимит конкурентности: maxConcurrentOperationCount = 3; в GCD для этого понадобился бы семафор.
Отмена: cancel() и cancelAllOperations() выставляют isCancelled, но отмена всё равно кооперативная: операция должна сама это проверять. У DispatchWorkItem отмена такая же кооперативная (не начавшийся элемент пропускается, а выполняющийся может проверить isCancelled); обычное замыкание в async { } отменить нельзя вообще.
Состояние: isExecuting и isFinished наблюдаемы через KVO; асинхронной операции нужен подкласс, который вручную управляет этими флагами, и отсюда большинство багов с Operation.
Стоимость: каждая операция является объектом; для замыканий «запустил и забыл» GCD проще и дешевле.
В новом коде большую часть этих задач закрывает Swift Concurrency (task group’ы дают и лимиты, и отмену), но OperationQueue по-прежнему часто встречается в старых кодовых базах и в API, которые её принимают, например URLSession(configuration:delegate:delegateQueue:).
Гонка данных (data race) возникает, когда два потока одновременно обращаются к одной памяти, хотя бы один пишет, а синхронизации нет, и это неопределённое поведение. Race condition представляет собой логическую ошибку, когда результат зависит от тайминга даже при синхронизированном доступе. Deadlock возникает, когда потоки бесконечно ждут друг друга.
Data race: count += 1 из двух потоков теряет обновления или ломает словарь. Решение: актор, lock или serial-очередь. Режим языка Swift 6 отклоняет большинство гонок данных на этапе компиляции; остальное находит Thread Sanitizer в рантайме.
Race condition: «проверил, потом сделал»: оба вызывающих видят cache[key] == nil и оба запускают загрузку, даже внутри актора, если между проверкой и записью есть await. Решение: сделать проверку и действие одним шагом без приостановки или хранить уже идущую задачу.
Deadlock: DispatchQueue.main.sync с главного потока, два lock’а, взятые в разном порядке, семафор, ждущий работу, которой нужен заблокированный поток. Решение: единый порядок захвата lock’ов, никогда не делать sync в свою же очередь, никаких блокировок внутри async-кода.
Защита от гонок данных в Swift 6 устраняет большинство data race, но не race condition; это логические ошибки, которые компилятор не видит.
Семафор работает как счётчик, который пропускает до N потоков и блокирует остальные; мьютекс пускает в критическую секцию ровно один поток и имеет владельца: разблокировать его может только поток, который его захватил.
Семафор: DispatchSemaphore(value: n): wait() уменьшает счётчик и блокирует на нуле, signal() увеличивает, и вызвать signal() может любой поток. Применяется, чтобы ограничить параллельную работу N задачами, или, со значением 0, чтобы дождаться события.
Мьютекс: Mutex из модуля Synchronization (iOS 18+), OSAllocatedUnfairLock (iOS 16+), NSLock. Очень дёшев без конкуренции; предназначен для коротких критических секций.
Владение важно: у семафора в роли lock’а нет владельца, поэтому система не может поднять приоритет держателя. Когда высокоприоритетный поток ждёт низкоприоритетного держателя, возникает инверсия приоритетов; Thread Performance Checker в Xcode такое помечает.
Плохая идея: на главном потоке (замёрзший UI, убийство watchdog’ом), чтобы превратить асинхронный API в синхронный, и где угодно внутри Swift Concurrency, где он блокирует поток cooperative pool.
Structured concurrency привязывает дочерние задачи к области видимости, которая их создала: пережить её они не могут, отмена идёт вниз, ошибки поднимаются вверх, а родитель ждёт все дочерние задачи. async let подходит для фиксированного числа дочерних задач, а task group подходит для числа, известного только в рантайме.
**async let**: каждая привязка сразу запускает дочернюю задачу; дочерние задачи могут возвращать разные типы (пользователь, посты, аватар). Если выйти из области видимости, не сделав await на одну из них, она будет отменена, а родитель дождётся её завершения.
**withTaskGroup / withThrowingTaskGroup**: добавляйте сколько угодно дочерних задач одного типа результата и забирайте их в порядке завершения через for await или next().
**withDiscardingTaskGroup: для дочерних задач, чьи результаты не нужны; каждая завершившаяся задача освобождается сразу, а не хранится до того, как её заберут.
Неструктурированные**: Task { } и Task.detached не являются ничьими дочерними задачами; их временем жизни и отменой управляете вы.
Task { } наследует изоляцию актора, приоритет и task-local значения кода, который его создаёт; Task.detached не наследует ничего из этого и выполняется nonisolated со своим приоритетом. Ни тот, ни другой не являются дочерними задачами, поэтому отмена создавшего их кода их не отменяет.
**Task { }**: вариант по умолчанию. Созданный во view model с @MainActor, он выполняет тело на главном акторе и может трогать состояние модели.
**Task.detached**: редко правильный выбор; используйте, только когда контекст явно нельзя наследовать. Чтобы увести CPU-работу с главного актора, намерение лучше выражает @concurrent async-функция (Swift 6.2+).
Приоритет: наследуется от создателя, если не передан явно (.high, .medium, .low, .background, плюс псевдонимы .userInitiated и .utility). Дочерние задачи в async let и группах наследуют приоритет родителя.
Эскалация: когда задача с высоким приоритетом ждёт задачу с низким (await task.value) или стоит на акторе в очереди за низкоприоритетной работой, рантайм временно поднимает низкий приоритет.
Ловушка: Task.detached(priority: .background) для того, чего ждёт пользователь, например pull-to-refresh, троттлится и под нагрузкой или в режиме энергосбережения может занимать секунды.
Отмена кооперативная: cancel() лишь выставляет флаг у задачи и всех её дочерних задач, и ничего не остановится, пока код не проверит этот флаг или не сделает await на API, которое проверит его само.
Проверка: try Task.checkCancellation() бросает CancellationError; Task.isCancelled позволяет вместо этого прибраться или вернуть частичный результат.
Встроенные проверки: Task.sleep и async-методы URLSession бросают ошибку, когда задача отменена.
Распространение: отмена родителя отменяет его дочерние задачи из async let и групп; throwing task group отменяет оставшиеся дочерние задачи, когда ошибка выходит из её тела.
**withTaskCancellationHandler**: выполняет onCancel сразу, возможно на другом потоке, чтобы можно было остановить нижележащий legacy-запрос, а не ждать его.
Неструктурированным задачам нужен хэндл: сохраните Task и отмените его. Модификатор .task { } в SwiftUI отменяет работу автоматически, когда view исчезает.
Воспринимайте CancellationError как «пользователь ушёл дальше», а не как ошибку, которую нужно показать в алерте.
try? проглатывает CancellationError, который должен остановить цикл. После stop() отменённый Task.sleep сразу бросает ошибку, она становится nil, и while true крутится вхолостую: горячий цикл грузит CPU и продолжает слать запросы.
Отмена кооперативная: cancel() только ставит флаг. Код останавливается, лишь когда проверяет Task.isCancelled или пропускает наружу ошибку от Task.sleep или Task.checkCancellation().
**try? её прячет**: отменённый Task.sleep (и fetchPrice(), учитывающий отмену, например async URLSession) бросает ошибку сразу, а не через 5 с, поэтому каждая итерация занимает микросекунды вместо 5 с.
Следствие: объект живёт вечно: пока цикл не кончается, задача удерживает self сильной ссылкой; захватывайте [weak self] или всегда вызывайте stop() из deinit/при уходе с экрана.
Исправление: проверять !Task.isCancelled в условии цикла или писать try await Task.sleep внутри do/catch и делать return на CancellationError.
Как поймать в следующий раз: юнит-тест, который вызывает stop() и проверяет, что счётчик вызовов мок-API больше не растёт; любой try? await Task.sleep внутри цикла даёт повод присмотреться на ревью.
Ошибка всплывает на try await user, и перед выходом из функции Swift неявно отменяет posts и дожидается его, поэтому loadScreen() бросит ошибку только тогда, когда задача постов свернётся: почти сразу, если loadPosts уважает отмену, и через все 10 с, если игнорирует.
Дочерние задачи не переживают область видимости: выход из неё через return или throw отменяет каждый async let, на который не сделали await, и дожидается его завершения. Ничего не утекает.
**Порядок await важен**: если бы вы сначала ждали posts, вы бы прождали все 10 с, прежде чем увидеть ошибку пользователя. async let сообщает об ошибках в порядке ваших await, а не в порядке, в котором они случились.
Падать на первой случившейся ошибке: это throwing task group: next() отдаёт дочерние задачи в порядке завершения, а выброс ошибки из группы отменяет остальные.
**async let, на который вы так и не сделали await**, всё равно выполняется, а на выходе из области видимости отменяется и дожидается; его ошибка молча теряется. Это не способ «запустить и забыть».
Заполните группу 5 дочерними задачами, а потом каждый раз, когда одна завершается (next() возвращает результат), добавляйте следующую, и в работе никогда не будет больше 5.
Добавить сразу все 500: значит запустить 500 запросов: сотни таймаутов, память под каждый ответ и каждую декодированную картинку одновременно. Лимит соединений на хост в URLSession ставит в очередь сокеты, а не вашу работу по декодированию.
Скользящее окно держит лимит точно и при этом использует все 5 слотов: медленная картинка не задерживает остальные.
Ошибки: в withThrowingTaskGroup первая ошибка, вышедшая из цикла, отменяет оставшиеся дочерние задачи; ловите ошибку внутри дочерней задачи, если одна битая картинка не должна останавливать весь пакет.
Отмена приходит бесплатно: отмена родительской задачи отменяет идущие загрузки, а цикл перестаёт добавлять новые.
В legacy-коде тот же лимит дают OperationQueue.maxConcurrentOperationCount или DispatchSemaphore, но никогда внутри async-кода.
Устройте гонку операции с Task.sleep в throwing task group: берите ту дочернюю задачу, которая закончится первой, и отменяйте вторую. Если первым закончится сон, он бросит вашу ошибку таймаута.
Это держится на отмене: группа всё равно дожидается проигравшей дочерней задачи перед возвратом. Операция, игнорирующая отмену, всё равно заставит вызывающего ждать: structured concurrency не умеет бросать работу.
**defer { group.cancelAll() } останавливает проигравшего на любом пути, в том числе когда операция первой бросает ошибку.
Ограничения дженерика**: результат пересекает границу задачи, поэтому он должен быть Sendable, а замыкание операции должно быть @Sendable.
Если у API есть свой таймаут, используйте его: в URLSession timeoutIntervalForRequest задаёт таймаут простоя между пакетами, а timeoutIntervalForResource ограничивает всю передачу.
Используйте Duration и Task.sleep(for:), а не целые наносекунды.
Актор представляет собой ссылочный тип, который защищает своё изменяемое состояние: в нём одновременно выполняется только один фрагмент кода, на собственном последовательном executor’е. Снаружи к нему обращаются через await, потому что вызову, возможно, придётся подождать, пока актор освободится.
Внутри актора: методы обращаются к self синхронно; изоляцию гарантирует компилятор.
Снаружи: любое обращение к изолированному состоянию или методам идёт через await: вызывающий приостанавливается, а не блокируется, поэтому ни один поток не простаивает в ожидании, в отличие от lock’а или queue.sync.
Проверки на этапе компиляции: синхронный доступ снаружи считается ошибкой, а значения, передаваемые внутрь и возвращаемые наружу, должны быть Sendable.
**nonisolated**-члены содержат логику, которая читает только неизменяемые let-свойства; вызываются без await.
Это не транзакция: изоляция держится только до следующего await внутри актора; между ними могут выполниться другие вызовы (actor reentrancy).
@MainActor: глобальный актор с теми же правилами, у которого роль executor’а играет главный поток.
@MainActor является глобальным актором, чьим executor’ом служит главный поток: помеченный код гарантированно выполняется там, а вызывающие из других доменов изоляции обязаны делать await. Он для состояния UI, а не для работы: всё, что он покрывает, идёт по одному куску за раз на потоке, который рисует UI.
Где он уместен: view model и типы, обращённые к UI. UIViewController, UIView и SwiftUI-View уже изолированы на главном акторе.
Почему не всё подряд: декодирование JSON, обработка картинок и дисковый I/O на главном акторе замораживают UI. Пометка async не уводит функцию с главного актора; где она выполняется, решает изоляция.
Как уйти с него: вызвать метод другого актора или @concurrent async-функцию (Swift 6.2+), которая всегда выполняется на глобальном пуле. Task.detached годится лишь как последнее средство.
Как вернуться: внутри @MainActor-типа вы уже там; из других мест используйте await MainActor.run { } или вызов @MainActor-метода.
Гарантия статическая: колбэки из Objective-C или C не проверяются; MainActor.assumeIsolated проверяет в рантайме и падает, если вы не на main.
Новые таргеты приложений в Xcode 26 по умолчанию используют изоляцию MainActor, поэтому в них всё живёт на главном акторе, пока вы явно не откажетесь.
Маркерный протокол Sendable означает, что значение можно безопасно передавать между доменами конкурентности (задачами, акторами) без гонки данных. В режиме Swift 6 компилятор проверяет это на каждом таком пересечении и отклоняет код, который делил бы незащищённое изменяемое состояние.
Неявно: структуры и перечисления, у которых все хранимые свойства Sendable (для непубличных типов выводится, для публичных нужно явное соответствие), акторы и типы, изолированные на глобальном акторе вроде @MainActor.
Классы: только final class, у которого нет ничего, кроме let-свойств Sendable-типов, и соответствие объявлено явно.
Изменяемый класс: не Sendable. Сделайте его актором, изолируйте на @MainActor или защитите состояние Mutex и тогда объявите соответствие.
**@unchecked Sendable: вы сами обещаете потокобезопасность (обычно внутренний lock), и компилятор перестаёт проверять. Каждый такой случай заслуживает комментария и ревью.
Замыкания**: @Sendable-замыкание может захватывать только Sendable-значения и никаких изменяемых локальных переменных.
Изоляция по регионам позволяет только что созданному не-Sendable значению перейти в другой домен, когда до него больше никто не может дотянуться; параметры sending делают это явным.
Вызовите withCheckedThrowingContinuation, запустите внутри legacy-вызов и возобновите continuation ровно один раз из колбэка; ожидающая задача остаётся приостановленной до этого момента. Checked-continuation проверяет это «ровно один раз» в рантайме, а unsafe не проверяет.
Повторный resume у checked-continuation приводит к крашу SWIFT TASK CONTINUATION MISUSE … tried to resume its continuation more than once.
Если не вызвать resume, вызывающий останется приостановленным навсегда; checked-continuation пишет в лог об утечке, когда уничтожается без resume.
**withUnsafeContinuation пропускает обе проверки, и ошибка использования приводит к неопределённому поведению. Берите его только на измеренном горячем пути, после того как checked-версия доказала корректность.
Resume на каждом пути**: успех, ошибка, ранний выход. resume(with: result) передаёт Result одним вызовом.
Много событий (делегат, который срабатывает многократно) требует AsyncStream, а не continuation.
Сначала проверьте: у многих API Apple на колбэках уже есть async-версии, сгенерированные из Objective-C completion handler’ов.
AsyncSequence описывает последовательность, элементы которой приходят со временем и потребляются через for await; AsyncStream даёт готовую реализацию, которую вы собираете сами, чтобы превратить колбэки, делегаты или нотификации в такую последовательность.
Встроенные последовательности: URL.lines, URLSession.bytes(for:), NotificationCenter.default.notifications(named:) и сама task group.
Как собрать: AsyncStream { continuation in … } или AsyncStream.makeStream(of:): yield доставляет элемент, finish() завершает поток, onTermination прибирается, когда потребитель остановился или был отменён.
Буферизация: по умолчанию буфер неограничен; .bufferingNewest(1) даёт семантику «только последнее значение» для быстрых источников.
Один потребитель: AsyncStream не мультикаст; два цикла for await поделят элементы между собой. Для нескольких потребителей создавайте поток на каждого подписчика.
Время жизни: цикл заканчивается, когда поток завершён или потребляющая задача отменена. Забытый finish() оставляет потребителя ждать вечно.
По сравнению с Combine операторов мало; пакет swift-async-algorithms добавляет debounce, merge, combineLatest и другие.
Акторы реентерабельны: на await актор освобождается, выполняется второй вызов, видит, что кэш всё ещё пуст, и запускает свою загрузку. Изоляция защищает состояние между точками приостановки, но не поверх них.
Почему акторы реентерабельны
Нереентерабельный актор попадал бы в deadlock, как только ждал бы что-то, что вызывает его обратно, и выстраивал бы всех вызывающих в очередь за медленным I/O.
Поэтому каждый await внутри актора становится точкой, где любой другой вызов может изменить его состояние. «Проверил, потом сделал» поверх await даёт race condition, хотя гонки данных нет и компилятор доволен.
Как исправить
Храните идущую работу: положите Task в словарь до первого await; второй вызывающий найдёт его и будет ждать ту же задачу, а не запускать новую.
**Перепроверяйте после каждого await, когда дедупликация неприменима: значение, прочитанное до приостановки, могло устареть.
Делайте изменения синхронными**: прочитать, решить и записать одним куском без точки приостановки.
При ошибке удаляйте запись, чтобы следующий вызывающий мог повторить попытку, а не вечно ждать сохранённую ошибку.
Swift Concurrency выполняет задачи на кооперативном пуле, где примерно по одному потоку на ядро CPU, и рассчитывает, что каждая задача либо выполняется, либо приостанавливается. Поток, заблокированный в wait() или Thread.sleep, потерян для пула, и ничто его не заменит.
Почему это ломается
GCD создаёт новый поток, когда какой-то блокируется; именно поэтому такой код раньше выживал, ценой thread explosion. Кооперативный пул намеренно не растёт.
Заблокируйте столько потоков, сколько ядер, и остановятся все задачи приложения. Ни краша, ни строчки в логе: приложение просто перестаёт продвигаться.
На главном акторе это происходит сразу: Task, запущенный из кода главного актора, наследует главный актор, и ему нужен тот самый поток, который заблокирован в wait().
Что делать вместо этого
Сделать вызывающего async и делать await. Синхронная точка входа запускает Task и отдаёт результат через состояние или колбэк.
Task.sleep(for:) приостанавливает и отдаёт поток обратно.
По-настоящему блокирующий C- или legacy-API выполняется на своей DispatchQueue и возвращается через continuation.
Для коротких критических секций подходит Mutex, который нельзя удерживать поверх await.
Переменная LIBDISPATCH_COOPERATIVE_POOL_STRICT=1 в схеме сужает пул до одного потока, и скрытый deadlock воспроизводится каждый раз.
Актор выбирают, когда вызывающие асинхронные и внутри секции нужен await; lock (Mutex, OSAllocatedUnfairLock) подходит, когда доступ короткий, синхронный и частый; serial-очередь уместна внутри GCD-кода. Все три сериализуют доступ; различаются тем, приостанавливаются вызывающие или блокируются, и ценой.
Актор
Вызывающие делают await и приостанавливаются, поэтому ни один поток не блокируется; изоляцию проверяет компилятор.
Каждый вызов снаружи означает возможный переход на executor плюс приостановку. В цикле из 500 мелких вызовов эти накладные расходы доминируют, и обычный lock в замерах быстрее, поэтому передавайте весь пакет одним вызовом актора.
Состояние может измениться на каждом await внутри него (реентерабельность).
Lock
Mutex (Synchronization, iOS 18+), OSAllocatedUnfairLock (iOS 16+), NSLock: наносекунды без конкуренции, можно вызывать из синхронного кода, например из не-async требования протокола.
Никогда не держите его поверх await: Mutex.withLock принимает синхронное замыкание, так что это и невозможно. Делайте секции крошечными и берите lock’и в одном фиксированном порядке.
Serial-очередь
sync для чтения, async для записи; concurrent-очередь с записями через .barrier даёт reader-writer lock.
sync блокирует вызывающего и даёт deadlock при повторном входе из той же очереди, а компилятор не может это проверить, поэтому класс в итоге становится @unchecked Sendable.
Годится на границе с API на GCD; чтобы актор выполнялся на конкретной очереди, дайте ему custom executor.