Task и structured concurrency в swift

В данной части из серии статей про Swift concurrency мы подробно остановимся на сущности под названием Task и разберем на примерах, как с ней работать. Также поговорим про structured concurrency. Что это такое, как это понятие связано с Task и почему механизмы языка async/await structured, но не concurrent.

Помимо этого, мы разберем инструменты и механизмы structured concurrency. Среди них async let, Task hierarchy и Task cancellation. И, как обычно, не оставлю вас без примеров применения этого букета конкуррентности.

Статьи из серии

  1. Swift async/await. Чем он лучше GCD?

  2. Swift async/await на примерах

  3. Task и structured concurrency в swift

Оглавление

Task

До выхода Swift Concurrency была возможность оперировать очередями (GCD), операциями (Operation) или напрямую потоками. Task — это основная сущность Swift Concurrency. Любая асинхронная функция выполняется в рамках какой-то Task. Без нее вы увидите сообщение, способное растрогать любого:

efe49b9a85d68804acb4a05c2ce8ae3b.png

Внутри одной Task может одновременно выполняться только одна функция. То есть в одной таске нет конкуррентности. Но одновременно с этим никто не мешает создать несколько Task, выполняющих разную работу, для обеспечения конкуррентности.

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

@frozen public struct Task : Sendable where Success : Sendable, Failure : Error {}

Как мы видим, Task — это структура с дженериками Success для возвращаемого значения и Failure для ошибки. Что такое Sendable, мы разберем в следующих частях, сейчас можно упустить этот момент.

Возвращаемое значение можно получить через асинхронное вычисляемое свойство value. Оно будет throws, если тип ошибки не Never, и не throws, если тип ошибки Never, соответственно.

public var value: Success { get async throws }

На основе этих вводных реализуем пример. В нем и далее буду использовать сервис jsonplaceholder, как и в предыдущей части.

Реализуем следующее: загрузка изображения по пользовательскому действию. В нашем случае это будет нажатие кнопки.

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

// 1
struct Photo: Decodable {
  let albumId: Int
  let id: Int
  let title: String
  let url: URL
  let thumbnailUrl: URL
}

// 2
func getPhotos() async throws -> [Photo] {
  let url = URL(string: "https://jsonplaceholder.typicode.com/albums/1/photos")!
  let request = URLRequest(url: url)

  let (data, _) = try await URLSession.shared.data(for: request)

  let photos = try JSONDecoder().decode([Photo].self, from: data)
  return photos
}

// 3
class ImageLoader {
  private let imageUrl: URL

  init(imageUrl: URL) {
    self.imageUrl = imageUrl
  }

  var image: UIImage? {
    get async throws {
      let (data, _) = try await URLSession.shared.data(from: imageUrl)
      return UIImage(data: data)
    }
  }
}
  1. Сущность для хранения метаданных изображения. Структуру задал на основе ответа с сервера по запросу из метода getPhotos

  2. Метод, загружающий массив объектов типа Photo

  3. Класс, загружающий изображение по URL

Далее напишем небольшой контроллер, который будет содержать в себе методы для загрузки изображения и простенький UI, чтобы отобразить необходимые для примера вьюхи:

Код контроллера

import UIKit

struct Photo: Decodable {
  let albumId: Int
  let id: Int
  let title: String
  let url: URL
  let thumbnailUrl: URL
}

class ImageLoader {
  private let imageUrl: URL

  init(imageUrl: URL) {
    self.imageUrl = imageUrl
  }

  var image: UIImage? {
    get async throws {
      let (data, _) = try await URLSession.shared.data(from: imageUrl)
      return UIImage(data: data)
    }
  }
}

class ViewController: UIViewController {
  private let imageView: UIImageView = {
    let view = UIImageView()
    view.translatesAutoresizingMaskIntoConstraints = false
    return view
  }()

  private let button: UIButton = {
    let button = UIButton(type: .system)
    button.setTitle("Load image", for: .normal)
    button.translatesAutoresizingMaskIntoConstraints = false
    return button
  }()

  init() {
    super.init(nibName: nil, bundle: nil)
    setupView()
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  private func onLoadImageButtonTap() {
    Task {
      let image = try await loadImage()
      imageView.image = image
    }
  }

  private func loadImage() async throws -> UIImage? {
    let photos = try await getPhotos()
    guard !photos.isEmpty else { return nil }

    return try await ImageLoader(imageUrl: photos[0].url).image
  }

  func getPhotos() async throws -> [Photo] {
    let url = URL(string: "https://jsonplaceholder.typicode.com/albums/1/photos")!
    let request = URLRequest(url: url)

    let (data, _) = try await URLSession.shared.data(for: request)

    let photos = try JSONDecoder().decode([Photo].self, from: data)
    return photos
  }

  private func setupView() {
    view.backgroundColor = .white
    button.addAction(
      UIAction { [weak self] _ in
        self?.onLoadImageButtonTap()
      },
      for: .touchUpInside
    )

    addSubviews()
    setupConstraints()
  }

  private func addSubviews() {
    view.addSubview(imageView)
    view.addSubview(button)
  }

  private func setupConstraints() {
    NSLayoutConstraint.activate([
      imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
      imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
      imageView.widthAnchor.constraint(equalToConstant: 248),
      imageView.heightAnchor.constraint(equalToConstant: 248),

      button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
      button.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 24),
    ])
  }
}

Данный контроллер содержит в себе UIImageView и кнопку для старта загрузки изображения. По кнопке мы создаем Task загрузки, в которой вызываем асинхронную функцию загрузки и присваиваем результат в нашу UIImageView.

private func onLoadImageButtonTap() {
  Task {
    let image = try await loadImage()
    imageView.image = image
  }
}

private func loadImage() async throws -> UIImage? {
  let photos = try await getPhotos()
  guard !photos.isEmpty else { return nil }

  return try await ImageLoader(imageUrl: photos[0].url).image
}

Но в данной реализации есть проблема. Метод onLoadImageButtonTap вызывается каждый раз при нажатии кнопки. И каждый раз загружает изображение с нуля через метод loadImage. Нужно ли нам грузить повторно уже загруженное изображение? Вопрос риторический, давайте исправим этот момент.

// 1
private var imageLoadingTask: Task?

private func onLoadImageButtonTap() {
  Task {
    // 2
    let loadingTask: Task
    if let imageLoadingTask {
      loadingTask = imageLoadingTask
    } else {
      loadingTask = Task {
        try await loadImage()
      }
      imageLoadingTask = loadingTask
    }

    // 3
    imageView.image = try await loadingTask.value
  }
}
  1. Так как Task — это структура, то и работать мы можем с ней как и со всеми структурами: создавать, прокидывать между классами, уничтожать. Создадим переменную типа Task? в контроллере.

  2. Проверяем, создавалась ли ранее Task на загрузку изображения. Если да, значит просто переиспользуем ее. Если нет, то создаем новую Task для загрузки изображения и присваиваем ее нашей переменной.

  3. На предыдущем шаге мы получили Task (создали новую, либо взяли уже существующую). На этой строчке мы достаем из таски значение и присваиваем ее нашей UIImageView. value либо сразу отдаст результат (в случае, если Task уже загрузила изображение ранее), либо отпустит поток и будет ждать завершения Task.

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

// 1
private var imageShowingTask: Task?

private func onLoadImageButtonTap() {
  // 2
  guard imageShowingTask == nil else { return }

  imageShowingTask = Task {
    let image = try await loadImage()
    imageView.image = image
  }
}
  1. Теперь мы храним не Task по загрузке изображения, а Task по ее показу (которая в себя включает и загрузку). Тип Success Task теперь обозначим как Void (было UIImage?), так как наша Task теперь ничего не возвращает.

  2. Проверяем, существует ли imageShowingTask. Если да, то изображение уже загружено и отображено, либо в процессе загрузки и скоро отобразится. От нас в таком случае ничего не требуется, просто выходим из функции.

Так же не стоит забывать про обработку ошибок. В случае, если функция loadImage вернет ошибку, то пользователю ничего не отобразится, и при нажатии на кнопку повторно ничего не произойдет, так как Task уже существует.

// 3
private var imageShowingTask: Task?

private func onLoadImageButtonTap() {
  guard imageShowingTask == nil else { return }

  imageShowingTask = Task {
    // 1
    do {
      let image = try await loadImage()
      imageView.image = image
    } catch {
      // 2
      imageShowingTask = nil
    }
  }
}
  1. Заворачиваем вызов функции loadImage в do/catch блок.

  2. Если loadImage выбросит ошибку, то присваиваем в переменной imageShowingTask nil, чтобы при повторном нажатии на кнопку загрузка стартанула снова.

  3. Заменяем тип Task? на Task?. Теперь Task не может вернуть ошибку, так как обработка осуществляется в теле автоматически. В таком варианте при вызове imageShowingTask.value не нужно будет использовать ключевое слово try, так как нет случая, при котором Task бы выбросила ошибку.

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

И последний нюанс. У нас нет необходимости продолжать загрузку, если пользователь закрыл экран. Следовательно, нам нужно как-то отменить выполняющуюся Task при закрытии экрана. Работая с Task, это очень легко сделать, ведь у нее есть специальный метод cancel(). Если не отменить ее, то контроллер будет жить в памяти, пока Task не закончит свое выполнение. Реализуем отмену в методе viewWillDisappear

override func viewWillDisappear(_ animated: Bool) {
  super.viewWillDisappear(animated)
  imageShowingTask?.cancel()
}

Разобравшись, как можно работать с Task на примере, выделю еще несколько полезных моментов:

У Task есть 3 возможных состояния:

  1. Suspended. У Task еще есть незавершенная работа, но она ожидает пока система (планировщик) подхватит ее для дальнейшего выполнения.

  2. Running. Task выполняется в данный момент.

  3. Completed. Task завершилась (с ошибкой или без).

Узнать состояние Task из коробки (вне асинхронного контекста) никак нельзя. Как раз для этого в нашем примере мы присваивали nil в переменную контроллера по завершению Task. Если в переменной не nil, значит, Task существует и выполняется. Если же nil, то Task завершена (либо еще никогда не создавалась).

В примере выше мы создавали Task через ее инициализатор. Такой способ создания позволяет таске унаследовать данные об Actor’е. Мы разберем эту сущность в следующих частях, сейчас же важно знать, что UIViewController — это MainActor, и весь асинхронных код MainActor’а выполняется на main потоке.

Таску можно так же создать с помощью метода Task.detached. Такое создание не наследует информацию об Actor’е и, следовательно, не гарантирует выполнение на main потоке. В примере мы работали с UI, все взаимодействие должно проходить на main потоке, следовательно, создание Task через ее инициализатор было для нас оптимальным решением.

Но даже если бы мы попробовали создать Task через Task.detached, то компилятор бы не дал нам это сделать, как раз из-за несоответствия потоков. Снова возвращаемся к тому, что допустить ошибку, работая с async/await, сложнее, ведь компилятор в контексте конкуррентности.

2c89faa20069e63bf48a9365c6339f28.png

Structured concurrency

Task — это основополагающий блок для structured concurrency, да и в целом для Swift concurrency. С Task мы познакомились, теперь познакомимся и со вторым зверем.

Давайте разберем дословно. Имеем 2 составляющие:

  1. Structured. Тут подразумевается структурная парадигма программирования, которая нацелена на упрощение понимания/читаемости кода и на уменьшение времени разработки. Ключевые слова async/await в Swift как раз и позволяют взаимодействовать с асинхронными функциями, используя те же конструкции, что мы используем и при написании синхронного кода (условия, циклы, try/catch). И главное — это то, что они позволяют использовать эти конструкции последовательно друг за другом.

  2. Concurrency. Это свойство, позволяющее выполнять несколько задач (функций) одновременно. Механизмы языка async/await не позволяет оперировать несколькими асинхронными функциями одновременно, поэтому async/await structured, но не concurrent. То есть, используя только async/await, мы не сможем выполнять работу конкуррентно.

Так как конструкция await не предоставляют возможности выполнять несколько функций одновременно, то явно напрашивается набор сущностей, функций и механизмов, которые позволят это. И помимо этого, хочется не только уметь запускать задачи конкуррентно, но и также иметь удобный функционал для построения зависимостей между ними. В этом и есть суть structured concurrency в Swift. Это своего рода продолжение/расширение async/await, предоставляющее инструменты для построения иерархии задач, которые будут выполняться конкуррентно.

Давайте посмотрим на примере из предыдущей части. Кратко напомню суть. У нас была функция, которая загружала несколько изображений и после объединяла их в одно.

ImageLoader

class ImageLoader {
  private let imageUrl: URL
  
  init(imageUrl: URL) {
    self.imageUrl = imageUrl
  }
  
  var image: UIImage? {
    get async throws {
      let (data, _) = try await URLSession.shared.data(from: imageUrl)
      return UIImage(data: data)
    }
  }
}
let photos = try await getPhotos()

let firstImage = try await ImageLoader(imageUrl: photos[0].url).image
let secondImage = try await ImageLoader(imageUrl: photos[1].url).image

let size = firstImage?.size ?? .zero
let mergedImage = UIGraphicsImageRenderer(size: size).image { ctx in
  let rect = CGRect(origin: .zero, size: size)

  firstImage?.draw(in: rect)
  secondImage?.draw(in: rect, blendMode: .normal, alpha: 0.5)
}

imageView.image = mergedImage

В этом примере есть проблема. Изображения загружались последовательно. То есть, secondImage ожидала загрузки firstImage, несмотря на то, что знать что-то о первом изображении ей не нужно. В этом и есть ограничение await. С помощью только этой конструкции нельзя решить эту проблему. В предыдущей части решили ее следующим способом:

let photos = try await getPhotos()

async let firstImageTask = ImageLoader(imageUrl: photos[0].url).image
async let secondImageTask = ImageLoader(imageUrl: photos[1].url).image

let firstImage = try await firstImageTask
let secondImage = try await secondImageTask

let size = firstImage?.size ?? secondImage?.size ?? .zero
let mergedImage = UIGraphicsImageRenderer(size: size).image { ctx in
  let rect = CGRect(origin: .zero, size: size)

  firstImage?.draw(in: rect)
  secondImage?.draw(in: rect, blendMode: .normal, alpha: 0.5)
}

imageView.image = mergedImage

С помощью async let загрузка изображений осуществляется конкуррентно. async let как раз и является одной из конструкций structured concurrency в Swift.

Выше в статье я упоминал, что в рамках одной Task нет конкуррентности, но как же тогда работает async let? Все очень просто: конструкция async let создает другую дочернюю Task, в рамках которой и происходит конкуррентное выполнение какой-либо асинхронной функции.

Task hierarchy

Тут мы вплотную подошли к понятию иерархии тасок. Мы уже узнали, что structured concurrency не только про конкуррентность, но и про построение взаимосвязей между Task. В примере выше Task’s выстраиваются в иерархию:

8a6bd1089b4be5e3933ce54fe3a77d9c.png

У иерархии есть очень важная гарантия: родительская Task не может завершиться, пока не завершаться все ее Child Task’s.

Помимо этого, подобное построение имеет следующие преимущества:

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

Отмена второй задачи происходит автоматически при ошибке в Root Task. Удостоверимся в этом на абстрактном примере:

struct SomeError: Error {}

Task {
  // 1
  async let throwingTask: Void = throwingTask()
  async let nonThrowingTask: Void = nonThrowingTask()

  // 2
  try await _ = [throwingTask, nonThrowingTask]
  print("Successfully completed")
}

func throwingTask() async throws {
  // 3
  try await Task.sleep(nanoseconds: 1_000_000_000)
  throw SomeError()
}

func nonThrowingTask() async {
  // 4
  do {
    try await Task.sleep(nanoseconds: 2_000_000_000)
    print("Completed without error")
  } catch {
    print("Finished with error \(error)")
  }
}
  1. Создаем 2 сhild Task, которые будут выполняться конкуррентно.

  2. Ждем их завершения. Обратите внимание на то, что явно мы не обрабатываем возможные ошибки и не завершаем Task’s.

  3. throwingTask всегда будет завершаться раньше nonThrowingTask. И будет завершаться с ошибкой.

  4. Внутри nonThrowingTask обрабатываем ошибки. Метод Task.sleep автоматически выбросит ошибку, если Task была отменена в процессе ожидания

Запустив этот код, увидим следующее:

Finished with error CancellationError ()

5881693d972ab530258854a533b90dbd.png

В примере с загрузкой изображений тоже может возникнуть ситуация, что одна из Task по загрузке изображений выбросит ошибку, и из-за этого вся функция прекратит свою работу, отменит все выполняющиеся дочерние Task’s и выбросит ошибку.

Преимущество structured concurrency в том, что в каждом узле (Task) мы вправе самостоятельно решать, что делать с ошибками. К примеру, мы не хотим обрывать исполнение всей функции и ее дочерних Task при выбросе ошибки из throwingTask.

struct SomeError: Error {}

Task {
  async let throwingTask: Void = throwingTask()
  async let nonThrowingTask: Void = nonThrowingTask()

  // 1 
  do {
    try await throwingTask
  } catch {
    print("Error? Doesn't matter")
  }

  await nonThrowingTask
  
  print("Successfully completed")
}

func throwingTask() async throws {
  try await Task.sleep(nanoseconds: 1_000_000_000)
  throw SomeError()
}

func nonThrowingTask() async {
  do {
    try await Task.sleep(nanoseconds: 2_000_000_000)
    print("Completed without error")
  } catch {
    print("Finished with error \(error)")
  }
}
  1. Обрабатываем ошибку throwingTask, тем самым позволяя функции выполняться дальше. Запустив этот вариант, мы увидим в консоли:

Error? Doesn’t matter

Completed without error

Successfully completed

8d4a53cc61304bc61b46d0e7e77ce68c.png

А что будет если создать Child Task с помощью async let и не ждать ее результата с помощью await?

struct SomeError: Error {}

Task {
  async let throwingTask: Void = throwingTask()
  async let nonThrowingTask: Void = nonThrowingTask()

  do {
    try await throwingTask
  } catch {
    print("Error? Doesn't matter")
  }

// Не будем ожидать завершения nonThrowingTask
// await nonThrowingTask
  
  print("Successfully completed")
}

func throwingTask() async throws {
  try await Task.sleep(nanoseconds: 1_000_000_000)
  throw SomeError()
}

func nonThrowingTask() async {
  do {
    try await Task.sleep(nanoseconds: 2_000_000_000)
    print("Completed without error")
  } catch {
    print("Finished with error \(error)")
  }
}

Увидим в консоли следующее:

Error? Doesn’t matter

Successfully completed

Finished with error CancellationError ()

Task’s, которые еще не завершились на момент завершения их родителя, автоматически отменяются. Для соблюдения гарантии (родительская Task не может завершиться пока не завершаться все ее Child Task’s) после отмены неявно происходит ожидание завершения всех Child Task’s, так как отмененные Task’s не прекращаются моментально. Не совсем очевидный нюанс, который нужно держать в голове.

Есть ли еще способы создать child task? Да, child task (помимо async let) можно создать еще и с помощью TaskGroup (но о ней мы поговорим подробнее в следующей части).

Еще один неочевидный момент. При создании Task внутри другой Task внутренняя не будет считается Child по отношению к внешней и, соответственно, никак не будет от нее зависеть. Она будет являться отдельной верхнеуровневой таской. Такие Task’s называются unstructured.

Task {
  // ...

  // Данная Task никак не зависит от той, в которой она создается. 
  // То есть при отмене корневой Task текущая продолжит свое выполнение.
  // Она не является Child Task. 
  Task {
    // ...
  }
}

В целом, мы уже познакомились со всеми способами создания Task (корневых и дочерних). На WWDC apple показали вот такую табличку, она достаточно полно описывает отличия между способами создания.

ab14062c84cf5657da74432a4cd9c757.png

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

caeceb1a2e7b882328e5c66154dc12d6.png

Каждая Child Task на своем уровне может либо обработать ошибки, либо выбросить их выше.

Task Cancellation

Выше в статье я уже упоминал, что у Task есть волшебная функция cancel(), которая позволяет прервать выполнение задачи и не тратить ресурсы попусту. Механизм отмены как раз очень тесно связан с иерархией. Иерархия подразумевает, что если корневая таска была отменена, то и все дочерние таски уже не имеют смысла, поэтому они тоже автоматически отменяются.

Но как происходит эта «автоматическая отмена» и полностью ли она автоматическая? На самом деле не совсем, и это как раз еще один нюанс, который нужно держать в голове при работе со structured concurrency. Посмотрим на примере:

// 1
let longTask = Task.detached {
  print("Start of hard work")
  Thread.sleep(forTimeInterval: 2)
  print("The middle of hard work")
  Thread.sleep(forTimeInterval: 2)
  print("The hard work is done")
}

// 2
Task.detached {
  try await Task.sleep(nanoseconds: 500_000_000)
  longTask.cancel()
  print("Task canceled")
}
  1. Создаем Task, в которой эмулируется ресурсоемкая работа на потоке. В действительности мы просто блокируем текущий поток с помощью Thread.sleep. Не делайте так в реальных проектах, ожидать внутри Task можно с помощью неблокирующей функции Task.sleep. Создаем ее с помощью Task.detached, так как при создании через обычный инициализатор мы можем заблокировать main поток, если создаем таску в UIViewController (тк мы унаследуем MainActor)

  2. Сразу после старта ресурсоемкой Task запускаем следующую, которая ждет пол секунды и после пытается отменить первую.

Посмотрим в консоль после запуска этого кода:

Start of hard work

Task canceled

The middle of hard work

The hard work is done

Ресурсоемкая Task продолжила свое выполнение даже после того, как мы отменили ее. Связано это с тем, что при отмене у Task лишь выставляется в true значение isCancelled, дальнейшее прекращение работы уже ложиться на плечи разработчика. Это касается только собственных асинхронных функций. В асинхронных функциях из Foundation, UIKit (и т.д.) отмена уже заложена в реализации функций. Даже в функции Task.sleep заложена обработка отмены, благодаря которой ожидание прекратится при отмене и выбросится ошибка, поэтому нужно использовать try при вызове ее.

Предусмотрим механизм отмены в нашем примере:

let longTask = Task.detached {
  print("Start of long task")
  Thread.sleep(forTimeInterval: 2)
  // 1
  if Task.isCancelled {
    print("Long task canceled")
    throw CancellationError()
  }
  print("The middle of long task")
  Thread.sleep(forTimeInterval: 2)
  print("The hard work is done")
}

Task.detached {
  try await Task.sleep(nanoseconds: 500_000_000)
  longTask.cancel()
  print("Task canceled")
}
  1. Проверяем переменную isCancelled и выбрасываем ошибку в случае, когда она true

Теперь при запуске увидим в консоли следующее:

Start of long task

Task canceled

Long task canceled

Проблема решена, ресурсоемкая Task не крутится лишнее время и не тратит ресурсы процессора. Код можно еще чуть упростить:

let longTask = Task.detached {
  print("Start of long task")
  Thread.sleep(forTimeInterval: 2)
  // 1
  try Task.checkCancellation()
  print("The middle of long task")
  Thread.sleep(forTimeInterval: 2)
  print("The hard work is done")
}
  1. Используем функцию Task.checkCancellation(), которая осуществляет проверку на isCancelled и выбрасывает CancellationError (ровно та же логика, которую мы и написали в первый раз). Преимущество в использовании isCancelled в том, что мы можем что-то сделать перед тем как выбросить ошибку.

Проверять isCancelled важно на границе ресурсозатратных функций или ресурсозатратного кода. В нашем примере таких функций было 2, поэтому мы проверили isCancelled между ними. Проверку можно было осуществить и до старта первой тяжелой функции, но в нашем случае не было такой необходимости. В теории можно проверять хоть на каждой итерации цикла. Главное держать в голове, что Task сама себя не отменит.

Значение isCancelled выставляется не только у исходной Task, но и у всех дочерних:

func longFunc() async throws {
  print("Start of long task")
  Thread.sleep(forTimeInterval: 2)
  if Task.isCancelled {
    print("Long task canceled")
    throw CancellationError()
  }
  print("The middle of long task")
  Thread.sleep(forTimeInterval: 2)
  print("The hard work is done")
}

let longTask = Task.detached {
  // 1
  async let longSubtask: Void = self.longFunc()
  try await longSubtask
}

Task.detached {
  try await Task.sleep(nanoseconds: 500_000_000)
  longTask.cancel()
  print("Task canceled")
}
  1. Выполняем ресурсозатратный код не в корневой Task, а в дочерней (создав ее с помощью async let)

В консоли увидим аналогичную последовательность принтов:

Start of long task

Task canceled

Long task canceled

Ошибка в иерархии появляется где-то в дочерних Task’s и двигается снизу верх. Отмена в свою очередь инициируется сверху и продвигается вниз по всем дочерним Task’s.

990c2d5e8df6d965322acd7b1067b8e4.png

Итоги

В этой статье мы подробно поработали c Task, узнали, что такое structured concurrency, разобрали иерархию тасок, посмотрели, как легко с помощью нее работать с ошибками и отменой для нескольких Task. Помимо этого рассмотрели саму отмену Task и тонкости при работе с ней. Можете посмотреть через поиск, насколько часто упоминалось слово Task в статье, чтобы понять, насколько все серьезно (243 раза). Но это еще далеко не все, что умеет Swift concurrency и structured concurrency (в частности). Нам предстоит узнать еще много нового, поэтому будем на связи.

Полезные ссылки

© Habrahabr.ru