AIO-что?

Одна из регулярных критических замечаний в адрес Python заключается в том, что у него нет хорошей истории параллелизма. Если вы опытный программист на Python, то наверняка слышали о GIL или Global Interpreter Lock. Эта блокировка защищает доступ к объектам Python, так что только один поток может выполнять байт-код одновременно. Это необходимо, потому что Python (в частности, стандартная реализация CPython) не поддерживает поточно-ориентированное управление памятью. Если мы позволим нескольким потокам работать с одной и той же памятью, могут начаться странные вещи, а подход Python к решению этой проблемы состоит в том, чтобы просто запретить это. И что еще хуже, GIL вызывает накладные расходы из-за переключения контекста, так что выполнение одного и того же кода в двух разных потоках занимает больше времени, чем выполнение его дважды в одном потоке.

Итак, параллелизм в Python невозможен? Нисколько! На самом деле, у нас есть несколько различных инструментов, каждый из которых имеет свои преимущества и недостатки. Есть многопроцессорность, которая хорошо работает во многих ситуациях на практике, даже несмотря на большие начальные накладные расходы по сравнению с многопоточностью. Кроме того, хотя я упоминал, что у многопоточности в Python есть некоторые недостатки по сравнению с другими языками, это не значит, что они бесполезны как способ структурировать работу вашего кода. Существует также третий вариант, о котором я расскажу более подробно: асинхронное выполнение.

Пару лет назад среди языков программирования наблюдался некоторый асинхронный бум, и теперь кажется, что каждый основной язык интегрировал не только способ включения асинхронного выполнения через фьючерсы, сопрограммы или обработчики событий, но также синтаксис async / await. Подробнее об этом позже. Так что это? В Python async на первый взгляд очень похож на многопоточность, с одним большим отличием: планирование. Если вы используете потоки в Python, ядро ​​вашей операционной системы знает об этом и переключается между ними, когда считает нужным. Это называется упреждающей многозадачностью, код, выполняющийся в потоке, не знает, что он прерывается, и не может контролировать, когда это произойдет. С помощью async сам Python выполняет планирование, и это обеспечивает то, что называется совместной многозадачностью. Код, который вы пишете, использующий этот подход, должен каким-то образом передавать управление интерпретатору, чтобы другой код мог продолжить выполнение. В Javascript это управление происходит при каждом вызове метода, в Python у вас больше контроля над тем, когда это происходит, а когда нет.

Зачем нам это использовать? Что ж, оказывается, есть класс операций, в которых имеет смысл отказаться на время от управления: ввод-вывод! По сравнению с выполнением логики программы получение чего-либо с жесткого диска занимает много времени, а получение ответа на сетевой запрос занимает еще больше времени. Если нам все равно придется подождать, почему бы пока не сделать что-нибудь полезное? Это также означает, что выполнение нескольких задач ввода-вывода может происходить почти одновременно, что дает некоторые преимущества подлинного параллельного выполнения, если время для запуска новой задачи достаточно короткое. В программах с привязкой к вводу-выводу, где вы читаете и записываете много файлов, много взаимодействуете с базой данных или отправляете много сетевых запросов, это действительно может ускорить работу.

Немного истории

Совместная многозадачность в Python не новость. Люди уже довольно давно используют Python для реализации веб-серверов и ищут способы увеличить количество запросов, которые они могут обрабатывать в минуту, в течение столь же длительного времени, многие из решений основаны на каком-то цикле событий с совместным планированием. . Twisted, среда сетевого программирования, управляемая событиями, существует с 2002 года и используется до сих пор. Это волшебство, лежащее в основе таких проектов, как Scrapy, и Twitch использует его для своей серверной части. В его нынешнем виде нет недостатка в библиотеках и фреймворках, которые реализуют ту или иную форму асинхронного рабочего процесса, многие из которых специально предназначены для работы в сети. Назвать несколько:

  1. торнадо, веб-сервер / фреймворк
  2. gevent, сетевая библиотека на основе greenlet
  3. curio, библиотека для одновременного ввода-вывода, разработанная Dabeaz из сообщества Python.
  4. trio, библиотека, вдохновленная некоторыми другими, стремящаяся сделать асинхронное программирование более доступным

