Как стать автором
Обновить
86.33
KTS
Создаем цифровые продукты для бизнеса

Structured concurrency в Swift

Время на прочтение12 мин
Количество просмотров11K
Автор оригинала: Federico Zanetello

Примечание переводчиков: В Swift 5.5 появилась новая концепция языка async/await. Мы решили опубликовать перевод статьи, чтобы разобраться с structured concurrency.

За время подготовки перевода оригинал успели переписать, и в статье ниже мы совместили обе версии: сначала идет развернутая версия, а в конце — краткие чек-листы с избранной информацией.

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

  • познакомим вас с разными типами параллельных задач, которые вы можете создавать в Swift

  • покажем, как создавать группы задач

  • научим отменять выполняемые задачи

  • разберем, когда может быть предпочтительнее использовать неструктурированные задачи

Для максимальной пользы сначала рекомендуем посмотреть «Знакомство с async/await в Swift».

📁 Содержание

🟧 Развернутая версия

🟠 Введение

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

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

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

В структурном программировании сложно сгенерировать и выкинуть ошибку.

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

async-функция не принимает обработчик выполнения (Completion handler) и вместо этого помечается как async и возвращает значение.

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

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

Задачи (Tasks) — новая фича в Swift. Задача предоставляет контекст выполнения, в котором мы можем писать асинхронный код. Каждая задача выполняется одновременно с другими задачами. При необходимости они будут работать параллельно.

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

🟠 Async let task 

async let — самая простая задача.

При написании let thing = something() сначала вычисляется something(), его результат присваивается к let thing.

Если функция something() является асинхронной, вам нужно сначала запустить ее, а затем установить результат выполнения в let thing. Вы можете сделать это, пометив let thing как async:

async let thing = something()

При выполнении этой строки создается дочерняя задача. В то же время к let thing присваивается плэйсхолдер. Родительская задача продолжает работать до тех пор, пока в какой-то момент мы не захотим использовать thing. Нам нужно отметить это место с помощью await:

async let thing = something()

//some stuff

makeUseOf(await thing)

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

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

func performAsyncJob() async throws -> Output {
  let (data, _) = try await fetchData()
  let (meta, _) = try await meta()
  
  return Output(data, meta)
}

Этот код сначала запустит и будет ожидать окончания fetchData, а потом запустится meta.

По завершении meta мы возвращаем Output.

Если два вызова await не зависят друг от друга, мы можем запустить их параллельно, используя async let:

func performAsyncJob() async throws -> Output {
  async let (data, _) = fetchData()
  async let (meta, _) = meta()
  
  return Output(try await data, try await meta)
}

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

  • Родительская задача может порождать одну или несколько дочерних задач. 

  • Родительская задача может завершить свою работу только в том случае, если ее дочерние задачи завершили свою работу. 

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

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

  • Если у задачи есть какие-либо дочерние задачи при отмене, ее дочерние задачи также будут автоматически помечены как отмененные.

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

Эта даёт гарантию того, что задачи всегда будут завершаться — либо успешно, либо путем отмены, либо путем вывода ошибки), что является фундаментальным для многозадачности в Swift.

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

Один из вариантов — try Task.checkCancellation(). Это проверит статус отмены для текущей задачи и выдаст ошибку, если/когда это так. Другой вариант — через Task.isCancelled. Когда ваша задача отменена, вы можете бросить ошибку, что и делает Task.checkCancellation(). Но также вы можете вернуть пустой или частичный результат. Убедитесь, что вы задокументировали это явно, чтобы клиенты, вызывающие ваши функции, знали, чего ожидать.

🟠 Group task

Пример такой задачи ниже:

func fetchSeveralThings(for ids: [String]) async throws -> [String: Output] {
  var output = [String: Output]()
  for id in ids {
    output[id] = try await performAsyncJob()
  }
  return output
}

func performAsyncJob() async throws -> Output {
  async let (data, _) = fetchData()
  async let (meta, _) = meta()
  
  return Output(try await data, try await meta)
}

Нужно получить для всех id дополнительную информацию.

Для каждого id создается задача с двумя дочерними задачами. await PerformAsyncJob является родительской задачей, а fetchData и meta создают дочерние задачи. В цикле в каждый момент времени у нас активна только одна задача await performAsyncJob. Это означает, что Swift может дать определенные гарантии о нашей многозадачности. Он точно знает, сколько задач активно одновременно.

