Асинхронное программирование в Swift на практике
Асинхронное программирование в Swift стало значительно понятнее после появления async/await. Вместо цепочек замыканий разработчик описывает последовательность действий почти так же, как синхронный алгоритм: получить данные, преобразовать ответ, обновить экран. При этом поток интерфейса не блокируется, а система может эффективно использовать сетевые операции, файловый ввод-вывод и фоновые вычисления.
Подход особенно полезен в мобильных приложениях, где задержка сети или медленная база данных не должны приводить к зависанию интерфейса. В этой статье разберём практическую модель concurrency в Swift: объявление асинхронных функций, параллельный запуск задач, обработку ошибок, отмену операций, работу с MainActor и переход от старого API на completion handlers.
Как работает async/await
Асинхронная функция объявляется с ключевым словом async, а операция, которая может завершиться ошибкой, дополнительно получает throws:
func loadProfile() async throws -> Profile {
let request = URLRequest(url: URL(string: "https://example.com/profile")!)
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(Profile.self, from: data)
}
await означает точку возможной приостановки. Он не создаёт новый поток и не замораживает текущий поток до получения результата. Swift приостанавливает текущую задачу, освобождая исполнитель для другой работы, а затем возобновляет задачу после завершения операции.
Вызвать такую функцию можно из другой асинхронной функции или внутри Task:
Task {
do {
let profile = try await loadProfile()
print(profile.name)
} catch {
print("Ошибка: \(error)")
}
}
Важно различать асинхронность и параллельность. Асинхронная функция может выполнять операции последовательно с паузами, а параллельность означает одновременное выполнение нескольких независимых задач. async/await предоставляет удобный синтаксис для первого случая, а structured concurrency помогает безопасно организовать второй.
Сетевые запросы через URLSession
Современный URLSession поддерживает async-методы, поэтому для обычного HTTP-запроса больше не требуется вручную создавать CheckedContinuation. Например, сетевой слой может возвращать уже декодированную модель:
struct Article: Decodable {
let id: Int
let title: String
}
enum NetworkError: Error {
case invalidResponse
}
func fetchArticles() async throws -> [Article] {
let url = URL(string: "https://example.com/articles")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse,
200..<300 ~= http.statusCode else {
throw NetworkError.invalidResponse
}
return try JSONDecoder().decode([Article].self, from: data)
}
Проверка HTTP-статуса остаётся обязанностью приложения. URLSession может успешно вернуть данные на уровне транспорта даже при ответе 404 или 500, поэтому код должен отдельно проверять HTTPURLResponse. Для production-приложения также стоит учитывать тайм-ауты, кэширование, повторные попытки и ограничение размера ответа.
Асинхронный метод удобно вызывать из ViewModel. Она может хранить состояние загрузки, данные и ошибку, а представление будет реагировать на изменения. Такой слой отделяет сетевую реализацию от SwiftUI или UIKit и упрощает тестирование.
Параллельная загрузка данных
Если экрану требуются независимые ресурсы, последовательный код увеличивает время ожидания:
let user = try await fetchUser()
let settings = try await fetchSettings()
Здесь второй запрос начнётся только после завершения первого. При независимых операциях лучше использовать async let:
async let user = fetchUser()
async let settings = fetchSettings()
let profile = try await user
let preferences = try await settings
Обе задачи запускаются конкурентно, а ожидание результата происходит в момент обращения к переменной. Такой вариант подходит для фиксированного небольшого количества операций. Если количество задач формируется динамически, применяют withTaskGroup или withThrowingTaskGroup.
Группа задач автоматически связывает дочерние операции с родительской. При отмене родительской задачи дочерние задачи получают сигнал отмены, а их жизненный цикл не теряется в приложении. Это безопаснее, чем запускать множество независимых Task и хранить их идентификаторы вручную.
| Сценарий | Подход | Особенность |
|---|---|---|
| Один сетевой запрос | async/await |
Последовательный и читаемый код |
| Два независимых запроса | async let |
Параллельное получение фиксированных ресурсов |
| Динамический список задач | TaskGroup |
Управление набором дочерних операций |
| Обновление интерфейса | MainActor |
Выполнение UI-изменений на главном исполнителе |
| Прерывание операции | Task.isCancelled и checkCancellation() |
Контроль лишней работы |
Акторы и обновление интерфейса
Swift Concurrency использует акторы для защиты изменяемого состояния от конкурентного доступа. Актор изолирует свои свойства и методы, поэтому обращаться к ним из другой конкурентной области нужно через await:
actor ImageCache {
private var storage: [URL: Data] = [:]
func value(for url: URL) -> Data? {
storage[url]
}
func insert(_ data: Data, for url: URL) {
storage[url] = data
}
}
Такой кэш не требует ручной блокировки с помощью NSLock. Актор гарантирует, что его изолированное состояние изменяется безопасно. При этом он не делает каждую операцию параллельной: методы выполняются последовательно внутри изолированного контекста.
Для интерфейса SwiftUI или UIKit используется MainActor. ViewModel можно объявить так:
@MainActor
final class ArticlesViewModel: ObservableObject {
@Published private(set) var articles: [Article] = []
@Published private(set) var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
do {
articles = try await fetchArticles()
} catch {
articles = []
}
}
}
Сетевой запрос при этом не блокирует главный исполнитель. После получения результата присваивание articles и изменение isLoading выполняются в безопасном для UI контексте. Аннотацию @MainActor лучше применять к объектам, которые действительно управляют состоянием представления, а не ко всему приложению без необходимости.
Ошибки, отмена и жизненный цикл
Асинхронная функция может завершиться успешно, выбросить ошибку или быть отменена. Отмена в Swift кооперативная: система не прерывает выполнение принудительно, а передаёт задаче соответствующий флаг. Операция должна периодически проверять его:
func process(items: [Item]) async throws -> [ResultItem] {
var result: [ResultItem] = []
for item in items {
try Task.checkCancellation()
result.append(transform(item))
}
return result
}
Вызов Task.checkCancellation() выбрасывает CancellationError. Для необязательной проверки можно использовать Task.isCancelled и самостоятельно завершить вычисление. Это особенно важно для поиска, фильтрации больших массивов, обработки изображений и синхронизации данных.
В SwiftUI задачу, связанную с жизненным циклом представления, удобно запускать через .task:
.task {
await viewModel.load()
}
Когда экран исчезает, SwiftUI может отменить связанную задачу. Если внутри выполняется URLSession, отмена обычно передаётся сетевому запросу. Для ручного Task нужно сохранить ссылку и отменить её в нужный момент, например при закрытии экрана или смене параметров поиска.
Переход от completion handlers
Старый стиль часто выглядит так:
func load(completion: @escaping (Result<[Article], Error>) -> Void) {
// сетевой запрос и вызов completion
}
При нескольких зависимых операциях появляются вложенные замыкания, сложнее становится обработка ошибок и легко забыть вызвать completion на нужной ветке. async/await переносит результат в возвращаемое значение, а ошибку — в стандартный механизм throws.
Для собственного callback API можно временно использовать withCheckedThrowingContinuation:
func loadAsync() async throws -> [Article] {
try await withCheckedThrowingContinuation { continuation in
load { result in
continuation.resume(with: result)
}
}
}
Continuation должна быть возобновлена ровно один раз. Двойной вызов resume приводит к ошибке, а отсутствие вызова оставляет задачу навсегда приостановленной. Поэтому такой адаптер следует покрывать тестами и постепенно заменять асинхронным API там, где это возможно.
При миграции полезно двигаться по слоям: сначала адаптировать сетевой клиент, затем репозитории и ViewModel, а UI менять последним. Для старых версий iOS можно оставить callback-методы и добавить обёртки с доступностью через @available, чтобы новая реализация не нарушала поддержку существующих устройств.
Архитектура асинхронного приложения
Хорошая архитектура разделяет ответственность. Сетевой клиент формирует запросы и декодирует ответы, репозиторий решает, брать ли данные из сети или локального хранилища, а ViewModel управляет состоянием экрана. Асинхронные методы проходят между этими уровнями без необходимости передавать completion через каждый слой.
Для больших проектов полезно выделить протокол:
protocol ArticlesRepository {
func articles() async throws -> [Article]
}
В production-реализации протокол обращается к серверу, а в тестах используется mock с заранее заданными данными. Это позволяет проверять состояния загрузки, ошибки сервера и отмену без настоящих HTTP-запросов. Практические примеры приложений и разработок можно посмотреть в каталоге проектов, где cross-platform подход сочетается с конкретными прикладными задачами.
Следует также следить за избыточным созданием задач. Task {} внутри цикла или при каждом изменении текста поиска может породить гонку ответов: старый запрос завершится позже нового и перезапишет актуальные данные. Для поиска обычно применяют отмену предыдущей задачи, debounce или AsyncStream, чтобы обрабатывать только нужные события.
Диагностика и безопасная практика
Ошибки конкурентного доступа не всегда проявляются сразу. Инструменты Xcode, Thread Sanitizer и строгие настройки Swift Concurrency помогают обнаруживать передачу несобезопасных значений между задачами. Предупреждения о Sendable не стоит бездумно подавлять: они показывают, что тип может быть изменён одновременно из разных контекстов.
Значение Sendable безопасно передавать между конкурентными областями. Структуры с неизменяемыми свойствами обычно соответствуют этому требованию естественно, а классы могут потребовать дополнительной синхронизации или изоляции актором. Особое внимание нужно уделять замыканиям, кэшам, singleton-объектам и моделям, которые используются несколькими задачами.
На практике асинхронный код должен иметь ясную стратегию: где запускается задача, кто владеет её жизненным циклом, как обрабатываются ошибки и что происходит при отмене. Если эти правила определены заранее, async/await превращается из синтаксического удобства в основу предсказуемого приложения, которое сохраняет отзывчивость интерфейса и эффективно работает с сетью, файлами и базами данных.