«Самое худшее, когда нужно ждать и не можешь ничего сделать. От этого можно сойти с ума»
Э.М. Ремарк
Привет! Меня зовут Артём, я backend-разработчик в отделе спецпроектов KTS. Я занимаюсь проектами, где повсеместно используется асинхронное программирование, и веду курсы по нему в нашей школе Metaclass.
Сегодня я постараюсь объяснить, что такое асинхронное программирование, зачем оно нужно, какие задачи решает и как ему научиться. Так как мой основной язык — Python, то и материал будет Python-ориентированным.
Что будет в статье:
Поехали! ?
Чтобы более наглядно разобраться в материале, нам вызвались помогать две подружки: Сима и Ася. Сима пропагандирует Синхронный образ жизни, а Ася предпочитает Асинхронный. Я постараюсь иллюстрировать различия между асинхронным и синхронным подходами бытовыми примерами с их участием, а затем приводить реальный кейс из мира разработки.
Что такое асинхронное программирование
Начнем с бытового примера
Сима и Ася устроили соревнование — кто быстрее приготовит жаркое. Для жаркого нужно купить цыпленка и овощей в ближайшем магазине, где, к сожалению, нет самообслуживания.
? Сначала в магазин пошла Сима. Она попросила продавца взвесить цыпленка и стала пристально следить за процессом взвешивания. Продавец взвешивал цыпленка одну минуту, а затем отдал ее Симе. После этого Сима пошла в другой отдел и также же пристально смотрела на продавца, которая взвешивал ей овощи тоже в течение одной минуты. В итоге в магазине Сима провела 2 минуты, не считая времени на кассе.
? Ася же попросила взвесить цыпленка, а потом, не дожидаясь продавца, пошла в другой отдел и попросила взвесить овощей. Почти целую минуту Ася наблюдала, как плавают карпы в магазинном аквариуме, а потом ее окликнул продавец с мясом. Сразу же за этим ее окликнул продавец овощей. В итоге в магазине Ася провела всего 1 минуту и ещё полюбовалась на рыбок.
В мире разработки часто встречаются задачи, которые требуют для своего выполнения информацию от внешних ресурсов. В примере выше, информация — это картошка и курица.
Теперь перейдем к примерам из разработки
Вы создали сайт, который показывает прогноз погоды на завтра. Естественно, что у вас нет своей метеостанции во дворе и, чтобы узнать температуру и скорость ветра, вам необходимо сделать запрос к какому-то сервису, который эти данные может предоставить.
Как же разные подходы будут отправлять этот запрос и обрабатывать ответ?
Посмотрите на иллюстрацию выше.
? Слева изображён синхронный подход. Синхронная программа отправляет запрос. Пока она ожидает результатов выполнения этого запроса, она не может выполнять другую полезную работу. Программа сможет продолжить работу только тогда, когда получит ответ от внешнего сервера, так как дальнейший ход ее действий зависит от внешних данных.
? Справа изображен асинхронный подход. Асинхронная программа сначала ведет себя также как и синхронная — она посылает запрос на сервер. Но затем ее поведение кардинально отличается — она не блокируется, ожидая результатов, а делает какие-то другие действия, которые не требуют ответа внешнего сервера. Только после того, как сервером будет возвращён ответ, она сможет вернуться к дальнейшей обработке полученных данных.
Асинхронное программирование нацелено на то, чтобы программа как можно меньше времени бездействовала в ожидании какого-то внешнего события, другими словами, основная проблема синхронного программирования — «бездействие» во время ожидания.
Другие варианты решения проблемы блокировки во время ожидания
Конечно, асинхронное программирование — не единственный подход, при котором можно не блокироваться на время ожидания ответа. Начнем опять с бытового примера.
Представим, что в предыдущем примере в магазин отправились сестры-близняшки Сима_1 и Сима_2, одна из которых пошла в овощной, а другая в мясной отдел. Общее время их пребывания в магазине будет такое же как и у Аси — одна минута. Но на существование второй Симы нужны ресурсы — ей надо как минимум где-нибудь жить и чем-то питаться, чтобы ходить в магазины.
Немного утрируя, можно сказать, что Сима_1 и Сима_2 используют параллелизм для похода в магазин.
Параллелизм — подход, при котором код запускается в новом процессе или потоке компьютера, то есть несколько операций выполняются параллельно и не могут быть прерваны на выполнения каких-то других задач. В данном случае мы говорим именно о синхронной реализации программы внутри таких параллельных потоков/процессов.
Возможно, это объяснение покажется несколько сложным и неполным, но главная идея должна быть ясна: чтобы применять эти подходы на практике, надо создавать дополнительную сущность (процесс или поток). Дополнительные сущности требуют лишние ресурсы на свое поддержание, хотя в целом выполняют задачу за то же время, что и асинхронный подход. Конечно, было бы неправильно говорить, что параллелизм неэффективен, но вот в чём именно он эффективен, мы обсудим ниже.
Важно разделять понятия однопоточный и многопоточный с понятиями синхронный и асинхронный. Существуют все комбинации этих понятий, например можно написать многопоточную асинхронную программу, то есть вообразить ситуацию, когда в магазин пошли две Аси, а не две Симы. В этой статье я рассказываю про однопоточный асинхронный подход, основанный на принципе кооперативной многозадачности, так как именно этот принцип используется в asyncio и учитывает ограничения GIL. Таким образом: Ася - однопоточный асинхронный подход, Сима - однопоточный синхронный подход.
В каких задачах полезно асинхронное программирование
Задачи, в которых эффективен асинхронный подход, называются IO-bound задачи, где IO значит Input/Output (Ввод/Вывод), а bound переводится как ограничение. То есть задачи, время выполнения которых в основном регулируется временем выполнения всех операций ввода/вывода. Операции ввода/вывода — это обычно работа с сетью и файловой системой. Важно понимать, что асинхронный подход оптимизирует не время выполнения этих операций по-отдельности, а общее время работы программы, которая обычно состоит из множества таких операций.
В мире веб-программирования особенно много операций, в которых есть элемент ожидания. Давайте рассмотрим некоторые задачи, которые эффективно решать с помощью асинхронного подхода.
Пример 1. Создание файлового хранилища
Допустим, что вам поручили создать хостинг файлов — аналог Google-диска. Пользователь загружает файл, а сервис отдаёт ему ссылку, по которому этот файл можно скачать. Пусть сервис сохраняет файл на жёсткий диск, а связку (путь до файла):(ссылка, по которой он доступен) вставляет в базу данных.
Мы видим в таком задании сразу две очевидные IO-bound операции: запрос к внешней базе данных и запись файла на диск. Кроме этого, в задаче есть несколько неочевидных IO-bound операций — получение файла от пользователя полностью (это происходит по кусочкам) и раздача файла при его скачивании пользователем (тоже по кусочкам). Эти кусочки также называются чанками и их длина обусловлена протоколом передачи данных в Интернете.
? В однопоточном синхронном подходе сервис будет обрабатывать только один запрос, т.е. одного пользователя, в момент времени. Синхронному сервису предстоит сначала дождаться получения файла на сетевую карту, затем его сохранения в файловую систему, ответа от базы данных, а затем передачи пользователю сгенерированной ссылки на скачивание.
? В асинхронном подходе сервис сможет обрабатывать сразу много запросов конкурентно, постоянно переключаясь между ними при наличии новых данных.
Например, если от пользователя пришёл кусочек файла — сервис сохранит его на жёсткий диск, пришёл ответ от базы данных — отправит пользователю ссылку на файл и так далее.
А что, если придёт пользователь, который захочет загрузить фильм в качестве 4k, размером 40 GB?
Сначала бытовой пример
Представим, что Ася и Сима хотят сделать себе кофе с помощью новой кофемашины. Они не знают как ей пользоваться и поэтому читают инструкцию, где алгоритм описан по пунктам.
? Сима сначала прочитала всю инструкцию, полностью запомнила ее, а затем начала делать кофе.
? Ася выбрала другой подход: она читает один пункт инструкции, затем выполняет его и сразу же забывает.
В итоге Ася хранит меньше информации (только один пункт инструкции) и в любой момент ожидания может переключиться на другую задачу, а потом продолжить варить себе кофе.
? Однопоточный синхронный сервис столкнётся сразу с двумя проблемами. Во-первых, пока он не обработает этого пользователя полностью, сервис будет недоступен для остальных. Есть ещё одна неочевидная проблема: при использовании стандартных подходов предстоит сначала дождаться полной загрузки файла в оперативную память сервера (то есть, в случае Симы, прочитать и запомнить всю инструкцию) и только после этого начать его обработку. Даже в наше время сервер с 40 GB RAM — достаточно дорогое удовольствие.
? Асинхронный сервис обладает возможностью стримить файл сразу в хранилище по кускам (чанкам). То есть при получении нового чанка данных нам нет смысла хранить его у себя, если конечная цель — это запись на диск. Мы можем сразу же сохранить его на диск и очистить память. То есть размер памяти нашей программы всегда остаётся более-менее одинаковым вне зависимости от размера получаемых файла. Кроме этого, появляется возможность асинхронно обрабатывать каждый кусочек (то есть, прерываться после выполнения очередного пункта инструкции, если проводить аналогию с бытовым примером). Например, можно сообщать о текущем прогрессе загрузки файла через websocket-канал.
Вот пример наивной реализации такого подхода к скачиванию файлов с использованию библиотеки aiohttp.
Пример 2: Работа чат-бота
Некоторые технологии вообще не предполагают использования чисто синхронного подхода. Например, нам нужен чат-бот, который получает сообщение и отправляет на него ответное.
Бытовой пример
Вспомним пример с магазином. Представим, что курицу только разгружают на склад и продавец не может ее взвесить, пока не увидит, что грузчики закончили свою работу.
? У Симы два варианта:
спросить продавца один раз и долго стоять, ожидая пока ей отдадут курицу, то есть завершится процесс разгрузки;
постоянно спрашивать, сильно раздражая продавца и срывая свой голос, можно ли ей наконец-то получить курицу;
В первом случае Сима потратит время, а во втором — нервы и силы.
В первом случае Сима использовала Long Polling подход, а во втором просто Polling. Давайте разберемся, что это за подходы и как они связаны с чат-ботами.
Long Polling — это метод, при котором мы посылаем «долгий» НТТР-запрос к серверу соцсети и получаем новые сообщения в ответе на этот запрос. «Долгий» означает, что соцсеть вернёт нам ответ только в двух случаях: когда произойдет новое событие или истечет время ожидания, т.е. мы долгое время ждём ответа. Во многих соцсетях мы можем получить сообщения с помощью этого варианта, например, в VK и в Telegram и именно этот подход мы используем в своем конструкторе ботов Smartbot Pro.
Long Polling неэффективен при однопоточном синхронном подходе, потому что программа будет просто заблокирована на время ожидания ответа от сервера (а это обычно 30-60 секунд, если новых событий нет). Если у вас только один бот, это решение имеет право на жизнь. Но если вы хотите обрабатывать сразу двух ботов, сначала вам надо завершить запрос к первому боту, затем обработать полученного от него сообщения, затем завершить запрос ко второму боту и так далее… В итоге, в худшем случае, пользователь напишет свое сообщение, как только закончится запрос к его боту, и будет ждать 30-60 секунд, прежде чем получить ответ.
Мы можем использовать Polling и отвечать значительно быстрее, постоянно спрашивая сервер: «А есть новые сообщения?». Сервер будет отвечать моментально, что «Нет, новых сообщений нет» или «Да, вот новые сообщения», но в этом случае мы каждый раз будем тратить время на установку соединения и делать много «бесполезных» запросов.
В каких задачах бесполезно асинхронное программирование
Рассмотрим бытовой пример:
Компания, в которой работают Сима и Ася, на Новый год подарила им пазлы.
? Сима взяла пазлы и начала методично собирать их, не отвлекаясь ни на что другое. В итоге сбор пазлов занял один час.
Все знали, что Сима не любит отвлекаться, поэтому никто на нее не обиделся за то, что она этот час не отвечала в чатике.
? Ася тоже взяла пазлы и начала их собирать, но из-за её немного непоседливого характера ей потребовалось чуть больше самообладания, чтобы собрать их за тот же один час. При этом люди, привыкшие, что она быстро отвечает в чате, обиделись на неё за игнор.
Обе девушки собрали пазлы за 1 час, но у Симы этот процесс был чуть более эффективен. Такие задачи, как сбор пазлов, в мире программирования можно назвать CPU-bound операциями.
? CPU Bound операции — вычислительные задачи, которые полностью утилизируют вычислительные ресурсы, то есть эти задачи ограничены быстродействием процессора, а не ожиданием внешних ресурсов.
Можно ли выполнять такие задачи в сервисе, созданном в асинхронной парадигме? Да, можно, но это будет не совсем правильно.
Во-первых, асинхронный подход требует компонента-планировщика, который будет продолжать выполнение ожидающего кода при наступлении внешних событий. Обычно он называется событийный цикл или event loop. В CPU-bound операциях этот компонент бесполезен, но при этом всё равно тратит время на свои создание и работу.
Во-вторых, асинхронный сервис обычно пишется для взаимодействия со множеством операций в один момент времени, поэтому некоторые пользователи могут «обидеться», что такой сервис не отвечает им, так как выполняет долгую синхронную операцию. Это частая ошибка новичков: выполнять долгие вычисления в момент обработки запроса, приводящая к тому, что сервис перестает отвечать на другие запросы и «делает вид», будто он завис.
Несколько примеров CPU-bound операций: сортировка больших объемов данных, обучение ML-модели, архивирование файлов и т.д. Именно в таких задачах полезен такой подход как параллелизм, то есть эффективна помощь Симы_2 или, другими словами, возможность распределенных вычислений параллельно на нескольких ядрах процессора или на многих процессорах.
Важно ли знать, как писать асинхронный код в наше время
Поддержка современных принципов разработки
Стоит начать издалека и посмотреть, как вообще развивался веб.
На заре веба все сервисы были монолитными: есть одна большая сущность, в которой реализована вся бизнес-логика сервиса. Со временем люди поняли, что монолитная архитектура не всегда является лучшим решением. Из-за нее страдает общая отказоустойчивость приложения, часто скорость разработки, и её невозможно оптимально масштабировать.
Сейчас многие проекты пишутся или переписываются с использованием принципов микросервисной архитектуры. Отличительно чертой такого подхода является огромное число перекрестных запросов от микросервиса к микросервису или от микросервиса к базе данных. А как мы знаем, запросы - это IO-bound операции и с ними отлично справляется асинхронный подход. Поэтому если вы создаете приложение с микросервисной архитектурой, скорее всего, вам нужно будет использовать асинхронный подход.
Развитие библиотек и фреймворков в сторону асинхронности
Лучшей иллюстрацией движения мира к асинхронному подходу, пожалуй, является Django. Еще совсем недавно это был полностью синхронный фреймворк. С 3-й версии добавилась поддержка асинхронности и сейчас она становится все более полной. То есть, даже если вы будете писать проекты на скорее всего самом распространенном Python-фреймворке, то вам надо будет знать принципы асинхронности.
Многие сервисы изначально были написаны в синхронной парадигме. Например, возьмем Instagram, который был написан именно на синхронной версии Django. Казалось бы, как он справлялся с большой нагрузкой? Все благодаря команде Instagram, которая еще задолго до становления Django асинхронной, переписала его ядро и добавила поддержку асинхронности.
Вообще сам факт того, что Python добавил в 2015 году в свою стандартную библиотеку для работы с асинхронными операциями - asyncio, уже говорит о многом: команда разработчиков ядра Python понимает, что за этим будущее разработки. На самом деле — уже и настоящее. С каждым годом выпускается всё больше и больше фреймворков и библиотек для асинхронного программирования. Для «старых» синхронных модулей либо пишется аналог (например, asyntnt), либо в них добавляется асинхронная логика.
Если вы захотите в команду, которая делает большие и современные проекты, то знаний только синхронного подхода вам скорее всего будет недостаточно. Если вы хотите пойти в какой-то крупный проект, который будет держать большую нагрузку, то с большой долей вероятности вам нужно будет уметь писать асинхронный код.
Почему стоит писать асинхронный код именно на Python
Скорость и простота разработки
Многие слышали, что Python — это относительно медленный язык. Логично предположить, что стоит сразу изучать асинхронную парадигму на более эффективном языке, но всё не так однозначно. Рассмотрим Golang для примера и начнем с бытовой аналогии.
Ася решила заняться вязанием и пришла на мастер-класс. На этот же мастер-класс пришла целая семья по фамилии Голыгины.
? Ася быстро научилась вязать, хотя вяжет пока медленно. Ей потребовалось всего одно занятие и учитель-новичок.
? Голыгины решили вязать несколько вещей сразу, при этом ещё и помогая друг другу. Чтобы наладить такое тесное взаимодействие, им понадобился опытный учитель (занятия с которым дороже) и пять занятий, зато они научились вязать вещи очень быстро.
В итоге для обучения меньше всего ресурсов потребовалось Асе, и она достаточно производительна для того, чтобы вязать своим друзьям подарки. Голыгиным потребовалось в несколько раз больше времени и денег, чтобы наладить свою работу, но при этом они могут делать в 10 раз больше вещей, чем Ася за то же время.
После учебы выяснилось, что у Голыгиных просто нет столько друзей, чтобы нужно было вязать всей семьей одновременно. Поэтому работать на пределе своих возможностей они не будут и вряд ли начнут в ближайшем будущем. Кроме того, если Ася поймет, что она не успевает, она легко может попросить Голыгиных, и они помогут ей.
Разработка на Python аналогична примеру: она требует меньше времени и компетенций на создание работающего продукта. Асинхронный код на Python с помощью asyncio можно писать даже не зная о примитивах синхронизации: мьютексах, семафорах, атомиках и так далее, можно практически не думать и о race conditions. С помощью asyncio вы пишете код, который выглядит как синхронный. Конечно, чем больше погружение, тем больше появляется нюансов, но код для выполнения базовых задач писать очень легко. Во многом это благодаря GIL, который снимает с разработчика ответственность за конкурентный доступ к памяти.
В Go же наоборот: необходимо всегда следить, что общая память используется правильно, то есть иными словами обеспечивать потокобезопасность. Говоря словами из примера, каждый член семьи Голыгиных вяжет именно тот кусок, который должен и не пришивает третий рукав на свитер, не зная, что там уже есть два. Это замедляет разработку, а баги становятся более сложными для диагностики и исправления.
В любом случае, если скорость работы в каком-то месте программы становится очень важна, можно вынести эту часть в виде отдельного микросервиса, написанного на более производительном языке программирования, или написать ее на C и встроить в Python. Например, вот бенчмарки (там же есть сравнение с Go) сервера на UVLoop — цикла событий, написанного на C и встроенного в Python в виде библиотеки.
Популярность языка
Если посмотреть на индекс популярности языков TIOBE, основанный на поисковых запросах пользователей, количестве квалифицированных программистов этого языка, и других факторах, можно увидеть, что Python стоит на первом месте.
Популярность Python означает, что:
Для него больше обучающих материалов.
Если у вас есть специфическая задача, с большей вероятностью вы найдёте на неё ответ на Stack Overflow именно для Python.
Больше людей параллельно с вами учат Python и разбираются в нём. А значит, есть большое комьюнити, в котором можно задать вопрос, и оно даст квалифицированный ответ.
Больше вакансий и больше проектов. Например, сейчас в Москве открыты 5000 вакансий на Python разработчика и всего 600 на Go.
Итоги
Асинхронное программирование показывает свою эффективность только там, где преобладают IO-bound операции. В CPU-bound операциях асинхронный подход бесполезен и даже вреден.
Следует отличать понятия синхронности/асинхронности и однопоточности/многопоточности.
Асинхронная программа хороша тем, что она в одном потоке может обработать столько же IO-bound операций, что и многопоточная синхронная, при этом потребляя минимум ресурсов и в, большинстве случаев, за меньшее время.
Существуют технологии, которые не предполагают использование однопоточного синхронного подхода, например Long Polling или стриминг.
В сложных и современных проектах в большинстве случаев используется микросервисная архитектура, которая предполагает множество IO-bound операций, а значит и асинхронный подход.
На Python есть множество асинхронных модулей, которые покрывают все потребности для создания почти любого проекта.
Для ускорения конкретных частей программы можно создать отдельный микросервис на более производительном языке или написать свой биндинг прямо в Python на Cython.
Python самый популярный язык в мире на текущий момент.
Другие наши статьи по бэкенду и асинхронному программированию для начинающих:
Цикл статей «Первые шаги в aiohttp»: пишем первое hello-world-приложение, подключаем базу данных, выкладываем проект в Интернет
Другие наши статьи по бэкенду и асинхронному программированию для продвинутого уровня:
С чего начать учить asyncio
Если вы учите Python и уже кое-что умеете, приходите на наш бесплатный курс по бэкенду, который стартует 6 февраля. Уже после регистрации вам будут доступны некоторые задачи, которые можно начинать решать:
? Начинающий Backend-разработчик на Python
Асинхронное программирование вы можете начать учить на другом бесплатном курсе KTS. Он рассчитан именно на работу с асинхронным кодом на Python с использованием библиотек aiohttp. Начинать учиться можно в любое время.
Заходите посмотреть программу по ссылке: