Перейти к основному содержимому

Асинхронность и Concurrency в Swift

Разработчику Архитектору

Асинхронность и Concurrency в Swift

Что такое асинхронность

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

В языке Swift асинхронность реализована на уровне языка через ключевые слова async и await, введённые начиная с версии 5.5. Эта модель заменяет устаревшие подходы, основанные на замыканиях и делегатах, и позволяет писать код, который выглядит как последовательный, но при этом эффективно использует ресурсы системы.


Основные принципы Swift Concurrency

Swift Concurrency — это встроенная система управления асинхронными задачами. Она включает в себя:

  • Асинхронные функции (async), которые могут приостанавливать своё выполнение.
  • Оператор await, указывающий точку возможной приостановки.
  • Задачи (Task), представляющие собой независимые единицы работы.
  • Акторы (actor), обеспечивающие безопасный доступ к общему состоянию.
  • Группы задач (TaskGroup), позволяющие управлять множеством параллельных операций.

Эти компоненты работают совместно, образуя целостную модель, в которой компилятор и среда выполнения контролируют корректность и производительность.


Объявление и вызов асинхронных функций

Функция становится асинхронной, когда в её сигнатуре появляется ключевое слово async:

func fetchData() async -> Data { ... }

Разбор:

  • async в сигнатуре показывает, что функция может приостанавливать выполнение.
  • Возвращаемый тип Data означает успешный результат после завершения асинхронной операции.
  • Такой контракт позволяет вызывать функцию через await и писать линейный читаемый код.

Такая функция может содержать точки приостановки — места, где выполнение временно передаётся системе. Чтобы вызвать асинхронную функцию, необходимо использовать await:

let data = await fetchData()

Разбор:

  • await обозначает точку потенциальной приостановки текущей задачи.
  • data получит значение только после завершения fetchData().
  • Во время ожидания поток не блокируется: рантайм может выполнять другие задачи.

Ключевое слово await допустимо только внутри контекста, который сам является асинхронным: либо в другой async-функции, либо в специальном асинхронном замыкании.

Этот подход сохраняет линейную структуру кода, избегая "ада колбэков" — ситуации, когда вложенные замыкания затрудняют чтение и отладку.

Минимальный сквозной пример (сеть → UI):

Код ITЗагрузка примера кода…

Разбор:

  • @MainActor на функции гарантирует обновление UILabel на главном потоке.
  • try await URLSession.shared.data — асинхронный запрос без вложенных completion-handler.
  • JSONDecoder().decode превращает Data в тип Quote; ошибки декодирования попадают в catch.
  • Внешний Task { ... } создаёт async-контекст из синхронного UIKit-кода (например, из viewDidLoad).

Управление задачами через Task

Task — это структура, которая представляет собой отдельную асинхронную операцию. Создание задачи позволяет запустить асинхронный код вне текущего потока выполнения:

let task = Task {
let result = await performLongOperation()
print(result)
}

Разбор:

  • Task { ... } запускает новую асинхронную единицу работы.
  • Внутри можно использовать await без отдельного объявления функции.
  • task хранит ссылку на задачу, что полезно для последующей отмены или отслеживания.

Задачи могут быть отменены. Отмена не прерывает выполнение насильственно — она устанавливает флаг, который задача может проверить через Task.isCancelled или с помощью выбрасывания ошибки через Task.checkCancellation(). Это обеспечивает кооперативную отмену: задача сама решает, как корректно завершить работу.

Отмена особенно полезна при работе с интерфейсом — если пользователь покидает экран, все связанные с ним задачи можно отменить, предотвратив утечки памяти и ненужную работу.


Параллельное выполнение нескольких операций

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


Конструкция async let

Позволяет запустить несколько операций параллельно и дождаться их результатов:

async let first = fetchUser()
async let second = fetchSettings()
async let third = fetchPreferences()

let user = await first
let settings = await second
let prefs = await third

Разбор:

  • async let запускает несколько асинхронных операций параллельно.
  • Каждая переменная (first, second, third) — обещание будущего результата.
  • await при чтении конкретной переменной синхронизирует и извлекает готовое значение.
  • Подход уменьшает общее время ожидания при независимых запросах.

Все три вызова начинаются сразу, а не последовательно. Это сокращает общее время выполнения, особенно при независимых сетевых запросах.


Группы задач (withTaskGroup)

Для динамического управления набором задач используется withTaskGroup:

await withTaskGroup(of: String.self) { group in
for id in [1, 2, 3, 4] {
group.addTask {
return await fetchItem(id: id)
}
}
for await result in group {
print(result)
}
}

Разбор:

  • withTaskGroup создаёт группу динамически добавляемых параллельных задач.
  • group.addTask добавляет дочерние операции для каждого id.
  • for await result in group читает результаты по мере готовности, а не строго по порядку запуска.
  • Конструкция удобна для пакетной обработки коллекций неизвестного размера.

Группы задач особенно эффективны при обработке коллекций, когда количество операций заранее неизвестно или зависит от условий выполнения.


Интеграция с существующими API

Многие системные фреймворки Apple уже поддерживают асинхронные методы. Например, URLSession предоставляет метод data(from:delegate:), помеченный как async throws. Это позволяет загружать данные без использования делегатов или замыканий:

let (data, response) = try await URLSession.shared.data(from: url)

Разбор:

  • try await показывает комбинацию асинхронности и возможной ошибки.
  • Метод URLSession.shared.data(from:) возвращает кортеж: полезные байты и метаданные ответа.
  • Это современная альтернатива callback-API для сетевых запросов.

Для совместимости со старыми API, основанными на замыканиях, Swift предлагает механизм continuation. С его помощью можно преобразовать callback-функцию в асинхронную:

func legacyFetch(completion: @escaping (Result<Data, Error>) -> Void)

func modernFetch() async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
legacyFetch { result in
continuation.resume(with: result)
}
}
}

Разбор:

  • withCheckedThrowingContinuation переводит старый callback-API в async throws.
  • legacyFetch отдаёт Result, который напрямую передаётся в continuation.resume(with:).
  • Мост позволяет постепенно мигрировать код без полной переписи нижележащего слоя.

Это упрощает миграцию устаревшего кода и обеспечивает постепенный переход к современной модели.


Безопасность данных и акторы

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

Актор — это специальный тип, который гарантирует, что его состояние изменяется только одним потоком в каждый момент времени. Доступ к свойствам и методам актора из внешнего кода автоматически становится асинхронным:

actor Counter {
var value = 0
func increment() { value += 1 }
}

let counter = Counter()
await counter.increment()
print(await counter.value)

Разбор:

  • actor изолирует изменяемое состояние и предотвращает гонки данных.
  • Метод increment() безопасно изменяет value внутри изолированной области актора.
  • Внешние обращения требуют await, потому что доступ синхронизируется рантаймом concurrency.

Эта модель исключает необходимость ручной синхронизации через блокировки или очереди, делая многопоточный код надёжным по умолчанию.


@MainActor и протокол Sendable

@MainActor помечает код, который должен выполняться на главном потоке (обновление UI, многие методы SwiftUI/UIKit). Компилятор требует await при вызове такого кода из фоновой задачи — это замена ручному DispatchQueue.main.async.

@MainActor
func applyToUI(_ text: String) {
label.text = text
}

func load() async {
let value = await fetchFromNetwork()
await applyToUI(value)
}

Разбор:

  • @MainActor закрепляет функцию за главным потоком, что критично для UI.
  • await applyToUI(value) явно переключает выполнение на main actor из фонового контекста.
  • Такой контракт заменяет ручной DispatchQueue.main.async и снижает риск ошибок потоков.

Sendable — маркер типов, которые безопасно передавать между задачами и акторами (например, Int, String, immutable struct). В Swift 6 несоответствие Sendable чаще становится ошибкой компиляции, а не редким сбоем в рантайме.


Асинхронность и жизненный цикл представлений в SwiftUI

В SwiftUI асинхронные операции тесно связаны с жизненным циклом представлений. Модификатор task позволяет запускать асинхронный код в момент появления представления на экране и автоматически отменять его при исчезновении:

Код ITЗагрузка примера кода…

Разбор:

  • @State private var user хранит локальное состояние представления.
  • .task { ... } запускает асинхронную загрузку при появлении экрана.
  • if let user безопасно распаковывает optional перед отображением имени.
  • SwiftUI автоматически отменит задачу при исчезновении view, что уменьшает лишнюю работу.

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

Если требуется повторный запуск задачи при изменении определённых условий, используется перегрузка task(id:). Задача пересоздаётся каждый раз, когда значение id меняется:

.task(id: userID) {
user = await fetchUser(by: userID)
}

Разбор:

  • task(id:) пересоздаёт асинхронную задачу при изменении userID.
  • Этот механизм синхронизирует UI с параметром экрана без ручного lifecycle-кода.
  • Удобно для экранов, где источник данных зависит от выбранного элемента.

Такой механизм идеально подходит для динамических интерфейсов, где данные зависят от внешнего состояния, например, от выбранного элемента списка.


Работа с Core Data в асинхронном контексте

Core Data — это фреймворк для управления объектной моделью данных. Он требует выполнения операций в правильном контексте, привязанном к определённому потоку. В асинхронной среде Swift это достигается с помощью perform или performAndWait, но начиная с iOS 15, Apple предоставила более удобные инструменты.

Метод perform можно обернуть в асинхронную функцию:

Код ITЗагрузка примера кода…

Разбор:

  • Расширение добавляет async-обёртку поверх perform Core Data контекста.
  • withCheckedThrowingContinuation связывает callback-модель с await.
  • Внутри запускается Task, чтобы поддерживать асинхронный block с throws.
  • Ошибки и успешный результат корректно возвращаются через continuation.resume.

Теперь можно безопасно выполнять запросы к базе данных внутри await:

let users = try await context.perform { context in
let request = User.fetchRequest()
return try context.fetch(request)
}

Разбор:

  • context.perform гарантирует выполнение блока в правильной очереди контекста.
  • Внутри формируется fetchRequest и выполняется context.fetch.
  • try await делает вызов безопасным и совместимым с общей моделью ошибок Swift.

Такой подход сохраняет целостность данных и совместим с общей моделью асинхронности Swift. Он также позволяет использовать акторы для изоляции контекста Core Data, обеспечивая полную потокобезопасность.


Обработка ошибок в асинхронном коде

Асинхронные функции в Swift могут быть помечены как throws, что позволяет им выбрасывать ошибки. Вызов такой функции требует использования try await:

do {
let data = try await fetchDataFromNetwork()
} catch NetworkError.timeout {
// Обработка таймаута
} catch {
// Обработка других ошибок
}

Разбор:

  • do-catch формирует явную область обработки ошибок.
  • В первой ветке catch NetworkError.timeout перехватывается конкретный доменный сценарий.
  • Последний catch ловит все остальные ошибки, сохраняя устойчивость потока выполнения.

Ошибки распространяются по цепочке вызовов точно так же, как в синхронном коде. Это упрощает логику обработки исключений и делает её предсказуемой. Swift не разделяет ошибки на "проверяемые" и "непроверяемые" — любая ошибка может быть перехвачена, если это необходимо.

Важно помнить, что отмена задачи через Task.cancel() не приводит к выбрасыванию ошибки автоматически. Разработчик должен явно проверять флаг отмены и реагировать на него, обычно выбрасывая CancellationError:

func longOperation() async throws -> Result {
for i in 0..<1000 {
try Task.checkCancellation()
// Выполнение шага
}
return Result()
}

Разбор:

  • В цикле for работа разбита на шаги, между которыми проверяется отмена.
  • try Task.checkCancellation() выбросит CancellationError, если задачу отменили.
  • Такой шаблон делает долгие операции кооперативно отменяемыми и отзывчивыми.

Функция Task.checkCancellation() выбрасывает CancellationError, если задача была отменена. Это позволяет интегрировать отмену в стандартную систему обработки ошибок.


Потоки значений — AsyncStream и AsyncThrowingStream

Для случаев, когда результат не единичный, а представляет собой последовательность значений во времени, Swift предлагает AsyncStream и AsyncThrowingStream. Эти типы позволяют создавать асинхронные последовательности, которые можно перебирать с помощью for await:

Код ITЗагрузка примера кода…

Разбор:

  • AsyncStream<Double> создаёт асинхронный поток чисел, поступающих во времени.
  • continuation.yield(temp) публикует новое значение в поток при каждом тике таймера.
  • onTermination освобождает ресурс (timer.invalidate) при завершении/отмене.
  • for await подписывается на поток и обрабатывает значения по мере их появления.

Потоки особенно полезны для работы с сенсорами, WebSocket-соединениями, уведомлениями или любыми источниками данных, которые генерируют значения непрерывно. Они интегрируются с системой отмены: при отмене задачи вызывается замыкание onTermination, что позволяет освободить ресурсы.


Повторяемые задачи и долгоживущие операции

Некоторые задачи должны выполняться периодически или оставаться активными в течение всего времени работы приложения. Для этого Swift предоставляет Task.sleep и возможность создавать бесконечные циклы с проверкой отмены:

Task {
while !Task.isCancelled {
await performBackgroundSync()
try await Task.sleep(for: .seconds(30))
}
}

