Урок по треку Swift и iOS 8 мин

Swift Concurrency: async/await, акторы, Sendable

async/await, Task и структурированная конкурентность, actors, MainActor, Sendable и Swift 6.

С 2024-2025 годов Swift Concurrency спрашивают на каждом iOS-собесе уровня middle и выше: async/await, Task, акторы, Sendable и что изменилось в Swift 6.

async/await

func loadProfile() async throws -> Profile {
    let (data, response) = try await URLSession.shared.data(from: url)
    try validate(response)
    return try JSONDecoder().decode(Profile.self, from: data)
}

await отмечает точку возможной приостановки. Пока результата нет, поток не блокируется, а выполняет другую работу. Продолжение может выполниться на другом потоке, если функция не изолирована на актор (например, на MainActor).

Вызвать async-функцию из синхронного кода можно через Task:

@MainActor func buttonTapped() {
    Task { await viewModel.refresh() }
}

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

async let для фиксированного числа параллельных операций:

async let profile = api.profile()
async let feed = api.feed()
let (p, f) = try await (profile, feed)

withTaskGroup для заранее неизвестного числа:

let images = await withTaskGroup(of: (URL, UIImage?).self) { group in
    for url in urls { group.addTask { (url, await load(url)) } }
    var result: [URL: UIImage] = [:]
    for await (url, image) in group { result[url] = image }
    return result
}

Родитель ждёт всех детей, ошибка или отмена родителя отменяет детей. Это и есть «структурированность»: задачи образуют дерево и не теряются.

Task и Task.detached

  • Task { } наследует контекст места создания: изоляцию актора, приоритет, task-local значения. Из @MainActor-кода тело выполняется на главном акторе.
  • Task.detached { } ничего не наследует. Нужен редко.

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

Отмена

Отмена кооперативная: task.cancel() ставит флаг. Код проверяет его сам (Task.isCancelled, try Task.checkCancellation()), а многие системные API (Task.sleep, URLSession) бросают ошибку при отмене. В SwiftUI модификатор .task отменяет задачу при исчезновении view.

Акторы

Актор это ссылочный тип, чьё изменяемое состояние доступно только изнутри. Методы выполняются по одному, поэтому гонок данных нет. Снаружи обращения асинхронные:

actor TokenStore {
    private var token: String?
    func set(_ t: String) { token = t }
    func get() -> String? { token }
}
let t = await store.get()

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

actor ImageCache {
    private var tasks: [URL: Task<UIImage, Error>] = [:]
    func image(_ url: URL) async throws -> UIImage {
        if let task = tasks[url] { return try await task.value }
        let task = Task { try await download(url) }
        tasks[url] = task                  // кладём до await
        return try await task.value
    }
}

Хранение Task вместо результата склеивает одновременные запросы одного URL.

MainActor

@MainActor это глобальный актор главного потока. Помечают view model и всё, что трогает UI. С SDK iOS 18 протокол View в SwiftUI изолирован на @MainActor. В Swift 6.2 появилась настройка модуля Default Actor Isolation = MainActor: весь код без аннотаций считается изолированным на главном акторе, а фоновую работу помечают явно (nonisolated, @concurrent).

Sendable

Sendable помечает типы, которые безопасно передавать между доменами изоляции (задачами, акторами):

  • структуры и enum, у которых все поля Sendable (внутри модуля соответствие выводится само);
  • акторы;
  • final class с только let-свойствами Sendable-типов;
  • @unchecked Sendable для типов, которые вы синхронизируете сами (например, блокировкой).

@Sendable-замыкание захватывает только Sendable-значения и не мутирует захваченные переменные.

Swift 6: безопасность от гонок

В режиме Swift 6 проверки изоляции и Sendable становятся ошибками компиляции. Компилятор Swift 6 умеет и режим Swift 5, поэтому переходят помодульно:

  1. Включить строгие проверки (complete) в режиме Swift 5, собрать предупреждения.
  2. Изолировать UI на @MainActor, общее состояние спрятать в акторы.
  3. Сделать модели Sendable.
  4. Для старых зависимостей @preconcurrency import.
  5. Переключить модуль на Swift 6.

Мосты из старого кода

func legacyLoad() async throws -> Data {
    try await withCheckedThrowingContinuation { cont in
        oldLoad { result in cont.resume(with: result) }
    }
}

Продолжение возобновляют ровно один раз. Для потоков событий от делегатов есть AsyncStream с yield и finish.

Чего нельзя делать

  • Блокировать поток пула: DispatchSemaphore.wait(), Thread.sleep, синхронные сетевые вызовы. Пул размером с число ядер, и блокировка может подвесить всё.
  • Использовать Task.detached по привычке.
  • Считать, что актор защищает от логических гонок через await.

Что спросят на собесе

  • Что происходит на await, на каком потоке продолжится функция?
  • Разница Task и Task.detached, async let и TaskGroup.
  • Что такое актор, реентерабельность и как с ней жить?
  • Что такое Sendable, что меняет режим Swift 6?
  • Как обернуть callback-API в async?

Закрепите за 5 минут

После урока в тренажёре 3-5 проверочных вопросов, дальше модуль уходит в повторения.