Расследуем дело асинхронного программирования с Леонидом Каневским

Привет! Я Александр, iOS Developer в Clevertec. Количество гайдов по Modern Concurrency в Swift все увеличивается, тема актуальна для разработчиков любого уровня. Я предлагаю вам ламповую экскурсию в историю асинхронного программирования. Можно относиться к этому как к расследованию нераскрытого дела. Эта статья не станет настольным справочником, но будет интересным обзором истоков концепции. Заваривайте чай или кофе, устраивайтесь поудобнее и давайте начнем!

Бро, корутины — это про Котлин

e3c8ba177ba94f94dcab9eb917d80fef.jpg

Давайте поговорим немного о возникновении концепции корутин. Причём тут корутины и Swift? Очень многие возразят: «Бро, корутины — это про Котлин. Зачем Swift-разработчику думать про корутины?» Это справедливый вопрос. Но если не закапываться в конкретные реализации, а посмотреть на концепт и сравнить функционал Task«ов и корутин (котлиновских или любых других), можно увидеть много общих черт. Каких именно — давайте посмотрим внимательнее.

Как известно, отцом-основателем концепции корутин является Мелвин Конвей. Впервые про эту интересную штуковину он рассказал в уже легендарной статье «Design of a Separable Transition-Diagram Compiler» в далёком 1963 году. Эта статья посвящена компилятору для Кобола, но нас будет интересовать небольшой фрагмент материала, который называется «Coroutines and Separable Programs». Сомнительно, что в 2k25 кто-то будет читать подобные древние работы, но я это сделал за вас.

В начале части, посвященной корутинам, Конвей говорит, что модульность программы (или разделимость дизайна программы) — это её способность быть приспособленной к различным сегментам конфигураций. И программу можно считать разделимой (или модульной), если она разбита на модули, которые взаимодействуют друг с другом, соблюдая следующие ограничения:

1. Единственная форма взаимодействия между модулями заключается в обмене отдельными элементами информации;

2. Поток каждого из этих элементов информации проходит по фиксированным, односторонним путям.

Некоторые куски определений мне и самому непонятны и кажутся бессмысленным набором фраз, поэтому давайте попробуем разобраться. Первое, что может вызвать недоумение — что имел в виду Конвей, когда писал о различных сегментах конфигураций? Вероятно, он имел в виду различные способы организации или структурирования программного кода или данных в рамках программы. Разделимость дизайна программы — это ее способность приспосабливаться к различным способам структурирования программного кода. Вот так уже проще и понятнее. По ограничениям взаимодействия модулей программы друг с другом вроде бы вопросов нет, но формулировка третьего ограничения мне лично напомнила VIP-цикл в VIP-архитектурах.

Рутина aka routine

Далее Конвей говорит, что соблюдая эти ограничения каждый модуль может быть преобразован в корутину — каждый модуль закодирован как автономная программа, которая взаимодействует с соседними модулями так, как если бы они были сабрутинами для ввода или вывода. Здесь появляются два понятия — корутина и сабрутина.

Что касается корутины, то из информации понятно, что Конвей под этим термином подразумевает автономную программу, способную взаимодействовать с другими модулями или автономными программами. И не только с другими корутинами –, а также с сабрутинами.

Что же такое сабрутина? Обратимся снова к Конвею. Далее он говорит, что корутины представляют собой сабрутины, находящиеся на одном уровне, каждая из которых ведёт себя так, как будто она является главной программой, тогда как по факту такой главной программы просто нет. И это определение не накладывает ограничений на корутину по количеству входов и выходов, которые она может иметь. Ок, понятнее не стало, только немного запутаннее. 

e98136ad6ab3074a7f0184731e6fe3d6.jpg

Корутины представляют собой сабрутины? Вы серьёзно? Давайте отмотаем немного назад и определимся с понятиями.

Прежде, чем мы двинемся дальше, надо бы определиться окончательно, что же такое корутина, сабрутина и собственно рутина. Итак, рутина aka routine. Это собственно и есть основная программа, т. е. рутина = программа. Соответственно, корутина — со-программа. Или параллельная программа. И уже совсем просто: сабрутина — подпрограмма. Вот теперь картина проясняется и можно вернуться к этим определениям, которые нас немного запутали. 

7340083b65817330b7573df9cf4740d9.jpg

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

Причём тут вообще асинхронные функции и наш любимый async/await?

Те, кому хватило терпения дочитать до этого места, на периферии сознания могут уловить мысль-вопрос: «Ок, мы поняли, корутины-сабрутины, модули, разделимость дизайна, интерфейсы взаимодействия, все дела. Но при чём тут вообще асинхронные функции и наш любимый async/await?» 

37e774eb82558c649d425d03de372808.jpg