Разбор:

  • Фоновая задача выполняет периодическую синхронизацию в цикле.
  • Условие !Task.isCancelled обеспечивает корректный выход при отмене.
  • Task.sleep даёт паузу без блокировки потока между итерациями.

Такой подход безопасен, потому что Task.sleep является точкой приостановки, и система может приостановить выполнение без блокировки потока. При переходе приложения в фон режим или при завершении работы задача будет отменена, и цикл прекратится.

Для более сложных сценариев, таких как фоновая загрузка или обработка уведомлений, рекомендуется использовать комбинацию асинхронных задач с системными API, например, BGProcessingTask или UNNotificationServiceExtension, чтобы соблюдать ограничения операционной системы.


Сравнение async/await и Grand Central Dispatch

Grand Central Dispatch (GCD) — это низкоуровневая система управления параллелизмом, существующая в экосистеме Apple с 2009 года. Она предоставляет очереди (DispatchQueue), группы (DispatchGroup), семафоры и другие примитивы для организации многопоточного выполнения. GCD остаётся актуальной технологией, особенно в случаях, требующих тонкого контроля над потоками или интеграции с C-совместимыми API.

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

Переход от GCD к async/await не означает отказ от очередей. Наоборот, Swift Concurrency использует диспетчеры, которые могут быть привязаны к конкретным очередям. Например, для выполнения кода на главном потоке используется MainActor:

@MainActor
func updateUI() { ... }

Разбор:

  • Аннотация закрепляет функцию за главным актором.
  • Любой вызов из фонового контекста будет безопасно маршализован на главный поток.
  • Это базовый паттерн для обновления интерфейса в Swift Concurrency.

Любой вызов такой функции из асинхронного контекста автоматически переносится на главный поток. Это заменяет конструкцию DispatchQueue.main.async, делая код чище и безопаснее.

В то же время, если требуется выполнить синхронную блокирующую операцию — например, вызов сторонней библиотеки без поддержки асинхронности — её можно обернуть в Task.detached или использовать withCheckedContinuation совместно с GCD:

func legacyBlockingCall() -> Data { ... }

func modernAsyncCall() async -> Data {
return await withCheckedContinuation { continuation in
DispatchQueue.global().async {
let result = legacyBlockingCall()
continuation.resume(returning: result)
}
}
}

Разбор:

  • withCheckedContinuation превращает блокирующий вызов в async-интерфейс.
  • DispatchQueue.global().async уводит тяжёлую работу с текущего потока.
  • continuation.resume(returning:) возвращает результат обратно в async-контекст.

Такой подход позволяет постепенно мигрировать кодовую базу, сохраняя совместимость и получая преимущества новой модели.


Производительность и профилирование асинхронных задач

Асинхронные задачи в Swift не создают по одному потоку на каждую операцию. Вместо этого система использует пул потоков и кооперативную многозадачность: задача добровольно отдаёт управление в точках await, позволяя другим задачам выполняться на том же потоке. Это значительно снижает накладные расходы по сравнению с традиционными потоками.

Для анализа производительности асинхронного кода рекомендуется использовать Instruments, в частности шаблон Swift Concurrency. Он визуализирует жизненный цикл задач, показывает точки приостановки, время выполнения и возможные блокировки. Это помогает выявить "висячие" задачи, избыточные переключения контекста или неэффективные цепочки вызовов.

Особое внимание следует уделить задержкам между await. Если между двумя точками приостановки выполняется длительная синхронная операция, она может блокировать поток, даже если сама функция объявлена как async. Такие участки кода называются синхронными хвостами и нарушают принцип отзывчивости. Их следует выносить в отдельные задачи или оборачивать в фоновые очереди.


Тестирование асинхронного кода в XCTest

Тестирование асинхронных функций в Swift стало значительно проще благодаря поддержке async/await в XCTest. Методы тестов могут быть объявлены как async, и внутри них допустимо использовать await:

func testFetchUser() async throws {
let user = try await userService.fetchUser(id: 123)
XCTAssertEqual(user.name, "Timur")
}

Разбор:

  • Тест помечен как async throws, поэтому внутри можно использовать try await.
  • XCTAssertEqual проверяет бизнес-результат после асинхронного вызова сервиса.
  • Такой формат делает async-тесты читаемыми и ближе к боевому коду.

Для проверки отмены задач используется XCTExpectation в сочетании с ручным управлением временем или моками. Также доступен метод fulfill() для явного подтверждения завершения асинхронной операции.

