[Async/await] Structured concurrency Pt.1

—

от автора

Всем привет, с вами все также Никита, мы продолжаем разбираться в асинхронном Swift! Прежде чем разбираться с этой статьей настоятельно рекомендуем прочитать предыдущие:

  1. Проблемы Swift 5.4

  2. Как работает Coroutine Model

  3. Новые синтаксические конструкции языка Swift

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

Содержание

  1. Подробнее про Task

  2. Структурированные задачи

  3. Parallelism

    1. Async let

    2. Task Group

  4. Вместо вывода

Task

В прошлой статье мы уже определились с тем, что такое Task, напомню:

Task — это базовая единица concurrency в системе. Каждая асинхронная функция выполняется в рамках задачи (task-и). Здесь Apple проводит аналогию с `Thread` : `Task` для асинхронной функции то же самое, что `Thread` для синхронной.

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

У каждой задачи (Task) может быть 3 состояния: 

  1. Running ? — выполняется на потоке и пока не дошла либо до return, либо до suspension point.

  2. Suspended ? — не выполняется в моменте, но есть задачи для выполнения. Бывает два подтипа:

    1. Waiting — ждет, пока выполнится дочерняя задача.

    2. Schedulable — готова к исполнению и ждет, пока ее час настанет.

  3. Completed — выполнена, делать нечего. Финальное состояние задачи.

У каждой задачи есть две интересующие нас сейчас поля: 

  1. isCancelled: Bool- значение, которое говорит нужно ли задаче останавливать выполнение своей работы.

  2. priority: TaskPriority — значение, показывающее приоритет задачи. С ее помощью Runtime Executor понимает, как выстраивать выполнение задач. Его можно создать через init(rawValue: UInt8) или воспользоваться дефолтными значениями:

    1. high

    2. medium (раньше назывался default)

    3. low

    4. userInitiated

    5. utility

    6. background

Приоритет задача может быть изменен системой, чтобы избежать инверсии приоритетов (priority inversion). Задача с высоким приоритетом, зависящая от более низко приоритетной задачей, может поднять приоритет этой задачи. Также на это может повлиять работа actor, которых мы разберем позже.

Помимо полей у Task есть 3 основных метода:

  1. Task.sleep()— откладывается выполнение задачи на время, переданное в параметре.

  2. Task.checkCancellation() — выбрасывает ошибку типа `CancellationError`, если задача была отменена.

  3. Task.yield() — откладывает работу задачи на некоторый промежуток времени, который даст система. Если задача имеет высокий приоритет, то продолжит выполнение без остановок.

Задачи могут быть двух типов: _структурированные_ и _неструктурированные_

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

Асинхронная функция может создавать дочерние задачи — child task. Они в свою очередь могут создавать другие, что формирует древовидную структуру из задач, иерархию. С ее помощью мы можем:

1.  В Runtime отменять все дочерние задачи, если родительская была отменена. Если родительская задача поменяла свое поле isCancelled сама или из вне, то мы начинаем проход по дереву и менять это же поле на аналогичное родительскому. 

2. Ожидать выполнение всех дочерних задач. Если в коде мы вызываем несколько асинхронных функций последовательно, то они будут выполняться “последовательно” — только тогда, когда предыдущая закончит свое выполнение:

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

Parallelism

Помимо плюсов, описанных выше, создание подзадач может решить задачу с обеспечением параллелизма в нашем коде (при наличии такой возможности у системы и ее загруженности). Как же их создать? Существует 2 основных способа: 

  1. Использование async let переменных.

  2. Использование TaskGroup.

Async let

Разберем пример: 

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

struct DetailInfo {     let cardInfo: CreditCard // struct CreditCard - Основная информация по карте.     let lastOperations: [Operation] // struct Operation - Совершенная операция по карте. }  // Загрузка основной информации по карте. func fetchCreditCardInfo(for id: String) async throws -> CreditCard // Загрузка списка последних операций по карте. func fetchLastOperations(for id: String) async throws -> [Operation]

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

Реализуем загрузку детальной информации: 

func fetchDetailInfo(for id: String) async throws -> DetailInfo {     let creditCardInfo = try await fetchCreditCardInfo(for: id)     let lastOperations = try await fetchLastOperations(for: id)      return DetailInfo(cardInfo: creditCardInfo, lastOperations: lastOperations) }

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

Перепишем наше решение: 

// Загрузка детальной информации по карте. func fetchDetailInfo(for id: String) async throws -> DetailInfo {     async let creditCardInfo = fetchCreditCardInfo(for: id)     async let lastOperations = fetchLastOperations(for: id)      return DetailInfo(cardInfo: try await creditCardInfo, lastOperations: try await lastOperations) }

Теперь загрузка данных из сети состоит из двух задач, которые запускаются параллельно, а сама задача при это не блокируется. Это означает, что, если бы в промежутке между созданием async let переменных и return был бы код, то он продолжил бы свое выполнение. Мы перешли от последовательного выполнения к параллельному:

Данная новая конструкция работает следующим образом:

  1. Создается child-задача с таким же приоритетом

  2. Начинается выполнение

  3. При обращении к данной переменной необходимо указывать слово await — тем самым мы ставим здесь suspension point, потому что вычисление async let переменной может не закончится и нам придется ждать ее завершения

 Аpple в своем докладе грамотно иллюстрирует работу данного синтаксиса: 

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

 TaskGroup

Дополним нашу задачу c кредиткой новым требованием: Изменился бэк, теперь вместе с карточкой приходит массив ids операций типа [String]. Для загрузки каждой операции необходимо отправлять запрос. Сама операция содержит в себе сумму и дату: 

struct Operation {     let date: Date     let amount: Double }

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

func fetchOperations(ids: [String]) async throws -> [Operation] // Загрузка массива операций по карте. func fetchOperation(id: String) async throws -> Operation // Уже реализованный метод загрузки данных о операции.

Для решения этой задачи давайте воспользуемсям механизмомTaskGroup. Он предоставляет нам асинхронный scope, в котором мы можем создавать дочерние задачи. Интересный момент, что при возникновении ошибки у одной задачи, другие будут отменены автоматически.

Группы бывают двух типов — пробрасывающие ошибки или нет, соответственно,withTaskGroup, withThrowingTaskGroup. Если провалиться и посмотреть их интерфейс, то мы увидим: 

// Функция создания группы задач. @inlinable public func withTaskGroup<ChildTaskResult, GroupResult>(     of childTaskResultType: ChildTaskResult.Type, // Возвращаемый подзадачей тип.     returning returnType: GroupResult.Type = GroupResult.self, // Возвращаемый группой тип.     body: (inout TaskGroup<ChildTaskResult>) async -> GroupResult // Замыкание или же scope, которое принимает группу и асинхронно возвращает результат. ) async -> GroupResult  

Нам необходим следующий вызов этой функции:

await withTaskGroup(     of: Operation.self,     returning: [Operation].self // Можно опустить, Swift поймет сам по типу замыкания.      body: { group in         /* Работа с группой */     } )  

Методы TaskGroup: 

  1. func addTask(priority: TaskPriority?, operation: @Sendable () -> ChildTaskResult) — добавление задачи в группу с возможностью указать приоритет задачи. 

  2. func addTaskUnlessCancelled(priority: TaskPriority?, operation: @Sendable () -> ChildTaskResult) -> Bool — добавление задачи, если группа не была отменена.

  3. func waitForAll() async throws — асинхронный метод с ожиданием выполнения всех задач.

  4. Методы нового протокола AsyncSequence, который мы разберем чуть позже.

  5.  cancelAll()— отменяет запущенные задачи в группе.

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

func fetchOperations(ids: [String]) async throws -> [Operation] {     try await withThrowingTaskGroup(of: Operation.self) { group in         for id in ids { // Проходим по массиву ids             group.addTask { // Добавляем в группу задачу                 try await fetchOperation(id: id) // Ожидаем выполнение загрузки информации по операции             }         }         // Собираем все операции в один общий массив, порядок добавление не гарантирован         let unsortedOperations = try await group.reduce(into: [Operation]()) { $0.append($1) }          return unsortedOperations.sorted { $0.date < $1.date } // Сортируем по времени и возвращаем результат.     } }  

Вывод

Мы познакомились с основами абсолютно новым для iOS-разработки синтаксисом языка Swift, его преимущества над работой с замыканиями и потоками, увидели обработку ошибок. Самое интересное, что это только верхушка айсберга — дальше больше!

Никита Сосюк

iOS-разработчик


Также подписывайтесь на наш телеграм-канал «Голос Технократии». Каждое утро мы публикуем новостной дайджест из мира ИТ, а по вечерам делимся интересными и полезными мастридами.


ссылка на оригинал статьи https://habr.com/ru/post/681628/


Комментарии

Добавить комментарий

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