До Swift 5.5 асинхронный код на iOS писали через замыкания, DispatchQueue и Combine. Это работало, но читать такой код — отдельное удовольствие: вложенные колбэки, ручное переключение между потоками, утечки памяти из-за забытых [weak self]. Swift Concurrency появился как ответ на этот хаос — и за три года стал стандартом.

Эта статья — практический разбор. Не «что такое async/await теоретически», а как это выглядит в реальном коде, где работает хорошо, а где нужно быть осторожным.

Зачем вообще нужен async/await

Рассмотрим типичную задачу: загрузить данные пользователя, потом загрузить его посты, потом показать всё на экране.

Старый способ через замыкания:

fetchUser(id: userId) { result in
    switch result {
    case .success(let user):
        fetchPosts(for: user.id) { result in
            switch result {
            case .success(let posts):
                DispatchQueue.main.async {
                    self?.updateUI(user: user, posts: posts)
                }
            case .failure(let error):
                DispatchQueue.main.async {
                    self?.showError(error)
                }
            }
        }
    case .failure(let error):
        DispatchQueue.main.async {
            self?.showError(error)
        }
    }
}

То же самое с async/await:

func loadUserData() async throws {
    let user = try await fetchUser(id: userId)
    let posts = try await fetchPosts(for: user.id)
    await MainActor.run {
        updateUI(user: user, posts: posts)
    }
}

Разница очевидна. Код читается сверху вниз, обработка ошибок через throws в одном месте, никаких вложенных колбэков.

Как это работает под капотом

Async/await — это не магия и не новые потоки. Swift использует механизм continuation: когда функция встречает await, она приостанавливается и освобождает поток. Когда ответ приходит, выполнение продолжается — возможно, на другом потоке из пула.

Важный момент: await не блокирует поток. Это ключевое отличие от Thread.sleep или семафоров. Поток свободен и может выполнять другую работу пока идёт ожидание.

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

Task — основа всего

Чтобы вызвать async-функцию из синхронного контекста, нужен Task:

class ProfileViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        Task {
            do {
                try await loadUserData()
            } catch {
                showError(error)
            }
        }
    }
}

Task запускает асинхронную работу и наследует приоритет от контекста. Если создаёте Task внутри MainActor-контекста (а UIViewController — это MainActor), задача тоже будет выполняться на главном потоке, если не указать иное.

Task с приоритетом

Task(priority: .background) {
    await processHeavyData()
}

Доступные приоритеты: .high, .medium, .low, .background, .utility, .userInitiated. Система учитывает их при планировании, но не гарантирует строгий порядок.

Отмена задачи

var loadTask: Task<Void, Never>?

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    loadTask?.cancel()
}

override func viewDidLoad() {
    super.viewDidLoad()
    loadTask = Task {
        await loadData()
    }
}

Отмена в Swift Concurrency — кооперативная. Task не убивается принудительно, он получает флаг isCancelled. Ваш код должен этот флаг проверять:

func processItems(_ items: [Item]) async throws {
    for item in items {
        try Task.checkCancellation() // бросает CancellationError если задача отменена
        await process(item)
    }
}

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

Если нужно загрузить несколько независимых вещей одновременно, async let делает это элегантно:

func loadDashboard() async throws {
    async let user = fetchUser(id: userId)
    async let stats = fetchStats(for: userId)
    async let notifications = fetchNotifications()
    
    // Всё три запроса идут параллельно
    let (userData, statsData, notificationsData) = try await (user, stats, notifications)
    
    updateDashboard(user: userData, stats: statsData, notifications: notificationsData)
}

Без async let те же три запроса шли бы последовательно. Если каждый занимает 300мс, разница между 900мс и ~300мс ощутима.

TaskGroup — когда задач много

async let удобен для фиксированного числа задач. Для динамических коллекций — TaskGroup:

func downloadImages(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        for url in urls {
            group.addTask {
                try await downloadImage(from: url)
            }
        }
        
        var images: [UIImage] = []
        for try await image in group {
            images.append(image)
        }
        return images
    }
}

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

Actor — защита от гонок данных

Одна из главных проблем многопоточности — race condition: два потока одновременно читают и пишут одни данные. Swift решает это через actor.

actor UserCache {
    private var cache: [String: User] = [:]
    
    func get(id: String) -> User? {
        cache[id]
    }
    
    func set(_ user: User, for id: String) {
        cache[id] = user
    }
}

// Использование
let cache = UserCache()

Task {
    let user = await cache.get(id: "123")
}

Компилятор гарантирует: к свойствам актора можно обратиться только через await. Одновременно внутри актора выполняется не более одной задачи — взаимное исключение встроено в систему типов.

MainActor

MainActor — встроенный актор для главного потока. Обновлять UI нужно именно на нём:

@MainActor
class ProfileViewModel: ObservableObject {
    @Published var user: User?
    @Published var isLoading = false
    
