Основы и продвинутые концепции Swift Concurrency

Курс охватывает эволюцию асинхронного программирования в Swift от базовых async/await до продвинутых механизмов изоляции в Swift 6. Вы изучите работу с Task, Actors, Executors и научитесь избегать состояния гонки (data races), опираясь на актуальные практики [habr.com](https://habr.com/ru/articles/862844/) и опыт крупных компаний [dev.go.yandex](https://dev.go.yandex/blog/swift-concurrency-v-yandex-dostavke-article-2025-11-19).

1. Введение в Swift Concurrency: переход от замыканий к async/await

Введение в Swift Concurrency: переход от замыканий к async/await

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

Долгое время разработчики Apple-платформ использовали Grand Central Dispatch (GCD) и блоки завершения (completion handlers) для управления асинхронностью. Однако с выходом Swift 5.5 парадигма полностью изменилась. На смену ручному управлению потоками пришел структурированный параллелизм и механизм async/await.

Эпоха замыканий и пирамида обреченности

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

Рассмотрим типичный пример загрузки данных с использованием старого подхода:

В этом фрагменте скрыто сразу несколько критических проблем:

  • Нарушение потока управления: Код прыгает между контекстами. Разработчику сложно отследить, в какой момент вызывается completion.
  • Риск утечек памяти: Использование @escaping замыканий требует постоянного контроля за сильными ссылками (необходимость писать [weak self]), что часто приводит к циклическим зависимостям.
  • Потеря обработки ошибок: Очень легко забыть вызвать completion(.failure(error)) в одной из веток switch, из-за чего приложение может навсегда зависнуть в состоянии загрузки.
  • Вероятность ошибки программиста возрастает с каждым новым уровнем вложенности. Если базовая вероятность допустить логическую ошибку на одном уровне обработки равна , то для уровней вероятность написать полностью безошибочный код составит . При (5% шанс ошибки) и уровнях вложенности, шанс написать код без багов падает до . Чем сложнее логика, тем быстрее накапливается технический долг.

    > Сложность программы растет не линейно, а экспоненциально в зависимости от количества состояний, которые разработчик должен держать в голове. > > Фредерик Брукс, "Мифический человеко-месяц"

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

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

    Ключевое слово async в сигнатуре функции говорит компилятору: "Эта функция может приостановить свою работу, не блокируя при этом текущий поток".

    Ключевое слово await используется в месте вызова такой функции и означает: "Приостанови выполнение текущего кода здесь и дождись результата".

    Перепишем предыдущий пример с использованием новых инструментов:

    Разница колоссальна. Код читается линейно, сверху вниз. Исчезли вложенные конструкции switch, пропала необходимость вручную передавать результаты через замыкания. Ошибки теперь обрабатываются стандартным механизмом try/catch, встроенным в язык.

    В реальном коммерческом проекте из 100 000 строк кода переход на async/await сокращает объем сетевого слоя примерно на 20-30%. Это эквивалентно удалению от 3 000 до 5 000 строк шаблонного кода, который больше не нужно поддерживать и тестировать.

    Как работает неблокирующее ожидание

    Главная магия async/await кроется в механизме работы с потоками. В классическом GCD, если вы вызываете синхронную долгую задачу на главном потоке, интерфейс приложения замерзает.

    Когда система встречает ключевое слово await, создается точка приостановки (suspension point). В этот момент функция отдает контроль над потоком обратно системе.

    Для понимания этого процесса отлично подходит аналогия с шеф-поваром в ресторане: * Синхронный подход: Повар ставит вариться макароны и стоит перед плитой 10 минут, ничего не делая, пока они не сварятся. Остальные заказы ждут. Асинхронный подход (async/await): Повар ставит макароны на плиту (await*), заводит таймер и идет нарезать салат для другого заказа. Когда таймер звенит, повар возвращается к макаронам.

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

    Сравнение подходов: Замыкания против Async/Await

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

    | Характеристика | Замыкания (Closures) | Async / Await | | :--- | :--- | :--- | | Чтение кода | Нелинейное, прыжки по контекстам | Линейное, как у синхронного кода | | Обработка ошибок | Ручная (через тип Result или опционалы) | Нативная (через do-catch и throws) | | Управление памятью | Требует [weak self] для избежания утечек | Безопасное по умолчанию, нет циклических ссылок | | Отмена задач | Сложная, требует кастомной логики и флагов | Встроенная кооперативная отмена (Task cancellation) | | Инверсия контроля | Вызываемая функция решает, когда вернуть результат | Вызывающий код контролирует поток выполнения |

    Интеграция с синхронным кодом: сущность Task

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

    Для создания моста между синхронным и асинхронным миром используется сущность Task.

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

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

    Итоги

    * Классические замыкания приводят к сильной вложенности кода, усложняют обработку ошибок и повышают риск утечек памяти из-за циклических ссылок. Ключевые слова async и await* позволяют писать асинхронный код линейно, сохраняя естественный порядок чтения сверху вниз. Точка приостановки (suspension point*) освобождает текущий поток для других задач системы, обеспечивая эффективное неблокирующее ожидание. Для вызова асинхронного кода из синхронного контекста необходимо использовать структуру Task*, которая служит мостом между двумя парадигмами.

    2. Управление задачами: Task и Structured Concurrency

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

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

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

    От хаоса к структурированному параллелизму

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

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

    > Структурированный параллелизм делает для асинхронного кода то же самое, что структурное программирование сделало для синхронного: устраняет запутанные переходы (goto) и вводит четкую иерархию выполнения. > > Официальная документация Swift

    В Swift структурированный параллелизм опирается на иерархию: у каждой задачи может быть родительская задача (parent task) и дочерние задачи (child tasks). Если родительская задача отменяется или завершается с ошибкой, система автоматически уведомляет все дочерние задачи о необходимости прекратить работу.

    Параллелизм фиксированного числа задач: async let

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

    Рассмотрим пример загрузки данных для дашборда:

    В этом коде fetchProfile() и fetchStats() начинают выполняться одновременно. Ключевое слово await применяется только в тот момент, когда нам действительно нужны результаты их работы.

    Математически выигрыш во времени очевиден. При последовательном выполнении общее время равно сумме времени всех задач: . При использовании async let общее время равно времени выполнения самой долгой задачи: .

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

    Динамическое управление: TaskGroup

    Конструкция async let идеально подходит для 2-3 разнородных запросов. Но что делать, если нужно загрузить массив из 50 изображений? Мы не можем написать 50 переменных вручную. Для работы с динамическим количеством задач используется TaskGroup.

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

    Внутри блока group.addTask создается дочерняя задача. Цикл for try await позволяет получать результаты не в том порядке, в котором задачи были добавлены, а в том порядке, в котором они завершаются. Это максимизирует производительность.

    Рассмотрим числовой пример. Допустим, у нас есть 100 URL-адресов картинок. Загрузка одной картинки занимает в среднем миллисекунд. Последовательная загрузка займет миллисекунд (10 секунд). При использовании TaskGroup, если система выделит пул из 10 потоков, время загрузки сократится до миллисекунд (1 секунда). Приложение становится на порядок отзывчивее.

    Сравнение инструментов управления задачами

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

    | Инструмент | Тип параллелизма | Количество задач | Возвращаемые типы | Иерархия (Parent-Child) | | :--- | :--- | :--- | :--- | :--- | | Task { } | Неструктурированный | Любое | Разные | Нет (создает корневую задачу) | | async let | Структурированный | Фиксированное | Разные | Да | | TaskGroup | Структурированный | Динамическое | Одинаковые | Да |

    Обычный Task { }, который мы рассматривали в предыдущей статье как мост между синхронным и асинхронным кодом, является инструментом неструктурированного параллелизма. Он не привязан к области видимости и требует ручного управления (например, сохранения ссылки на задачу для ее последующей отмены).

    Кооперативная отмена задач (Cancellation)

    Важнейшая особенность структурированного параллелизма в Swift — это механизм отмены. В отличие от некоторых других языков программирования, где система может жестко «убить» поток выполнения (что часто приводит к повреждению данных или утечкам памяти), в Swift отмена является кооперативной.

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

    Сделать это можно двумя способами:

  • Проверить свойство Task.isCancelled и вернуть частичный результат.
  • Вызвать метод try Task.checkCancellation(), который выбросит ошибку CancellationError, если задача была отменена.
  • Если пользователь нажмет кнопку «Назад» во время обработки массива из миллиона элементов, контроллер деинициализируется, родительская задача отменится, и цикл прервется при следующей проверке checkCancellation(). Это спасет процессор устройства от выполнения бесполезной работы и сохранит заряд батареи.

    Итоги

    * Структурированный параллелизм (Structured Concurrency) связывает жизненный цикл асинхронных задач с областью видимости кода, предотвращая утечки ресурсов. Конструкция async let* используется для параллельного выполнения заранее известного количества задач с разными возвращаемыми типами. Структура TaskGroup* применяется для динамического создания дочерних задач, возвращающих данные одного типа (например, при обработке массивов). * Отмена задач в Swift носит кооперативный характер: система лишь выставляет флаг отмены, а разработчик должен явно проверять его с помощью Task.checkCancellation().