Swift Concurrency в SwiftUI: как перестать бояться async/await и data race

Swift Concurrency в SwiftUI: как перестать бояться async/await и data race

С чего всё началось

Когда я впервые столкнулся с Swift Concurrency, было ощущение, что это просто красивая обертка над DispatchQueue. Написал async/await - и вроде всё работает. Но потом начались странные баги: данные периодически некорректные, иногда краш в случайном месте, и воспроизвести стабильно не получается. Оказалось, проблема была в том, что я не понимал, где выполняется мой код и кто вообще имеет доступ к данным.

В этой статье разберём, что такое data race, как Swift Concurrency решает эту проблему на уровне компилятора, и как правильно писать асинхронный код в SwiftUI.

Что такое data race и почему это неочевидно

Data race - это когда два потока одновременно обращаются к одной и той же памяти, и хотя бы один из них в неё пишет. Результат непредсказуем, может быть неправильное значение, может быть краш, а может вообще всё работать нормально - пока не окажется в продакшене.

Классический пример с DispatchQueue:

var count = 0 DispatchQueue.global().async { count += 1 } DispatchQueue.global().async { count += 1 } // Ожидаем 2, но можем получить 1 или краш

Проблема в том, что count += 1 это не одна операция. Под капотом это чтение, вычисление и запись. Если два потока делают это одновременно, один перезаписывает результат другого.

Раньше это решалось вручную: serial queue, NSLock, DispatchSemaphore. Работало, но легко ошибиться, забыл поставить блокировку, и всё. Компилятор молчит.

Structured Concurrency: задачи как дерево

Swift 5.5 принёс принципиально другой подход. Ключевая идея Structured Concurrency - задачи организованы в иерархию. Время жизни дочерней задачи не может превышать время жизни родительской. Это сразу даёт несколько преимуществ:

  • нет "потерянных" задач, которые продолжают работать после завершения родительского контекста
  • отмена автоматически распространяется вниз по иерархии
  • ошибки нормально пробрасываются через throws

Самый простой способ запустить параллельную работу - async let:

func loadProfile() async throws -> Profile { async let avatar = fetchImage("avatar.jpg") async let bio = fetchBio() // Обе задачи запущены параллельно return Profile( avatar: try await avatar, bio: try await bio ) }

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

func fetchAll(ids: [Int]) async throws -> [Item] { try await withThrowingTaskGroup(of: Item.self) { group in for id in ids { group.addTask { try await fetchItem(id: id) } } return try await group.reduce(into: []) { $0.append($1) } } }

Группа не вернётся, пока каждая дочерняя задача не завершится или не будет отменена. Это и есть структурированность.

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

Async/await решает вопрос «когда». Но есть ещё важный вопрос - «где» и «кто имеет доступ к данным».

Swift Concurrency меняет угол зрения. Вместо «на каком потоке это выполняется?» правильный вопрос: «кому разрешено обращаться к этим данным?». Это называется изоляцией.

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

В Swift есть три основных домена:

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

@MainActor — и не надо усложнять

Для большинства SwiftUI-приложений достаточно просто пометить ViewModel атрибутом @MainActor. Это говорит компилятору: всё, что принадлежит этому классу, выполняется на главном потоке.

@MainActor class ItemViewModel: ObservableObject { @Published var items: [Item] = [] @Published var isLoading = false func load() async { isLoading = true items = try await repository.fetchItems() isLoading = false } }

Никакого DispatchQueue.main.async. Компилятор сам следит за тем, чтобы обновления UI происходили там, где надо.

actor — когда нужно общее состояние

Если есть данные, к которым обращаются из разных мест, и их нельзя держать на MainActor - используется actor. Он гарантирует, что в один момент времени к состоянию обращается только одна задача:

actor DataStore { private var cache: [String: Data] = [:] func store(_ data: Data, for key: String) { cache[key] = data } func retrieve(for key: String) -> Data? { cache[key] } }

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

Sendable: что можно передавать между доменами

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

Если передать ссылку на изменяемый класс между двумя акторами, оба смогут изменять его одновременно — и это ровно та data race, от которой мы убегали. Поэтому Swift требует, чтобы типы, пересекающие границы изоляции, соответствовали протоколу Sendable.

Хорошая новость: большинство типов становятся Sendable автоматически:

  • struct и enum только с Sendable-свойствами - неявно Sendable
  • actor - всегда Sendable, потому что защищает своё состояние
  • @MainActor типы - Sendable, потому что MainActor сериализует доступ

Для классов сложнее - класс может быть Sendable, только если он final и все свойства неизменяемы:

// Безопасно — неизменяемый класс final class APIConfig: Sendable { let baseURL: URL let timeout: Double } // Небезопасно — изменяемое состояние без синхронизации class Counter { var count = 0 // два места изменяют это = проблема }

Реальный пример в SwiftUI

Покажу одну и ту же ViewModel - сначала как не надо, потом нормально.

Плохо

class ItemViewModel: ObservableObject { @Published var items: [Item] = [] func load() { Task { let result = try await repository.fetchItems() self.items = result // не на main thread! } } }

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

Ещё одна проблема - этот Task неуправляемый. После создания нет способа его отменить или узнать, завершился ли он вообще.

Хорошо

@MainActor class ItemViewModel: ObservableObject { @Published var items: [Item] = [] @Published var errorMessage: String? func load() async { do { items = try await repository.fetchItems() } catch { errorMessage = error.localizedDescription } } }

В SwiftUI вызывать это лучше через .task модификатор, а не через Task { } напрямую:

struct ItemListView: View { @StateObject var viewModel = ItemViewModel() var body: some View { List(viewModel.items) { item in Text(item.name) } .task { await viewModel.load() } } }

Модификатор .task автоматически отменяет задачу при исчезновении View. Task { } - нет.

Типичные ошибки и как их исправить

Ниже - самые частые проблемы, с которыми сталкиваются на практике:

Swift Concurrency в SwiftUI: как перестать бояться async/await и data race

Отдельно стоит упомянуть одну ловушку: async не означает «выполняется в фоне». Это означает «может приостановиться». Если внутри async-функции есть тяжёлая синхронная работа - она всё равно заблокирует поток, на котором выполняется:

@MainActor func badExample() async { // Это заблокирует главный поток! let result = heavyCPUCalculation() data = result }

Для CPU-тяжёлой работы нужно явно выйти из главного потока через Task.detached или (в Swift 6.2+) через @concurrent.

Итог

Swift Concurrency - это не просто новый синтаксис. Это другая модель мышления: вместо «на каком потоке» думаем «кому принадлежат данные».

Если коротко:

  • async/await - для последовательного и параллельного ожидания без callback-ада
  • @MainActor на ViewModel - решает большинство проблем с потокобезопасностью UI
  • actor - когда нужно общее изменяемое состояние вне главного потока
  • Sendable - компилятор проверяет, что данные безопасно пересекают границы
  • .task вместо Task { } - управляемые задачи, привязанные к жизненному циклу View

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

Начать дискуссию