Если тестируемый код зависит от времени — например, содержит Task.sleep — его следует заменить на внедряемую зависимость, чтобы избежать реальных задержек в тестах. Это достигается через протоколы или замыкания:

struct TimeProvider {
var sleep: (Duration) async -> Void = { duration in
await Task.sleep(for: duration)
}
}

// В тесте:
let mockTimeProvider = TimeProvider(sleep: { _ in /* ничего не делать */ })

Разбор:

  • TimeProvider инкапсулирует зависимость от времени в виде внедряемой функции sleep.
  • В проде используется реальный Task.sleep, в тесте — заглушка без задержки.
  • Это устраняет флаки-тесты и ускоряет прогон за счёт детерминированного времени.

Такой подход делает тесты быстрыми, детерминированными и независимыми от системных условий.


Практические паттерны асинхронного программирования

Загрузка изображений с кэшированием

Асинхронная загрузка изображений — типичный сценарий в мобильных приложениях. Эффективная реализация включает три этапа — проверка кэша в памяти, проверка кэша на диске, сетевой запрос. Все этапы могут быть объединены в одну async-функцию:

Код ITЗагрузка примера кода…

Разбор:

  • Функция реализует многоуровневый pipeline: memory cache -> disk cache -> сеть.
  • guard let image = UIImage(data:) валидирует полученные данные и выбрасывает доменную ошибку при неудаче.
  • После загрузки изображение сохраняется в кэшах, чтобы ускорить следующие обращения.
  • async throws делает контракт прозрачным: операция может ждать сеть и завершиться ошибкой.

Такой код легко читается, не содержит вложенных замыканий и корректно обрабатывает ошибки.


Retry-логика с экспоненциальной задержкой

При работе с сетью часто требуется повторять запрос в случае временного сбоя. Асинхронность упрощает реализацию стратегии повторных попыток:

Код ITЗагрузка примера кода…

Разбор:

  • Обобщённая функция принимает любую асинхронную операцию operation.
  • Цикл по попыткам повторяет вызов до maxAttempts, сохраняя последнюю ошибку.
  • pow(2.0, ...) реализует экспоненциальный backoff между ретраями.
  • Шаблон уменьшает нагрузку на сервисы и повышает устойчивость к временным сбоям.

Эта функция принимает любую асинхронную операцию и автоматически повторяет её с увеличивающейся задержкой. Такой подход универсален и легко тестируется.


Отмена при смене состояния

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

@State private var activeSearchTask: Task<Void, Never>?

func search(query: String) {
activeSearchTask?.cancel()
activeSearchTask = Task {
let results = await performSearch(query: query)
if !Task.isCancelled {
self.results = results
}
}
}

Разбор:

  • Хранится ссылка на текущую поисковую задачу, чтобы отменять устаревшие запросы.
  • Перед запуском нового поиска вызывается activeSearchTask?.cancel().
  • Проверка Task.isCancelled защищает UI от применения уже неактуальных результатов.
  • Паттерн решает проблему "гонки ответов" при быстром вводе пользователем.

Проверка Task.isCancelled перед обновлением состояния предотвращает применение устаревших данных.


Практика архитектуры асинхронного кода

Асинхронная модель работает лучше, когда роли разделены между слоями и отмена задач учитывается в каждом пользовательском сценарии.


Рабочий шаблон для экранов

  • Экран запускает Task только как точку входа.
  • Сервисы возвращают async throws и не знают о UI.
  • Обновление состояния интерфейса выполняется на @MainActor.
  • При новом запросе предыдущая задача отменяется.

Типичные ошибки и как их избегать

  1. Хранение сетевой логики в представлении вместо отдельного сервиса.
  2. Игнорирование Task.checkCancellation() в длительных операциях.
  3. Общий изменяемый state без actor и без изоляции доступа.

Рекомендуемые переходы


Сквозной пример для экрана

Ниже упрощенный шаблон для типичного экрана списка, где есть загрузка, отмена и безопасное обновление состояния UI.

Код ITЗагрузка примера кода…

Разбор:

  • @MainActor гарантирует, что изменения @Published состояния безопасны для UI.
  • activeTask хранит единственную текущую загрузку и отменяется перед новым reload().
  • defer { isLoading = false } обеспечивает снятие индикатора загрузки при любом исходе.
  • catch is CancellationError отделяет штатную отмену от настоящих ошибок.
  • Шаблон объединяет конкурентность, обработку ошибок и управление состоянием в одном предсказуемом потоке.

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


Содержание