Отвечаю — далее Конвей указывает, что понятие корутины может значительно упростить концепцию программы, если её модули не взаимодействуют синхронно. Старина Мелвин подчеркивает, что корутины особенно полезны для программ, где обмен данными между модулями происходит асинхронно, без строгой привязки ко времени выполнения. Давайте немного скорректируем и упорядочим определение корутин с учётом того, что мы только что прочитали. 

Итак, корутины — обобщенная форма подпрограмм, которые находятся на одном уровне ответственности и не подчиняются главной программе. Это позволяет им взаимодействовать друг с другом напрямую, как если бы они были автономными программами. Они особенно полезны в системах с асинхронным обменом данными, где модули программы обмениваются информацией через фиксированные каналы и работают независимо. Ключевое — автономность, независимость от главной программы, удобство в асинхронных системах.

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

Task VS  корутина

И теперь давайте вернёмся к сравнению Task и корутин, посмотрим — можно ли считать Task«и в языке Swift неким частным случаем корутин.

  • И корутины, и Task«и могут приостановлены и возобновлены позже.

  • И те, и другие способны сохранять свои состояния, чтобы продолжить выполнение с того места, на котором оно было приостановлено.

  • И Task, и корутины управляют потоками и ресурсами и берут на себя переключение контекста, чтобы программисту не приходилось делать это раками.

  • И конечно, корутины и Task служат для организации асинхронного выполнения кода.

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

В дальнейшем развитии программных технологий и возникновении новых ЯП корутины интегрировались в языки. Да, в какой-то момент исторически традиционное многопоточное программирование не то чтобы победило и вытеснило корутины и асинхронные вызовы — скажем так, стало куда более популярно. Но и концепция корутин тоже развивалась и никак не умирала. И сегодня конкурентность в выполнении программного кода в виде именно асинхронных вызовов функций и процедур приобретает всё большую популярность и хайп среди разработчиков для разных платформ на разных языках.

Всем известно, что Modern Concurrency пришла в Swift в версии 5.5 только в 21-м году, но внедренный в семантику языка инструментарий предлагает настолько приятный, удобный, можно сказать «сахарный», синтаксис, что нет причин не использовать конструкцию async/await в 2k25.

Крис Латтнер — один из тех, кто внедрял Modern Concurrency в Swift, это легендарный и талантливый разработчик, если не сказать больше. Он получил докторскую степень в 2005, и темой его научного исследования стало создание LLVM — инфраструктуры компилятора Swift (не только Swift, естественно, но нас интересует тема именно в разрезе Swift). И он же был одним из создателей собственно Swift, так что вклад Криса в карьеру разработчиков под платформы от Apple переоценить нельзя. Работал он в Apple вплоть до 2017-го года и можно сказать, что внедрение async/await в Swift происходило без него. Но в 17-м году команда разработчиков во главе с Крисом представила статью «Swift Concurrency Manifesto». По сути, это декларация о намерениях внести определенные новшества в язык, которые откроют для разработчиков путь к написанию кода в асинхронном стиле. 

Внедрение асинхронной парадигмы в Swift

Давайте нырнем вглубь статьи и посмотрим на некоторые мысли Криса (не все, естественно) по поводу внедрения асинхронной парадигмы в Swift.

В вводной части статьи Крис говорит, что этот манифест представляет долгосрочное видение, развития конкурентной парадигмы в Swift. Вместе с тем подчеркивается тезис, что одна из целей манифеста — стимулирование обсуждения подходов к реализации анонсированных изменений. Далее указывает, что до появления async/await в Swift создатели языка намеренно избегали преждевременного внедрения конкурентности, и для разработчиков на Swift были успешно функционирующие инструменты и библиотеки, такие как GCD, pthreads, и другие.

09671a7f1026b8cfdb80cf96c0524614.jpg

Несмотря на то, что разработчики языка намеренно избегали тем, связанных с конкурентностью, в Swift были сделаны некоторые уступки. Например, операции подсчёта ссылок в ARC являются атомарными операциями (что на самом деле логично). Это позволяет осуществлять безопасный доступ к ссылкам из разных потоков. Чтобы немного сузить фокус такой обширной темы как конкурентность в программировании, Крис предлагает определиться с тем, что не является целью введения новшеств:

— Так как разработчики изначально рассматривали конкурентность, основанную на задачах, а не параллелизме данных, то упор сделан на GCD и потоках как фундаменте конкурентности в Swift, полностью игнорируя все другие подходы конкурентности;

— Возможность сохранить низкоуровневый доступ к чему-либо для разработчиков на Swift важная штуковина, но эта тема перпендикулярна (я не шучу, Крис выбрал именно это слово) теме данного манифеста;

— Ну и улучшение существующих инструментов конкурентности в Swift также не является темой манифеста.

Тогда какие цели?

Дизайн. У разработчиков должно быть достаточно рабочих средств, чтобы решать любые задачи;

