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

в 13:33, , рубрики: async/await, concurrency, GCD, iOS, kandinsky art, swift, разработка под iOS
Промпт: Иконка языка программирования swift на черном фоне

Промпт: Иконка языка программирования swift на черном фоне

Прошло уже больше года с момента выпуска async/await. Многие крупные и не очень проекты уже успели поднять минимальную версию до iOS 13, следовательно открылась возможность полноценно использовать новые языковые возможности по работе с многопоточным кодом. Но перед тем как начать полноценно рефакторить старый код и/или писать новый код используя относительно новую технологию в голове невольно всплывает вопрос: "А зачем? Чем это лучше того же GCD?". В этой вступительной статье из серии по async/await постараемся вместе ответить на этот вопрос.

Оглавление

  • Что такое swift async/await

  • Кто такая эта ваша многопоточность

  • Инструменты для работы с многопоточностью до async/await

  • Проблемы при работе с многопоточностью до async/await

    • Семантические проблемы

    • Технические проблемы

  • Итоги

Что такое swift async/await

Swift async/await - это новая фича языка, добавленная в swift 5.5. Она позволяет работать с многопоточным кодом в синхронном стиле. Чем это лучше предыдущих инструментов по работе с многопоточным кодом - разберемся в этой части.

Кто такая эта ваша многопоточность

Перед тем как погрузится в проблемы при работе с многопоточным кодом очень кратко напомню что вообще такое многопоточность.

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

Например код:

func printCucumbers() {
  for _ in 0..<100 {
    print("🥒")
  }
}

printCucumbers()

for _ in 0..<100 {
  print("🍅")
}

Выведет в консоль следующее:

🥒
🥒
... еще 98 огурцов ...
🍅
🍅
... еще 98 помидоров ...

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

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

Немного видоизменю пример:

func printCucumbers() {
  DispatchQueue.global().async {
    for _ in 0..<100 {
      print("🥒")
    }
  }
}

printCucumbers()

for _ in 0..<100 {
  print("🍅")
}

При исполнении данного кода мы увидим в консоли:

🍅
🍅
🍅
🥒
🍅
🍅
🥒
🥒
... Беспорядочная последовательность огурцов и помидоров ...

Теперь функция printCucumbers() вызывается асинхронно в другом потоке. Из-за этого мы теперь не видим последовательные 100 огурцов и помидоров. Они выводятся в консоль беспорядочно (начиная с помидоров), что говорит о том что циклы выполняются в разных потоках. Это позволяет нам параллелить тяжелые операции не блокируя текущий поток.

Инструменты для работы с многопоточностью до async/await

Async/await появился в swift 5.5. До этого и по сей день для запуска кода в других потоках можно использовать Grand Central Dispatch (GCD), Operation и Thread как обертки над pthread (которым тоже можно пользоваться напрямую).

Swift async-await. Чем он лучше GCD? - 2

Работа с Thread и pthread трудозатратна, ведь разработчику приходится самостоятельно управлять потоками и строить систему по эффективному планированию задач для запуска на этих потоках. И как следствие вероятность допустить ошибку возрастает. С operation уже легче, тк разработчику предоставляются очереди из коробки, пропадает необходимость управлять потоками напрямую. Но функциональность операций зачастую избыточна. По этой причине чаще всего при разработке iOS приложений используют GCD, тк это самый простой в использовании инструмент из всего вышеперечисленного. Но и он не лишен недостатков.

Проблемы при работе с многопоточностью до async/await

Все нижеперечисленные проблемы полностью или частично можно поправить внедрив async/await. Разделю их на 2 подгруппы:

  • Семантические - проблемы связанные с читаемостью конструкций языка. Большинство людей привыкло читать текст сверху вниз, чтение кода - не исключение. Читать и анализировать синхронный код намного проще, чем вникать в множество вложенных друг в друга замыканий которые выполняются асинхронно

  • Технические - проблемы связанные с реализацией и использованием текущих инструментов для работы с многопоточностью

