Нужно выполнять асинхронные операции, поддерживать многозадачность в приложении? Async/await к вашим услугам - просто и приятно. Кооперативный пул эффективно переключает потоки между задачами, а компилятор проверяет типы на потокобезопасность. И даже можно подсоединять старые части кода, написанные ещё на GCD!

Вот только приложение на проде почему-то начало виснуть…

Ниже разберем конкретные примеры (со схемами), как не стоит смешивать async/await-код с DispatchQueue (то же справедливо и для других блокирующих примитивов).

Источник проблем

Система не выдаёт каждой Task отдельный поток. Задачи выполняются на кооперативном пуле потоков, а количество доступных потоков не превышает числа активных ядер процессора.

Apple описывает это в докладе Swift concurrency: Behind the scenes (WWDC21, сессия 10254)
Apple описывает это в докладе Swift concurrency: Behind the scenes (WWDC21, сессия 10254)

Когда задача не может выполниться тут же, она приостанавливается без блокировок. await - это потенциальная точка приостановки: если приостановка действительно происходит, задача освобождает поток, и он идёт обратно в пул, а своё состояние задача сохраняет в куче как continuation. Освободившийся поток тут же подхватывает другую задачу. Если же задача завершается сразу и приостановки нет, то поток не освобождается.

Поэтому можно дёшево порождать тысячи задач, это всего лишь небольшие аллокации в куче, а не отдельные потоки.

А вот блокирующий вызов GCD или бесконечная задача (цикл) - это не точка приостановки, они захватывают поток и не возвращают его в пул. Чем больше таких задач, тем выше шанс опустошить пул. Каждый способ из тех, что ниже, по-своему приводит к этой ситуации.

Способ №1. Набить пул блокирующими задачами

Самый простой способ - занять каждый поток пула задачей, которая его заблокирует до конца своего выполнения. Пример с DispatchQueue.sync:

// Размер пула 2. Запускаем 2 блокирующие задачи, каждая на своей очереди.
for i in 0..<2 {
    Task {
        DispatchQueue(label: "blocking-\(i)").sync {  // блокирует поток
            // какая-то тяжёлая работа
        }
        print("готово")
    }
}

Task { print("До связи...") }  // застряла в очереди, выполнится не скоро

Задача внутри sync не приостанавливается. Поток из пула ждет, пока блок отработает на очереди. Если сделать так одновременно на каждом потоке пула, его пропускная способность упадёт до нуля.

Задачи не приостановлены, потоки заблокированы
Задачи не приостановлены, потоки заблокированы

Пул оживает после завершения блоков. Но пока они выполняются, не продвигается ничего из того, что живёт на нём: неизолированные async-функции, обычные акторы, TaskGroup (@MainActor и очереди GCD при этом продолжают работать - у главного актора собственный executor на главном потоке, а у GCD свой пул). Чем тяжелее задача - синхронный сетевой запрос, большие вычисления, файловый ввод-вывод - тем дольше простой.

Как это может пройти через тесты? Если проверять только на мощных устройствах. Если в рантайме одновременно возникнут, скажем, 4 блокирующие задачи, то код может отработать нормально на 8 ядрах, а потом упадёт на 2-ядерном CI-раннере или на слабом устройстве.

Дополнительно

Истощение пула из-за блокирующих вызовов разбирается в ветке Swift Forums Deadlock When Using DispatchQueue from Swift Task, где подсистема «читатели-писатели», управляемая из TaskGroup, уходит во взаимоблокировку, как только достаточное число задач одновременно блокирует свои потоки пула.

Проблема в фреймворке Vision

Блокирующий вызов может быть внутри чужого кода, и в своём вы его не увидите. В ветке Swift Forums Cooperative pool deadlock when calling into an opaque subsystem описан такой случай: синхронный на вид API от Apple (VNImageRequestHandler.perform из Vision) внутри уходит в GCD и блокирует вызывающий поток. Достаточно нескольких параллельных задач, вызывающих его, чтобы истощить кооперативный пул и подвесить всё приложение.

Способ №2. Устроить взаимоблокировку между очередями

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

let queueA = DispatchQueue(label: "A")
let queueB = DispatchQueue(label: "B")

Task {
    queueA.sync {            // удерживает поток пула на A...
        queueB.sync { }      // ...затем ждёт B
    }
}

Task {
    queueB.sync {            // удерживает поток пула на B...
        queueA.sync { }      // ...затем ждёт A → циклическое ожидание
    }
}

Блок queueA не завершится, пока не освободится queueB, а блок queueB не завершится, пока не освободится queueA.

Важно: это не гарантированный deadlock. Он случается, только если оба внешних sync успевают захватить свои очереди раньше, чем выполнятся внутренние sync. Если первая задача целиком завершится до старта второй, то ничего не произойдёт. Это может приводить к "плавающим" багам.

Способ №3. Устроить взаимоблокировку на одной очереди

Подвид 1. Два вызова sync рядом

Знакомая ситуация:

let queue = DispatchQueue(label: "serial")

Task {
    queue.sync {                 // блокирует кооперативный поток
        // ...работа...
        queue.sync { }           // sync на ту же последовательную очередь
    }
}

