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

    image

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

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

    Similar posts

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 26

      +4
      Ситуацию немного исправило добавление специальных директив async и await (как в JavaScript, что важно)

      Вы ничего не перепутали?
      * Python 3.5 was released on September 13, 2015
      * ES8 was released on 2017

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

      Python 3.7 was released on June 27, 2018
      The new provisional asyncio.run() function can be used to run a coroutine from synchronous code by automatically creating and destroying the event loop. (Contributed by Yury Selivanov in bpo-32314.)
        0
        Вроде не перепутал, синтаксис async/await в JavaScript появился ещё в ES6, нашёл это — немного погуглив.

        asyncio.run() выполняет корутины, я никогда не заставлю обычную функцию выполнятся асинхронно, так что по сути не решает проблему никак

        P.S. Ошибка, нашёл инфу о том, что официальная поддержка async/await появилась только в ES8. Спасибо, исправляю
          0
          я никогда не заставлю обычную функцию выполнятся асинхронно

          docs.python.org/3/library/asyncio-eventloop.html#executing-code-in-thread-or-process-pools
            0
            Объясните, пожалуйста, что Вы имеете ввиду. Я из приведённых ссылок понял, что у меня есть возможность запустить блокирующую функцию в параллельном потоке в цикле, в котором так же крутятся корутины. Но в итоге блокирующая функция конкурентно не выполняется. Или я что-то неверно понимаю?
            +1
            asyncio.run() выполняет корутины, я никогда не заставлю обычную функцию выполнятся асинхронно, так что по сути не решает проблему никак

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


            В исходной задаче асинхронную версию библиотеки можно использовать в качестве основной и базовой реализации. А работу с ним из синхронного окружения либо переложить на пользователей (пускай сами явно используют asyncio.run() или loop.run_until_complete()). Или сделать какой-то декоратор, который ко всем вашим функциям и методам добавит синхронный враппер, использующий внутри себя asyncio.run(). Например такой враппер можно сохранять в свойство самой функции, что бы вызывать это как-то так:


            my_async_func.sync()

            PS: Стоит заметить, что asyncio.run(), вероятно, будет добавлять какой-то оверхед на создание евент-лупа и его удаление при каждом вызове ваших методов из синхронного окружения. Поэтому требуется тестирование. Как вариант можно попробовать реализовать однократное создание евент-лупа для вашей библиотеки с помощью какой-то функции для "настройки" работы библиотеки в синхронном окружении. И в дальнейшем использовать loop.run_until_complete().

              0
              Согласен, проблема, конечно, в постоянном возвращении управления циклу событий, но и моё решение этого не исправляет. Наверное, это было бы намного лаконичнее, спасибо!
                0
                Провёл бенчмарки — запускать асинхронный код синхронно через единично существующий loop намного быстрее, нежели через сгенерированные шаблоны! Ну окей, попытка не пытка, спасибо за хорошую идею!
            +2

            Вот ещё один взгляд на ту же тему, но с другой стороны.
            https://habr.com/ru/company/oleg-bunin/blog/512650/

              +1
              Есть идеологически близкие утилиты sync_to_async и async_to_sync в asgiref. Как они вам?
                0

                Это упрощает вызов, но по сути синхронная функция не становится асинхронной, просто она в отдельном потоке. Оригинальная же async def отдаёт управление тогда, когда считает это нужным.


                Спасибо за ссылку!

                –2
                А что async в питоне отменил GIL?

                Автору все же не мешало бы понять как различаются параллельное и асинхронное исполнение.

                Да и с работой goroutine в Golang тоже стоит разобраться, что бы не сравнивать ее с асинхронностью в питоне.
                  0

                  Нет, но разве GIL как-то влияет на асинхронный код?

                    0
                    GIL влияет на весь код исполняемый интерпретатором Python.
                    Он в итоге не дает работать более чем в один поток. Хотите больше чем в один — subprocess и запуск несколких отдельных рантаймов питона. Все что решает async — это просто смена концепции исполнения кода с синхронного на асинхронный вариант.
                    Зато с GIL не надо парится про параллельный доступ к разделяемым данным.

                    Goroutine — является по сути блоком данных, являющийся стеком. А исполняют их воркеры, которые рантайм Golang запускает при старте программы по числу доступных потоов процессора (если не ограничено явно). И вот в goroutine (если воркеров более одного) и есть параллельное исполнение (и асинхронное тоже). Но это не бесплатно — при параллельном исполнении нужно внимательно следить и защищать данные используемые разными goroutene-ами. Ситуацию чуть упрощает использование каналов, но они конечно же не покрывают все необходимости. Поэтому в Golang кроме каналов и атомики и мьютексы тоже используются.
                      0

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

                        0
                        Я знаю, я зачем-то смешал горутины из Go и корутины из Python, но я хотел просто обратить внимание на синтаксис. В Go код никак не делится на синхронный и асинхронный, а в Python — это уже 2 разных материка, смешивать функции и корутины становится в разы тяжелее. GIL ведь не управляет асинхронным кодом внутри, он может менеджить его снаружи потока, в котором запущен loop

                        P.S. Ну в общем если считать, что асинхронность в Python основана на генераторах и на возвращении управлении в цикл, то очевидно, что сравнивать их и нельзя, совсем по разному работают
                    +1
                    Покажу небольшой пример шаблона, который предполагает возможность делать запросы в google:

                    К сожалению, шаблон получился длиннее и сложнее для восприятия, чем синхронная и асинхронная функция по отдельности.


                    Не проще ли просто продублировать код в разных стилях?


                    # синхронная функция в вашей библиотеке
                    from instalib import get_photos
                    # асинхронная
                    from instalib import async_get_photos
                    #  асинхронная вариант 2
                    from instalib_async import get_photos

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


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

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


                    Может быть просто скрыть ваш шаблон в импортированном модуле, а пользователю отдавать функцию, которая внутри содержит фабрику, которая и будет запускать функцию асинхронно или синхронно?
                    Как-то так:


                    from instalib import get_photos
                    
                    get_photos(id, 'sync')
                    
                    # or
                    async get_photos(id, 'async')

                    Насколько я вижу, все эти сложности приводят к тому, что библиотеки питона для работы с сетью четко делятся на синхронные и асинхронные.


                    P.S. Позвольте порекомендовать вам к чтению вот эту статью: https://habr.com/ru/post/479186/

                      0
                      Шаблон я тоже пишу для себя — я собирался реализовать такие асинхронные и синхронные функции и пользователю выдавать уже готовые для использования — не шаблоны. За ссылку спасибо, обязательно прочту!
                        0
                        Прочёл! Отличная концепция, но как и было сказано в статье — для того, чтобы не было проблем — нужно гарантированное отсутствие go команд, поэтому пока что её можно просто придерживаться
                        0
                        go язык с дурацким синтаксисом, но там есть горутины и каналы, за что приходится его уважать, т.е. писать на нем
                        python язык с отличным синтаксисом… (пишу на обоих, если что)
                        Итого — нужен питоно-образный golang, а Сиpython надо переписать на go, чтоб на несколько ядер забегал. (на заметку людям, зачем-то совершающим подвиги создания идентичных клонов go (имею ввиду vlang и umka) ) вместо {{см.выше}} В конце-концов go это же такой новый Си. Пора, пора на нем питон забабахать! Не знаю, как остальные, а я бы конечно хотел питон с горутинами и канальями :-)

                        вот, написал, в качестве концепта :-)
                        print в golang:

                        package main
                        import "fmt"
                        
                        func main() {
                            bar := 789
                            var c int
                            c = 987
                        
                            print := fmt.Printf
                        
                            fmt.Printf("fmt.Printf ==> %d", c)
                            print("\n python print ==> %d", c)
                            print("\n python print bar ==> %d", bar)
                        } 
                        
                        
                          0
                          Это совсем 2 разных языка, боюсь, что это огромная работа — создавать что-то среднее между ними. Если интересно, можете обратить внимание на Nim. Python в принципе в параллельную работу не может из-за высокого уровня абстракции над Си, дело даже не в том, что он на Си основан, а в том, что там слишком много действий, которые требуют синхронизации при параллельном выполнении
                            0
                            Итого — нужен питоно-образный golang, а Сиpython надо переписать на go, чтоб на несколько ядер забегал.

                            В сторону Julia не пробовали смотреть?


                            Сиpython надо переписать на go

                            Я вас умоляю, для этого больше подойдет Rust.

                            0

                            Послушайте, мне одному кажется, что автор проводит знак равенства между асинхронизмом и параллелизмом?

                              0

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

                                0

                                Асинхронизм(отложенные вычисления) может/могут быть построен/ы на многопоточности(java case) а могут и на EventLoop(который так же часто называют частной надстройкой поверх многопоточности) или на связке компилятор/"многопоточность" (.net case). В питоне отложенные вычисления базируются на EventLoop, но если я не ошибаюсь, то за все годы многопоточность в питон так и не завезли, есть многозадачность, но это немного не то.

                                  0
                                  Многопоточность в Python была всегда, другой вопрос, что эти потоки никогда не выполнялись параллельно
                              0
                              Как раз недавно столкнулся с необходимостью создать синхронный и асинхронный интерфейс к своей питоновской либе: github.com/pbelskiy/helix-swarm

                              Нет как таковой проблемы дублирования кода, код API одинаковый, разные только адаптеры (requests и aiohttp)

                              Only users with full accounts can post comments. Log in, please.