Поддерживаемость. Конкурентный код должен стать более понятным и читаемым;

Безопасность. Новшества призваны исправить ситуацию с наличием проблем в конкурентном коде;

Масштабируемость. Возможность одновременно обрабатывать множество задач;

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

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

При этом предполагается сохранение существующих инструментов конкурентности.

Какое отношение корутины имеют к Swift

Далее асинхронную парадигму предлагается осуществить с использованием зарекомендовавшего себя синтаксиса async/await (рискну предположить, что основной ролевой моделью послужил C#), а также особо подчеркивается важность внедрения модели акторов. В следующем разделе уже пошла некоторая конкретика, и в рассмотрении асинхронных API предлагается просто принять элегантную модель async/await, которая, как мы уже знаем, по итогу и была принята на вооружение. И сразу же интересная формулировка: Латтнер пишет, что лучше всего модификатор async добавлять к функциям (и типам функций). И что функции с таким модификатором становятся корутинами (привет тем, кто спрашивал, какое отношение корутины имеют к Swift). И в завершение раздела, посвященного асинхронным API, Крис говорит, использование модели async/await позволит писать чистый императивный и интуитивно понятный код.

f5f590a96912f344db2e4a01df51352f.jpg

Окей, теперь Крис предлагает немного поговорить об акторах. Команда разработчиков Swift, после того, как были представлены асинхронные API, предлагает разбивать приложения на несколько параллельных задач (Мелвин Конвей с его определением корутин шлёт привет). И использовать для этого модель акторов. В качестве актора понимается совокупность очереди DispatchQueue, данных, которые эта очередь защищает, и сообщений, которые могут выполняться в этой очереди. Акторы гарантируют, что их данные изменяются только кодом, выполняемым в этой очереди. Дальше идёт объяснение различий реализации модели акторов в Swift от академических, «чистых» акторов:

  • Вместо однонаправленных асинхронных сообщений (академические акторы) лучше позволить сообщениям возвращать значения;

  • Глубокое копирование каждого аргумента в сообщениях (академические акторы) — не подходящая модель для акторов Swift;

  • Решить проблему синхронизации данных и глобального доступа к ним.

Крис размышляет, как именно более ловко внедрить акторы в Swift. И говорит, что лучшим решением может быть реализация акторов как особого вида классов (что по итогу и произошло). Детализирует этот путь, объясняет некоторые свойства акторов. При этом подчеркиваются определенные ограничения такой системы. Латтнер приводит примеры, как обойти такие ограничения, не будем вдаваться в детали конкретной реализации.

Изоляция данных: проблема синхронизации данных в академических акторах решается глубоким копированием при их передаче в сообщениях. На практике это может привести к большому количеству операций копирования и стать неэффективным. Но Swift уже имеет неплохой фундамент для решения этой проблемы: существование value types вместе с механизмом copy-on-write.

Дальше Латтнер рассматривает некоторые проблемы реализации модели акторов и возможную связь с GCD. А также размышляет, почему бы не сделать акторы отдельным видом классов со всеми вытекающими. Достаточно про акторы, впрочем, это уже совсем другая история. Она заслуживает отдельной статьи (пишите в комментариях хотели, бы вы об этом прочитать).

Обработка ошибок и исключений

Следующий раздел посвящен этой важной и тоже обширной теме. В ней также приводятся рассуждения о внедрении «надёжного» актора. Что это за специальный актор такой, вы сможете узнать, прочитав раздел про обработку ошибок в самом манифесте.

Последняя номерная часть статьи. Размышления о будущем. Это даже не мысли о прочих возможностях применения асинхронной парадигмы — это скорее пара реплик о более широкой возможности применения, например, разделение между микроядрами и монолитными ядрами. Явно видна точка зрения Латтнера, направление его видения развития языка Swift не только в сторону разработки под экосистему Apple, но также и в качестве Enterprise-разработки.

deed02b348668447869a3dbc84d4bb87.jpg

На этом все. Мы познакомились с некоторыми явлениями, послужившими источником возникновения в языке Swift асинхронной парадигмы программирования. И выяснили, что модели Task«ов как корутин берут своё начало ещё в 50–60-х г.г. минувшего века, смогли немного познакомиться с трудом, который сделал описание функций и возможностей корутин как независимых, равноправных сопрограмм, способных сохранять свой контекст выполнения, прерывать выполнение и возобновлять с точки прерывания. Также взглянули на манифест-размышление одного из авторов компилятора и собственно языка Swift, его идеи по поводу роли асинхронных функций и акторов в Swift непосредственно перед внедрением Modern Concurrency в язык.

Напишите в комментариях, что вам кажется интересным в этом погружении?  

Мою первую статью можно прочитать здесь: разобрал неочевидные моменты при использовании памяти в Swift.

© Habrahabr.ru