Всем привет, с вами все также Никита, мы продолжаем разбираться в асинхронном 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-разработчик
Также подписывайтесь на наш телеграм-канал «Голос Технократии». Каждое утро мы публикуем новостной дайджест из мира ИТ, а по вечерам делимся интересными и полезными мастридами.