Семантические проблемы

1) Pyramid of doom

Работая с GCD в swift мы оперируем замыканиями. К примеру мы передаем completionHandler замыкание для того чтоб оно вызвалось после завершения вызываемой асинхронной функции. Например мы хотим загрузить изображение из сети:

func loadImage(
  from url: URL, 
  completionHandler: @escaping (UIImage) -> Void
) {
  // ...
}

loadImage(from: URL) { image in
  self.imageView.image = image
}

Но часто возникает необходимость как-то обработать полученные данные. И если обработка так же осуществляется асинхронно, то из этого вырождается pyramid of doom. Дополню пример:

func loadImage(
  from url: URL, 
  completionHandler: @escaping (UIImage) -> Void
) {
  // ...
}

func applyBlurFilter(
  to image: UIImage, 
  completionHandler: @escaping (UIImage) -> Void
) {
  // ...
}

func cacheToDisk(
  _ image: UIImage, 
  completionHandler: @escaping () -> Void
) {
  // ...
}

func loadAndProcessImage(
  from url: URL, 
  completionHandler: @escaping (UIImage) -> Void
) {
  loadImage(from: url) { image in
    applyBlurFilter(to: image) { blurredImage in
      completionHandler(blurredImage)
      cacheToDisk(image) {
        print("Image cached")
      }
    }
  }
}

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

2) Неудобная обработка ошибок

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

func loadImage(
  from url: URL, 
  completionHandler: @escaping (Result<UIImage, Error>) -> Void
) {
  // ...
}

func applyBlurFilter(
  to image: UIImage, 
  completionHandler: @escaping (Result<UIImage, Error>) -> Void
) {
  // ...
}

func cacheToDisk(
  _ image: UIImage, 
  completionHandler: @escaping (Result<Void, Error>) -> Void
) {
  // ...
}

func loadAndProcessImage(
  from url: URL,
  completionHandler: @escaping (Result<UIImage, Error>) -> Void
) {
  loadImage(from: url) { result in
    switch result {
    case .success(let image):
      applyBlurFilter(to: image) { result in
        switch result {
        case .success(let blurredImage):
          completionHandler(.success(blurredImage))
          cacheToDisk(blurredImage) { result in
            switch result {
            case .success:
              print("Image cached")
              completionHandler(.success(blurredImage))
            case .failure(let failure):
              completionHandler(.failure(failure))
            }
          }
        case .failure(let failure):
          completionHandler(.failure(failure))
        }
      }
    case .failure(let failure):
      completionHandler(.failure(failure))
    }
  }
}

Теперь каждая функция из нашей цепочки может вернуть ошибку. Обработка ошибок значительно раздула нашу функцию, что еще сильней усугубило проблему pyramid of doom. Вникнуть в данный код без монокля уже не получится. Каждый раз при обработке Result мы увеличиваем вложенность и пишем шаблонный код. Было бы удобно использовать конструкции do/try/catch, к тому же swift позволяет нам писать throwing замыкания, но в нашем случае замыкание является обработчиком для завершения наших функций и ошибка возникает до его вызова. Поэтому при использовании completionHandler'ов мы не можем использовать do/try/catch. В синхронных же функциях у нас нет таких ограничений и пользоваться данной языковой конструкцией весьма удобно.

3) Компилятор позволяет нам совершать ошибки с замыканиями

При работе с классическими синхронными функциями компилятор пристально следит за тем чтоб заявленные в сигнатуре функции условия выполнялись. Если в теле функции func getBool() -> Bool не будет возвращать заявленный Bool - код не скомпилируется. Если функция вызывает return дважды - проблемная строчка подсветится. В случае с completionHandler'ами разработчик самостоятельно должен следить за вызовом замыканий, и из-за этого могут всплыть ошибки.

Давайте вернемся к нашему примеру. В нем намеренно допущена ошибка, попробуйте ее найти.

func loadAndProcessImage(
  from url: URL,
  completionHandler: @escaping (Result<UIImage, Error>) -> Void
) {
  loadImage(from: url) { result in
    switch result {
    case .success(let image):
      applyBlurFilter(to: image) { result in
        switch result {
        case .success(let blurredImage):
          completionHandler(.success(blurredImage))
          cacheToDisk(blurredImage) { result in
            switch result {
            case .success:
              print("Image cached")
              completionHandler(.success(blurredImage))
            case .failure(let failure):
              completionHandler(.failure(failure))
            }
          }
        case .failure(let failure):
          completionHandler(.failure(failure))
        }
      }
    case .failure(let failure):
      completionHandler(.failure(failure))
    }
  }
}

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

Теперь об ошибке. В замыкании у функции applyBlurFilter(to: image) при case .success(let blurredImage) мы вызываем completionHandler(.success(blurredImage)). Но после этого мы пытаемся закэшировать наше заблюренное изображение с помощью функции cacheToDisk(blurredImage), и по результату внутри замыкания мы снова вызываем completionHandler. Если бы этой функцией пользовался какой-либо UI компонент, то там бы явно что-то пошло не так после второго вызова комплишена. Особенно если сначала мы вызвали его с .success, а потом с .failure.

Для решения данной проблемы нам нужно убрать вызов completionHandler из замыкания функции applyBlurFilter, или если мы не хотим ждать завершения кэширования чтоб вернуть изображение - можно убрать оба вызова completionHandler из замыкания функции cacheToDisk.

Пример на async/await

Давайте посмотрим на идентичную функцию, только написанную с помощью async/await, чтоб на контрасте с предыдущим примером увидеть насколько все становится лучше.

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

func loadImage(from url: URL) async throws -> UIImage { /* ... */ }
func applyBlurFilter(to image: UIImage) async throws -> UIImage { /* ... */ }
func cacheToDisk(_ image: UIImage) async throws { /* ... */ }

func loadAndProcessImage(from url: URL) async throws -> UIImage {
  let image = try await loadImage(from: url)
  let blurredImage = try await applyBlurFilter(to: image)
  try await cacheToDisk(blurredImage)
  return blurredImage
}

И наша жаба превратилась в принцессу. Резюмируем семантические улучшения:

  • Удобно читать. Мы работаем с асинхронными функциями в синхронном стиле

  • Удобно работать с ошибками. Можно пользоваться конструкциями do/try/catch при работе с асинхронными функциями

  • Как и с обычной синхронной функцией компилятор теперь не допустит случая при котором разработчик не вызывает return, либо вызывает его несколько раз в одной ветви исполнения. Все как и с синхронными функциями

Технические проблемы

1) Thread explosion

Работая с GCD мы не взаимодействуем с потоками напрямую. Мы работаем с ними с помощью очередей. Если помещать в очереди много потокоблокирующих задач (которые используют локи, sync или sleep), то GCD не хватит потоков из его пулла, и он начнет создавать новые. При увеличении количества потоков работа приложения становится только медленней, тк переключение между ними (context switch) - это достаточно ресурсоемкая задача.

Новая модель работы с многопоточкой async/await абстрагирует разработчика от понятий потоков и очередей. Новая модель включает в себя Cooperative Thread Pull, который инкапсулирует всю логику по работе с потоками и очередями. В зависимости от окружения будет создаваться оптимальное количество потоков близкое к количеству ядер, что избавляет систему от переключений между потоками. Вместо этого код может приостанавливаться на заранее известных местах (например помеченных с помощью await), и затем возобновляться. То есть поток теперь "активно ожидает" выполняя какую-то другую полезную работу.

Давайте посмотрим на примере. Начнем со старого доброго GCD. Запустим вот такую ресурсозатратную функцию и посмотрим на количество созданных потоков.

func gcdThreadPullTest() {
  for i in 1...100 {
    DispatchQueue.global(qos: .default).async {
      for n in 1...10000 {
        let a = i * n
      }
      sleep(2)
      for n in 1...10000 {
        let a = i * n
      }
    }
  }
}

На моей машине вышло 67 потоков.

Все потоки не влезли в скрин

Все потоки не влезли в скрин

Перепишем эту же функцию на async/await стиль и убедимся в том что потоки не плодятся как хомячки:

func cooperativePoolTest() async {
  await withTaskGroup(of: Void.self) { group in
    for i in 1...100 {
      group.addTask {
        for n in 1...10000 {
          let a = i * n
        }
        try? await Task.sleep(nanoseconds: 2_000_000_000)
        for n in 1...10000 {
          let a = i * n
        }
      }
    }
  }
}

Опять же не будем вникать в тонкости работы в этой части. Единственное что тут стоит отметить, так это то что метод Task.sleep(nanoseconds: 2_000_000_000) не блокирует текущий поток как это делает sleep из предыдущего примера. На iPhone 12 pro получилось 6 потоков, которые являются частью одной concurrent cooperative queue.

iPhone 12 Pro

iPhone 12 Pro

На iPhone SE первого поколения система создала 2 потока.

iPhone SE

iPhone SE

На симуляторе вообще создается только одна serial очередь с одним потоком.

Simulator

Simulator

Данные примеры демонстрируют нам, что система в зависимости от окружения создает разное кол-во потоков. И как следствие сама способна масштабироваться без возникновения Thread Explosion.

2) Deadlock

Если разработчик имеет возможность взаимодействовать с очередями напрямую, то в некоторых ситуациях может всплыть еще одна неприятная проблема под названием deadlock (взаимная блокировка).

Посмотрим на примере:

override func viewDidLoad() {
  super.viewDidLoad()

  DispatchQueue.main.sync {
    // ...
  }
}

В данном коде находясь в main потоке мы пытаемся синхронно запустить какой-то блок кода. Main Queue - serial (синхронная). Serial queue запускает таски последовательно на одном потоке, вызов sync же блокирует текущую очередь до того момента пока не дождется завершения переданной таски. И в нашем случае к сожалению не дождется, так как из-за того что очередь заблокирована через sync она не может выполнять переданную ей таску. Таска не может начаться пока очередь заблокирована, а очередь заблокирована пока таска не закончится. Получился замкнутый круг который никогда не разомкнется. Данная проблема актуальна не только для main очереди, но и для любой другой синхронной очереди. Взаимно заблокироваться могут так же 2 разных потока, если заблокируют ресурсы друг друга в одно время и вместе будут бесконечно ждать разблокировки.

Если работать с async/await, то вероятность возникновения deadlock'а многократно уменьшается, ведь всю работу с потоками и очередями теперь осуществляет система.

3) Priority inversion

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

func printCucumbers() {
  for _ in 0..<7 {
    print("🥒")
  }
}

func printTomatos() {
  for _ in 0..<7 {
    print("🍅")
  }
}

func priorityInversionDemo() {
  let userInteractiveQueue = DispatchQueue(label: "com.demo.userInteractive", qos: .userInteractive)
  let userInitiatedQueue = DispatchQueue(label: "com.demo.userInitiated", qos: .userInitiated)
  let backgroundQueue = DispatchQueue(label: "com.demo.background", qos: .background)

  userInteractiveQueue.async {
    backgroundQueue.async {
      printCucumbers()
    }

    userInitiatedQueue.async {
      printTomatos()
    }
  }
}

Есть 2 функции которые выводят в консоль огурцы и помидоры. И так же имеем основную функцию priorityInversionDemo(). В ней создаем 3 очереди с разными QoS (приоритетами). Напомню что из этих трех самая приоритетная - userInteractive, а самая менее приоритетная - background. Из userInteractive очереди асинхронно запустим вывод в консоль нашего огорода. Помидоры выводим в более приоритетной очереди чем огурцы. Запустив эту функцию в среднем увидим вот такой результат:

🍅
🍅
🍅
🍅
🍅
🥒
🍅
🍅
🥒
🥒
🥒
🥒
🥒
🥒

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

func printCucumbers() {
  for _ in 0..<7 {
    print("🥒")
  }
}

func printTomatos() {
  for _ in 0..<7 {
    print("🍅")
  }
}

func priorityInversionDemo() {
  let userInteractiveQueue = DispatchQueue(label: "com.demo.userInteractive", qos: .userInteractive)
  let userInitiatedQueue = DispatchQueue(label: "com.demo.userInitiated", qos: .userInitiated)
  let backgroundQueue = DispatchQueue(label: "com.demo.background", qos: .background)

  userInteractiveQueue.async {
    backgroundQueue.async {
      printCucumbers()
    }

    userInitiatedQueue.async {
      printTomatos()
    }

    // Новый код
    backgroundQueue.sync {
      print("Cucumber boss 🥒")
    }
  }
}

И запустим:

🥒
🥒
🥒
🥒
🍅
🍅
🥒
🥒
🥒
🍅
Cucumber boss 🥒
🍅
🍅
🍅
🍅

Теперь огурцы впереди. Но почему так? Дело в том, что вызов sync выполняется на потоке очереди из которой он был вызван. Мы вызываем из userInteractive очереди, следовательно print("Cucumber boss 🥒") будет самой высокоприоритетной задачей. Все наши очереди синхронные, высокоприоритетная задача добавилась в очередь самой последней, следовательно ей нужно подождать пока выполнятся все предыдущие таски из background очереди (это принт огурцов). Из-за этого GCD повышает приоритет предыдущих задач background очереди, в таком случае наша высокоприоритетная таска выполнится быстрее, но и вдобавок к этому огурцы в консоли мы увидим раньше томатов (хотя приоритет у них ниже).

Работая с async/await разработчик не взаимодействует с очередями напрямую. Все взаимодействие теперь происходит под капотом, что минимизирует вероятность возникновения в том числе и priority inversion.

4) Race condition

Race condition (состояние гонки) возникает когда несколько потоков одновременно работают с одним ресурсом (например массивом). В результате этого получаются либо некорректные данные, либо вообще крэш приложения. Посмотрим на примере:

var array: [Int] = []
let queue = DispatchQueue(label: "com.demo.queue", attributes: .concurrent)

queue.async {
  for i in 0..<100 {
    array.append(i)
  }
}

queue.async {
  for i in 0..<100 {
    array.append(i)
  }
}

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

Существует много методик по борьбе с race condition. Блокировать доступ во время модификации с помощью локов, работать с массивом в приватной синхронной очереди, использовать барьерные операции при изменении объекта в concurrent очереди. Мы не будем подробно рассматривать все эти способы.

Больше всего нас интересует как обстоят дела с этой проблемой в swift 5.5+. Поменялось ли что-то c внедрением async/await? К сожалению проблема полностью не ушла, помнить о ней все еще нужно. Но есть и хорошая новость, вместе с async/await apple внедрили новые сущности и протоколы, которые минимизируют вероятность состояния гонки. Это actor'ы и sendable, мы поговорим про них в следующих частях. Сейчас же важно отметить, что компилятор теперь в контексте новых сущностей сможет следить за тем чтоб объекты были действительно потоко-безопасными.

Итоги

Мы рассмотрели ряд недостатков, которые полностью или частично уходят если работать с async/await. Это действительно очень удобная и простая модель работы в многопоточной среде, уже давно обкатанная в многих других языках программирования. Но в любом случае хорошо, что swift активно развивается и улучшается. Давайте быть как swift. И я не про то что нужно делать все с опозданием в несколько лет)

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

Автор: Влад Яндола

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js