Конкурентность в Swift 6: async/await, акторы и структурированная конкурентность на практике

Подробное руководство по конкурентности в Swift 6 — async/await, акторы, TaskGroup, Sendable, реентерабельность и новые возможности Swift 6.2. Практические паттерны для реальных iOS-приложений.

Swift 6 Concurrency 2026: async/await и акторы

Конкурентность в Swift 6: async/await, акторы и структурированная конкурентность на практике

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

Apple долгое время предлагала Grand Central Dispatch (GCD) как основной инструмент для работы с многопоточностью. Но с выходом Swift 5.5 и последующим развитием языка до версии 6.x мир конкурентности в Swift изменился навсегда. В этом руководстве мы подробно разберём все ключевые механизмы — от базового async/await до акторов, структурированной конкурентности и новейших изменений в Swift 6.2.

От GCD к современной конкурентности

Чтобы по-настоящему оценить современный подход Swift к конкурентности, стоит вспомнить, откуда мы пришли. Grand Central Dispatch, представленный Apple в 2009 году, был революционным для своего времени. Он абстрагировал управление потоками и предлагал удобную модель очередей:

// Классический подход с GCD
DispatchQueue.global(qos: .userInitiated).async {
    let data = try? Data(contentsOf: url)
    let image = UIImage(data: data!)

    DispatchQueue.main.async {
        self.imageView.image = image
    }
}

Знакомо, правда? Проблемы этого подхода были многочисленны: вложенные замыкания (callback hell), отсутствие компиляторных гарантий безопасности потоков, невозможность простой отмены операций и сложность обработки ошибок. Разработчик мог легко забыть вернуться на главный поток, случайно захватить self сильной ссылкой или допустить гонку данных, о которой компилятор никак не предупреждал.

В 2021 году с выходом Swift 5.5 Apple представила совершенно новую модель конкурентности, основанную на async/await, акторах и структурированной конкурентности. И это не просто «более удобный синтаксис» — впервые в истории Swift компилятор получил возможность проверять корректность конкурентного кода ещё на этапе компиляции. Swift 6.0 сделал эти проверки обязательными, а Swift 6.2 значительно улучшил их доступность и эргономику.

Основы async/await

Фундаментом современной конкурентности в Swift является механизм async/await. Идея проста: функция, помеченная ключевым словом async, может приостанавливать своё выполнение, не блокируя текущий поток. Это позволяет системе эффективно использовать ограниченное число потоков для обработки множества асинхронных задач.

Объявление и вызов асинхронных функций

Асинхронная функция объявляется с помощью ключевого слова async после списка параметров и перед возвращаемым типом. Если функция может выбрасывать ошибки, порядок ключевых слов — async throws:

struct User: Codable, Sendable {
    let id: Int
    let name: String
    let email: String
}

func fetchUser(id: Int) async throws -> User {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, response) = try await URLSession.shared.data(from: url)

    guard let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 200 else {
        throw NetworkError.invalidResponse
    }

    return try JSONDecoder().decode(User.self, from: data)
}

// Вызов асинхронной функции
func loadUserProfile() async {
    do {
        let user = try await fetchUser(id: 42)
        print("Загружен пользователь: \(user.name)")
    } catch {
        print("Ошибка загрузки: \(error)")
    }
}

Каждый вызов await — это точка приостановки (suspension point). В этой точке выполнение функции может быть приостановлено, а поток освобождён для другой работы. Когда асинхронная операция завершается, выполнение возобновляется — возможно, уже на другом потоке. Честно говоря, поначалу это может сбивать с толку, но к этому быстро привыкаешь.

Преобразование callback-функций в async/await

Существующий код на замыканиях (callbacks) можно преобразовать к async/await с помощью механизма continuations. Swift предоставляет несколько типов продолжений: withCheckedContinuation, withCheckedThrowingContinuation, а также их unchecked-варианты для участков, критичных к производительности:

// Старый API на основе callback
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        if let error = error {
            completion(.failure(error))
        } else if let data = data {
            completion(.success(data))
        }
    }.resume()
}