Однако долгое время не хватало интегрированного в язык способа выполнения асинхронности с функциональностью, упакованной прямо в стандартной библиотеке, и удобными ключевыми словами, которые позволяют упростить и удобнее программировать, избегая ада обратных вызовов, который так знаком давним программистам Javascript. Признавая эту необходимость, PEP 3156 представил BDFL видение Гвидо ван Россума асинхронного ввода-вывода в Python, реализованное в Python 3.3 как модуль asyncio. PEP 492, реализованный в Python 3.5, принес нам ключевые слова async / await, и с тех пор было внесено гораздо больше дополнений и улучшений, особенно в версии 3.7.

Чтобы было ясно, я не защищаю использование asyncio по сравнению с альтернативами, о которых я упоминал ранее, все они имеют свои сильные и слабые стороны, но поскольку asyncio является частью стандартной библиотеки Python 3, это хорошее место для начала.

Добро пожаловать в страну асинцио

Итак, допустим, вы убеждены, что хотели бы попробовать эту асинхронную вещь, и вы решили, что встроенный asyncio - это лучший способ, какие полезности вам доступны? Во-первых, это сам модуль asyncio, который предоставляет несколько необходимых нам примитивов и на котором построены другие библиотеки, упомянутые в этом разделе. Мы рассмотрим несколько примеров этих примитивов в практическом разделе ниже.

Ядро asyncio - это цикл событий. Цикл событий - это то, что обеспечивает все совместное планирование, и на высоком уровне это именно то, на что это похоже, просто простой цикл, который отслеживает все и обрабатывает события одно за другим, когда они происходят. Цикл событий отслеживает Задачи, они помещаются в очередь, откуда их можно разбудить, когда все, чего они ждут (если что-либо), будет выполнено. Задачи можно проверить, чтобы узнать, выполнены ли они, или отменить, если их результат больше не нужен или истекло время ожидания. Задача, в свою очередь, является оболочкой для сопрограммы, это код, который фактически запускается, когда задача снова активируется. Мы увидим, как их использовать в примерах. Просто помните, что, поскольку цикл событий фактически запускает только одну задачу за раз и переключается только тогда, когда эта задача передает управление, если у вас есть одна задача, которая продолжает выполняться, другие, которые все еще находятся в очереди, никогда не будут завершены.

Многие библиотеки, которые вы, вероятно, будете использовать при работе с asyncio, содержатся в группе aio-libs на GitHub. Это включает в себя aiohttp, который мы обсудим более подробно ниже, а также библиотеки для большого количества других задач, таких как aioftp, aiopg (для PostgreSQL), aioredis, aioelasticsearch, aiokafka, aiodocker, ... Есть некоторые другие библиотеки, которые опирайтесь на них, в частности, на пару библиотек, подобных веб-фреймворку, но я позволю вам открыть их самостоятельно.

Aiohttp, безусловно, является наиболее активным проектом aio-libs и, возможно, основным вариантом использования asyncio. Aiohttp предоставляет как HTTP-клиент, так и сервер, с поддержкой веб-сокетов и такими тонкостями, как промежуточное ПО для обработки запросов и подключаемая маршрутизация. Вики предоставляет два минимальных примера для начала работы с клиентом или сервером прямо на первой странице, так что очень легко быстро попробовать.

Покажи мне код!

Начнем с довольно минимального примера:

Если вы не используете версию 3.7 или более позднюю, вам нужно заменить вызов run на что-то вроде:

Довольно просто, не правда ли? Этот a_square - это функция сопрограммы, асинхронность перед определением функции означает, что если вы ее вызываете, она на самом деле ничего не начинает делать, только когда вы оборачиваете ее в Задачу, и цикл событий будит ее, что происходит любое вычисление. . В большинстве случаев нам не нужно делать это явно. Здесь функция run позаботилась обо всем, она запустила цикл за нас, обернула наш объект сопрограммы и запланировала его как задачу, после чего она сразу же запустилась, потому что других задач не было.

Давайте посмотрим на кое-что посложнее: мы можем связать сопрограммы, заставляя их ждать друг друга. Например:

