Асинхронное программирование в Python, часть первая

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

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

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

Но зачем нам вообще может понадобиться заставлять программы выполняться подобным образом? Короткий ответ заключается в том, чтобы решить определённый ряд проблем, возникающих в процессе разработки приложений.

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

И это будет совершенно жуткой и бестолковой реализацией.

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

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

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

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

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

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

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

Что такое неблокирующий код? А что такое блокирующий? Всё это звучало для меня как бессмысленный набор звуков.

Реальный мир асинхронен

Принципы разработки асинхронных приложений богаты разнообразием подходов и местами даже сложны для понимания. Но наряду с этим, это всё жутко интересно, поскольку мир, в котором мы живём и с которым ежесекундно взаимодействуем, является асинхронным.

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

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

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

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

Мысленный эксперимент №1: пакетный родитель

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

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

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

Мысленный эксперимент №2: наблюдающий родитель

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

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

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

Мысленный эксперимент №3: потоковый родитель

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

Если представить каждую из задач, о которых речь шла выше, в виде отдельной программы, то мы можем всю нашу деятельность соответствующим образом разбить на части, запуская работу над каждой задачей в отдельном потоке. Таким образом мы получим несколько "клонированных" родителей, каждый их которых самостоятельно и независимо от остальных занимается порученным ему делом: один считает расходы, второй следит за детьми, третий контролирует стиральную машину, четвёртый сушилку и так далее.

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

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

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

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

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

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

Дивіться також

HTMLer Технології
HTMLer
9 липня 2019
DicMer Технології
DicMer
7 липня 2019