Асинхронность: async/await и сеть

Реальные приложения загружают данные из интернета. Swift делает асинхронный код читаемым с помощью async/await — он выглядит почти как обычный последовательный код.
Суть урока: async помечает функцию, которая может приостановиться (например, ждать ответа сети), а await отмечает точку ожидания. Код читается сверху вниз, но не блокирует интерфейс.

Загрузка данных занимает время, и блокировать интерфейс на это время нельзя — приложение «зависнет». Раньше для этого писали запутанные колбэки. Swift предлагает async/await: асинхронная функция помечается async, а места ожидания — словом await:

struct Post: Decodable, Identifiable {
    let id: Int
    let title: String
}

func loadPosts() async throws -> [Post] {
    let url = URL(string: "https://api.example.com/posts")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode([Post].self, from: data)
}

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

struct FeedView: View {
    @State private var posts: [Post] = []

    var body: some View {
        List(posts) { post in Text(post.title) }
            .task {
                posts = (try? await loadPosts()) ?? []
            }
    }
}

Обновление интерфейса должно происходить в главном потоке. SwiftUI-вью уже работают на @MainActor, поэтому присваивание posts безопасно. Современный совет 2024-2025: держите UI-код на @MainActor, а тяжёлые вычисления выносите в фон. Для параллельных задач есть async let и TaskGroup, образующие структурированную конкурентность — задачи живут в предсказуемой иерархии и не «теряются».

Главный поток (UI)             Сеть
   |  .task стартует
   |  await loadPosts() ----------+
   |  (поток свободен, UI живёт)   | загрузка...
   |                              |
   |  <---- данные пришли ---------+
   |  posts = [...]  (на @MainActor)
   v  список обновлён

Попробуй сам ▶ — запусти код прямо в браузере (Pyodide). Здесь нет Swift, но логика та же, что под капотом мобильного кода:

# Имитируем async/await: ожидание без блокировки через asyncio.
import asyncio

async def load_posts():
    await asyncio.sleep(0)          # как await на сети — точка приостановки
    return [{'id': 1, 'title': 'Swift 6'},
            {'id': 2, 'title': 'SwiftUI'}]

async def main():
    posts = await load_posts()      # дождались, не блокируя
    for p in posts:
        print(p['id'], p['title'])

asyncio.run(main())

Как работает под капотом

Когда выполнение доходит до await, функция может приостановиться (suspend): её состояние сохраняется, поток освобождается для другой работы, а при готовности результата функция возобновляется — возможно, уже на другом потоке. Актор @MainActor гарантирует, что отмеченный им код всегда исполняется на главном потоке, защищая UI-состояние от гонок. Структурированная конкурентность связывает дочерние задачи с родительской: если родитель отменён, дети тоже отменяются, что исключает «осиротевшие» задачи и утечки.

Частые ошибки

  • Обновлять UI из фонового потока. Это приводит к гонкам; UI-код должен быть на @MainActor.
  • Запускать сетевой запрос напрямую в body. Используйте .task, а не побочные эффекты в body.
  • Игнорировать ошибки. Сеть ненадёжна — обрабатывайте throws через try/catch.

Best practices

  • Запускайте загрузку через .task — она сама стартует и отменяется вместе с вью.
  • Держите UI на @MainActor, тяжёлые вычисления выносите в фоновые задачи.
  • Для параллельных независимых запросов используйте async let.

Итоги. async/await делает асинхронный код линейным и читаемым, не блокируя интерфейс. Модификатор .task связывает загрузку с жизнью вью, @MainActor защищает UI, а структурированная конкурентность держит задачи под контролем. Это современный стандарт работы с данными в iOS.

Шире контекста

Появление async/await изменило мобильную разработку так же, как когда-то опционалы. До него асинхронный код тонул в вложенных колбэках — «пирамиде гибели», где обработка ошибок и порядок выполнения становились нечитаемыми. Теперь асинхронный код выглядит почти как обычный последовательный, но при этом не блокирует интерфейс. Вокруг этого выстроена целая модель: акторы изолируют изменяемое состояние и исключают гонки данных, @MainActor держит UI на главном потоке, структурированная конкурентность с async let и TaskGroup связывает дочерние задачи с родителем, чтобы ни одна не потерялась и не утекла. Современные рекомендации 2024-2025 годов советуют по умолчанию держать код на главном акторе и выносить в фон только тяжёлые вычисления — это проще и безопаснее, чем вручную жонглировать потоками. Строгий режим Swift 6 доводит идею до предела, превращая потенциальные гонки данных в ошибки компиляции, и хотя поначалу это строго, в итоге вы получаете асинхронный код, которому можно доверять.

Проверьте себя
1. Что означает ключевое слово await перед вызовом функции?
AФункция выполнится мгновенно
BЭто точка, где функция может приостановиться, не блокируя поток
CФункция гарантированно упадёт
DРезультат игнорируется
2. Почему обновлять UI-состояние нужно на @MainActor?
AТак быстрее компилируется
BЧтобы избежать гонок данных — интерфейс должен меняться в главном потоке
CЭто требование URLSession
DИначе данные не декодируются