Чертовски понятный
Swift Concurrency

Наконец-то поймите async/await, Tasks и почему компилятор постоянно на вас ругается.

Огромная благодарность Matt Massicotte за то, что сделал конкурентность Swift понятной. Составлено Pedro Piñera, сооснователем Tuist. Нашли ошибку? Откройте issue или отправьте PR.

Асинхронный код: async/await

Большую часть времени приложения просто ждут. Получить данные с сервера - ждать ответа. Прочитать файл с диска - ждать байтов. Запросить базу данных - ждать результатов.

До появления системы конкурентности Swift вы выражали это ожидание через callback'и, делегаты или Combine. Они работают, но вложенные callback'и становятся трудночитаемыми, а у Combine крутая кривая обучения.

async/await даёт Swift новый способ обработки ожидания. Вместо callback'ов вы пишете код, который выглядит последовательным - он приостанавливается, ждёт и возобновляется. Под капотом runtime Swift эффективно управляет этими паузами. Но чтобы ваше приложение оставалось отзывчивым во время ожидания, важно где выполняется код, о чём мы поговорим позже.

Асинхронная функция - это функция, которая может приостановиться. Вы помечаете её async, а при вызове используете await, чтобы сказать "подожди здесь, пока это не закончится":

func fetchUser(id: Int) async throws -> User {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, _) = try await URLSession.shared.data(from: url)  // Приостанавливается здесь
    return try JSONDecoder().decode(User.self, from: data)
}

// Вызов
let user = try await fetchUser(id: 123)
// Код здесь выполняется после завершения fetchUser

Ваш код приостанавливается на каждом await - это называется приостановка (suspension). Когда работа завершается, ваш код возобновляется ровно там, где остановился. Приостановка даёт Swift возможность делать другую работу во время ожидания.

Ожидание нескольких

Что если вам нужно получить несколько вещей? Можно ждать их по одной:

let avatar = try await fetchImage("avatar.jpg")
let banner = try await fetchImage("banner.jpg")
let bio = try await fetchBio()

Но это медленно - каждая ждёт завершения предыдущей. Используйте async let для параллельного выполнения:

func loadProfile() async throws -> Profile {
    async let avatar = fetchImage("avatar.jpg")
    async let banner = fetchImage("banner.jpg")
    async let bio = fetchBio()

    // Все три загружаются параллельно!
    return Profile(
        avatar: try await avatar,
        banner: try await banner,
        bio: try await bio
    )
}

Каждый async let стартует немедленно. await собирает результаты.

await требует async

Вы можете использовать await только внутри async функции.

Управление работой: Tasks

Task - это единица асинхронной работы, которой вы можете управлять. Вы написали async функции, но Task - это то, что их реально выполняет. Это способ запустить async код из синхронного кода, и он даёт вам контроль над этой работой: дождаться результата, отменить её или пустить в фоне.

Допустим, вы делаете экран профиля. Загрузите аватар при появлении view, используя модификатор .task, который автоматически отменяется при исчезновении view:

struct ProfileView: View {
    @State private var avatar: Image?

    var body: some View {
        Group {
            if let avatar {
                avatar
            } else {
                ProgressView()
            }
        }
        .task { avatar = await downloadAvatar() }
    }
}

Если пользователи могут переключаться между профилями, используйте .task(id:) для перезагрузки при смене выбора:

struct ProfileView: View {
    var userID: String
    @State private var avatar: Image?

    var body: some View {
        Group {
            if let avatar {
                avatar
            } else {
                ProgressView()
            }
        }
        .task(id: userID) { avatar = await downloadAvatar(for: userID) }
    }
}

Когда пользователь нажимает "Сохранить", создайте Task вручную:

Button("Save") {
    Task { await saveProfile() }
}

Доступ к результатам Task

Когда вы создаёте Task, вы получаете дескриптор. Используйте .value, чтобы дождаться и получить результат:

let handle = Task {
    return await fetchUserData()
}
let userData = await handle.value  // Приостанавливается до завершения задачи

Это полезно, когда результат нужен позже, или когда вы хотите сохранить дескриптор задачи и дождаться его в другом месте.

Что если нужно загрузить аватар, био и статистику одновременно? Используйте TaskGroup для параллельной загрузки:

try await withThrowingTaskGroup(of: Void.self) { group in
    group.addTask { avatar = try await downloadAvatar(for: userID) }
    group.addTask { bio = try await fetchBio(for: userID) }
    group.addTask { stats = try await fetchStats(for: userID) }
    try await group.waitForAll()
}

Задачи внутри группы - это дочерние задачи, связанные с родительской. Несколько важных моментов:

  • Отмена распространяется: отмените родителя, и все дочерние тоже будут отменены
  • Ошибки: выброшенная ошибка отменяет соседей и пробрасывается дальше, но только когда вы потребляете результаты через next(), waitForAll() или итерацию
  • Порядок завершения: результаты приходят по мере завершения задач, а не в порядке добавления
  • Ожидание всех: группа не возвращается, пока каждый ребёнок не завершится или не будет отменён

Это структурированная конкурентность: работа, организованная в дерево, которое легко понимать и очищать.

Где выполняется код: от потоков к доменам изоляции

До сих пор мы говорили о том, когда код выполняется (async/await) и как его организовать (Tasks). Теперь: где он выполняется и как обеспечить его безопасность?

Большинство приложений просто ждут

Большая часть кода приложений - это I/O-bound операции. Вы получаете данные из сети, ждёте ответа, декодируете и отображаете. Если у вас несколько I/O операций для координации, вы прибегаете к tasks и task groups. Реальная работа CPU минимальна. Главный поток справляется с этим нормально, потому что await приостанавливает, не блокируя.

Но рано или поздно у вас появится CPU-bound работа: парсинг гигантского JSON файла, обработка изображений, сложные вычисления. Эта работа ничего внешнего не ждёт. Ей просто нужны циклы CPU. Если запустить её на главном потоке, ваш UI зависнет. Вот тут и становится важно "где выполняется код".

Старый мир: много вариантов, никакой безопасности

До системы конкурентности Swift у вас было несколько способов управления выполнением:

Подход Что делает Компромиссы
Thread Прямой контроль потоков Низкоуровневый, подвержен ошибкам, редко нужен
GCD Dispatch очереди с замыканиями Просто, но нет отмены, легко вызвать взрыв потоков
OperationQueue Зависимости задач, отмена, KVO Больше контроля, но многословно и тяжеловесно
Combine Реактивные потоки Отлично для потоков событий, крутая кривая обучения

Всё это работало, но безопасность была полностью на вас. Компилятор не мог помочь, если вы забыли dispatch на main, или если две очереди одновременно обращались к одним данным.

Проблема: гонки данных

Гонка данных случается, когда два потока одновременно обращаются к одной памяти, и хотя бы один пишет:

var count = 0

DispatchQueue.global().async { count += 1 }
DispatchQueue.global().async { count += 1 }

// Неопределённое поведение: падение, порча памяти или неправильное значение

Гонки данных - это неопределённое поведение. Они могут крашить, портить память или молча выдавать неправильные результаты. Ваше приложение прекрасно работает в тестах, а потом случайно падает в продакшене. Традиционные инструменты вроде блокировок и семафоров помогают, но они ручные и подвержены ошибкам.

Конкурентность усугубляет проблему

Чем больше конкурентности в вашем приложении, тем вероятнее становятся гонки данных. Простое iOS приложение может обойтись небрежной потокобезопасностью. Веб-сервер, обрабатывающий тысячи одновременных запросов, будет падать постоянно. Вот почему безопасность на этапе компиляции в Swift важнее всего в высококонкурентных средах.

Сдвиг парадигмы: от потоков к изоляции

Модель конкурентности Swift задаёт другой вопрос. Вместо "на каком потоке это должно выполняться?" она спрашивает: "кому разрешено обращаться к этим данным?"

