Swift Concurrency в SwiftUI: как перестать бояться async/await и data race
С чего всё началось
Когда я впервые столкнулся с Swift Concurrency, было ощущение, что это просто красивая обертка над DispatchQueue. Написал async/await - и вроде всё работает. Но потом начались странные баги: данные периодически некорректные, иногда краш в случайном месте, и воспроизвести стабильно не получается. Оказалось, проблема была в том, что я не понимал, где выполняется мой код и кто вообще имеет доступ к данным.
В этой статье разберём, что такое data race, как Swift Concurrency решает эту проблему на уровне компилятора, и как правильно писать асинхронный код в SwiftUI.
Что такое data race и почему это неочевидно
Data race - это когда два потока одновременно обращаются к одной и той же памяти, и хотя бы один из них в неё пишет. Результат непредсказуем, может быть неправильное значение, может быть краш, а может вообще всё работать нормально - пока не окажется в продакшене.
Классический пример с DispatchQueue:
Проблема в том, что count += 1 это не одна операция. Под капотом это чтение, вычисление и запись. Если два потока делают это одновременно, один перезаписывает результат другого.
Раньше это решалось вручную: serial queue, NSLock, DispatchSemaphore. Работало, но легко ошибиться, забыл поставить блокировку, и всё. Компилятор молчит.
Structured Concurrency: задачи как дерево
Swift 5.5 принёс принципиально другой подход. Ключевая идея Structured Concurrency - задачи организованы в иерархию. Время жизни дочерней задачи не может превышать время жизни родительской. Это сразу даёт несколько преимуществ:
- нет "потерянных" задач, которые продолжают работать после завершения родительского контекста
- отмена автоматически распространяется вниз по иерархии
- ошибки нормально пробрасываются через throws
Самый простой способ запустить параллельную работу - async let:
Каждый async let стартует немедленно. await только собирает результаты. Когда количество задач динамическое - используется TaskGroup:
Группа не вернётся, пока каждая дочерняя задача не завершится или не будет отменена. Это и есть структурированность.
Где выполняется код: домены изоляции
Async/await решает вопрос «когда». Но есть ещё важный вопрос - «где» и «кто имеет доступ к данным».
Swift Concurrency меняет угол зрения. Вместо «на каком потоке это выполняется?» правильный вопрос: «кому разрешено обращаться к этим данным?». Это называется изоляцией.
Есть хорошая аналогия: представьте приложение как офисное здание. Каждый домен изоляции - это офис с замком. Только один человек может находиться внутри одновременно и работать с документами в этом офисе. Чтобы войти в чужой офис, нужно постучать (await) и подождать.
В Swift есть три основных домена:
- @MainActor - ресепшен, где происходит всё взаимодействие с пользователем. UIKit и SwiftUI требуют именно его.
- actor - офисы отделов. Каждый защищает своё состояние, доступ строго последовательный.
- nonisolated - коридор. Общее пространство без защиты, но и без приватных данных.
@MainActor — и не надо усложнять
Для большинства SwiftUI-приложений достаточно просто пометить ViewModel атрибутом @MainActor. Это говорит компилятору: всё, что принадлежит этому классу, выполняется на главном потоке.
Никакого DispatchQueue.main.async. Компилятор сам следит за тем, чтобы обновления UI происходили там, где надо.
actor — когда нужно общее состояние
Если есть данные, к которым обращаются из разных мест, и их нельзя держать на MainActor - используется actor. Он гарантирует, что в один момент времени к состоянию обращается только одна задача:
Важно понимать: actor - это не поток. Это граница изоляции. Swift сам решает, какой поток реально выполняет код актора. Нам это контролировать не нужно.
Sendable: что можно передавать между доменами
Домены изоляции защищают данные, но иногда нужно передать данные из одного в другой. Вот тут Swift проверяет: а безопасно ли это вообще?
Если передать ссылку на изменяемый класс между двумя акторами, оба смогут изменять его одновременно — и это ровно та data race, от которой мы убегали. Поэтому Swift требует, чтобы типы, пересекающие границы изоляции, соответствовали протоколу Sendable.
Хорошая новость: большинство типов становятся Sendable автоматически:
- struct и enum только с Sendable-свойствами - неявно Sendable
- actor - всегда Sendable, потому что защищает своё состояние
- @MainActor типы - Sendable, потому что MainActor сериализует доступ
Для классов сложнее - класс может быть Sendable, только если он final и все свойства неизменяемы:
Реальный пример в SwiftUI
Покажу одну и ту же ViewModel - сначала как не надо, потом нормально.
Плохо
Task без привязки к актору выполняется на background executor по умолчанию. Обновление @Published свойства не на главном потоке - это либо предупреждение в консоли, либо краш.
Ещё одна проблема - этот Task неуправляемый. После создания нет способа его отменить или узнать, завершился ли он вообще.
Хорошо
В SwiftUI вызывать это лучше через .task модификатор, а не через Task { } напрямую:
Модификатор .task автоматически отменяет задачу при исчезновении View. Task { } - нет.
Типичные ошибки и как их исправить
Ниже - самые частые проблемы, с которыми сталкиваются на практике:
Отдельно стоит упомянуть одну ловушку: async не означает «выполняется в фоне». Это означает «может приостановиться». Если внутри async-функции есть тяжёлая синхронная работа - она всё равно заблокирует поток, на котором выполняется:
Для CPU-тяжёлой работы нужно явно выйти из главного потока через Task.detached или (в Swift 6.2+) через @concurrent.
Итог
Swift Concurrency - это не просто новый синтаксис. Это другая модель мышления: вместо «на каком потоке» думаем «кому принадлежат данные».
Если коротко:
- async/await - для последовательного и параллельного ожидания без callback-ада
- @MainActor на ViewModel - решает большинство проблем с потокобезопасностью UI
- actor - когда нужно общее изменяемое состояние вне главного потока
- Sendable - компилятор проверяет, что данные безопасно пересекают границы
- .task вместо Task { } - управляемые задачи, привязанные к жизненному циклу View
Переход с DispatchQueue поначалу кажется громоздким, но довольно быстро становится понятно, что компилятор берёт на себя то, что раньше приходилось держать в голове самому. И это стоит того.