// Обёртка для async/await
func fetchData() async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        fetchData { result in
            switch result {
            case .success(let data):
                continuation.resume(returning: data)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

Важный момент: продолжение (continuation) должно быть вызвано ровно один раз. Вызовете дважды — краш. Не вызовете вообще — задача зависнет навечно. Вариант withCheckedContinuation добавляет проверки в режиме отладки и помогает обнаружить такие ошибки на этапе разработки.

Структурированная конкурентность

Одна из самых мощных (и, на мой взгляд, самых элегантных) концепций в Swift — структурированная конкурентность (structured concurrency). Суть в том, что дочерние задачи всегда привязаны к родительской, образуя иерархическое дерево. Родительская задача отменяется — автоматически отменяются и все потомки. Родительская задача завершается — она гарантированно дожидается завершения всех дочерних.

Красиво, не правда ли?

async let — параллельное выполнение

Самый простой способ запустить несколько задач параллельно — использовать async let. Это объявление создаёт дочернюю задачу, которая начинает выполняться немедленно:

func loadDashboard() async throws -> Dashboard {
    // Все три запроса выполняются параллельно
    async let user = fetchUser(id: currentUserId)
    async let notifications = fetchNotifications()
    async let recommendations = fetchRecommendations()

    // Ожидаем результаты всех трёх запросов
    return try await Dashboard(
        user: user,
        notifications: notifications,
        recommendations: recommendations
    )
}

В этом примере три сетевых запроса выполняются одновременно, а не последовательно. Если бы каждый запрос занимал по 500 мс, последовательное выполнение заняло бы 1500 мс, а параллельное — примерно 500 мс. И если любой из запросов выбрасывает ошибку, остальные автоматически отменяются.

TaskGroup — динамическое количество задач

Когда количество параллельных задач неизвестно заранее, на помощь приходит TaskGroup. Это особенно полезно, когда нужно обработать коллекцию элементов параллельно:

func fetchAllUsers(ids: [Int]) async throws -> [User] {
    try await withThrowingTaskGroup(of: User.self) { group in
        for id in ids {
            group.addTask {
                try await self.fetchUser(id: id)
            }
        }

        var users: [User] = []
        for try await user in group {
            users.append(user)
        }
        return users
    }
}

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

func fetchAllUsersOrdered(ids: [Int]) async throws -> [User] {
    try await withThrowingTaskGroup(of: (Int, User).self) { group in
        for (index, id) in ids.enumerated() {
            group.addTask {
                let user = try await self.fetchUser(id: id)
                return (index, user)
            }
        }

        var indexedUsers: [(Int, User)] = []
        for try await pair in group {
            indexedUsers.append(pair)
        }

        return indexedUsers
            .sorted { $0.0 < $1.0 }
            .map { $0.1 }
    }
}

Ограничение конкурентности в TaskGroup

По умолчанию TaskGroup запускает все добавленные задачи одновременно. Если у вас тысячи элементов, это запросто может привести к перегрузке сервера или исчерпанию ресурсов. К счастью, ограничить количество одновременных задач можно паттерном «скользящего окна»:

func downloadImages(urls: [URL], maxConcurrency: Int = 5) async throws -> [Data] {
    try await withThrowingTaskGroup(of: (Int, Data).self) { group in
        var results: [(Int, Data)] = []
        var nextIndex = 0

        // Запускаем начальную порцию задач
        for _ in 0..<min(maxConcurrency, urls.count) {
            let index = nextIndex
            group.addTask {
                let (data, _) = try await URLSession.shared.data(from: urls[index])
                return (index, data)
            }
            nextIndex += 1
        }

        // По мере завершения задач добавляем новые
        for try await result in group {
            results.append(result)

            if nextIndex < urls.count {
                let index = nextIndex
                group.addTask {
                    let (data, _) = try await URLSession.shared.data(from: urls[index])
                    return (index, data)
                }
                nextIndex += 1
            }
        }

        return results.sorted { $0.0 < $1.0 }.map { $0.1 }
    }
}

Этот паттерн гарантирует, что одновременно выполняется не более maxConcurrency запросов, при этом новые задачи запускаются сразу после завершения предыдущих — максимальная пропускная способность без перегрузки ресурсов.

Акторы: защита изменяемого состояния

Акторы — это ссылочные типы (подобно классам), которые обеспечивают безопасный доступ к своему изменяемому состоянию в конкурентной среде. Актор гарантирует, что в любой момент времени только одна задача обращается к его внутреннему состоянию, автоматически сериализуя все обращения.

По сути, это тот самый «потокобезопасный класс», о котором мы всегда мечтали, но который раньше приходилось собирать вручную из блокировок и очередей.

actor BankAccount {
    let accountNumber: String
    private(set) var balance: Decimal

    init(accountNumber: String, initialBalance: Decimal) {
        self.accountNumber = accountNumber
        self.balance = initialBalance
    }

    func deposit(amount: Decimal) {
        balance += amount
    }

    func withdraw(amount: Decimal) throws -> Decimal {
        guard balance >= amount else {
            throw BankError.insufficientFunds
        }
        balance -= amount
        return amount
    }

    func transfer(amount: Decimal, to other: BankAccount) async throws {
        guard balance >= amount else {
            throw BankError.insufficientFunds
        }
        balance -= amount
        await other.deposit(amount: amount)
    }
}

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

let account = BankAccount(accountNumber: "12345", initialBalance: 1000)
let currentBalance = await account.balance
try await account.withdraw(amount: 200)

@MainActor

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

@MainActor
class ProfileViewModel: ObservableObject {
    @Published var user: User?
    @Published var isLoading = false
    @Published var errorMessage: String?

    func loadUser() async {
        isLoading = true
        defer { isLoading = false }

        do {
            // fetchUser выполняется асинхронно, но результат
            // обрабатывается гарантированно на главном потоке
            user = try await APIService.shared.fetchUser(id: 42)
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

Когда весь класс помечен @MainActor, все его свойства и методы автоматически изолированы на главном потоке. Прощайте, ошибки обновления UI из фонового потока!

Пользовательские глобальные акторы

Помимо @MainActor, вы можете определять собственные глобальные акторы для организации работы конкретных подсистем:

@globalActor
actor DatabaseActor {
    static let shared = DatabaseActor()
}

@DatabaseActor
class DatabaseManager {
    private var connection: DatabaseConnection?

    func executeQuery(_ query: String) async throws -> [Row] {
        guard let connection = connection else {
            throw DatabaseError.notConnected
        }
        return try await connection.execute(query)
    }

    func connect(to url: URL) async throws {
        connection = try await DatabaseConnection(url: url)
    }
}

Реентерабельность акторов: скрытая ловушка

Итак, перейдём к одной из самых коварных проблем при работе с акторами — реентерабельности (reentrancy). Актор гарантирует, что только одна задача выполняет код внутри него в любой момент. Но (и это большое «но») когда метод актора содержит точку приостановки (await), актор освобождается на время ожидания, и другая задача может войти в него и изменить состояние.

Пример проблемы

Рассмотрим классический пример — кэширование с использованием актора:

actor ImageCache {
    private var cache: [URL: UIImage] = [:]

    func loadImage(from url: URL) async throws -> UIImage {
        // Проверяем кэш
        if let cached = cache[url] {
            return cached
        }

        // ⚠️ Точка приостановки! Актор освобождается!
        let (data, _) = try await URLSession.shared.data(from: url)
        let image = UIImage(data: data)!

        // К этому моменту другая задача уже могла загрузить
        // то же самое изображение и поместить его в кэш.
        cache[url] = image
        return image
    }
}

Если две задачи одновременно запрашивают одно и то же изображение, обе проверят кэш, обе обнаружат его пустым и обе начнут загрузку. Гонки данных тут не будет (актор от этого защищает), но работа продублируется. А на медленном соединении это особенно неприятно.

Решение проблемы реентерабельности

Эффективное решение — хранить не только результаты, но и текущие задачи загрузки:

actor ImageCache {
    private var cache: [URL: UIImage] = [:]
    private var inProgress: [URL: Task<UIImage, Error>] = [:]

    func loadImage(from url: URL) async throws -> UIImage {
        // Проверяем готовый кэш
        if let cached = cache[url] {
            return cached
        }

        // Проверяем, не загружается ли уже это изображение
        if let existingTask = inProgress[url] {
            return try await existingTask.value
        }

        // Создаём новую задачу загрузки
        let task = Task {
            let (data, _) = try await URLSession.shared.data(from: url)
            guard let image = UIImage(data: data) else {
                throw ImageError.invalidData
            }
            return image
        }

        inProgress[url] = task

        do {
            let image = try await task.value
            cache[url] = image
            inProgress[url] = nil
            return image
        } catch {
            inProgress[url] = nil
            throw error
        }
    }
}

Теперь, если две задачи запрашивают одно изображение, вторая просто присоединится к уже выполняющейся загрузке через existingTask.value. Никакого дублирования.

Золотое правило: после каждой точки await внутри актора предполагайте, что любое его состояние могло измениться. Всегда перепроверяйте условия после приостановки.

Протокол Sendable

Протокол Sendable — краеугольный камень безопасности конкурентности в Swift. Он маркирует типы, экземпляры которых можно безопасно передавать между конкурентными контекстами (между акторами, задачами и т.д.).

Какие типы являются Sendable

  • Типы-значения (структуры, перечисления), все хранимые свойства которых тоже Sendable, автоматически соответствуют протоколу.
  • Акторы всегда являются Sendable, поскольку доступ к их состоянию сериализован.
  • Классы, помеченные final, с неизменяемыми (let) и Sendable-свойствами, тоже могут соответствовать.
  • Базовые типы (Int, String, Bool, Double) — Sendable по умолчанию.

@Sendable замыкания

Замыкания, передаваемые между конкурентными контекстами, должны быть помечены @Sendable. Такие замыкания не могут захватывать изменяемые переменные из окружающего контекста:

func processItems(_ items: [Item]) async {
    await withTaskGroup(of: Void.self) { group in
        for item in items {
            // Замыкание, передаваемое в addTask, неявно является @Sendable
            group.addTask {
                await self.process(item) // item должен быть Sendable
            }
        }
    }
}

// Явное указание @Sendable
let handler: @Sendable () -> Void = {
    print("Это замыкание безопасно для передачи между потоками")
}

@unchecked Sendable

Иногда тип не может автоматически соответствовать Sendable, но вы точно знаете, что он безопасен — скажем, внутренняя синхронизация реализована через блокировки. В таких случаях пригодится @unchecked Sendable:

final class ThreadSafeCache<Key: Hashable, Value>: @unchecked Sendable {
    private var storage: [Key: Value] = [:]
    private let lock = NSLock()

    subscript(key: Key) -> Value? {
        get {
            lock.lock()
            defer { lock.unlock() }
            return storage[key]
        }
        set {
            lock.lock()
            defer { lock.unlock() }
            storage[key] = newValue
        }
    }
}

Будьте осторожны: @unchecked Sendable — это, по сути, обещание компилятору, что вы самостоятельно обеспечиваете потокобезопасность. Нарушите обещание — компилятор не спасёт от гонки данных. Используйте только когда действительно уверены.

Swift 6.2 и «Доступная конкурентность» (Approachable Concurrency)

Swift 6.0 сделал проверку безопасности данных обязательной. И, скажем прямо, это вызвало лавину предупреждений и ошибок компилятора во многих проектах. Разработчики, чей код был фактически однопоточным, вдруг были вынуждены разбираться с Sendable, изоляцией акторов и прочими сложностями, не имевшими отношения к их задачам.

Swift 6.2 кардинально меняет этот подход, вводя концепцию «доступной конкурентности» (Approachable Concurrency).

Изоляция MainActor по умолчанию (SE-0466)

Ключевое нововведение — возможность сделать @MainActor изоляцией по умолчанию для всего модуля. Весь код в модуле автоматически выполняется на главном потоке, если явно не указано иное. Для типичного iOS-приложения это идеально — большая часть кода и так должна работать на главном потоке.

Включается в Package.swift:

// Package.swift
.target(
    name: "MyApp",
    swiftSettings: [
        .defaultIsolation(MainActor.self)
    ]
)

С этой настройкой следующий код работает без единой аннотации @MainActor:

// Всё автоматически изолировано на MainActor
class ViewModel: ObservableObject {
    @Published var items: [String] = []
    @Published var isLoading = false

    func refresh() async {
        isLoading = true
        defer { isLoading = false }
        items = try await APIService.shared.fetchItems()
    }
}

nonisolated(nonsending) — новое поведение по умолчанию

В прежних версиях Swift асинхронная функция с nonisolated могла неожиданно переключиться на фоновый поток. Это было источником множества ошибок (особенно при работе с UI). Swift 6.2 вводит модификатор nonisolated(nonsending), который становится поведением по умолчанию.

Суть: асинхронная функция без явной изоляции теперь наследует контекст вызывающей стороны. Вызвали с @MainActor — выполнится на главном потоке. Вызвали из фонового контекста — выполнится там. Функция больше не «убегает» на произвольный поток:

// Swift 6.2: функция наследует контекст вызывающей стороны
func processData(_ data: Data) async -> ProcessedResult {
    // Если вызвана из @MainActor — выполнится на главном потоке
    // Если вызвана из фоновой задачи — выполнится в том же контексте
    let result = transform(data)
    return result
}

Атрибут @concurrent

А когда вам действительно нужно, чтобы функция выполнялась в фоне параллельно с вызывающим кодом? Swift 6.2 вводит явный атрибут @concurrent:

// Явно указываем: эта функция должна работать в фоне
@concurrent
func performHeavyComputation(data: [Double]) async -> Double {
    // Гарантированно выполняется вне MainActor
    // Параметры должны быть Sendable
    var sum = 0.0
    for value in data {
        sum += value * value
    }
    return sum.squareRoot()
}

Вот это и есть прогрессивное раскрытие (progressive disclosure) в действии. На первом уровне разработчик пишет обычный синхронный код на главном потоке. На втором — подключает async/await, оставаясь в контексте вызывающей стороны. И только на третьем, когда действительно нужна параллельность, он помечает функцию @concurrent и берёт на себя ответственность за Sendable-совместимость.

Практические паттерны

Теория — это хорошо, но давайте посмотрим на реальные паттерны, с которыми вы столкнётесь в боевых приложениях.

Паттерн: обновление токена авторизации с помощью актора

Классическая задача — когда токен авторизации протух, его нужно обновить ровно один раз, даже если несколько запросов одновременно обнаружили проблему:

actor AuthManager {
    private var accessToken: String?
    private var refreshToken: String?
    private var refreshTask: Task<String, Error>?

    func validToken() async throws -> String {
        // Если есть действующий токен — возвращаем
        if let token = accessToken, !isExpired(token) {
            return token
        }

        // Обновление уже идёт — присоединяемся
        if let existingTask = refreshTask {
            return try await existingTask.value
        }

        // Начинаем обновление
        let task = Task { () -> String in
            guard let refresh = refreshToken else {
                throw AuthError.notAuthenticated
            }

            let newTokens = try await APIService.shared.refreshToken(refresh)
            return newTokens.accessToken
        }

        refreshTask = task

        do {
            let newToken = try await task.value
            self.accessToken = newToken
            self.refreshTask = nil
            return newToken
        } catch {
            self.refreshTask = nil
            self.accessToken = nil
            self.refreshToken = nil
            throw error
        }
    }

    private func isExpired(_ token: String) -> Bool {
        guard let payload = decodeJWT(token),
              let exp = payload["exp"] as? TimeInterval else {
            return true
        }
        return Date().timeIntervalSince1970 >= exp
    }
}

Этот паттерн элегантно решает сразу несколько проблем: предотвращает множественные запросы на обновление, обеспечивает потокобезопасный доступ и корректно обрабатывает ошибки. Я использовал его в нескольких проектах, и он ни разу не подвёл.

Паттерн: параллельные API-запросы с обработкой ошибок

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

struct DashboardData {
    var profile: UserProfile?
    var orders: [Order]
    var recommendations: [Product]
    var notifications: [Notification]
}

func loadDashboard() async -> DashboardData {
    async let profileResult = Result { try await fetchProfile() }
    async let ordersResult = Result { try await fetchOrders() }
    async let recommendationsResult = Result { try await fetchRecommendations() }
    async let notificationsResult = Result { try await fetchNotifications() }

    let profile = try? await profileResult.get()
    let orders = (try? await ordersResult.get()) ?? []
    let recommendations = (try? await recommendationsResult.get()) ?? []
    let notifications = (try? await notificationsResult.get()) ?? []

    return DashboardData(
        profile: profile,
        orders: orders,
        recommendations: recommendations,
        notifications: notifications
    )
}

Все четыре запроса идут параллельно. Один упал? Не страшно, остальные продолжат работу. Для пользователя это означает, что экран загрузится быстро, даже если один из сервисов тормозит.

Паттерн: ограничение конкурентности при массовой загрузке

Вот более продвинутый вариант «скользящего окна» с отслеживанием прогресса и поддержкой отмены:

actor DownloadManager {
    private var activeTasks = 0
    private let maxConcurrent: Int

    init(maxConcurrent: Int = 4) {
        self.maxConcurrent = maxConcurrent
    }

    func downloadAll(
        urls: [URL],
        onProgress: @Sendable (Int, Int) -> Void
    ) async throws -> [URL: Data] {
        try await withThrowingTaskGroup(of: (URL, Data).self) { group in
            var results: [URL: Data] = [:]
            var completed = 0
            var iterator = urls.makeIterator()

            // Заполняем начальный пул задач
            for _ in 0..<min(maxConcurrent, urls.count) {
                guard let url = iterator.next() else { break }
                group.addTask { [url] in
                    try Task.checkCancellation()
                    let (data, _) = try await URLSession.shared.data(from: url)
                    return (url, data)
                }
            }

            // Обрабатываем результаты и добавляем новые задачи
            for try await (url, data) in group {
                results[url] = data
                completed += 1
                onProgress(completed, urls.count)

                if let nextURL = iterator.next() {
                    group.addTask { [nextURL] in
                        try Task.checkCancellation()
                        let (data, _) = try await URLSession.shared.data(from: nextURL)
                        return (nextURL, data)
                    }
                }
            }

            return results
        }
    }
}

Миграция с GCD на Swift Concurrency

Переход с GCD на современную модель конкурентности — это не тот процесс, который стоит делать за один вечер. Нужно планирование и понимание ключевых различий.

Пошаговый подход к миграции

  1. Начните снизу. Оборачивайте существующие callback-API в async/await через withCheckedThrowingContinuation. Новый код сможет использовать async/await, а внутренняя реализация остаётся нетронутой.
  2. Замените DispatchQueue.main.async на @MainActor. Вместо ручного переключения на главную очередь используйте аннотацию для классов и функций, работающих с UI.
  3. Замените общие мутабельные состояния на акторы. Класс с блокировками или последовательной очередью? Самое время превратить его в актор.
  4. Замените DispatchGroup на TaskGroup. Паттерн «запустить несколько задач и дождаться всех» естественно выражается через withTaskGroup или async let.
  5. Замените DispatchWorkItem на Task. Задачи в Swift Concurrency поддерживают кооперативную отмену из коробки.

Типичные ошибки при миграции

Ошибка 1: Блокировка потока внутри async-функции. Нельзя использовать семафоры, блокировки или DispatchQueue.sync внутри асинхронных функций — это может привести к deadlock из-за ограниченного пула потоков кооперативного исполнителя:

// ❌ НЕПРАВИЛЬНО: блокировка потока в async-контексте
func fetchData() async -> Data {
    let semaphore = DispatchSemaphore(value: 0)
    var result: Data?

    URLSession.shared.dataTask(with: url) { data, _, _ in
        result = data
        semaphore.signal()
    }.resume()

    semaphore.wait() // ⚠️ Блокирует поток кооперативного пула!
    return result!
}

// ✅ ПРАВИЛЬНО: использование continuation
func fetchData() async throws -> Data {
    let (data, _) = try await URLSession.shared.data(from: url)
    return data
}

Ошибка 2: Неструктурированные задачи повсюду. Многие разработчики, привыкшие к GCD, лепят Task { } на каждом шагу, теряя все преимущества структурированной конкурентности — автоматическую отмену, распространение ошибок и гарантии времени жизни:

// ❌ НЕПРАВИЛЬНО: неструктурированная задача без необходимости
func loadImages(urls: [URL]) async -> [UIImage] {
    var images: [UIImage] = []
    for url in urls {
        let task = Task {
            try await downloadImage(from: url)
        }
        if let image = try? await task.value {
            images.append(image)
        }
    }
    return images // Последовательная загрузка, не параллельная!
}

// ✅ ПРАВИЛЬНО: структурированная конкурентность
func loadImages(urls: [URL]) async -> [UIImage] {
    await withTaskGroup(of: UIImage?.self) { group in
        for url in urls {
            group.addTask {
                try? await downloadImage(from: url)
            }
        }

        var images: [UIImage] = []
        for await image in group {
            if let image { images.append(image) }
        }
        return images
    }
}

Ошибка 3: Игнорирование кооперативной отмены. В отличие от DispatchWorkItem.cancel(), отмена задачи в Swift Concurrency — кооперативная. Задача не прекращает выполнение автоматически, нужно периодически проверять статус:

func processLargeDataset(_ items: [Item]) async throws -> [Result] {
    var results: [Result] = []

    for item in items {
        // Проверяем отмену перед каждой итерацией
        try Task.checkCancellation()

        let result = await process(item)
        results.append(result)
    }

    return results
}

Ошибка 4: Избыточное использование Task.detached. Task.detached создаёт полностью отвязанную задачу — без наследования приоритета или контекста актора. В подавляющем большинстве случаев обычный Task { } или структурированная конкурентность подходят лучше.

Ошибка 5: Неправильная работа с AsyncSequence. При итерации по AsyncStream текущая задача блокируется до получения следующего элемента. Две последовательные итерации — и вторая просто никогда не начнётся:

// ❌ НЕПРАВИЛЬНО: две последовательности обрабатываются последовательно
func monitor() async {
    for await event in eventStream {  // Блокирует здесь
        handleEvent(event)
    }
    for await metric in metricStream { // Никогда не дойдём сюда!
        handleMetric(metric)
    }
}

// ✅ ПРАВИЛЬНО: параллельная обработка через TaskGroup
func monitor() async {
    await withTaskGroup(of: Void.self) { group in
        group.addTask {
            for await event in self.eventStream {
                self.handleEvent(event)
            }
        }
        group.addTask {
            for await metric in self.metricStream {
                self.handleMetric(metric)
            }
        }
    }
}

Советы для плавной миграции

  • Мигрируйте постепенно. Не пытайтесь переписать весь проект разом. Начните с одного модуля или фичи.
  • Используйте Swift 6.2 с MainActor по умолчанию. Это существенно уменьшит количество ошибок компилятора при миграции.
  • Включите strict concurrency checking в режиме warning. Так вы увидите проблемы, не блокируя сборку.
  • Пишите тесты. Конкурентный код особенно важно тестировать — ошибки могут проявляться недетерминированно.
  • Документируйте изоляцию. Для каждого публичного API чётко указывайте, на каком акторе он выполняется.
  • Используйте инструменты Xcode. Thread Sanitizer (TSan) и Instruments помогут найти гонки данных, которые компилятор не в состоянии поймать (особенно в @unchecked Sendable типах).

Заключение

Модель конкурентности Swift прошла впечатляющий путь — от GCD и замыканий до системы, где безопасность гарантируется компилятором. Вот что стоит запомнить:

  • async/await — фундамент. Асинхронный код в линейном стиле, без вложенных замыканий и ручного управления потоками.
  • Структурированная конкурентность (async let, TaskGroup) даёт иерархическое управление задачами с автоматической отменой и распространением ошибок.
  • Акторы защищают состояние от гонок данных, но помните о реентерабельности — после каждого await состояние может измениться.
  • Sendable гарантирует безопасность передачи данных между контекстами. @unchecked Sendable — только когда вы точно знаете, что делаете.
  • Swift 6.2 с «доступной конкурентностью» делает всё это проще: @MainActor по умолчанию, nonisolated(nonsending) и явный @concurrent — это прогрессивное раскрытие сложности в лучшем виде.

Миграция с GCD на Swift Concurrency — это инвестиция, которая окупается надёжностью и читаемостью кода. Начните с оборачивания callback-API, постепенно внедряйте акторы и используйте структурированную конкурентность. С каждой версией Swift конкурентная модель становится всё зрелее — и сейчас самое время полностью освоить её возможности.

Об авторе Priya Raghavan

Priya spent six years at Instacart building the iOS shopper app, where she led the migration from UIKit to SwiftUI across 80+ screens and cut crash-free sessions from 99.2% to 99.87%. Before that, she was a contractor at a Bay Area design studio shipping App Store apps for two Fortune 500 retail clients. She focuses on practical SwiftUI architecture - what holds up when you have 12 engineers committing to the same codebase, not just toy MVVM examples. Her recent work involves The Composable Architecture, Swift concurrency migration audits, and reducing main-thread hangs on older devices like the iPhone XR that enterprise fleets still ship. Priya runs a small consultancy in Oakland and occasionally speaks at try! Swift NYC. She has been writing Swift since the Objective-C bridging days of 2015.