Всем привет, с вами все также Никита, мы продолжаем разбираться в асинхронном Swift! Прежде чем разбираться с этой статьей настоятельно рекомендуем прочитать предыдущие:
В рамках этой статьи мы познакомимся с тем, как писать зависящие друг от друга асинхронные задачи, познакомимся с Task поближе и разберем несколько интересных примеров, погнали!
Содержание
Task
В прошлой статье мы уже определились с тем, что такое Task, напомню:
Task — это базовая единица concurrency в системе. Каждая асинхронная функция выполняется в рамках задачи (task-и). Здесь Apple проводит аналогию с `Thread` : `Task` для асинхронной функции то же самое, что `Thread` для синхронной.
Из приведенной аналогии можно понять, что Task — это некий контекст асинхронной функции. Она может содержать всю информацию о своем исполнении, стеке вызова. С ее помощью мы можем выполнять разные задачи, не меняя Thread, потому что контекст для любой асинхронной задачи уже не Thread, а Task.
У каждой задачи (Task) может быть 3 состояния:
-
Running ? — выполняется на потоке и пока не дошла либо до return, либо до suspension point.
-
Suspended ? — не выполняется в моменте, но есть задачи для выполнения. Бывает два подтипа:
-
Waiting — ждет, пока выполнится дочерняя задача.
-
Schedulable — готова к исполнению и ждет, пока ее час настанет.
-
-
Completed — выполнена, делать нечего. Финальное состояние задачи.
У каждой задачи есть две интересующие нас сейчас поля:
-
isCancelled: Bool-значение, которое говорит нужно ли задаче останавливать выполнение своей работы. -
priority: TaskPriority— значение, показывающее приоритет задачи. С ее помощью Runtime Executor понимает, как выстраивать выполнение задач. Его можно создать черезinit(rawValue: UInt8)или воспользоваться дефолтными значениями:-
high -
medium(раньше называлсяdefault) -
low -
userInitiated -
utility -
background
-
Приоритет задача может быть изменен системой, чтобы избежать инверсии приоритетов (priority inversion). Задача с высоким приоритетом, зависящая от более низко приоритетной задачей, может поднять приоритет этой задачи. Также на это может повлиять работа actor, которых мы разберем позже.
Помимо полей у Task есть 3 основных метода:
-
Task.sleep()— откладывается выполнение задачи на время, переданное в параметре. -
Task.checkCancellation()— выбрасывает ошибку типа `CancellationError`, если задача была отменена. -
Task.yield() — откладывает работу задачи на некоторый промежуток времени, который даст система. Если задача имеет высокий приоритет, то продолжит выполнение без остановок.
Задачи могут быть двух типов: _структурированные_ и _неструктурированные_
Структурированные задачи
Асинхронная функция может создавать дочерние задачи — child task. Они в свою очередь могут создавать другие, что формирует древовидную структуру из задач, иерархию. С ее помощью мы можем:
1. В Runtime отменять все дочерние задачи, если родительская была отменена. Если родительская задача поменяла свое поле isCancelled сама или из вне, то мы начинаем проход по дереву и менять это же поле на аналогичное родительскому.

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

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

Parallelism
Помимо плюсов, описанных выше, создание подзадач может решить задачу с обеспечением параллелизма в нашем коде (при наличии такой возможности у системы и ее загруженности). Как же их создать? Существует 2 основных способа:
-
Использование
async letпеременных. -
Использование
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 был бы код, то он продолжил бы свое выполнение. Мы перешли от последовательного выполнения к параллельному:

Данная новая конструкция работает следующим образом:
-
Создается child-задача с таким же приоритетом
-
Начинается выполнение
-
При обращении к данной переменной необходимо указывать слово
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:
-
func addTask(priority: TaskPriority?, operation: @Sendable () -> ChildTaskResult)— добавление задачи в группу с возможностью указать приоритет задачи. -
func addTaskUnlessCancelled(priority: TaskPriority?, operation: @Sendable () -> ChildTaskResult) -> Bool— добавление задачи, если группа не была отменена. -
func waitForAll() async throws— асинхронный метод с ожиданием выполнения всех задач. -
Методы нового протокола AsyncSequence, который мы разберем чуть позже.
-
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/
Добавить комментарий