Это изоляция. Вместо ручной отправки работы на потоки вы объявляете границы вокруг данных. Компилятор обеспечивает эти границы на этапе сборки, а не в runtime.

Под капотом

Swift Concurrency построен поверх libdispatch (тот же runtime, что и GCD). Разница в слое компиляции: акторы и изоляция обеспечиваются компилятором, в то время как runtime управляет планированием на кооперативном пуле потоков, ограниченном количеством ядер вашего CPU.

Три домена изоляции

1. MainActor

@MainActor - это глобальный актор, представляющий домен изоляции главного потока. Он особенный, потому что UI-фреймворки (UIKit, AppKit, SwiftUI) требуют доступа с главного потока.

@MainActor
class ViewModel {
    var items: [Item] = []  // Защищено изоляцией MainActor
}

Когда вы помечаете что-то @MainActor, вы не говорите "dispatch это на главный поток". Вы говорите "это принадлежит домену изоляции главного актора". Компилятор гарантирует, что всё, что к этому обращается, либо находится на MainActor, либо должно использовать await для пересечения границы.

Если сомневаетесь - используйте @MainActor

Для большинства приложений пометить ваши ViewModel атрибутом @MainActor - правильный выбор. Беспокойства о производительности обычно преувеличены. Начните отсюда, оптимизируйте только если измерите реальные проблемы.

2. Actors

Актор защищает своё собственное изменяемое состояние. Он гарантирует, что только один кусок кода может обращаться к его данным в один момент времени:

actor BankAccount {
    var balance: Double = 0

    func deposit(_ amount: Double) {
        balance += amount  // Безопасно: актор гарантирует эксклюзивный доступ
    }
}

// Извне нужно использовать await для пересечения границы
await account.deposit(100)

Акторы - это не потоки. Актор - это граница изоляции. Runtime Swift решает, какой поток реально выполняет код актора. Вы это не контролируете, и вам не нужно.

3. Nonisolated

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

actor BankAccount {
    var balance: Double = 0

    nonisolated func bankName() -> String {
        "Acme Bank"  // Не обращается к состоянию актора, безопасно вызывать откуда угодно
    }
}

let name = account.bankName()  // await не нужен

Approachable Concurrency: меньше трения

Approachable Concurrency упрощает ментальную модель с помощью двух настроек сборки Xcode:

  • SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor: Всё выполняется на MainActor, если вы не скажете иначе
  • SWIFT_APPROACHABLE_CONCURRENCY = YES: nonisolated async функции остаются на акторе вызывающего вместо прыжка на фоновый поток

Новые проекты Xcode 26 имеют оба включёнными по умолчанию. Когда нужна CPU-интенсивная работа вне главного потока, используйте @concurrent.

// Выполняется на MainActor (по умолчанию)
func updateUI() async { }

// Выполняется на фоновом потоке (opt-in)
@concurrent func processLargeFile() async { }

Офисное здание

Представьте ваше приложение как офисное здание. Каждый домен изоляции - это приватный офис с замком на двери. Только один человек может находиться внутри одновременно, работая с документами в этом офисе.

  • MainActor - это ресепшен, где происходит всё взаимодействие с клиентами. Он один, и он обрабатывает всё, что видит пользователь.
  • actor типы - это офисы отделов: бухгалтерия, юридический, HR. Каждый защищает свои конфиденциальные документы.
  • nonisolated код - это коридор, общее пространство, через которое может пройти кто угодно, но никаких приватных документов там нет.

Вы не можете просто ворваться в чужой офис. Вы стучите (await) и ждёте, пока вас впустят.

Что может пересекать домены изоляции: Sendable

Домены изоляции защищают данные, но рано или поздно вам нужно передавать данные между ними. Когда вы это делаете, Swift проверяет, безопасно ли это.

Подумайте: если вы передаёте ссылку на изменяемый класс от одного актора другому, оба актора могут изменять его одновременно. Это именно та гонка данных, которую мы пытаемся предотвратить. Поэтому Swift должен знать: можно ли безопасно делиться этими данными?