Здесь сопрограмма официанта должна дождаться завершения сопрограммы со спящим. asyncio.sleep () - это асинхронная версия time.sleep (), поэтому это также функция сопрограммы, как и в случае с официантом и спящим. Ключевое слово await здесь выполняет несколько функций: оно гарантирует, что все, что мы передаем ему, заключено в задачу, оно планирует эту задачу в очереди активного цикла и, когда задача завершена, возвращает результат. Это также означает, как вы могли понять из его названия, что код после этой строки начинает выполняться только тогда, когда этот результат доступен. Если вы хотите запланировать задачу и продолжить выполнение, это тоже возможно:

Здесь есть пара интересных вещей. Во-первых, мы видим функцию create_task, которая была добавлена ​​в Python 3.7 и принимает сопрограмму, которую она оборачивает в Task и расписывает. До версии 3.7 эквивалент был sure_future (который на самом деле немного более низкоуровневый, но выполняет то же самое для нас здесь. Затем есть сбор, который, как следует из названия, собирает результаты всех задач и сопрограмм, которые вы ему передаете, и возвращает их как список.

Теперь, когда мы разобрались с основами, давайте перейдем к тому, для чего мы на самом деле здесь: aiohttp! Следующий пример демонстрирует клиентскую сторону:

Этот код является версией примера на первой странице aiohttp docs с несколькими запросами, он получает (HTML) текст страниц Википедии за период с 1990 по 2019 год. Эти GET-запросы запускаются одновременно, поэтому выполнение всех из них занимает примерно столько же времени, сколько и выполнение самого длительного. Конечно, есть ограничения на количество запросов, которые вы можете выполнить таким образом, и если вам нужно выполнить тысячи, вы, вероятно, должны запускать их по частям, но выполнение их, как показано, должно работать в большинстве случаев. Как и в случае с обычной библиотекой запросов, вы можете сделать гораздо больше, чем просто получить, но я отсылаю вас к документации по всем различным параметрам.

Библиотека aiohttp также включает в себя серверный компонент, в комплекте с маршрутизатором и все необходимое для простого веб-сервера или REST API. В следующем примере показано, что с ним можно делать:

Это последний и самый сложный пример. Он представляет собой веб-приложение с 4 маршрутами, один из которых перенаправляется, один возвращает пустой ответ, третий возвращает статический файл HTML, а третий имеет необязательную переменную пути и параметры запроса и возвращает документ JSON.

Зацикливание на ультрафиолетовых скоростях

Одна из интересных особенностей цикла событий asyncio заключается в том, что он подключаемый. Это означает, что вы можете предоставить свою собственную реализацию. Хотя стандартная реализация, основанная на libev, уже неплоха, есть и другой вариант. Более быстрый вариант! Он называется uvloop и основан на libuv, библиотеке асинхронного ввода-вывода, первоначально разработанной для node.js, которая теперь, помимо прочего, также используется в Julia. Библиотека uvloop проста в использовании и делает все, что вы делаете с asyncio, быстрее, поэтому было бы стыдно не упомянуть об этом здесь. Узнайте, как начать работу с ним здесь. Все еще не уверены, что это так быстро? Результаты тестов можно найти здесь.

Заключение

В этот момент вам может быть интересно, почему в блоге Medium для AI-компании есть статья о выполнении и обработке HTTP-запросов асинхронным способом на Python. Что ж, хорошая модель машинного обучения ничего не стоит, если вы не можете запустить ее в производство, и довольно часто прогнозы, сделанные с помощью такой модели, обслуживаются с помощью каких-то клиентов REST API, которые могут вызывать. Также иногда нам нужно получать много данных по HTTP из разных источников, и количество запросов может довольно быстро расти. В этих случаях важна хорошая производительность, и на данный момент кажется, что чистый Aiohttp с uvloop не может быть побежден с точки зрения сырой производительности, если Python является вашим предпочтительным языком, как для нас. Так что, если у вас есть такие рабочие нагрузки, почему бы не попробовать асинхронность сегодня!

Отредактировано 09/2020: исходное сообщение дважды связано с одним и тем же примером, я также добавил и удалил некоторые знаки препинания здесь и там.