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

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

Конкурентность в 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 конкурентная модель становится всё зрелее — и сейчас самое время полностью освоить её возможности.

Об авторе Editorial Team

Our team of expert writers and editors.