Как стать автором
Обновить

[Async/await] Structured concurrency Pt.1

Время на прочтение7 мин
Количество просмотров3.6K

Всем привет, с вами все также Никита, мы продолжаем разбираться в асинхронном 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-разработчик


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

Теги:
Хабы:
Всего голосов 2: ↑1 и ↓10
Комментарии0

Публикации

Истории

Работа

iOS разработчик
24 вакансии
Swift разработчик
31 вакансия

Ближайшие события