Ответ - протокол Sendable. Это маркер, который говорит компилятору "этот тип безопасно передавать через границы изоляции":

  • Sendable типы могут пересекать безопасно (типы значений, неизменяемые данные, акторы)
  • Не-Sendable типы не могут (классы с изменяемым состоянием)
// Sendable - это тип значения, каждое место получает копию
struct User: Sendable {
    let id: Int
    let name: String
}

// Не-Sendable - это класс с изменяемым состоянием
class Counter {
    var count = 0  // Два места изменяют это = катастрофа
}

Делаем типы Sendable

Swift автоматически выводит Sendable для многих типов:

  • Структуры и перечисления только с Sendable свойствами неявно Sendable
  • Акторы всегда Sendable, потому что они защищают своё собственное состояние
  • @MainActor типы являются Sendable, потому что MainActor сериализует доступ

Для классов это сложнее. Класс может соответствовать Sendable, только если он final и все его хранимые свойства неизменяемы:

final class APIConfig: Sendable {
    let baseURL: URL      // Неизменяемый
    let timeout: Double   // Неизменяемый
}

Если у вас есть класс, который потокобезопасен другими средствами (блокировки, атомики), можно использовать @unchecked Sendable, чтобы сказать компилятору "доверься мне":

final class ThreadSafeCache: @unchecked Sendable {
    private let lock = NSLock()
    private var storage: [String: Data] = [:]
}

@unchecked Sendable - это обещание

Компилятор не будет проверять потокобезопасность. Если вы ошиблись, получите гонки данных. Используйте осторожно.

Approachable Concurrency: меньше трения

С Approachable Concurrency ошибки Sendable становятся намного реже:

  • Если код не пересекает границы изоляции, вам не нужен Sendable
  • Async функции остаются на акторе вызывающего вместо прыжка на фоновый поток
  • Компилятор умнее в определении, когда значения используются безопасно

Включите, установив SWIFT_DEFAULT_ACTOR_ISOLATION в MainActor и SWIFT_APPROACHABLE_CONCURRENCY в YES. Новые проекты Xcode 26 имеют оба включёнными по умолчанию. Когда вам нужен параллелизм, пометьте функции @concurrent, и тогда думайте о Sendable.

Ксерокопии vs оригиналы документов

Вернёмся к офисному зданию. Когда вам нужно поделиться информацией между отделами:

  • Ксерокопии безопасны - если юридический отдел делает копию документа и отправляет в бухгалтерию, у обоих своя копия. Они могут черкать на них, изменять, что угодно. Никакого конфликта.
  • Оригиналы подписанных контрактов должны оставаться на месте - если два отдела могут оба изменять оригинал, наступает хаос. У кого настоящая версия?

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

Как наследуется изоляция

Вы видели, что домены изоляции защищают данные, а Sendable контролирует, что пересекает границы между ними. Но как код вообще оказывается в домене изоляции?

Когда вы вызываете функцию или создаёте замыкание, изоляция течёт через ваш код. С Approachable Concurrency ваше приложение стартует на MainActor, и эта изоляция распространяется на код, который вы вызываете, если только что-то явно её не меняет. Понимание этого потока помогает предсказать, где выполняется код и почему компилятор иногда жалуется.

Вызовы функций

Когда вы вызываете функцию, её изоляция определяет, где она выполняется:

@MainActor func updateUI() { }      // Всегда выполняется на MainActor
func helper() { }                    // Наследует изоляцию вызывающего
@concurrent func crunch() async { }  // Явно выполняется вне актора

С Approachable Concurrency большая часть вашего кода наследует изоляцию MainActor. Функция выполняется там, где выполняется вызывающий, если она явно не отказывается.

Замыкания

Замыкания наследуют изоляцию от контекста, где они определены:

@MainActor
class ViewModel {
    func setup() {
        let closure = {
            // Наследует MainActor от ViewModel
            self.updateUI()  // Безопасно, та же изоляция
        }
        closure()
    }
}

Вот почему замыкания action в SwiftUI Button могут безопасно обновлять @State: они наследуют изоляцию MainActor от view.

Tasks

Task { } наследует изоляцию актора от места, где он создан:

@MainActor
class ViewModel {
    func doWork() {
        Task {
            // Наследует изоляцию MainActor
            self.updateUI()  // Безопасно, await не нужен
        }
    }
}

Обычно это то, что вам нужно. Task выполняется на том же акторе, что и код, который его создал.

Разрыв наследования: Task.detached

Иногда вам нужен task, который ничего не наследует:

@MainActor
class ViewModel {
    func doHeavyWork() {
        Task.detached {
            // Нет изоляции актора, выполняется на кооперативном пуле
            let result = await self.expensiveCalculation()
            await MainActor.run {
                self.data = result  // Явный прыжок обратно
            }
        }
    }
}

Task и Task.detached - антипаттерны

Tasks, которые вы планируете через Task { ... }, не управляются. Нет способа отменить их или узнать, когда они закончатся, если вообще закончатся. Нет способа получить их возвращаемое значение или узнать, возникла ли ошибка. В большинстве случаев лучше использовать tasks, управляемые через .task или TaskGroup, как объясняется в разделе "Типичные ошибки".

Task.detached должен быть крайней мерой. Отсоединённые tasks не наследуют приоритет, task-local значения или контекст актора. Если нужна CPU-интенсивная работа вне главного актора, пометьте функцию @concurrent вместо этого.

Сохранение изоляции в async утилитах

Иногда вы пишете обобщённую async функцию, принимающую замыкание — обёртку, помощник для повторных попыток, scope транзакции. Вызывающий передаёт замыкание, ваша функция его выполняет. Просто, правда?

func measure<T>(
    _ label: String,
    block: () async throws -> T
) async rethrows -> T {
    let start = ContinuousClock.now
    let result = try await block()
    print("\(label): \(ContinuousClock.now - start)")
    return result
}

Но когда вы вызываете это из контекста @MainActor, Swift жалуется:

Sending value of non-Sendable type '() async throws -> T' risks causing data races

Что происходит? Ваше замыкание захватывает состояние MainActor, но measurenonisolated. Swift видит не-Sendable замыкание, пересекающее границу изоляции — именно то, что он призван предотвращать.

Простейшее решение — nonisolated(nonsending). Это говорит Swift, что функция должна оставаться на том же executor'е, что и вызывающий:

nonisolated(nonsending)
func measure<T>(
    _ label: String,
    block: () async throws -> T
) async rethrows -> T {
    let start = ContinuousClock.now
    let result = try await block()
    print("\(label): \(ContinuousClock.now - start)")
    return result
}

Теперь вся функция выполняется на executor'е вызывающего. Вызовите из MainActor — останется на MainActor. Вызовите из кастомного актора — останется там. Замыкание никогда не пересекает границу изоляции, поэтому проверка Sendable не нужна.

Когда какой подход использовать

nonisolated(nonsending) — простой выбор. Просто добавьте атрибут. Используйте, когда нужно просто остаться на executor'е вызывающего.

isolation: isolated (any Actor)? = #isolation — явный выбор. Добавляет параметр, который даёт доступ к экземпляру актора. Используйте, когда нужно передать контекст изоляции другим функциям или узнать, на каком вы акторе.

Если вам нужен явный доступ к актору, используйте параметр #isolation:

func measure<T>(
    isolation: isolated (any Actor)? = #isolation,
    _ label: String,
    block: () async throws -> T
) async rethrows -> T {
    let start = ContinuousClock.now
    let result = try await block()
    print("\(label): \(ContinuousClock.now - start)")
    return result
}

Оба подхода необходимы для создания async утилит, которые естественно использовать. Без них вызывающим пришлось бы делать свои замыкания @Sendable или изворачиваться, чтобы удовлетворить компилятор.

Прогулка по зданию

Когда вы в офисе ресепшена (MainActor) и зовёте кого-то помочь, они приходят в ваш офис. Они наследуют ваше местоположение. Если вы создаёте task ("сделай это для меня"), этот помощник тоже начинает в вашем офисе.

Единственный способ оказаться в другом офисе - это явно туда пойти: "Мне нужно поработать в бухгалтерии для этого" (actor), или "Я займусь этим в заднем офисе" (@concurrent).

Складываем всё вместе

Давайте отступим назад и посмотрим, как все части сочетаются.

Swift Concurrency может ощущаться как куча концепций: async/await, Task, акторы, MainActor, Sendable, домены изоляции. Но на самом деле в центре всего одна идея: изоляция наследуется по умолчанию.

С включённым Approachable Concurrency ваше приложение стартует на MainActor. Это ваша отправная точка. Оттуда:

  • Каждая функция, которую вы вызываете, наследует эту изоляцию
  • Каждое замыкание, которое вы создаёте, захватывает эту изоляцию
  • Каждый Task { }, который вы порождаете, наследует эту изоляцию

Вам не нужно ничего аннотировать. Вам не нужно думать о потоках. Ваш код выполняется на MainActor, и изоляция просто распространяется через вашу программу автоматически.

Когда вам нужно выйти из этого наследования, вы делаете это явно:

  • @concurrent говорит "выполняй это на фоновом потоке"
  • actor говорит "этот тип имеет свой собственный домен изоляции"
  • Task.detached { } говорит "начни с чистого листа, ничего не наследуй"

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

Вот и всё. Вот вся модель:

  1. Изоляция распространяется от MainActor через ваш код
  2. Вы отказываетесь явно, когда нужна фоновая работа или отдельное состояние
  3. Sendable охраняет границы, когда данные пересекают домены

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

Куда двигаться дальше

Хорошая новость: вам не нужно осваивать всё сразу.

Большинству приложений нужны только основы. Пометьте ваши ViewModel атрибутом @MainActor, используйте async/await для сетевых вызовов и создавайте Task { }, когда нужно запустить async работу по нажатию кнопки. Вот и всё. Это покрывает 80% реальных приложений. Компилятор подскажет, если нужно больше.

Когда нужна параллельная работа, используйте async let для получения нескольких вещей одновременно, или TaskGroup, когда количество задач динамическое. Научитесь изящно обрабатывать отмену. Это покрывает приложения со сложной загрузкой данных или real-time фичами.

Продвинутые паттерны придут позже, если вообще понадобятся. Кастомные акторы для общего изменяемого состояния, @concurrent для CPU-интенсивной обработки, глубокое понимание Sendable. Это код фреймворков, серверный Swift, сложные десктопные приложения. Большинству разработчиков этот уровень никогда не понадобится.

Начните просто

Не оптимизируйте под проблемы, которых у вас нет. Начните с основ, выпустите приложение и добавляйте сложность только когда столкнётесь с реальными проблемами. Компилятор вас направит.

Осторожно: типичные ошибки

Думать, что async = фон

// Это ВСЁ ЕЩЁ блокирует главный поток!
@MainActor
func slowFunction() async {
    let result = expensiveCalculation()  // Синхронная работа = блокировка
    data = result
}

async означает "может приостановиться". Реальная работа всё ещё выполняется там, где выполняется. Используйте @concurrent (Swift 6.2) или Task.detached для CPU-тяжёлой работы.

Создание слишком многих акторов

// Переусложнено
actor NetworkManager { }
actor CacheManager { }
actor DataManager { }

// Лучше - большинство вещей могут жить на MainActor
@MainActor
class AppState { }

Вам нужен кастомный актор только когда у вас есть общее изменяемое состояние, которое не может жить на MainActor. Правило Matt Massicotte: вводите актор только когда (1) у вас есть не-Sendable состояние, (2) операции над этим состоянием должны быть атомарными, и (3) эти операции не могут выполняться на существующем акторе. Если не можете это обосновать, используйте @MainActor вместо этого.