На практике тут скорее будет не зависание, а краш. libdispatch распознаёт простой случай - поток уже владеет очередью и снова делает на неё sync - и намеренно крашит приложение с EXC_BAD_INSTRUCTION и сообщением BUG IN CLIENT OF LIBDISPATCH: dispatch_sync called on queue already owned by current thread.

Это относится к последовательной очереди. Вложенный sync на concurrent-очереди не вызовет deadlock, но поток пула всё равно удержит.

Взаимоблокировки sync между очередями и на одной очереди - известные "ловушки" GCD, в них легко попасть в кооперативном пуле ограниченного размера.

Подвид 2. Скрытый реентерабельный sync и одна очередь на всё

Блокирующий вызов может быть спрятан за невинной вспомогательной функцией. Например, в таком, вроде бы, безопасном синхронном аксессоре:

let queue = DispatchQueue(label: "store")

func currentUser() -> User {          // используется по всему коду
    queue.sync { _user }              // нормально — пока вы не на `queue`
}

А теперь кто-то где-то начинает работу на той же очереди и вызывает этот хелпер внутри:

Task {
    queue.sync {                      // теперь выполняется НА `queue`
        let user = currentUser()      // currentUser() снова вызывает queue.sync
        apply(user)                   // та же последовательная очередь → краш
    }
}

Каждый вызов сам по себе выглядит нормально. Проблема живёт только в связке, а две её половины могут лежать в разных концах кодовой базы. В итоге приложение падает с тем же сообщением libdispatch, что и в подвиде 1, но по стектрейсу не сразу видно, что виноваты две «нормальные» половины кода из разных файлов.

Способ №4. Не следить за @MainActor

Главный поток не входит в кооперативный пул, у @MainActor собственный executor на главном потоке. Но модель планирования та же, кооперативная, и блокирующий sync ломает её точно так же:

@MainActor
func onTap() {
    let worker = DispatchQueue(label: "load")
    worker.sync {  // блокирует главный поток, UI зависает
        let data = loadDataSync()
        DispatchQueue.main.sync {  // worker теперь ждёт main...
            render(data)  // ...но main заблокирован выше → deadlock
        }
    }
}

Блокировка главного потока останавливает отрисовку, обработку жестов и событий run loop. Пользователь видит застывший экран, и watchdog может убить приложение.

Способ №5. Не приостанавливать тяжелые синхронные задачи

Без GCD и всяких примитивов. Задача, выполняющая долгую синхронную работу между точками await, тоже не отдаёт свой поток обратно:

Task {
    while true {
        heavySynchronousWork()   // никогда не доходит до await
    }                            // удерживает свой поток навсегда
}

В кооперативном пуле рантайм может переназначить поток только в точке приостановки. Нет await - нет передачи. Плотный CPU-цикл без await с точки зрения пула неотличим от блокирующего вызова, просто он делает полезную работу, пока "морит голодом" всех остальных.

Возможное решение - разбить долгую работу на куски с await Task.yield() между ними:

Task {
    while !Task.isCancelled {
        heavySynchronousWork()
        await Task.yield()
    }
}

Документация Apple по Task.yield() описывает её как приостановку текущей задачи, чтобы дать выполниться другим задачам. Но это не идеальное решение, потому что между точками yield работа всё равно занимает поток пула.

Есть другой вариант: вынести тяжелую работу из пула целиком, например, через GCD+continuation или отдельный executor.

Как не сломать Swift Concurrency

  • Не вызывайте внутри Task долгие задачи под блокирующими примитивами или queue.sync. Короткие критические секции под быстрым локом (os_unfair_lock, NSLock, мгновенный queue.sync вокруг чтения поля) допустимы: поток, держащий лок, сам же выполнит работу и отпустит его сразу.

  • Вызывайте callback-API через continuations. Чтобы превратить GCD-API с completion-хендлером в async-функцию, оберните его в withCheckedContinuation (или withCheckedThrowingContinuation, когда возможна ошибка). Continuation приостанавливает задачу и возобновляет её из коллбэка без блокировок потока.

  • Держите блокирующие sync с одной очереди в одном месте. Если публичная функция блокирует поток, укажите это явно (с помощью сигнатуры/комментария) или используйте async.

  • Следите за вызовами в методах под @MainActor. Не вызывайте на главном потоке тяжелые задачи под sync , исключение - короткий sync ради атомарного чтения. Тяжёлую работу запускайте в отдельной Task или очереди и обновляйте UI асинхронно.

  • Используйте приостановку в тяжелых циклах. Вставляйте await Task.yield(), чтобы долгая (бесконечная) задача не захватила себе поток пула, либо вынесите работу из кооперативного пула.

  • Тестируйте на слабых устройствах и под нагрузкой. В окружении с 1–2 ядрами или на пуле, наполненном конкурентными задачами.

Что почитать

Документация Apple:

Swift Forums:

  • Deadlock When Using DispatchQueue from Swift Task - истощение пула из-за работы GCD, управляемой задачами (Способ №1). Обсуждение, почему короткие локи в кооперативном пуле допустимы, а ожидание чужой работы - нет.

  • Cooperative pool deadlock when calling into an opaque subsystem - синхронный на вид API фреймворка, который внутри блокируется и истощает пул (Способ №1).

  • Semaphore alternatives for structured concurrency - почему блокирующие примитивы в целом (включая семафоры) не подходят для async/await и что использовать вместо них.