Python. Итераторы, гинераторы и асинхронность
Асинхронное программирование
Асинхронное программирование в мире Python-разработки на пике популярности. Про него пишут статьи и делают доклады на конференциях. Концепция уже прижилась и во многих других популярных языках. Давайте восполним пробелы и погрузимся в работу с асинхронным кодом на Python.
Прежде всего вам предстоит разобраться с практической стороной асинхронного программирования. В дальнейших уроках сосредоточимся на теоретических знаниях, которые помогут лучше осознать применимость асинхронного подхода на практике.
Но главное — поговорим об ограничениях, которые могут ухудшать производительность программ.
Работа с разными типами задач
Раньше разработчики не сильно заостряли своё внимание на типе выполняемых задач внутри приложения — это было не нужно для индустрии в целом. Все писали достаточно большие монолитные приложения, а проблемы с производительностью обычно решались на уровне горизонтального масштабирования: через потоки, процессы или даже через несколько приложений на разных виртуальных машинах.
Сейчас использование только процессов и потоков не даёт нужной производительности.
Рассмотрим три основных типа задач, с которыми сталкивается большинство разработчиков:
CPU bound-задачи
Задачи, для которых необходимо интенсивное использование процессора. К ним относят использование сложных математических моделей, обучение нейронных сетей, рендеринг графики и вычисление хэшей.
I/O bound-задачи (non-RAM I/O bound)
Задачи, в которых основная часть работы приходится на ввод/вывод информации — I/O или input/output. В основном такие задачи относятся к работе с файловой системой и с сетью.
Memory bound-задачи (RAM I/O bound)
Задачи, в которых происходит интенсивная работа с оперативной памятью. Как правило, такие задачи появляются в сложных математических моделях.
Из-за медленной работы с оперативной памятью всё больше моделей обрабатывают с помощью видеокарт, в которых работа с памятью устроена по-другому. Другой пример — обработка огромного объёма данных в Map-Reduce-системах, например, таких как Spark. Обработка будет идти быстрее, если оперативной памяти будет больше.
Подробнее об этом можно прочитать в англоязычных статьях:
Из-за массового перехода на микросервисы количество сетевого взаимодействия между системами многократно возросло, как и нагрузка на базы данных. Проблемы работы с сетью или с доступом к БД относятся к I/O bound-задачам. То есть их основная работа — ожидание обработки запроса к внешней системе. Такой класс задач в монолитных системах решался пулом потоков — thread pool. Однако, его стало не хватать из-за достаточно интенсивной нагрузки на сеть между множеством сервисов.
Классический метод решения I/O bound задач — добавление ресурсов к существующим системам.
Однако, многие компании не могут позволить себе «заливать всё железом» — докупать новые железные серверы, вместо оптимизации кода. Например, Instagram может себе такое позволить, поэтому они до сих пор используют Django даже с учётом всей нагрузки.
Перейдём к практике. Представьте приложение, которое ходит на некий сайт-агрегатор, достаёт данные по фильмам и сохраняет в БД. Код будет выглядеть так (ссылка на сайт выдуманная):
import requests

def do_some_logic(data):
  pass
  
def save_to_database(data):
  pass

data = requests.get('https://data.aggregator.com/films')
processed_data = do_some_logic(data)
save_to_database(data)
Этот код достаточно линейный. Если вместо загрузки фильмов в БД такой код будет выполнять отдачу данных о фильмах с этого же сайта, то при достаточной нагрузке приложение начнёт сильно проседать по скорости ответа клиентам. При этом бо́льшую часть времени код будет просто ждать запроса от клиента, делать запрос к сайту data.aggregator.com/films и отдавать данные. То есть в эти моменты интерпретатор не будет делать никаких полезных действий, а клиенты будут ждать.
Схематично изобразить выполнение программы можно вот так:
Теперь определим тип задачи в каждой ячейке:
Интуитивно выполнение программы кажется примерно таким, как указано на картинке выше. Однако, если привести картинку в соответствие с реальностью, получим следующий результат:
То есть бо́льшую часть времени программа ждёт ввода/вывода, а меньшая часть времени отводится на выполнение полезной работы.
Эту проблему можно решить, распараллелив код на процессы и потоки. Такой вариант поможет, но на короткое время — при таком подходе сильно увеличатся расходы ресурсов сервера. Плюс количество допустимых процессов и потоков ограничено: либо закончится оперативная память под процессы, либо закончатся ядра под потоки.
Ещё одна неприятная проблема состоит в том, что распределением работы на потоки и процессы занимается ОС, но часто возникает потребность сделать переключатель выполнения напрямую в программе. Вишенкой на торте становится GIL, который даёт работать только одному потоку в единицу времени.
Всё это не позволяет эффективно использовать массовый параллелизм на потоках и добавляет свои накладные расходы, хоть и не очень заметные.
Посмотрим, как применение потоков сказывается на выполнении программы:
Действительно, два потока почти в два раза лучше отрабатывают задачи I/O bound.
Но при таком подходе очень просто ошибиться и столкнуться с проблемой «состояния гонки».
Также написание многопоточного кода требует от разработчика большей внимательности, чем при написании линейного.
Стоит внимательнее присмотреться к проблеме. Всё ещё бо́льшую часть времени интерпретатор не совершает активных действий, а только ждёт ответа от ОС, завершилась ли та или иная операция ввода/вывода. В целом процессы и потоки не сильно помогут, ведь интерпретатор будет по-прежнему простаивать на каждом из них. При этом появится много накладных расходов на переключение контекста между процессами или на потребление оперативной памяти потоками, что может только ухудшить положение.
В последнее время на сайтах для интерактивных чатов пользуются веб-сокетами. Они заставляют код держать соединение открытым и постоянно ждать новых данных из чата. При использовании обычного кода на Python невозможно обрабатывать сотни открытых чатов — не хватит ресурсов.
Все эти проблемы необходимо решать эффективно. С этим может помочь использование асинхронного кода.