Делать всё Sendable

Не всё должно пересекать границы. Если вы добавляете @unchecked Sendable везде, остановитесь и спросите, действительно ли данным нужно перемещаться между доменами изоляции.

Использование MainActor.run когда не нужно

// Не нужно
Task {
    let data = await fetchData()
    await MainActor.run {
        self.data = data
    }
}

// Лучше - просто сделайте функцию @MainActor
@MainActor
func loadData() async {
    self.data = await fetchData()
}

MainActor.run редко правильное решение. Если вам нужна изоляция MainActor, аннотируйте функцию @MainActor вместо этого. Это понятнее, и компилятор сможет помочь больше. См. мнение Matt об этом.

Блокировка кооперативного пула потоков

// НИКОГДА так не делайте - риск deadlock
func badIdea() async {
    let semaphore = DispatchSemaphore(value: 0)
    Task {
        await doWork()
        semaphore.signal()
    }
    semaphore.wait()  // Блокирует кооперативный поток!
}

Кооперативный пул потоков Swift имеет ограниченное количество потоков. Блокировка одного через DispatchSemaphore, DispatchGroup.wait() или подобные вызовы может вызвать deadlock. Если нужно связать sync и async код, используйте async let или перестройте, чтобы оставаться полностью async.

Создание неуправляемых tasks

Tasks, которые вы создаёте вручную через Task { ... } или Task.detached { ... }, не управляются. После создания неуправляемых tasks вы не можете их контролировать. Вы не можете отменить их, если task, из которого вы их запустили, отменён. Вы не можете узнать, завершили ли они работу, выбросили ли ошибку, или получить их возвращаемое значение. Запуск такого task подобен бросанию бутылки в море в надежде, что она доставит послание по назначению, никогда больше не увидев эту бутылку.

Офисное здание

Task похож на поручение работы сотруднику. Сотрудник обрабатывает запрос (включая ожидание других офисов), пока вы продолжаете свою непосредственную работу.

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

Что вам действительно нужно - это дать сотруднику рацию для связи с ней, пока она обрабатывает запрос. С рацией вы можете сказать ей остановиться, или она может сообщить вам, когда столкнётся с ошибкой, или может доложить результат запроса, который вы ей дали.

Вместо создания неуправляемых tasks используйте конкурентность Swift, чтобы сохранить контроль над создаваемыми подзадачами. Используйте TaskGroup для управления (группой) подзадач. Swift предоставляет несколько функций withTaskGroup() { group in ... } для создания групп tasks.

func doWork() async {

    // это вернётся, когда все подзадачи вернутся, выбросят ошибку или будут отменены
    let result = try await withThrowingTaskGroup() { group in
        group.addTask {
            try await self.performAsyncOperation1()
        }
        group.addTask {
            try await self.performAsyncOperation2()
        }
        // ожидайте и собирайте результаты tasks здесь
    }
}

func performAsyncOperation1() async throws -> Int {
    return 1
}
func performAsyncOperation2() async throws -> Int {
    return 2
}

Чтобы собрать результаты дочерних tasks группы, можно использовать цикл for-await-in:

var sum = 0
for await result in group {
    sum += result
}
// sum == 3

Подробнее о TaskGroup можно узнать в документации Swift.

Заметка о Tasks и SwiftUI.

При написании UI вы часто хотите запустить асинхронные tasks из синхронного контекста. Например, вы хотите асинхронно загрузить изображение в ответ на касание UI элемента. Запуск асинхронных tasks из синхронного контекста невозможен в Swift. Поэтому вы видите решения с использованием Task { ... }, что вводит неуправляемые tasks.

Вы не можете использовать TaskGroup из синхронного модификатора SwiftUI, потому что withTaskGroup() тоже async функция, как и её связанные функции.

В качестве альтернативы SwiftUI предлагает асинхронный модификатор для запуска асинхронных операций. Модификатор .task { }, который мы уже упоминали, принимает функцию () async -> Void, идеальную для вызова других async функций. Он доступен на каждом View. Он срабатывает перед появлением view, и создаваемые им tasks управляются и привязаны к жизненному циклу view, что означает отмену tasks при исчезновении view.