    func loadUser() async {
        isLoading = true
        defer { isLoading = false }
        
        do {
            user = try await userService.fetchUser()
        } catch {
            // обработка ошибки
        }
    }
}

Аннотация @MainActor на классе означает: все методы и свойства этого класса выполняются на главном потоке. Компилятор проверяет это на этапе сборки — никаких сюрпризов в runtime.

Интеграция со старым кодом

Не весь код можно переписать сразу. Часто нужно обернуть колбэк-based API в async-функцию. Для этого есть withCheckedContinuation:

func fetchData() async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        oldAPIClient.fetch { result in
            switch result {
            case .success(let data):
                continuation.resume(returning: data)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

Правило: continuation нужно вызвать ровно один раз. Если вызвать дважды — краш. Если не вызвать — утечка. withCheckedContinuation помогает: в Debug-сборках предупреждает если continuation не был вызван.

Типичные ошибки

Ошибка 1: Создавать слишком много Task

// Плохо — N задач на N элементов без ограничений
for item in hugeArray {
    Task {
        await process(item)
    }
}

// Лучше — TaskGroup с контролем параллелизма
await withTaskGroup(of: Void.self) { group in
    for item in hugeArray {
        group.addTask { await process(item) }
    }
}

TaskGroup автоматически управляет ресурсами лучше, чем сотни независимых Task.

Ошибка 2: Блокирующий код внутри async-функции

// Плохо — блокирует поток из кооперативного пула
func processData() async {
    Thread.sleep(forTimeInterval: 2) // НЕЛЬЗЯ
    let result = heavyComputation() // если занимает секунды — тоже проблема
}

// Лучше — вынести в detached task или использовать await Task.sleep
func processData() async {
    await Task.sleep(nanoseconds: 2_000_000_000)
}

Кооперативный пул ожидает, что задачи отдают управление через await. Если код блокирует поток — вся система страдает.

Ошибка 3: Игнорировать отмену

Если пользователь ушёл с экрана, а Task продолжает работать — это трата ресурсов. Всегда проверяйте Task.isCancelled в длинных циклах и вызывайте Task.checkCancellation() в критических точках.

Structured vs Unstructured Concurrency

Swift Concurrency делит задачи на два вида:

Structuredasync let, TaskGroup. Задачи живут в рамках родительского контекста: если родитель отменяется, дочерние тоже. Время жизни задачи ограничено областью видимости.

UnstructuredTask { }, Task.detached { }. Задача независима от контекста создания. Task.detached не наследует даже актор и приоритет — чистый лист.

Отдавайте предпочтение structured concurrency там, где можно. Она безопаснее: компилятор отслеживает время жизни задач и помогает избежать утечек.

Реальный пример: ViewModel для списка

@MainActor
final class ArticleListViewModel: ObservableObject {
    @Published var articles: [Article] = []
    @Published var state: LoadingState = .idle
    
    private let repository: ArticleRepository
    private var loadTask: Task<Void, Never>?
    
    init(repository: ArticleRepository) {
        self.repository = repository
    }
    
    func load() {
        loadTask?.cancel()
        loadTask = Task {
            state = .loading
            do {
                articles = try await repository.fetchArticles()
                state = .loaded
            } catch is CancellationError {
                // задача отменена — ничего не делаем
            } catch {
                state = .error(error)
            }
        }
    }
    
    func refresh() async {
        do {
            articles = try await repository.fetchArticles()
        } catch {
            state = .error(error)
        }
    }
    
    deinit {
        loadTask?.cancel()
    }
}

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

Где Swift Concurrency реально спасает

Несколько сценариев, где переход с GCD на async/await ощущается сильнее всего:

Цепочки зависимых запросов — вместо пирамиды колбэков получаете линейный код. Читаемость вырастает кратно.

Параллельные независимые запросыasync let делает это в две строки без DispatchGroup и семафоров.

Тестирование — async-функции тестируются напрямую через async throws в тестах, без expectation и wait.

Отмена — структурная отмена работает автоматически. В GCD-коде отмену нередко просто не реализовывали — слишком муторно.

Если вы разрабатываете iOS-приложение с нуля в 2026 году, Swift Concurrency — это не опция, это baseline. Команды в REEXY, которые берутся за мобильную разработку, пишут на async/await по умолчанию: меньше багов, проще код-ревью, быстрее онбординг.

Совместимость

Swift Concurrency доступен с iOS 13 при использовании back-deployment (Swift 5.5.2+, Xcode 13.2+). Для более старых версий iOS некоторые API недоступны — например, AsyncStream требует iOS 15. Всегда проверяйте минимальную версию deployment target перед активным использованием новых API.

Actor isolation проверяется компилятором — это значит ошибки гонок данных ловятся на этапе сборки, а не в production. Для проекта с командой из нескольких разработчиков это существенно снижает класс багов, которые вообще могут добраться до пользователей.