https://habr.com/ru/post/525998/
Сейчас практически каждый разработчик знаком с понятием «асинхронность» в программировании. В эру, когда информационные продукты настолько востребованы, что вынуждены обрабатывать одновременно огромное количество запросов и также параллельно взаимодействовать с большим набором других сервисов — без асинхронного программирования — никуда. Потребность оказалась такой большой, что был даже создан отдельный язык, главной фишкой которого (помимо минималистичности) является очень оптимизированная и удобная работа с параллельным/конкурентным кодом, а именно
Golang. Несмотря на то, что статья совершенно не про него, я буду часто делать сравнения и ссылаться. Но вот в
Python, про который и пойдёт речь в этой статье — есть некоторые проблемы, которые я опишу и предложу решение одной из них. Если заинтересовала эта тема — прошу под кат.
Так получилось, что мой любимый язык, с помощью которого я работаю, реализую пет-проекты и даже отдыхаю и расслабляюсь — это
Python. Я бесконечно покорён его красотой и простотой, его очевидностью, за которой с помощью различного рода синтаксического сахара скрываются огромные возможности для лаконичного описания практически любой логики, на которую способно человеческое воображение. Где-то даже читал, что
Python называют сверхвысокоуровневым языком, так как с его помощью можно описывать такие абстракции, которые на других языках описать будет крайне проблематично.
Но есть один серьёзный нюанс —
Python очень тяжело вписывается в современные представления о языке с возможностью реализации параллельной/конкурентной логики. Язык, идея которого зародилась ещё в 80-ых годах и являющийся ровесником
Java до определённого времени не предполагал выполнение какого-либо кода конкурентно. Если
JavaScript изначально требовал конкурентности для неблокирующей работы в браузере, а
Golang — совсем свежий язык с реальным пониманием современных потребностей, то перед
Python таких задач ранее не стояло.
Это, конечно же, моё личное мнение, но мне кажется, что
Python очень сильно опоздал с реализацией асинхронности, так как появление встроенной библиотеки
asyncio было, скорее, реакцией на появление других реализаций конкуретного выполнения кода для Python. По сути,
asyncio создан для поддержки уже существующих реализаций и содержит не только собственную реализацию событийного цикла, но также и обёртку для других асинхронных библиотек, тем самым предлагая общий интерфейс для написания асинхронного кода. И
Python, который изначально создавался как максимально лаконичный и читабельный язык из-за всех перечисленных выше факторов при написании асинхронного кода становится нагромождением декораторов, генераторов и функций. Ситуацию немного исправило добавление специальных директив
async и
await (как в JavaScript, что важно) (исправил, спасибо пользователю
tmnhy), но общие проблемы остались.
Я не буду перечислять их все и остановлюсь на одной, которую я и попытался решить: это описание общей логики для асинхронного и синхронного выполнения. Например, если я захочу в
Golang запустить функцию параллельно, то мне достаточно просто вызвать функцию с директивой
go:
Параллельное выполнение function в Golang
package main
import "fmt"
func function(index int) {
fmt.Println("function", index)
}
func main() {
for i := 0; i < 10; i++ {
go function(i)
}
fmt.Println("end")
}
При этом в
Golang я могу запустить эту же функцию синхронно:
Последовательное выполнение function в Golang
package main
import "fmt"
func function(index int) {
fmt.Println("function", index)
}
func main() {
for i := 0; i < 10; i++ {
function(i)
}
fmt.Println("end")
}
В
Python все корутины (асинхронные функции) основаны на генераторах и переключение между ними происходит во время вызова блокирующих функций, возвращая управление событийному циклу с помощью директивы
yield. Честно признаюсь, я не знаю, как работает параллельность/конкурентность в
Golang, но не ошибусь, если скажу, что работает это совсем не так, как в
Python. Несмотря даже на существующие различия во внутренностях реализации компилятора
Golang и интерпретатора
CPython и на недопустимость сравнения параллельности/конкурентности в них, всё же я это сделаю и обращу внимание не на само выполнение, а именно на синтаксис. В
Python я не могу взять функцию и запустить её параллельно/конкурентно одним оператором. Чтобы моя функция смогла работать асинхронно, я должен явно прописать перед её объявлением
async и после этого она уже не является просто функцией, она является уже корутиной. И я не могу без дополнительных действий смешивать их вызовы в одном коде, потому что функция и корутина в
Python — совсем разные вещи, несмотря на схожесть в объявлении.
def func1(a, b):
func2(a + b)
await func3(a - b) # Ошибка, так как await может выполняться только в корутинах
Моей основной проблемой оказалась необходимость разрабатывать логику, которая может работать и синхронно, и асинхронно. Простым примером является моя
библиотека по взаимодействию с
Instagram, которую я давно забросил, но сейчас снова за неё взялся (что и сподвигло меня на поиск решения). Я хотел реализовать в ней возможность работать с API не только синхронно, но и асинхронно, и это было не просто желание — при сборе данных в Интернет можно отправить большое количество запросов асинхронно и быстрее получить ответ на них всех, но при этом массивный сбор данных не всегда нужен. В данный момент в библиотеке реализовано следующее: для работы с
Instagram есть 2 класса, один для синхронной работы, другой для асинхронной. В каждом классе одинаковый набор методов, только в первом методы синхронные, а во втором — асинхронные. Каждый метод выполняет одно и то же — за исключением того, как отправляются запросы в Интернет. И только из-за различий одного блокирующего действия мне пришлось практически полностью продублировать логику в каждом методе. Выглядит это примерно так:
class WebAgent:
def update(self, obj=None, settings=None):
...
response = self.get_request(path=path, **settings)
...
class AsyncWebAgent:
async def update(self, obj=None, settings=None):
...
response = await self.get_request(path=path, **settings)
...
Всё остальное в методе
update и в корутине
update — абсолютно идентичное. А как многие знают, дублирование кода добавляет очень много проблем, особенно остро это ощущается в исправлении багов и тестировании.
Для решения этой проблемы я написал собственную библиотеку
pySyncAsync. Идея такова — вместо обычных функций и корутин реализуется генератор, в дальнейшем я буду называть его шаблоном. Для того, чтобы выполнить шаблон — его нужно сгенерировать как обычную функцию или как корутину. Шаблон при выполнении в тот момент, когда ему нужно выполнить внутри себя асинхронный или синхронный код — возвращает с помощью
yield специальный объект
Call, который указывает, что именно вызвать и с какими аргументами. В зависимости от того, как будет сгенерирован шаблон — как функция или как корутина — таким образом и будут выполнятся методы, описанные в объекте
Call.
Покажу небольшой пример шаблона, который предполагает возможность делать запросы в
google:
Пример запросов в google с помощью pySyncAsync
import aiohttp
import requests
import pysyncasync as psa
# Регистрируем функцию для синхронного запроса в google
# В скобочках указываем имя для дальнейшего указания в объекте Call
@psa.register("google_request")
def sync_google_request(query, start):
response = requests.get(
url="https://google.com/search",
params={"q": query, "start": start},
)
return response.status_code, dict(response.headers), response.text
# Регистрируем корутину для асинхронного запроса в google
# В скобочках указываем имя для дальнейшенго указания в объекте Call
@psa.register("google_request")
async def async_google_request(query, start):
params = {"q": query, "start": start}
async with aiohttps.ClientSession() as session:
async with session.get(url="https://google.com/search", params=params) as response:
return response.status, dict(response.headers), await response.text()
# Шаблон для получения первых 100 результатов
def google_search(query):
start = 0
while start < 100:
# В Call аргументы передавать можно как угодно, они так же и будут переданы в google_request
call = Call("google_request", query, start=start)
yield call
status, headers, text = call.result
print(status)
start += 10
if __name__ == "__main__":
# Синхронный запуск кода
sync_google_search = psa.generate(google_search, psa.SYNC)
sync_google_search("Python sync")
# Асинхронный запуск кода
async_google_search = psa.generate(google_search, psa.ASYNC)
loop = asyncio.get_event_loop()
loop.run_until_complete(async_google_search("Python async"))
Расскажу немного про внутреннее устройство библиотеки. Есть класс
Manager, в котором регистрируются функции и корутины для вызова с помощью
Call. Также есть возможность регистрировать шаблоны, но это необязательно. У класса
Manager есть методы
register,
generate и
template. Эти же методы в примере выше вызывались напрямую из
pysyncasync, только они использовали глобальный экземпляр класса
Manager, который уже создан в одном из модулей библиотеки. По факту можно создать свой экземпляр и от него вызывать методы
register,
generate и
template, таким образом изолируя менеджеры друг от друга, если, например, возможен конфликт имён.
Метод
register работает как декоратор и позволяет зарегистрировать функцию или корутину для дальнейшего вызова из шаблона. Декоратор
register принимает в качестве аргумента имя, под которым в менеджере регистрируется функция или корутина. Если имя не указано, то функция или корутина регистрируется под своим именем.
Метод
template позволяет зарегистрировать генератор как шаблон в менеджере. Это нужно для того, чтобы была возможность получить шаблон по имени.
Метод
generate позволяет на основе шаблона сгенерировать функцию или корутину. Принимает два аргумента: первый — имя шаблона или сам шаблон, второй — «sync» или «async» — во что генерировать шаблон — в функцию или в корутину. На выходе метод
generate отдаёт готовую функцию или корутину.
Приведу пример генерации шаблона, например, в корутину:
def _async_generate(self, template):
async def wrapper(*args, **kwargs):
...
for call in template(*args, **kwargs):
callback = self._callbacks.get(f"{call.name}:{ASYNC}")
call.result = await callback(*call.args, **call.kwargs)
...
return wrapper
Внутри генерируется корутина, которая просто итерируется по генератору и получает объекты класса
Call, потом берёт ранее зарегистрированную корутину по имени (имя берёт из
call), вызывает её с аргументами (которые тоже берёт из
call) и результат выполнения этой корутины также сохраняет в
call.
Объекты класса
Call являются просто контейнерами для сохранения информации о том, что и как вызывать и также позволяют сохранить в себе результат.
wrapper также может вернуть результат выполнения шаблона, для этого шаблон оборачивается в специальный класс
Generator, который здесь не показан.
Некоторые нюансы я опустил, но суть, надеюсь, в общем донёс.
Если быть честным, эта статья была написана мною скорее для того, чтобы поделится своими мыслями о решении проблем с асинхронным кодом в
Python и, самое главное, выслушать мнения хабравчан. Возможно, кого-то я натолкну на другое решение, возможно, кто-то не согласится именно с данной реализацией и подскажет, как можно сделать её лучше, возможно, кто-то расскажет, почему такое решение вообще не нужно и не стоит смешивать синхронный и асинхронный код, мнение каждого из вас для меня очень важно. Также я не претендую на истинность всех моих рассуждений в начале статьи. Я очень обширно размышлял на тему других ЯП и мог ошибиться, плюс есть возможность, что я могу путать понятия, прошу, если вдруг будут замечены какие-то несоответствия — опишите в комментарии. Также буду рад, если будут поправки по синтаксису и пунктуации.
И спасибо за внимание к данному вопросу и к этой статье в частности!