- PVSM.RU - https://www.pvsm.ru -
Прошло уже больше года с момента выпуска async/await. Многие крупные и не очень проекты уже успели поднять минимальную версию до iOS 13, следовательно открылась возможность полноценно использовать новые языковые возможности по работе с многопоточным кодом. Но перед тем как начать полноценно рефакторить старый код и/или писать новый код используя относительно новую технологию в голове невольно всплывает вопрос: "А зачем? Чем это лучше того же GCD?". В этой вступительной статье из серии по async/await постараемся вместе ответить на этот вопрос.
Что такое swift async/await
Кто такая эта ваша многопоточность
Инструменты для работы с многопоточностью до async/await
Проблемы при работе с многопоточностью до async/await
Семантические проблемы
Технические проблемы
Итоги
Swift async/await - это новая фича языка, добавленная в swift 5.5. Она позволяет работать с многопоточным кодом в синхронном стиле. Чем это лучше предыдущих инструментов по работе с многопоточным кодом - разберемся в этой части.
Перед тем как погрузится в проблемы при работе с многопоточным кодом очень кратко напомню что вообще такое многопоточность.
В большинстве случаев мы пишем синхронный код. Statements [1] которые следуют друг за другом и выполняются так же как мы их и написали в коде, последовательно друг за другом.
Например код:
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 появился в swift 5.5. До этого и по сей день для запуска кода в других потоках можно использовать Grand Central Dispatch (GCD), Operation и Thread как обертки над pthread (которым тоже можно пользоваться напрямую).

Работа с Thread и pthread трудозатратна, ведь разработчику приходится самостоятельно управлять потоками и строить систему по эффективному планированию задач для запуска на этих потоках. И как следствие вероятность допустить ошибку возрастает. С operation уже легче, тк разработчику предоставляются очереди из коробки, пропадает необходимость управлять потоками напрямую. Но функциональность операций [2] зачастую избыточна. По этой причине чаще всего при разработке iOS приложений используют GCD, тк это самый простой в использовании инструмент из всего вышеперечисленного. Но и он не лишен недостатков.
Все нижеперечисленные проблемы полностью или частично можно поправить внедрив 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 [3]) - это достаточно ресурсоемкая задача.
Новая модель работы с многопоточкой 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 SE первого поколения система создала 2 потока.
На симуляторе вообще создается только одна serial очередь с одним потоком.
Данные примеры демонстрируют нам, что система в зависимости от окружения создает разное кол-во потоков. И как следствие сама способна масштабироваться без возникновения 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. И я не про то что нужно делать все с опозданием в несколько лет)
Автор: Влад Яндола
Источник [8]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/ios/383942
Ссылки в тексте:
[1] Statements: https://en.wikipedia.org/wiki/Statement_(computer_science)
[2] операций: https://developer.apple.com/documentation/foundation/operation
[3] context switch: https://ru.wikipedia.org/wiki/%D0%9F%D0%B5%D1%80%D0%B5%D0%BA%D0%BB%D1%8E%D1%87%D0%B5%D0%BD%D0%B8%D0%B5_%D0%BA%D0%BE%D0%BD%D1%82%D0%B5%D0%BA%D1%81%D1%82%D0%B0
[4] Swift evolution. Async/await proposal: https://github.com/apple/swift-evolution/blob/main/proposals/0296-async-await.md
[5] WWDC 2021. Meet async/await in swift: https://developer.apple.com/videos/play/wwdc2021/10132
[6] WWDC 2021. Swift concurrency: Behind the scenes : https://developer.apple.com/videos/play/wwdc2021/10254/
[7] The Swift Programming Language. Concurrency: https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/
[8] Источник: https://habr.com/ru/articles/727788/?utm_source=habrahabr&utm_medium=rss&utm_campaign=727788
Нажмите здесь для печати.