Возвращаясь к примеру с касанием-для-загрузки-изображения: вместо создания неуправляемого task для вызова асинхронной функции loadImage() из синхронной функции .onTap() { ... }, вы можете переключить флаг при жесте касания и использовать модификатор task(id:) для асинхронной загрузки изображений при изменении значения id (флага).

Вот пример:

struct ContentView: View {

    @State private var shouldLoadImage = false

    var body: some View {
        Button("Нажми меня!") {
            // переключаем флаг
            shouldLoadImage = !shouldLoadImage
        }
        // View управляет подзадачей
        // она запускается перед отображением view
        // и останавливается при скрытии view
        .task(id: shouldLoadImage) {
            // когда значение флага меняется, SwiftUI перезапускает task
            guard shouldLoadImage else { return }
            await loadImage()
        }
    }
}

Шпаргалка: краткий справочник

Ключевое слово Что делает
async Функция может приостанавливаться
await Приостановиться здесь до завершения
Task { } Запустить async работу, наследует контекст
Task.detached { } Запустить async работу, без наследования контекста
@MainActor Выполняется на главном потоке
actor Тип с изолированным изменяемым состоянием
nonisolated Отказ от изоляции актора
nonisolated(nonsending) Остаётся на executor'е вызывающего
Sendable Безопасно передавать между доменами изоляции
@concurrent Всегда выполнять в фоне (Swift 6.2+)
#isolation Захватить изоляцию вызывающего как параметр
async let Запустить параллельную работу
TaskGroup Динамическая параллельная работа

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

Блог Matt Massicotte (очень рекомендуется)

Инструменты

  • Tuist - Разрабатывайте быстрее с большими командами и кодовыми базами

Навык для ИИ-агентов

Хотите, чтобы ваш ИИ-помощник по коду понимал Swift Concurrency? Мы предоставляем файл SKILL.md, который упаковывает эти ментальные модели для ИИ-агентов, таких как Claude Code, Codex, Amp, OpenCode и других.

Другие навыки

Что такое навык?

Навык - это markdown-файл, который обучает ИИ-агентов специализированным знаниям. Когда вы добавляете навык Swift Concurrency к вашему агенту, он автоматически применяет эти концепции при помощи в написании асинхронного Swift-кода.

Как использовать

Выберите вашего агента и выполните команды:

# Личный навык (все ваши проекты)
mkdir -p ~/.claude/skills/swift-concurrency
curl -o ~/.claude/skills/swift-concurrency/SKILL.md https://fuckingapproachableswiftconcurrency.com/SKILL.md
# Навык проекта (только этот проект)
mkdir -p .claude/skills/swift-concurrency
curl -o .claude/skills/swift-concurrency/SKILL.md https://fuckingapproachableswiftconcurrency.com/SKILL.md
# Глобальные инструкции (все ваши проекты)
curl -o ~/.codex/AGENTS.md https://fuckingapproachableswiftconcurrency.com/SKILL.md
# Инструкции проекта (только этот проект)
curl -o AGENTS.md https://fuckingapproachableswiftconcurrency.com/SKILL.md
# Инструкции проекта (рекомендуется)
curl -o AGENTS.md https://fuckingapproachableswiftconcurrency.com/SKILL.md
# Глобальные правила (все ваши проекты)
mkdir -p ~/.config/opencode
curl -o ~/.config/opencode/AGENTS.md https://fuckingapproachableswiftconcurrency.com/SKILL.md
# Правила проекта (только этот проект)
curl -o AGENTS.md https://fuckingapproachableswiftconcurrency.com/SKILL.md

Навык включает аналогию офисного здания, паттерны изоляции, руководство по Sendable, распространённые ошибки и таблицы быстрого справочника. Ваш агент автоматически использует эти знания при работе с кодом Swift Concurrency.