Мы можем использовать группу задач, чтобы активировать несколько вызовов для выполнения performAsyncJob и распараллелить выполнение. Задачи, созданные в группе, не могут выйти за рамки своей группы.

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

func fetchSeveralThings(for ids: [String]) async throws -> [String: Output] {
   var output = [String: Output]()
   try await withThrowingTaskGroup(of: Void.self) { group in 
     for id in ids {
       group.async {
         output[id] = try await performAsyncJob()
       }
     }
   }
   
   return output
  

Дочерние задачи запускаются сразу после их добавления в группу.

В конце замыкания group выходит из области видимости и ожидается результат всех добавленных дочерних задач.

Это означает, что внутри fetchSeveralThings есть одна задача. У этой задачи есть дочерняя задача для каждого id в списке. И каждая дочерняя задача имеет несколько собственных задач.

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

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

Для более подробной информации можно ознакомиться с сессией “Protect mutable state with Swift actors”.

Чтобы решить проблему параллельной записи в словарь, дочерние задачи могут возвращать значение, вместо того чтобы напрямую изменять словарь:

func fetchSeveralThings(for ids: [String]) async throws -> [String: Output] {
   var output = [String: Output]()
   try await withThrowingTaskGroup(of: (String, Output).self) { group in 
     for id in ids {
       group.async {
         return(id, try await performAsyncJob())
       }
     }
                                                   
     for try await (id, result) in group{
       output[id] = result
     }
   }
   
   return output
}

Цикл for try await выполняется последовательно, поэтому словарь output изменяется шаг за шагом.

Асинхронные последовательности более подробно рассматриваются в сессии знакомства с AsyncSequence.

Хотя группы задач представляют собой форму structured concurrency, правила дерева задач работают несколько иначе.

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

🟠 Неструктурированные задачи

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

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

🔸 Async tasks

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

Это не сработает:

// shortened
 func cellForRowAt() {
   let ids = getIds(for: item) // item is passed to cellForRowAt
   let content = await getContent(for: ids)
   cell.content = content
 }

Итак, нам нужно запустить неструктурированную задачу:

// shortened
 func willDisplayCellForItem() {
   let ids = getIds(for: item) // item is passed to willDisplayCellForItem
   async {
     let content = await getContent(for: ids)
     cell.content = content
   }
 }

Функция async асинхронно запускает код для текущего актора. Чтобы сделать его главным актором, вы можете добавить аннотацию классу @MainActor.

@MainActor
 class CollectionDelegate {
   // code
 }
  • Неструктурированная задача наследует изоляцию акторов и приоритет исходного контекста

  • Срок выполнения не ограничивается областью видимости

  • Может быть запущена в любом месте

  • Необходимо вручную отменить или дождаться

Вся асинхронная работа выполняется внутри задачи. Всегда.

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

Мы можем поместить задачи в словарь, чтобы отслеживать их:

@MainActor
 class CollectionDelegate {
   var tasks = [IndexPath: Task.Handle<Void, Never>]()

   func willDisplayCellForItem() {
     let ids = getIds(for: item) // item is passed to willDisplayCellForItem
     tasks[item] = async {
       defer { tasks[item] = nil }

       let content = await getContent(for: ids)
         cell.content = content
     }
   }
 }

Хранение задачи позволяет отменить ее. Когда задача будет завершена, ее нужно удалить. Это можно реализовать с помощью defer. Таким образом, мы ничего не отменяем.

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

Мы можем использовать tasks[item]?.cancel() в didEndDisplay, чтобы отменить задачу вручную.

🔸 Detached tasks

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

Представьте себе кэширование результата getContent в коде. Это можно отделить от главного актора:

@MainActor
 class CollectionDelegate {
   var tasks = [IndexPath: Task.Handle<Void, Never>]()

   func willDisplayCellForItem() {
     let ids = getIds(for: item) // item is passed to willDisplayCellForItem
     tasks[item] = async {
       defer { tasks[item] = nil }

       let content = await getContent(for: ids)
       
       asyncDetached(priority: .background) {
         writeToCache(content)
       }
       
         cell.content = content
     }
   }
 }

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

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

asyncDetached(priority: .background) {
   withTaskGroup(of: Void.self) { group in 
     group.async { writeToCache(content) }
     group.async { ... }
     group.async { ... }
   }
 }

detached-задача является фоновой задачей, и ее приоритет также применяется ко всем дочерним задачам.

Задачи — это всего лишь часть многозадачности в Swift. Они интегрируются с остальной частью этого большого функционала.

Задачи интегрируются с ОС и нагрузка от них на систему низка. В сессии WWDC21 представлена более подробная информация.

Запускается с помощью

Запускается из

Жизненный цикл

Отмена

Что наследует 

async-let tasks

async let x

asyncfunctions

ограничено оператором

автоматически

приоритет,   локальные значения задачи

Group tasks

group.async

withTaskGroup

ограничено группой задач

автоматически

приоритет,   локальные значения задачи

Неструктурированные задачи

async

Любое место

Без ограничений

С помощью Task.Handle

приоритет,   локальные значения задачи, акторы

Detached tasks

asyncDetached

Любое место

Без ограничений

С помощью Task.Handle

ничего

🟧 Сжатая версия

💮 Tasks

  • Вы можете создавать дополнительные задачи, чтобы добавить параллелизм в программу.

  • Задача предоставляет новый контекст выполнения для запуска асинхронного кода.

  • Каждая задача выполняется одновременно с другими контекстами выполнения.

  • Вызов функции async не создает новую задачу.

💮 Async-let задачи

  • Когда процесс сталкивается с async let, создается дочерняя задача, а основная задача продолжает выполняться.

  • Основная задача приостанавливается (при необходимости) только тогда, когда ей нужно получить результат от дочерней задачи async let, и она делает это с помощью ключевого слова (try) await. Другими словами, основная задача может приостановиться, когда она начнет использовать переменные, которые одновременно связаны.

💮 Дерево задач

  • Отслеживает сами задачи и их дочерние.

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

  • Дочерняя/подзадача наследует все атрибуты основной задачи.

  • Всякий раз, когда вы делаете вызов из одной async-функции в другую, для выполнения вызова используется одна и та же задача.

  • Задачи не являются дочерними элементами конкретной функции, но их время жизни может быть связано с ней.

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

  • Допустим, что у нас есть две дочерние задачи, и одна из них завершается с ошибкой, в результате чего основная задача, которая try await (ожидала) их, выдает ошибку: дерево отвечает за отмену других дочерних задач, а затем ожидает их завершения до того, как функция основной задачи может выйти/выдать ошибку

  • Пометка задачи как отмененной не останавливает задачу. Это просто сообщает задаче, что ее результаты больше не нужны.

  • При отмене задачи все подзадачи, являющиеся производными от этой задачи, также будут автоматически отменены.

💮 Отмена задачи является кооперативной

  • Задачи не останавливаются сразу после отмены.

  • Отмену можно проверить из любого места (async или нет).

  • Разрабатывайте свой код, держа в уме отмену.

💮 Групповые задачи

  • Группа задач — это форма структурированного параллелизма, предназначенная для обеспечения динамического объема параллелизма.

  • Вы можете ввести группу задач, вызвав функцию withThrowingTaskGroup

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

  • Задачи, добавленные в группу, не могут выйти за рамки блока, в котором определена группа.

  • Вы создаете дочерние задачи в группе, вызывая их метод async(_:)

  • Добавленные таким образом дочерние задачи начнут выполняться немедленно и в любом порядке.

  • Когда объект группы выходит за пределы области действия, выполнение всех задач внутри него будет неявно ожидаться.

  • Каждый раз, когда вы создаете новую задачу, работа, которую выполняет задача, относится к новому типу замыкания, называемому замыканием @Sendable.

  • Текст замыкания @Sendable не может содержать изменяемые переменные в своем лексическом контексте, поскольку эти переменные могут быть изменены после запуска задачи.

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

Отличия дерева задач от задач async let:

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

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

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

💮 Неструктурированные задачи

  • Дадут вам гораздо больше гибкости, но понадобится гораздо больше ручного управления.

  • Полезно, когда:

    • некоторые задачи необходимо запускать из неасинхронных контекстов

    • некоторые задачи выходят за рамки одной области

Характеристики:

  • Наследуют изоляцию актора и приоритет исходного контекста.

  • Работа не ограничивается какой-либо областью.

  • Может запускаться где угодно, даже с неасинхронными функциями.

  • Необходимо вручную отменить или дождаться.

💮 Detached Tasks

  • Неструктурированные задачи наследуют черты исходного контекста этой задачи, а отдельные задачи — нет.

  • Максимальная гибкость.

  • Неограниченный жизненный цикл, вручную отменяется и ожидается.

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

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


Другие наши статьи по iOS-разработке:

Теги:
Хабы:
Всего голосов 13: ↑13 и ↓0+13
Комментарии5

Публикации

Информация

Сайт
kts.tech
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия