Прошло уже больше года с момента выпуска async/await. Многие крупные и не очень проекты уже успели поднять минимальную версию до iOS 13, следовательно открылась возможность полноценно использовать новые языковые возможности по работе с асинхронным кодом. Но перед тем как начать полноценно рефакторить старый код и/или писать новый код используя относительно новую технологию в голове невольно всплывает вопрос: "А зачем? Чем это лучше того же GCD?". В этой вступительной статье из серии по async/await постараемся вместе ответить на этот вопрос. Swift async/await. Чем он лучше GCD? Swift async/await на примерах Что такое swift async/await Кто такая эта ваша многопоточность Инструменты для работы с многопоточностью до async/await Проблемы при работе с многопоточностью до async/await Семантические проблемы Технические проблемы Итоги Swift async/await - это новая фича языка, добавленная в swift 5.5. Она позволяет работать с асинхронными функциями в синхронном стиле. Чем это лучше предыдущих инструментов по работе с многопоточным кодом - разберемся в этой части. Перед тем как погрузится в проблемы при работе с многопоточным кодом очень кратко напомню что вообще такое многопоточность. В большинстве случаев мы пишем синхронный код. Statements которые следуют друг за другом и выполняются так же как мы их и написали в коде, последовательно друг за другом.  Например код: Выведет в консоль следующее: Наш код выполнился на одном потоке одного ядра. Выполнился идентично тому как мы этот код и прочитали. Последовательно и сверху вниз. При таком подходе сложно работать с тяжелыми операциями (сетевые запросы, обработка изображений/видео), так как тяжелая операция будет полностью блокировать поток, и другие функции будут вынуждены ждать ее завершения. В случае мобильной разработки если бы мы работали синхронно только в одном потоке, то у нас бы вечно фризил интерфейс, что не допустимо с точки зрения пользовательского опыта. Многопоточность в свою очередь - это свойство позволяющее коду выполнятся в нескольких потоках. Это может быть как полноценным параллельным выполнением на разных ядрах, так и симуляцией этого параллельного выполнения на одном ядре. Благодаря этому нам не обязательно ждать завершения ресурсоемких операций, тк мы можем выполнять их на других потоках. Немного видоизменю пример: При исполнении данного кода мы увидим в консоли: Теперь функция printCucumbers() вызывается в другом потоке. Из-за этого мы теперь не видим последовательные 100 огурцов и помидоров. Они выводятся в консоль беспорядочно (начиная с помидоров), что говорит о том что циклы выполняются в разных потоках. Это позволяет нам параллелить тяжелые операции не блокируя текущий поток. Async/await появился в swift 5.5. До этого и по сей день для запуска кода в других потоках можно использовать Grand Central Dispatch (GCD), Operation и Thread как обертки над pthread (которым тоже можно пользоваться напрямую). Работа с Thread и pthread трудозатратна, ведь разработчику приходится самостоятельно управлять потоками и строить систему по эффективному планированию задач для запуска на этих потоках. И как следствие вероятность допустить ошибку возрастает. С operation уже легче, тк разработчику предоставляются очереди из коробки, пропадает необходимость управлять потоками напрямую. Но функциональность операций зачастую избыточна. По этой причине чаще всего при разработке iOS приложений используют GCD, тк это самый простой в использовании инструмент из всего вышеперечисленного. Но и он не лишен недостатков. Все нижеперечисленные проблемы полностью или частично можно поправить внедрив async/await. Разделю их на 2 подгруппы: Семантические - проблемы связанные с читаемостью конструкций языка. Большинство людей привыкло читать текст сверху вниз, чтение кода - не исключение. Читать и анализировать синхронный код намного проще, чем вникать в множество вложенных друг в друга замыканий которые выполняются асинхронно Технические - проблемы связанные с реализацией и использованием текущих инструментов для работы с многопоточностью  1) Pyramid of doom Работая с GCD в swift мы оперируем замыканиями. К примеру мы передаем completionHandler замыкание для того чтоб оно вызвалось после завершения вызываемой функции. Например мы хотим загрузить изображение из сети: Но часто возникает необходимость как-то обработать полученные данные. И если обработка так же осуществляется в другом потоке (не блокируя поток вызова функции), то из этого вырождается pyramid of doom. Дополню пример: Теперь изображение не только загружается из сети, на него в добавок накладывается фильтр и после этого оно сохраняется на диск. Все эти операции выполняются на отдельных потоках и не блокируют поток вызова функции, поэтому у нас образуется вложенность замыканий, которая и называется pyramid of doom. Основная проблема здесь - это сложность визуального восприятия такого кода. Но стоит отметить что это еще не самый запущенный пример. 2) Неудобная обработка ошибок Рассмотренный выше пример лишен одного очень важного момента - в нем нет обработки ошибок. Доработаю это упущение: Теперь каждая функция из нашей цепочки может вернуть ошибку. Обработка ошибок значительно раздула нашу функцию, что еще сильней усугубило проблему pyramid of doom. Вникнуть в данный код без монокля уже не получится. Каждый раз при обработке Result мы увеличиваем вложенность и пишем шаблонный код. Было бы удобно использовать конструкции do/try/catch, к тому же swift позволяет нам писать throwing замыкания, но в нашем случае замыкание является обработчиком для завершения наших функций и ошибка возникает до его вызова. Поэтому при использовании completionHandler'ов мы не можем использовать do/try/catch. В синхронных же функциях у нас нет таких ограничений и пользоваться данной языковой конструкцией весьма удобно. 3) Компилятор позволяет нам совершать ошибки с замыканиями При работе с классическими синхронными функциями компилятор пристально следит за тем чтоб заявленные в сигнатуре функции условия выполнялись. Если в теле функции func getBool() -> Bool не будет возвращать заявленный Bool - код не скомпилируется. Если функция вызывает return дважды - проблемная строчка подсветится. В случае с completionHandler'ами разработчик самостоятельно должен следить за вызовом замыканий, и из-за этого могут всплыть ошибки. Давайте вернемся к нашему примеру. В нем намеренно допущена ошибка, попробуйте ее найти. Возвращаясь к проблеме читаемости, думаю вы прочувствовали что искать недочеты в таком коде еще сложнее чем просто его читать. Теперь об ошибке. В замыкании у функции applyBlurFilter(to: image) при case .success(let blurredImage) мы вызываем completionHandler(.success(blurredImage)). Но после этого мы пытаемся закэшировать наше заблюренное изображение с помощью функции cacheToDisk(blurredImage), и по результату внутри замыкания мы снова вызываем completionHandler. Если бы этой функцией пользовался какой-либо UI компонент, то там бы явно что-то пошло не так после второго вызова комплишена. Особенно если сначала мы вызвали его с .success, а потом с .failure.  Для решения данной проблемы нам нужно убрать вызов completionHandler из замыкания функции applyBlurFilter, или если мы не хотим ждать завершения кэширования чтоб вернуть изображение - можно убрать оба вызова completionHandler из замыкания функции cacheToDisk. Пример на async/await Давайте посмотрим на идентичную функцию, только написанную с помощью async/await, чтоб на контрасте с предыдущим примером увидеть насколько все становится лучше. Пока не будем акцентировать внимание на том как это работает, об этом поговорим в следующих частях серии, сейчас же просто сравним. И наша жаба превратилась в принцессу. Резюмируем семантические улучшения: Удобно читать. Мы работаем с асинхронными функциями в синхронном стиле Удобно работать с ошибками. Можно пользоваться конструкциями do/try/catch при работе с асинхронными функциями Как и с обычной синхронной функцией компилятор теперь не допустит случая при котором разработчик не вызывает return, либо вызывает его несколько раз в одной ветви исполнения. Все как и с синхронными функциями  1) Thread explosion Работая с GCD мы не взаимодействуем с потоками напрямую. Мы работаем с ними с помощью очередей. Если помещать в очереди много потокоблокирующих задач (которые используют локи, sync или sleep), то GCD не хватит потоков из его пулла, и он начнет создавать новые. При увеличении количества потоков работа приложения становится только медленней, тк переключение между ними (context switch) - это достаточно ресурсоемкая задача. Новая модель работы с многопоточкой async/await абстрагирует разработчика от понятий потоков и очередей. Новая модель включает в себя Cooperative Thread Pool, который инкапсулирует всю логику по работе с потоками и очередями. В зависимости от окружения будет создаваться оптимальное количество потоков близкое к количеству ядер, что избавляет систему от переключений между потоками. Вместо этого код может приостанавливаться на заранее известных местах (например помеченных с помощью await), и затем возобновляться. То есть поток теперь "активно ожидает" выполняя какую-то другую полезную работу. Давайте посмотрим на примере. Начнем со старого доброго GCD. Запустим вот такую ресурсозатратную функцию и посмотрим на количество созданных потоков. На моей машине вышло 67 потоков. Перепишем эту же функцию на async/await стиль и убедимся в том что потоки не плодятся как хомячки: Опять же не будем вникать в тонкости работы в этой части. Единственное что тут стоит отметить, так это то что метод Task.sleep(nanoseconds: 2_000_000_000) не блокирует текущий поток как это делает sleep из предыдущего примера. На iPhone 12 pro получилось 6 потоков, которые являются частью одной concurrent cooperative queue. На iPhone SE первого поколения система создала 2 потока. На симуляторе вообще создается только одна serial очередь с одним потоком.  Данные примеры демонстрируют нам, что система в зависимости от окружения создает разное кол-во потоков. И как следствие сама способна масштабироваться без возникновения Thread Explosion. 2) Deadlock Если разработчик имеет возможность взаимодействовать с очередями напрямую, то в некоторых ситуациях может всплыть еще одна неприятная проблема под названием deadlock (взаимная блокировка). Посмотрим на примере: В данном коде находясь в main потоке мы пытаемся синхронно запустить какой-то блок кода. Main Queue - serial (последовательная). Serial queue запускает таски последовательно на одном потоке, вызов sync же блокирует текущую очередь до того момента пока не дождется завершения переданной таски. И в нашем случае к сожалению не дождется, так как из-за того что очередь заблокирована через sync она не может выполнять переданную ей таску. Таска не может начаться пока очередь заблокирована, а очередь заблокирована пока таска не закончится. Получился замкнутый круг который никогда не разомкнется. Данная проблема актуальна не только для main очереди, но и для любой другой последовательной очереди. Взаимно заблокироваться могут так же 2 разных потока, если заблокируют ресурсы друг друга в одно время и вместе будут бесконечно ждать разблокировки. Если работать с async/await, то вероятность возникновения deadlock'а многократно уменьшается, ведь всю работу с потоками и очередями теперь осуществляет система. 3) Priority inversion Данная проблема заключается в том, что в некоторых случаях задача с более высоким приоритетом может ожидать задачу с более низким приоритетом, что и называется инверсией приоритетов. И да, данная проблема чаще всего тоже возникает из-за того что разработчик сам размещает задачи в очередях. Вернемся к огурцам и помидорам и посмотрим на пример: Есть 2 функции которые выводят в консоль огурцы и помидоры. И так же имеем основную функцию priorityInversionDemo(). В ней создаем 3 очереди с разными QoS (приоритетами). Напомню что из этих трех самая приоритетная - userInteractive, а самая менее приоритетная - background. Из userInteractive  очереди асинхронно запустим вывод в консоль нашего огорода. Помидоры выводим в более приоритетной очереди чем огурцы. Запустив эту функцию в среднем увидим вот такой результат: Помидоры не оставили шанса огурцам, что логично, ведь они принтятся из более приоритетной очереди. Теперь немного модифицируем нашу функцию. И запустим: Теперь огурцы впереди. Но почему так? Дело в том, что вызов sync выполняется на потоке очереди из которой он был вызван. Мы вызываем из userInteractive очереди, следовательно print("Cucumber boss 🥒") будет самой высокоприоритетной задачей. Все наши очереди последовательные, высокоприоритетная задача добавилась в очередь самой последней, следовательно ей нужно подождать пока выполнятся все предыдущие таски из background очереди (это принт огурцов). Из-за этого GCD повышает приоритет предыдущих задач background очереди, в таком случае наша высокоприоритетная таска выполнится быстрее, но и вдобавок к этому огурцы в консоли мы увидим раньше томатов (хотя приоритет у них ниже). Работая с async/await разработчик не взаимодействует с очередями напрямую. Все взаимодействие теперь происходит под капотом, что минимизирует вероятность возникновения в том числе и priority inversion.  4) Race condition Race condition (состояние гонки) возникает когда несколько потоков одновременно работают с одним ресурсом (например массивом). В результате этого получаются либо некорректные данные, либо вообще крэш приложения. Посмотрим на примере: В данном примере мы создаем массив, concurrent очередь и асинхронно запускаем на ней две задачи которые в цикле добавляют новые элементы в этот массив. В подавляющем большинстве запусков данный код упадет, тк мы одновременно модифицируем наш массив.  Существует много методик по борьбе с race condition. Блокировать доступ во время модификации с помощью локов, работать с массивом в приватной синхронной очереди, использовать барьерные операции при изменении объекта в concurrent очереди. Мы не будем подробно рассматривать все эти способы. Больше всего нас интересует как обстоят дела с этой проблемой в swift 5.5+. Поменялось ли что-то c внедрением async/await? К сожалению проблема полностью не ушла, помнить о ней все еще нужно. Но есть и хорошая новость, вместе с async/await apple внедрили новые сущности и протоколы, которые минимизируют вероятность состояния гонки. Это actor'ы и sendable, мы поговорим про них в следующих частях. Сейчас же важно отметить, что компилятор теперь в контексте новых сущностей сможет следить за тем чтоб объекты были действительно потоко-безопасными. Мы рассмотрели ряд недостатков, которые полностью или частично уходят если работать с async/await. Это действительно очень удобная и простая модель работы с асинхронными функциями, уже давно обкатанная в многих других языках программирования. Но в любом случае хорошо, что swift активно развивается и улучшается. Давайте быть как swift. И я не про то что нужно делать все с опозданием в несколько лет) Swift evolution. Async/await proposal WWDC 2021. Meet async/await in swift WWDC 2021. Swift concurrency: Behind the scenes  The Swift Programming Language. Concurrency  Ментально пожилой iOS разработчик