Swift Concurrency: Практическое применение в iOS-разработке

Курс поможет освоить современные инструменты многопоточности в Swift для решения реальных задач мобильной разработки. Вы научитесь работать с async/await, Structured Concurrency и Actors, а также безопасно интегрировать их с существующим кодом на GCD и Combine.

1. Основы async/await и обработка ошибок

Основы async/await и обработка ошибок

Асинхронное программирование — фундамент отзывчивых мобильных приложений. До появления Swift 5.5 разработчики iOS полагались на Grand Central Dispatch (GCD) и замыкания (closures). Этот подход часто приводил к глубокой вложенности вызовов и усложнял чтение логики. Современный инструмент Swift Concurrency решает эту проблему, предлагая линейный и безопасный синтаксис.

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

Синтаксис и точки приостановки

Чтобы объявить функцию асинхронной, необходимо добавить ключевое слово async перед типом возвращаемого значения. Это сигнализирует компилятору о том, что внутри функции могут находиться точки приостановки (suspension points).

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

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

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

Сравнение подходов

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

| Характеристика | Замыкания (GCD) | Swift Concurrency (async/await) | | --- | --- | --- | | Читаемость | Низкая (эффект "пирамиды смерти") | Высокая (линейный код) | | Обработка ошибок | Ручная проверка Result или Error | Встроенная через do-catch и throws | | Управление памятью | Требует [weak self] для избежания утечек | Автоматическое, риск утечек минимален | | Возврат значений | Через аргументы замыкания | Прямой возврат через return |

Переход на новый синтаксис значительно сокращает объем шаблонного кода.

Экономия строк кода = (Старые строки - Новые строки) / Старые строки × 100. Если функция с замыканиями занимала 25 строк, а переписанная на async/await занимает 10 строк, экономия составит 60%. Это напрямую влияет на скорость ревью кода и поддержку проекта.

Механика работы потоков под капотом

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

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

Количество потоков в пуле Swift Concurrency строго контролируется. Если — количество ядер процессора, а — количество активных потоков пула, то система поддерживает правило . Например, для 6-ядерного процессора () система создаст не более 6 активных потоков () для выполнения асинхронных задач. Это полностью исключает проблему Thread Explosion (взрывного роста потоков), которая часто возникала при злоупотреблении глобальными очередями GCD.

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

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

Ключевое слово throws ставится после async и означает, что функция может выбросить ошибку. При вызове такой функции необходимо использовать try await.

В этом примере используется встроенный асинхронный метод URLSession.shared.data(from:). Если сервер возвращает статус, отличный от 200, функция выбрасывает кастомную ошибку NetworkError.invalidResponse.

Использование do-catch

Чтобы перехватить и обработать выброшенную ошибку, асинхронный вызов оборачивается в блок do-catch. Это позволяет централизованно управлять логикой восстановления после сбоев или показывать пользователю соответствующие уведомления.

  • Откройте блок do и поместите в него вызовы с try await.
  • Добавьте один или несколько блоков catch для перехвата конкретных типов ошибок.
  • Реализуйте резервный сценарий (например, загрузку данных из локального кэша).
  • Количество обрабатываемых байт может варьироваться. Если сервер возвращает JSON размером 1500 байт, переменная data.count будет равна 1500. При скорости интернета 5 мегабит в секунду загрузка такого объема произойдет практически мгновенно, но await все равно корректно освободит поток на эти доли секунды.

    Интеграция с синхронным кодом через Task

    Одной из главных проблем при внедрении Swift Concurrency является вызов асинхронных функций из синхронного контекста. Например, методы жизненного цикла UIViewController (такие как viewDidLoad) являются синхронными. Вы не можете просто написать await внутри них.

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

    Внутри замыкания Task код выполняется последовательно. Сначала система дождется завершения fetchUserProfile, и только после успешного получения результата вызовет метод display. Если произойдет ошибка, выполнение немедленно перейдет в блок catch.

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

    Отмена задач (Cancellation)

    Еще одним важным аспектом основ является кооперативная отмена задач. В отличие от принудительного завершения потоков, Swift Concurrency просит задачу остановиться, но сама задача должна проверить, не была ли она отменена.

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

    2. Structured Concurrency: TaskGroup и async let

    Structured Concurrency: Управление параллельными задачами через TaskGroup и async let

    При использовании базового синтаксиса асинхронного программирования каждая операция с ключевым словом приостановки выполняется строго последовательно. Если приложению необходимо загрузить профиль пользователя, его аватар и список последних сообщений, последовательный вызов этих функций приведет к неоправданному ожиданию. Для решения подобных проблем применяется Structured Concurrency (структурированная конкурентность) — парадигма, позволяющая запускать несколько операций параллельно, связывая их в единую иерархию с предсказуемым жизненным циклом.

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

    Фиксированное количество задач: async let

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

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

    Вместо трех отдельных ожиданий можно использовать групповой кортеж: let (profile, settings, messages) = try await (profileTask, settingsTask, messagesTask). Это делает код еще более лаконичным.

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

    Если загрузка профиля занимает 200 миллисекунд, настроек — 150 миллисекунд, а сообщений — 500 миллисекунд, то последовательное выполнение займет 850 миллисекунд. При использовании параллельного подхода общее время составит всего 500 миллисекунд (по самой долгой задаче). Экономия времени составляет почти 41%, что критически важно для плавности пользовательского интерфейса.

    > Жизненный цикл async let привязан к локальной области, в которой оно создаётся, например, функциям, замыканиям или блокам do/catch. Когда выполнение выходит из этой области — либо нормально, либо из-за ошибки — все задачи, созданные с помощью async let, будут неявно отменены и дожидаться завершения. > > Хабр

    Динамическое количество задач: TaskGroup

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

    Группы задач создаются с помощью глобальных функций withTaskGroup или withThrowingTaskGroup (если дочерние операции могут выбрасывать ошибки). Внутри замыкания разработчик получает объект группы, в который можно динамически добавлять новые задачи с помощью метода addTask.

    Пошаговый алгоритм работы с группой

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

    Представим, что пользователь открывает галерею из 50 фотографий. Каждая фотография весит около 2 мегабайт. Группа задач не запустит 50 потоков одновременно — это привело бы к исчерпанию ресурсов устройства. Система автоматически распределит эти 50 задач по доступному пулу потоков (например, по 6 потокам на 6-ядерном процессоре), обеспечивая максимальную пропускную способность без перегрузки оперативной памяти.

    Сравнение подходов структурированной конкурентности

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

    | Характеристика | Фиксированный подход | Динамический подход (Группы) | | --- | --- | --- | | Количество задач | Известно на этапе компиляции | Определяется во время выполнения (Runtime) | | Синтаксис | Максимально краткий и линейный | Требует создания замыкания и ручного сбора результатов | | Типы возвращаемых данных | Задачи могут возвращать разные типы данных | Все задачи в группе должны возвращать один и тот же тип | | Управление памятью | Автоматическое связывание переменных | Требует аккуратной работы с массивами внутри замыкания |

    Иерархия и кооперативная отмена

    Главная сила структурированного подхода заключается в автоматическом управлении отменой (cancellation). Иерархия задач строится в виде дерева. Если родительская функция отменяется (например, пользователь закрыл экран загрузки), сигнал об отмене мгновенно распространяется на все дочерние элементы.

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

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