Шаблонные функции в Python, которые могут выполнятся синхронно и асинхронно

от автора

image

Сейчас практически каждый разработчик знаком с понятием «асинхронность» в программировании. В эру, когда информационные продукты настолько востребованы, что вынуждены обрабатывать одновременно огромное количество запросов и также параллельно взаимодействовать с большим набором других сервисов — без асинхронного программирования — никуда. Потребность оказалась такой большой, что был даже создан отдельный язык, главной фишкой которого (помимо минималистичности) является очень оптимизированная и удобная работа с параллельным/конкурентным кодом, а именно Golang. Несмотря на то, что статья совершенно не про него, я буду часто делать сравнения и ссылаться. Но вот в Python, про который и пойдёт речь в этой статье — есть некоторые проблемы, которые я опишу и предложу решение одной из них. Если заинтересовала эта тема — прошу под кат.


Так получилось, что мой любимый язык, с помощью которого я работаю, реализую пет-проекты и даже отдыхаю и расслабляюсь — это Python. Я бесконечно покорён его красотой и простотой, его очевидностью, за которой с помощью различного рода синтаксического сахара скрываются огромные возможности для лаконичного описания практически любой логики, на которую способно человеческое воображение. Где-то даже читал, что Python называют сверхвысокоуровневым языком, так как с его помощью можно описывать такие абстракции, которые на других языках описать будет крайне проблематично.

Но есть один серьёзный нюанс — Python очень тяжело вписывается в современные представления о языке с возможностью реализации параллельной/конкурентной логики. Язык, идея которого зародилась ещё в 80-ых годах и являющийся ровесником Java до определённого времени не предполагал выполнение какого-либо кода конкурентно. Если JavaScript изначально требовал конкурентности для неблокирующей работы в браузере, а Golang — совсем свежий язык с реальным пониманием современных потребностей, то перед Python таких задач ранее не стояло.

Это, конечно же, моё личное мнение, но мне кажется, что Python очень сильно опоздал с реализацией асинхронности, так как появление встроенной библиотеки asyncio было, скорее, реакцией на появление других реализаций конкуретного выполнения кода для Python. По сути, asyncio создан для поддержки уже существующих реализаций и содержит не только собственную реализацию событийного цикла, но также и обёртку для других асинхронных библиотек, тем самым предлагая общий интерфейс для написания асинхронного кода. И Python, который изначально создавался как максимально лаконичный и читабельный язык из-за всех перечисленных выше факторов при написании асинхронного кода становится нагромождением декораторов, генераторов и функций. Ситуацию немного исправило добавление специальных директив async и await (как в JavaScript, что важно), но общие проблемы остались.

Я не буду перечислять их все и остановлюсь на одной, которую я и попытался решить: это описание общей логики для асинхронного и синхронного выполнения. Например, если я захочу в 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 и, самое главное, выслушать мнения хабравчан. Возможно, кого-то я натолкну на другое решение, возможно, кто-то не согласится именно с данной реализацией и подскажет, как можно сделать её лучше, возможно, кто-то расскажет, почему такое решение вообще не нужно и не стоит смешивать синхронный и асинхронный код, мнение каждого из вас для меня очень важно. Также я не претендую на истинность всех моих рассуждений в начале статьи. Я очень обширно размышлял на тему других ЯП и мог ошибиться, плюс есть возможность, что я могу путать понятия, прошу, если вдруг будут замечены какие-то несоответствия — опишите в комментарии. Также буду рад, если будут поправки по синтаксису и пунктуации.

И спасибо за внимание к данному вопросу и к этой статье в частности!

ссылка на оригинал статьи https://habr.com/ru/post/525998/