Asyncio в Python 3

Цалеб Хаттингх

Copyright © 2018 O’Reilly Media, Inc

Данный документ предоставляется по лицензии Creative Commons Attribution 3.0 License, за исключением разделов со специальными оговорками.

http://creativecommons.org/licenses/by/3.0/legalcode

Первая публикация на английском языке: Март 2018

Опубликовано O’Reilly Media, Inc.,

1005 Gravenstein Highway North,

Sebastopol,

CA 95472.

ISBN 9781491999684

oreilly.com

2018-10-21

 Состав исполнителей

Издания на английском языке

Автор

Цалеб Хаттингх

Редактор

Джеф Блейел

Сьюзан Конант

Редактор выпуска

Николас Адамс

Литературный редактор

Дуайт Рамси

Редактор разработки содержания

Дэйвид Футейто

Разработчик обложки

Карен Монтгомери

Иллюстратор

Ребекка Димарест

Технический рецензент

Юрий Селиванов

 Содержание

Глава 1. Введение

Ресторан Серийных ботов

Эпилог

Какие проблемы пытается решать Asyncio?

Глава 2. Вся правда о потоках

Преимущества потоков

Изнанка потоков

Пример: Роботы и Столовые приборы

Обсуждение

Глава 3. Прогулка по Asyncio

Быстрое начало

Основная башня Asyncio

Выводы

Сопрограммы

Ключевое слово нового async def

Ключевое слово нового await

Цикл событий

Задачи и фьючерсы

Создать задачу? Обеспечить фьючерс? Прими решение!

Диспетчеры контекста Async: async with

Вариант contextlib

Итераторы Async: async for

Генераторы Async: yield внутри async def функций

Сообразительность Async

Запуск и останов (Аккуратные!)

Для чего предназначено return_exceptions=True

Сигналы

Ожидание исполнителя в процессе останова

Тестирование с помощью asyncio

Глава 4. 20 библиотек Asyncio, которые вы не применяете (Но... Да ладно, не берите в голову)

Streams (Стандартная библиотека)

Пример: Очередь сообщений

Пример: Улучшенная очередь сообщений

Twisted

Очередь Janus

aiohttp

Пример: Hello World

Пример: Вытаскивание новостей

ØMQ (ZeroMQ)

Пример: множественные сокеты

Пример: APM (мониторинг производительности приложения)

asyncpg и Sanic

Пример: несостоятельность кэширования

Прочие библиотеки и ресурсы

Глава 5. Заключительные соображения

Заключительные соображения

Глава 6. Дополнения

Краткая история поддержки Async в Python

В самом начале был asyncore

Путь к естественным сопрограммам

Пример Столовых приборов с использованием Asyncio

Вспомогательные материалы для вытаскивания новостей с вебсайтов

Вспомогательные материалы для примера ZeroMQ

Обработка переключателя базы данных для примера asyncpg

Вспомогательные материалы для примера Sanic: aelapsed и aprofiler

Указатель

Глава 1. Введение

Содержание

Глава 1. Введение

Ресторан Серийных ботов

Эпилог

Какие проблемы пытается решать Asyncio?

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

-- Бендер, Футурама, эпизод "Повар, на 30 % из железа"

Наиболее распространённым вопросом, который я получаю относительно Asyncio в Python 3 такой: "А что это такое и что мне с ним делать?" Приводимая далее история даёт вам обстановку для ответа на эти вопросы. Основным средоточием Asyncio является то, как наилучшим образом выполнять множество параллельных задач одновременно. Причём не просто задачи любого вида, а в особенности те, которые содержат временные периоды ожидания. Основная ключевая мысль, необходимая для такого стиля программирования состоит в том, что пока вы ожидаете пока эта задача завершится, может выполняться работа с прочими задачами.

Ресторан Серийных ботов

Сейчас 2051 год, а вы обнаруживаете себя в ресторанном деле! Автоматизация, по большей части роботами- рабочими, но, как оказывается, люди всё ещё время- от - времени любят поесть. В вашем ресторане все сотрудники являются роботами: естественно, гуманоиды, но вне всяких сомнений роботы. Наиболее успешным производителем роботов, конечно же, является Threading Inc., а роботы- исполнители от этой компании стали именоваться "ThreadBots" {Поточные боты - Серийные боты}.

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

Естественно, являясь новичком в роботизированном ресторанном бизнесе, вы делаете то же, что и прочие рестораторы и вы нанимаете небольшую флотилию роботов: одного для приёма новых посетителей на стойке регистрации (hostbot, метрдотель); одного ожидающего у столика для приёма заказов (waitbot, официант); одного на готовку (chefbot, повар); и одного на управление баром (winebot, бармен).

Голодные посетители появляются у стойки регистрации и встречаются вашим Серийным ботом метрдотелем. Затем Серийный бот официант принимает заказ на кухню на блокнотный листочек (потому что вы желаете сохранять старинную атмосферу, помните?). Ваш бот- повар просматривает заказ на листе из блокнота и начинает готовить еду. Принявший заказ официант периодически проверяет не готовы ли ваши блюда, и, если это так, немедленно доставляет яства за столик посетителя. Когда гости готовы покинуть заведение, они возвращаются к встречавшему их боту, который выставляет счёт и желает им приятного дальнейшего времяпровождения.

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

Со временем, однако, вы начинаете замечать некие проблемы. О, ничего серьёзного, впрочем. Просто пара вещей кажется идёт не очень хорошо. У всякого иного роботизированного ресторана, похоже, имеются подобные проблемы с придирками. Также немного беспокоит, пожалуй, что эти проблемы нарастают по мере того как вы становитесь всё успешно.

Несмотря на их редкость, происходят случайные коллизии, которые очень тревожат: иногда, когда блюдо с едой готовится на кухне, бот официант выхватывает его прежде чем бот- повар разрешает его забрать. Обычно это приводит к тому что тарелка разбивается и оставляет большой беспорядок. Бот- повар, конечно же, всё вычищает, но вы начинаете обдумывать как бы этим роботам- пионерам быть слегка более слаженными друг с другом. Это же происходит и в баре: порой бот- бармен уже поместит некий новый заказанный напиток на стойку, а бот официант уже его хватает прежде чем бармен ему позволит, в результате бокал вдребезги и пролит Недербургский Каберне Совиньон!

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

Ну да ладно, это были случайные события. Ваш ресторан получил такую популярность, что вам пришлось нанять несколько дополнительных Серийных ботов. На каждый загруженный вечер пятницы или субботы вам приходится добавлять второго метрдотеля и двух дополнительных официантов. К сожалению, контракты с Серийными ботами означают, что вам приходится нанимать их на полную неделю, следовательно на самом деле это означает, что большую часть такой недели вы содержите трёх дополнительных Серийных бота, которые в действительности вам не нужны.

Другая проблема ресурсов, помимо дополнительной стоимости, естественно, состоит в том, что стало гораздо больше работы для вас чтобы присматривать за этими дополнительными Серийными ботами. Было здорово следить за четырьмя ботами, но теперь их стало семь! Держать на глазу семь Серийных ботов это намного больше работы, а поскольку ваш ресторан становится всё более и более популярным, вы начинаете беспокоиться о том, чтобы привлечь к работе ещё больше Серийных ботов. Это станет самостоятельным занятием с полной занятостью отслеживать то, что делает каждый Серийный бот! И опять же: эти дополнительные Серийные боты потребляют намного больше пространства внутри вашего ресторана. Ваши посетители оказываются достаточно сильно стеснёнными со всеми этими снующими туда и обратно роботами. Вы обеспокоены тем, что когда вам потребуется добавить ещё больше ботов, данная проблема пространства станет ещё более сильной. Вы желаете использовать пространство ресторана для посетителей, а не для Серийных ботов!

Раз вы добавили больше Серийных ботов, коллизии стали ещё хуже. Теперь два официанта получают в точности тот же самый заказ с одного и того же столика в одно и тоже время. Это как если бы они оба заметили, что данный столик готов сделать заказ и приехали получить его, причём без какого бы то ни было уведомления о том, что другой бот официант делает в точности то же самое. Как вы можете себе представить, это приводит в результате к дублированию заказов блюд , что приводит к дополнительной нагрузке на кухню и увеличивает шансы накладок, при подборе уже готовых блюд. Вы обеспокоены тем, что если вы добавите ещё больше ботов официантов, проблема может только усугубиться.

Время идёт.

Затем, во время одного очень- очень загруженного вечера в пятницу у вас наступает особый момент прояснения: время замедляется, прояснение снисходит на вас и вы видите некий моментальный снимок вашего замороженного на мгновение ресторана. Мои Серийные боты ничего не делают! Честно говоря, не совсем ничего, но они просто ... ждут.

Каждый из ваших трёх ботов официантов на различных столиках ожидает пока один из посетителей за их столом сделает им заказ. Бот бармен уже приготовил 17 напитков, которые теперь ожидают пока их заберут (это отнимет всего несколько секунд), и теперь он ждёт новых заказов на напитки. Один их ботов метрдотелей встречает новую группу гостеу, говорит что им следует подождать минутку, пока он их рассадит и ждёт ответа от этих гостей. Другой бот метрдотель теперь обрабатывает некий платёж кредитной картой для следующего посетителя, который собирается покинуть заведение и ожидает подтверждения данного платежа от терминального устройства. Даже ваш бот повар, который в настоящий момент готовит 35 блюд, на самом деле не делает ничего в данный момент, а просто ожидает пока одно из этих блюд завершит готовиться с тем, чтобы поместить его на поднос, который в руках у одного из ботов официантов.

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

Момент истины проходит, но его осознание остаётся. Вы ждёте окончания недели и первое что вы делаете, вы добавляете модуль сбора данных для ваших Серийных ботов. Для каждого из Серийных ботов вы измеряете сколько времени потрачено на выполнение его работы. На протяжении следующей недели собираются все данные и в воскресенье вечером вы анализируете полученные результаты. Оказывается, даже когда ваш ресторан забит под завязку, ваш самый загруженный Серийный бот простаивает около 98% всего времени! Ваши Серийные боты настолько экономически эффективны, что они способны выполнять любую задачу за доли секунды.

Как для предпринимателя, такая неэффективность на самом деле является вашим сбоем. Вы знаете, что все прочие владельцы роботизированных ресторанов ведут свои дела так же как и вы со многими теми же самыми проблемами. Но, как вы полагаете, хлопнув кулаком по столу, "Должен быть иной способ!"

Итак, на следующий же день, который является спокойным понедельником, вы пытаетесь сделать нечто очень смелое: вы программируете одного из своих Серийных ботов на выполнение всех имеющихся задач. И всякий раз когда он начинает ждать, даже хотя бы секунду, вместо ожидания, этот Серийный бот будет переключён на следующую задачу, которая может возникнуть во всём ресторане. Это звучит невероятно на первый взгляд, только один Серийный бот выполняет всю работу всех оставшихся, но вы уверены, что ваши вычисления верны. И к тому же, понедельник - очень спокойный день, поэтому если что- то пойдёт не так, потери будут небольшими. Для такого нового проекта вы именуете своего бота "loopbot" (циклическим ботом, фигаро), потому что он в цикле выполняет все задания в вашем ресторане.

Такое программирование намного сложнее обычного. Дело не только в том, что вам пришлось запрограммировать одного из Серийных ботов на все различные задачи; вам также пришлось запрограммировать некую логику когда переключаться между задачами. Но на данном этапе у вас есть богатый опыт программирования таких Серийных ботов, поэтому вам это удалось.

Наступил понедельник, и вы видите, что ваш бот фигаро как ястреб. Он перемещается между позициями за доли секунды, проверяя нет ли для него какой работы. Через какое- то время после открытия за стойкой появляется самый первый посетитель. Ваш бот фигаро появляется почти мгновенно и спрашивает не желает ли клиент присесть за столик у окна или у стойки бара. А затем, поскольку этот бот фигаро начинает ждать, его программа заставляет его переключиться на следующую задачу и он со свистом уносится прочь! Это выглядит как ужасная ошибка, но вы видите, что ваш посетитель начинает говорить "у окна, пожалуйста", и бот фигаро снова на месте! Он принимает ответ и проводит гостя к столику 42. И покидает гостя снова, проверяя наличие заказа на напитки, еду, уборку стола и приём гостей снова и снова.

Поздно вечером в понедельник вы поздравляете себя со значительным успехом! Вы проверяете свой модуль сбора данных для бота фигаро и тот подтверждает, что даже когда один единственный Серийный бот делает работу семерых, он всё ещё простаивает почти 97%!. Такой результат вселяет в вас уверенность продолжить данный эксперимент на протяжении всей оставшейся недели.

По мере приближения сильно загруженной пятницы вы размышляете о великом успехе своего эксперимента. Для обслуживания на протяжении обычной рабочей недели вы можете запросто управляться одним единственным ботом фигаро. А к тому же есть ещё один момент, который вы заметили: больше нет накладок. на самом деле: так как имеется только один зацикленный бот, он не может конфликтовать сам с собой. Больше нет дублированных заказов, поступивших на кухню, и больше нет никакой неразберихи относительно того когда забирать блюдо или напиток.

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

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

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

Рассмотрев все стороны, вы приходите к заключению, что всё ещё лучше продолжать применять одного единственного циклического бота. Эти тревожные накладки больше не происходят, а в вашем ресторане стало намного больше свободного места, пространства, которое вы можете использовать для большего числа посетителей. Но вы осознаёте, что в вашем циклическом боте есть некая глубокая проблема: он может быть действенным только если все задачи очень краткие; или же они по крайней мере могут выполняться за очень непродолжительный период времени. Если некое действие удерживает вашего циклического бота слишком долго, прочие задачи могут не получить должного внимания.

Трудно знать наперёд какие именно задачи могут потребовать слишком много времени. Что если посетитель закажет некий коктейль, который потребует очень сложного приготовления, намного более длительного чем обычно? Что если некий посетитель пожелает жаловаться на еду, откажется платить и схватит вашего бота фигаро за руку, не позволяя ему переключиться на другую задачу? Вы решаете, что вместо того, чтобы разбираться со всеми этими проблемами в лоб, вам лучше продолжить с вашим циклическим ботом, записывая по возможности побольше информации и решать проблемы по мере их появления.

Прошло ещё какое- то время.

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

Эпилог

В нашей истории все имеющиеся роботы исполнители в нашем ресторане выступают в роли некоторого отдельного потока (thread). Основным ключевым наблюдением в нашей истории является то, что сама природа всех работ в нашем ресторане связана с большим ожиданием, просто потому что requests.get() ожидает отклика от некоего сервера.

В ресторане время его исполнителя, затрачиваемое на ожидание, не является гигантским когда медленный людской персонал делает свою работу вручную, однако когда сверх- эффективные и быстрые роботы берутся за эту работу, тогда почти всё их время тратится на ожидание. При программировании компьютера то же самое справедливо при вовлечении сетевого программирования. ЦПУ выполняют свою "работу" и "ждут" сетевого ввода/ вывода. В современных компьютерах ЦПУ чрезвычайно быстрые, в сотни и в тысячи раз быстрее чем сетевой обмен. Таким образом, исполняющие сетевые программы ЦПУ тратят слишком много времени на ожидание. {Прим. пер.: пожалуй, слишком ограничивающее утверждение, при наличии интерконнекта с очень высокой полосой пропускания и низкими значениями латентности тщательный подбор параметров обмена становится ещё более актуальным, например, см. Эффективное применение RDMA для служб ключ- значение, Рекомендации по разработке высокопроизводительных систем RDMA.}

Суть данного сюжета в том, что программы могут быть написаны в явном виде чтобы перемещать имеющийся ЦПУ между задачами по мере необходимости. Хотя и имеется некое улучшение экономичности (применение меньшего объёма ЦПУ для одной и той же работы), действительное преимущество, по сравнению с подходом потоков (множества ЦПУ) состоит в исключении условий состязательности.

Однако это ещё не все цветочки: как мы обнаружили в данном сюжете, для большинства технических решений имеются преимущества и недостатки. Наше введение в LoopBot решило определённый класс проблем, но также создало и новые проблемы - не последняя из которых состоит в том, что владельцам ресторанов придётся изучить слегка иные способы программирования.

Какие проблемы пытается решать Asyncio?

Для связанных со вводам/ выводом рабочих нагрузок существует в точности две причины (только!) для использования основанной на асинхронности одновременности перед одновременностью на основе потоков:

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

Asyncio предлагает простой способ поддержки многих тысяч одновременных взаимодействий сокетов, в том числе способных обрабатывать долго живущие соединения для новейших технологий, таких как websockets или MQTT в приложениях интернета- вещей (IoT).

Это всё.

Потоки - как некая модель программирования - лучше приспособлены для определённых видов вычислительных задач, которые лучше всего исполняются при множестве ЦПУ и совместно используемой памяти для эффективного взаимодействия между этими потоками. В таких задачах применение обработки со множеством ядер при наличии разделяемой памяти является необходимым злом, потому что требует того имеющаяся проблема доменов.

Сетевое программирование не относится к этим областям. Основная ключевая суть состоит в том, что сетевое программирование вовлечено в большое дело "ожидания того, что нечто произойдёт", а по этой причине нам нет нужды в том, чтобы имеющаяся операционная система действенно распределяла наши задачи по множеству ЦПУ. Более того, нам не нужно рисковать таким внесением многозадачности с приоритетами, подобным условиям конкуренции при работе с совместно используемой памятью.

Однако имеется множество дезинформации относительно прочих поддерживаемых преимуществ моделей программирования, движимых событиями, которые просто не являются таковыми. Вот некоторые из них:

Asyncio сделает мой код явно быстрее

К сожалению, нет. На самом деле, большинство эталонных тестов демонстрируют что решения с потоками являются слегка более быстрыми при сопоставлении с решениями Asyncio. Если значение дополнительной параллельности само по себе рассматривается в качестве измерения производительности, Asyncio, тем не менее, на самом деле делает его слегка быстрее за счёт создания очень большого числа одновременных соединений сокетов. Операционные системы зачастую имеют ограничения относительно того, сколько потоков можно создавать и это число значительно ниже чем общеее число соединений сокетов, которое она может выполнять. Эти пределы ОС могут быть изменены, однако несомненно проще это делать при помощи Asyncio. И пока мы ожидаем, что наличие многих тысяч потоков должно принять на себя дополнительную стоимость переключения контекста, которого избегают сопрограммы (coroutines), на практике это сложно подвергнуть эталонному тестированию [Представляется, что исследования в этой области сложно отыскать, но их значения, как кажется, находятся примерно в пределах 50 микросекунд на переключение контекста потока в Linux на современном оборудовании. Чтобы представить (очень) примерную оценку: тысяча потоков подразумевает суммарную стоимость в 50 мс для переключения контекстов. Оно действительно добавляется, но ни коим образом не будет разрушать ваше приложение.] Нет, скорость не является основным преимуществом Asyncio в Python; если это то что вам требуется, попробуйте вместо этого Cython!

Asyncio делает деление на потоки более устойчивым

Определённо нет! Истинное назначение потоков состоит в наличии способности создавать программы со множеством ЦПУ, в которых различные вычислительные задачи могут совместно использовать память. Имеющиеся численные библиотеки, numpy, к примеру, уже используют это расщепляя определённые матричные вычисления на использование множества ЦПУ, даже хотя вся память используется совместно для чистой производительности не существует конкурентов такой модели программирования при вычислениях, ограниченных ЦПУ.

Asyncio удаляет все проблемы с имеющимся GIL

Опять нет. Это правда,что Asyncio не испытывает воздействия со стороны GIL [GIL (gl имеется не obal interpreter lock, глобальная блокировка интерпретатора) делает код интерпретатора Python (а не ваш код!) потокобезопасным, блокируя обработку каждого кода операции; у этого имеется неудачный сторонний эффект действенного закрепления данного исполнения этого интерпретатора на отдельном ЦПУ и, таким образом, предотвращая параллельность множества ядер.], однако происходит только потому, что сам GIL оказывает воздействие на многопоточные программы. Основные "проблемы" с GIL, на которые ссылаются люди состоят в том, что он предотвращает истинный многоядерный подход распараллеливания при применении потоков. Так как Asyncio является однопоточным (почти по определению), он не подвержен воздействию GIL, однако он также не может получать преимуществ от множества ядер ЦПУ. [Это аналогично тому как JavaScript не имеет "проблемы" GIL: имеется всего один поток.] Стоит также отметить, что при многопоточном кодировании, имеющийся GIL Python может вызывать дополнительные проблемы производительности, выходящие за рамки тех, о которых уже упоминалось в прочих пунктах: Дейв Бизли выступил с докладом "Осознание Python GIL" на PyCon 2010, и многое из того, что обсуждалось там, остаётся верным и по сей день.

Asyncio предотвращает все условия состязательности

Ложь. Возможность условий состязательности всегда присутствует в любом параллельном программировании, вне зависимости от того, кокое программирование применяется: с потоками или на основе событий. Это правда, что Asyncio может фактически предотвращать определённый класс условий состязательности, общих для программ со множеством потоков, таких как межпроцессный доступ к общей памяти. Однако он не исключает возможность прочих видов условия конкуренции, к примеру, межпроцессные состязания относительно совместно используемых ресурсов, распространённых в архитектурах с распределёнными микрослужбами. Вам всё ещё следует уделять внимание тому как используются совместные ресурсы. Основное преимущество Asyncio относительно кодирования потоков состоит в том, что те пункты, в которых контролируется исполнение передаются между сопрограммами являются видимыми (благодаря наличию ключевых слов await) и гораздо проще обсуждать о доступе к совместным ресурсам.

Asyncio делает параллельное программирование намного более простым

Хм, давайте даже не начинать?

Самый последний миф - самый опасный. Работа с параллельностью всегда сложна, вне зависимости от того применяете ли вы потоки или Asyncio. Когда эксперт говорит "Asyncio делает распараллеливание более простым", то что он на самом деле имеет в виду, так это то, что он делает более простым обход определённых видов реальных ночных кошмаров ошибок условий состязательности; которые оставляют вас на ночь и о которых вы рассказываете прочим программистам в приглушённых тонах вокруг кострища волком, воющим на расстоянии.

Даже при Asyncio остаётся значительное место сложности, с которой приходится иметь дело. Как ваше приложение осуществляет поддержку проверки состояния здоровья? Как вы можете осуществлять взаимодействие с базой данных, которая может допускать только несколько подключений, намного меньше чем ваши 5 000 соединений с клиентами? Как вы пожелаете чтобы ваша программа аккуратно прекратила подключения после получения некого сигнала на останов? Как вы обработаете (блокирующий!) доступ к диску и протоколирование? Это всего несколько из множества сложных проектных решений, на которые вам придётся отвечать.

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

Глава 2. Вся правда о потоках

Содержание

Глава 2. Вся правда о потоках

Преимущества потоков

Изнанка потоков

Пример: Роботы и Столовые приборы

Обсуждение

Давайте на мгновенье будем откровенными - вы на самом деле не хотите использовать Curio. Все одинаковые вещи вам вероятно следует программировать в потоках. Да, потоках. ТЕХ САМЫХ потоках, Серьёзно. Я не шучу. [1].

-- Дейв,Бизли, Разработка с помощью Curio

Это именно та часть, в которой я рассказываю вам, "Потоки ужасны, и вам никогда не следует их применять", так? К сожалению ситуация не настолько простая. На м требуется взвесить все преимущества и риски при использовании потоков, впрочем, как и при прочем выборе технологии.

Данная книга совсем не предполагает быть посвящённой потокам. Тем не менее главная проблема в том что (а), Asyncio предлагается в качестве альтернативы потокам, поэтому трудно понимать предлагаемую пользу без какого бы то ни было сравнения; и (б), даже когда вы применяете Asyncio, вы, скорее всего, будете всё равно будете иметь дело с потоками и процессами, поэтому вам по- любому необходимо знать что- нибудь о потоках.

[Замечание]
Важно

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

Преимущества потоков

Вот самые главные преимущества применения потоков:

Простота чтения кода

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

Одновременность с совместно используемой памятью

Ваш код может эксплуатировать множество ЦПУ и в то же самое время всё ещё иметь совместную память потоков. Это важно во многих рабочих потоках, в которых, например, было бы слишком затратным перемещать большие объёмы данных между отдельными пространствами памяти различных процессов.

Знание дела и существующий код

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

Теперь, что касается Python, этот момент достаточно спорный, поскольку интерпретатор Python применяет некую глобальную блокировку, называемую Global Interpreter Lock, для защиты своего внутреннего состояния интерпретатора самого по себе - защита от возможного катастрофического эффекта конкуренции между множеством потоков. Неким побочным эффектом такой блокировки является то, что заканчивается пришпиливанием всех потоков из вашей программы к некому единственному ЦПУ. Как вы можете себе представить, это отвергает любые преимущества одновременной производительности (только если вы не используете такие инструменты как Cython или Numba для манёвра вокруг данного ограничения).

Тем не менее, самый первый пункт в отношении простоты является существенным: потоки в Python кажутся чрезвычайно простыми и, если вы не сгорите в огне невероятно жёстких ошибок конкуренции, работа с потоками предлагает очень привлекательную модель параллелизма. И даже если вы были сожжены в прошлом, работа с потоками остаётся неотразимым вариантом, так как скорее всего, вы прошли обучение (это трудный путь) как одновременно сохранять свой код простым и безопасным.

У меня нет достаточного пространства входить в детали безопасного программирования здесь, однако говоря в целом, наилучшей практикой применения потоков является применение класса ThreadPoolExecutor() из имеющегося модуля concurrent.future, передавая все необходимые данные через его метод submit().

Вот некий базовый пример:

Пример 2-1. Наилучшая практика использования потоков

from concurrent.future import ThreadPoolExecutor as Executor

def worker(data):
    <process the data>

with Executor(max_workers=10) as exe:
    future = exe.submit(worker, data)

Этот ThreadPoolExecutor предлагает чрезвычайно простой интерфейс для запуска функций в потоках - и самая лучшая часть состоит в том, если необходимо, что вы можете конвертировать имеющийся пул потоков в некий пул сопроцессов просто применяя вместо этого ProcessPoolExecutor. Он имеет тот же самый API, что и ThreadPoolExecutor, что означает что ваш код будет подвергнут небольшому воздействию данным изменением. Данный исполнитель API также применяется в asyncio и будет разъяснён в одном из последующих разделов.

В целом вы бы предпочли чтобы ваши задачи были чем- то с коротким временем жизни, с тем, чтобы когда вашей программе понадобится останов, вы могли бы просто вызвать Executor.shutdown(wait=True) и подождать секунду или две чтобы позволить данному исполнителю завершиться.

Что более важно: постарайтесь предотвратить в вашем коде потока (в приведённом выше примере, в функции worker()), доступ или запись в каких бы то ни было глобальных переменных!

Некоторые великолепные руководящие правила для более безопасного кодирования потоков были представлены Рэймондом Хеттингером на PyCon Russia 2016 и снова на PyBay 2017 и я настоятельно подгоняю вас добавить эти видео в ваш перечень просмотров.

Изнанка потоков

... нетривиальные многопоточные программы непонятны людям. Это верно, что имеющуюся модель программирования можно улучшить путём применения шаблонов проектирования, лучшей детализации атомарности (например, транзакций), улучшенных языков программирования и методов формализации. Однако, эти техники просто отсекаются при имеющемся ненужном огромном недетерминизме существующей модели потоков. Данная модель остаётся в сущности не поддающейся решению. [2].

-- Эдвард А Ли, Основная проблема потоков - Technical Report No. UCB/EECS20061 Electrical Engineering and Computer Sciences University of California at Berkeley

Это не является новым, и мы уже упоминали об этом в некоторых иных местах, однако для завершённости давайте соберём им в кучку, тем не менее:

Ошибки при написании потоков и конкуренция в программах с потоками являются самыми сложными видами ошибок для исправления. По опыту, можно будет разработать новое программное обеспечение, которое менее подвержено данным проблемам, но в нетривиальном, разработанном естественным образом программном обеспечении почти невозможно исправить их, даже экспертам! На самом деле!

Потоки являются ресурсоёмкими и требуют создания дополнительных ресурсов операционной системы, таких как предварительное выделение, пространство стека для каждого потока, которое потребляет виртуальную память процесса авансом. Это большая проблема для 32- разрядных операционных систем, потому что адресное пространство на процесс ограничено 3 ГБ [В теории адресное пространство для некоторого 32- разрядного процесса составляет 4 ГБ, однако имеющиеся операционные системы резервируют его часть. Зачастую только 3 ГБ остаётся адресуемым только 3 ГБ виртуальной памяти, а некоторые операционные системы могут уменьшать его до 2 ГБ. Будьте любезны рассматривать приводимые численные значения в этом разделе как обобщённые, а не абсолютные. Имеется слишком много специфичных для платформ (и исторически чувствительных) подробностей чтобы углубляться в них здесь.]

В наши дни, при широкой доступности 64- разрядных операционных систем, виртуальная память не так ценна, как ранее (адресуемое пространство для виртуальной памяти обычно составляет 48 бит, то есть 256 TiB), и в современных операционных системах рабочих мест физическая память, требуемая для пространства стека каждого потока даже не выделяется операционной системой пока она не требуется, включая пространство стека для каждого потока. Например, в современном 64битном Fedora 27 Linux с 8 ГБ памяти, создание 10 000 ничего не делающих при этом потоков короткий фрагмент кода...

# threadmem.py
import os
from time import sleep
from threading import Thread
threads = [
    Thread(target=lambda: sleep(60)) for i in range(10000)
]
[t.start() for t in threads]
print(f'PID = {os.getpid()}')
[t.join() for t in threads]

... приводящий к следующей информации в top:

MiB Mem : 7858.199 total, 1063.844 free, 4900.477 used
MiB Swap: 7935.996 total, 4780.934 free, 3155.062 used

  PID USER      PR  NI    VIRT    RES    SHR COMMAND
15166 caleb     20   0 80.291g 131.1m   4.8m python3

Предварительно выделяемая виртуальная память ошеломляет ~80 ГБ (из- за 8 МБ пространства стека на поток!), однако постоянная память составляет всего ~130 МБ. В некотором 32- разрядном Linux мне было бы невозможно создать настолько много потоков из- за предела на адресное пространство пользователя в 3 ГБ, независимо от реального потребления физической памяти. Для обхода этой проблемы (при 32- битах) иногда требуется снизить предварительно настроенный размер стека, что вы всё ещё можете делать сегодня в Python, с помощью threading.stack_size([size]). Очевидно, снижение размера стека оказывает воздействие на безопасность времени исполнения в отношении той степени, до которой может выполняться вложение вызовов, включая рекурсивные. Однопотоковые сопрограммы не имеют никаких из этих проблем и являются превосходящей альтернативой для одновременного ввода/ вывода.

При очень высокой степени распараллеливания (скажем >5 000 потоков), также может иметься воздействие на пропускную способность из- за стоимости переключения контекста [4, 5], допустим, вы можете отыскать способ как настроить вашу операционную систему даже чтобы допустить ещё больше потоков для вас! Сейчас это стало достаточно скучным, когда в последних версиях macOS, к примеру, для проверки моих ничего- не- делающих примеров, приведённых выше, я отказался от попыток поднимать значение параметра вовсе.

Потоки являются негибкими: ваша операционная система будет непрерывно разделять время ЦПУ со всеми потоками вне зависимости от того готов этот поток к работе или нет. Например, некий поток может ожидать данных в некотором сокете, однако планировщик ОС может всё- таки переключатся на него тысячи раз прежде чем необходимо сделать реальную работу. (В нашем асинхронном мире применяется системный вызов select() для проверки имеется ли ожидающая в сокете сопрограмма, на которую нужно переключиться или нет, причём эта сопрограмма даже не успеет прийти в себя, избегая стоимость переключения вовсе {Прим. пер.: ещё более впечатляет epoll}).

Ни одно из этих сведений не является новым, а все проблемы с потоками в виде модели программирования не являются даже особенными для платформы. Например, именно это говорит документация по руководству программированию MSDN Windows относительно построения потоков:

Самыми стержневым механизмом распараллеливания в существующем API Windows являются именно потоки. Обычно для создания потоков вы применяете функцию CreateThread. Хотя потоки относительно просты для создания и применения, сама операционная система уделяет значительное время и прочие ресурсы для управления ими. Кроме того, хотя каждому потоку гарантируется получение точно такого же времени исполнения что и для для остальных потоков с с тем же самым уровнем приоритета, а связанные с ними накладные расходы требуют того, чтобы создавала достаточно большие задачи. Для задач меньшего размера или с большей степенью детализации, те накладные расходы, которые связаны с распараллеливанием, могут перевешивать те преимущества, которые получают одновременно исполняемые задачи. [6].

-- Руководство программирования MSDN, Сопоставление Распараллеливание времени исполнения с прочими моделями одновременного исполнения

Но я слышу ваши протесты - это Windows, так? Несомненно, система Unix не имеет этих проблем? Вот приводим аналогичные рекомендации от Разработчиков библиотеки Mac:

Потоки имеют действительную стоимость для ваших программ (и всей системы) в терминах использования памяти и производительности. Каждый поток требует соответствующего выделения памяти как в пространстве самого ядра, так и в пространстве памяти вашей программы. Имеющиеся структуры ядра требуют управления вашим потоком и координации их планирования запоминаются в самом ядре при помощи зашитой памяти. Ваши пространство стека потока и данных каждого потока сохраняются в пространстве памяти вашей программы. Большая часть этих структур создаются и инициализируются при создании самом первом вашего потока - некого процесса, который может быть относительно затратным, так как он требует взаимодействия с сами ядром [7].

-- Библиотека разработчиков Mac, Руководство по программированию потоков

В своём Руководстве по параллельному программированию (выделено мною) они идут ещё дальше:

В прошлом введение распараллеливания в некое приложение требует определённого создания одного или более дополнительных потоков. К сожалению, написание кода с потоками является сложным. Потоки являются неким инструментом нижнего уровня, который должен управляться вручную. Принимая во внимание, что оптимальное число потоков для некоторого приложения может изменяться динамически на основе текущей загруженности системы и лежащего в её основе оборудования, реализация правильного решения с потоками становится чрезвычайно сложным, если даже невозможным для достижения. Кроме того, имеющиеся механизмы синхронизации обычно используются при добавлении сложности и рисков разработки потоков без каких бы то ни было гарантий улучшения производительности.[8].

-- Библиотека разработчиков Mac, Руководство по программированию потоков

Вот темы, которые постоянно повторяются:

потокии делают сложным обсуждение кода.

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

Следующим мы рассмотрим некий пример вовлечения потоков в котором я попытаюсь высветить самый первый и самый важный момент.

Пример: Роботы и Столовые приборы

Второе, и даже ещё более важное, мы не верили (и продолжаем сомневаться в ней) в стандартную модель со множеством потоков, которая является распараллеливанием с приоритетами при помощи совместно используемой памяти: мы всё ещё полагаем, что никто не может писать правильные программы на некотором языке программирования, в котором "a - a + 1" не детерминировано.[9].

-- Роберту Иерузалимски, Луиш Энрике ди Фигейреду, Валдемар Селиш, Эволюция Lua

В самом начале этой книге мы прослушали историю о ресторане, в котором роботы- гуманоиды - Серийные боты - выполняют всю работу. В соответствии с этой аналогией всякий исполнитель (worker) является каким- то потоком. В данном примере мы собираемся рассмотреть почему потоки рассматриваются как не безопасные.

Пример 2-2. Программирование Серийного бота для обслуживания стола

import threading
from queue import Queue

class ThreadBot(threading.Thread):⁽¹⁾
  def __init__(self):
    super().__init__(target=self.manage_table)⁽²⁾
    self.cutlery = Cutlery(knives=0, forks=0)⁽³⁾
    self.tasks = Queue()⁽⁴⁾

  def manage_table(self):
    while True:⁽⁵⁾
    task = self.tasks.get()
    if task == 'prepare table':
      kitchen.give(to=self.cutlery, knives=4, forks=4)⁽⁶⁾
    elif task == 'clear table':
      self.cutlery.give(to=kitchen, knives=4, forks=4)
    elif task == 'shutdown':
      return

⁽¹⁾ ThreadBot является подклассом потока.

⁽²⁾ Целевой функцией данного потока является метод manage_table(), определяемый ниже.

⁽³⁾ Этот бот собирается быть официантом и будет отвечать за некие столовые приборы. Здесь все боты отслеживают те столовые приборы, которые они взяли со своей кухни. (Соответствующий класс столовых приборов, Cutlery, будет определён позднее.

⁽⁴⁾ Конкретному боту также назначаются задачи. Они будут добавляться в данную очередь задач, а сам бот будет исполнять их далее в своём основном цикле обработки.

⁽⁵⁾ Самой основной процедурой данного бота является данный бесконечный цикл. Если вам потребуется остановить некоего бота, вам придётся выдать ему задание "shutdown".

⁽⁶⁾ Имеются только три задачи, определённые для данного бота. Самой первой, "prepare table", является та, которую нужно выполнить, чтобы получить новый стол готовым к обслуживанию. Для нашей проверки единственным требованием является получить набор столовых приборов с кухни и поместить их на данный стол. "clear table" служит для того чтобы указать необходимость очистить некий стол: данный бот должен вернуть использованные столовые приборы обратно на кухню. "shutdown" всего лишь останавливает данного бота.

А теперь определим объект Столового прибора:

Пример 2-3. Определение Столовых приборов

from attr import attrs, attrib

@attrs⁽¹⁾
class Cutlery:
    knives = attrib(default=0)⁽²⁾
    forks = attrib(default=0)

    def give(self, to: 'Cutlery', knives=0, forks=0):⁽³⁾
    self.change(knives, forks)
    to.change(knives, forks)

    def change(self, knives, forks):⁽⁴⁾
            self.knives += knives
            self.forks += forks

kitchen = Cutlery(knives=100, forks=100)⁽⁵⁾
bots = [ThreadBot() for i in range(10)]⁽⁶⁾

import sys
for bot in bots:
    for i in range(int(sys.argv[1])):⁽⁷⁾
        bot.tasks.put('prepare table')
        bot.tasks.put('clear table')
    bot.tasks.put('shutdown')⁽⁸⁾

print('Kitchen inventory before service:', kitchen)
for bot in bots:
    bot.start()

for bot in bots:
    bot.join()
print('Kitchen inventory after service:', kitchen)

⁽¹⁾ attrs, которая является библиотекой Python с открытым исходным кодом, которая ничего не предпринимает по отношению к потокам или asyncio, и является на самом деле восхитительной библиотекой для того чтобы сделать создания класса простым. В нашем случае соответствующий декоратор @attrs гарантирует что данный класс Cutlery получит весь обычный стереотипный код (подобный __init__()) автоматически установленным.

⁽²⁾ Функция attrib предоставляет некий простой способ создания атрибутов, в том числе значений по умолчанию, которые вы можете обычно обрабатывать как аргументы с ключевыми словами в своём методе __init__().

⁽³⁾ Этот метод применяется для передачи ножей и вилок из одного объекта Cutlery в другой. Обычно он применяется ботами для получения столовых приборов со своеё кухни для новых столов, а также для возврата этих столовых приборов обратно на кухню после уборки данного стола.

⁽⁴⁾ Это очень простая функция утилиты для для замены имеющихся инвентарных данных в данном экземпляре объекта.

⁽⁵⁾ Мы определили kitchen в виде определённого идентификатора для конкретной кухонной инвентаризации столовых приборов. Обычно каждый из ботов будет получать столовые приборы в этом месте. Также необходимо чтобы они возвращали столовые приборы в это хранилище после уборки стола.

⁽⁶⁾ Этот сценарий исполняется при проверке. Для нашей проверки мы будем применять 10 Серийных ботов (threadbots).

⁽⁷⁾ Мы получаем общее число столов в виде параметра командной строки, а затем задаём для кадого бота такое число задач для подготовки и уборки столов в своём ресторане.

⁽⁸⁾ Задача "shutdown" заставит бота остановиться (так что bot.join() будет выполнять возврат немного дальше). Оставшийся сценарий выводит диагностические сообщения и запускает имеющихся ботов.

Ваша стратегия проверки созданного кода в основном вовлекает запуск некой группы Серийных ботов для последовательности обслуживания столов. Каждый серийный юот должен:

подготовить некий "стол на четверых", что означает получение четырёх наборов ножей и вилок с кухни;

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

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

Поступая с умом, вы решаете проверить это, причём при подготовке и уборке 100 столов для каждого из Серийных ботов, и при этом все они работают одновременно, поскольку вы желаете убедиться что они могут работать совместно и всё пройдет хорошо. Вот вывод данной проверки:

$ python cutlery_test.py 100
Kitchen inventory before service: Cutlery(knives=100, forks=100)
Kitchen inventory after service: Cutlery(knives=100, forks=100)

Все ваши ножи и вилки вернулись обратно на кухню! Поэтому вы поздравляете себя с хорошо написанным кодом и успешным развёртыванием ботов. К сожалению, на практике, вы то и дело обнаруживаете что вы не завершаете полным пересчётом столовых приборов при каждом закрытии своего ресторана. Вы замечаете, что данная проблема ухудшается когда вы добавляете дополнительных ботов и/ или места становятся более загруженными. Разочаровавшись, вы проводите свою проверку вновь, ничего не изменяя за исключением установления размера данной проверки (10 000 столов!):

$ python cutlery_test.py 10000
Kitchen inventory before service: Cutlery(knives=100, forks=100)
Kitchen inventory after service: Cutlery(knives=96, forks=108)

Оп- ля- ля! Теперь вы видите, что проблема имеется на самом деле. При 10 000 обслуженных столах вы заканчиваете с неверным числом ножей и вилок, остающихся на кухне. Для воспроизведения вы проверяете эту ошибку на повторяемость:

$ python cutlery_test.py 10000
Kitchen inventory before service: Cutlery(knives=100, forks=100)
Kitchen inventory after service: Cutlery(knives=112, forks=96)

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

Обсуждение

Давайте подведём итог:

Ваш код ThreadBot очень простой и его легко читать. Вся логика исключительная!

Вы даже провели пробный тест (со 100 столами), он повторяемо проходил.

Вы провели более длительную проверку (со 10 000 столами) и она повторяемо проваливается.

Более продолжительная проверка оказывает различными, не повторяющимися способами.

Именно это является несколькими типичными признаками ошибки состязательности. Умудрённый читатель уже видел такие примеры, поэтому давайте сейчас это исследуем. Всё сводится к данному методу внутри нашего класса Cutlery:

def change(self, knives, forks):
    self.knives += knives
    self.forks += forks

Данное внутреннее суммирование, +=, реализуется внутренним образом (в рамках собственно кода C для самого интерпретатора Python) как несколько отдельных этапов:

Считывание текущего значения, self.knives, в некое временное местоположение.

Добавление некоторого нового значения, knives к имеющемуся значений в этом временном местоположении.

Копирование нового итога из временного местоположения обратно в его первоначальное место.

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

В таком случае, Серийный бот A может делать этап 1, затем планировщик ОС приостанавливает A и переключается на Серийного бота B, а B тоже считывает текущее значение self.knives, затем исполнение передаётся обратно A и его приращения записываются обратно в новый итог - но затем B продолжает с того места где он был приостановлен (после шага 1), и после этого выполняет приращение и записывает обратно свой новый итог, тем самым удаляя те изменения, которые сделаны A!

[Предостережение]
Предостережение

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

Данную проблему можно исправить помещая блокировку вокруг необходимых изменений совместно используемого состояния (допустим, мы добавили threading.Lock в свой класс Cutlery):

def change(self, knives, forks):
  with self.lock:
    self.knives += knives
    self.forks += forks

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

Заметим, что невозможно отследить данное условие состязательности рассматривая исключительно имеющийся исходный код. Это происходит потому, что такой исходный код не даёт никаких советов о том, где происходит переключение между потоками. В любом случае он будет бесполезен, так как ваша ОС может переключаться между потоками практически в любом месте.

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

И что ещё лучше: в наших асинхронных программах мы удем способны видеть в точности где контекст будет переключаться между множеством конкурирующих сопрограмм, потому что имеющееся ключевое слово await, которое указывает такие места в явном виде. Я решил не показывать некую асинхронную версию здесь, потому как наша следующая глава собирается подробно пояснять как использовать asyncio. Однако, если вы испытываете неутолимое любопытство, имеется некий снабжённый комментариями пример в наших Дополнениях; который, скорее всего, имеет смысл рассматривать только после прочтения нашей следующей главы!

Глава 3. Прогулка по Asyncio

Содержание

Глава 3. Прогулка по Asyncio

Быстрое начало

Основная башня Asyncio

Выводы

Сопрограммы

Ключевое слово нового async def

Ключевое слово нового await

Цикл событий

Задачи и фьючерсы

Создать задачу? Обеспечить фьючерс? Прими решение!

Диспетчеры контекста Async: async with

Вариант contextlib

Итераторы Async: async for

Генераторы Async: yield внутри async def функций

Сообразительность Async

Запуск и останов (Аккуратные!)

Для чего предназначено return_exceptions=True

Сигналы

Ожидание исполнителя в процессе останова

Тестирование с помощью asyncio

Asyncio предоставляет другой инструмент для параллельного программирования в Python, который обладает гораздо меньшим весом нежели потоки или многопроцессорность. В самом простом виде он выполняет это имея некий цикл событий, исполняющих некий набор задач с неким ключевым отличием в том, что каждая задача выбирает самостоятельно когда забирать управление обратно из этого цикла событий. [1].

-- Филип Джоунс, Носитель

Asyncio API в Python является сложным, так как имеет целью решать различные задачи для разных групп людей. К несчастью, чтобы помочь вам осознать какая именно часть asyncio важна для той группы, в которой находитесь вы, в доступности имеется очень мало руководств.

Моя цель состоит в том, чтобы помочь вам в этом разобраться. Существует две основные целевые аудитории для таких асинхронных свойств в Python:

Разработчики оконечных рабочих мест

Эта группа может желать разрабатывать приложения с помощью asyncio. Я собираюсь предполагать что вы относитесь к этой категории.

Разработчики инструментальных средств

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

Основная часть неразберихи относительно asyncio в имеющемся сообществе в наши дли проистекает и- за путаницы между этими двумя целями. Например, та документация для asyncio, которая представлена в официальной документации Python более подходит для разработчиков инструментальных средств, а не для конечных потребителей. Это означает, что читающие эту документацию разработчики для конечных пользователей быстро оказываются контуженными кажущейся сложностью. Вам придётся что- то предпринимать прежде чем вы сможете что- либо сделать с этим.

Я рассчитываю в этой книге помочь вам отделить в вашем сознании те свойства asyncio

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

[Совет]
Совет

Если вам интересны подробности нижнего уровня, относящиеся к тому как создавать параллельно работающие инструментальные средства со встройкой вовнутрь чего- то побобного asyncio, я настоятельно рекомендую обсуждение Дэйва Бизли Python Concurrency From the Ground Up: LIVE!, в котором он демонстрирует помещение воедино простейшей версии асинхронной инфраструктуры, подобной asyncio.

Моя цель состоит в в предоставлении вам только самого основополагающего понимания строительных блоков Asyncio; достаточного чтобы вы были способны писать простые программы с его помощью и, несомненно, достаточными чтобы вы могли погружаться в полные руководства. [Когда это становится необходимым! На момент написания книги, единственным справочным руководством по Asyncio была спецификация API в официальной документации Python, а также набор блог постов, большая часть которых имеет ссылки в данной книге.]

Прежде всего, у нас имеется раздел "Быстрое начало", которое служит цели представления наиболее важных строительных блоков для приложений asyncio.

Быстрое начало

Чтобы применять Asyncio [на повседневной основе], вам необходимо знать о семи функциях.

-- Юрий Селиванов, автор PEP 492

Открывать официальную документацию Asyncio достаточно страшно. Имеется множество разделов с новыми, загадочными словами, а также понятия, с которыми не знакомы даже искушённые программисты Python, поскольку asyncio является очень новой вещью. Мы собираемся поломать всё это и объяснить какой подход следует применять к документации asyncio в дальнейшем, но на текущий момент вам следует знать, что реальная площадь поверхности, о которой вам придётся беспокоиться в имеющейся библиотеке asyncio намного меньше чем может показаться.

Юрий Селиванов, автор PEP 492 и основной разработчик асинхронного Python, объяснил в своём выступлении async/await в Python 3.5 и почему это восхитительно, представленном на PyCon 2016, относительно того, что большая часть API в модуле asyncio на самом деле предназначается для разработчиков инструментальных средств, а не для разработчиков конечных приложений. В э том выступлении он выделил те основные функции, о которых следует беспокоиться конечным пользователям, а эти функции всего лишь составляют небольшое подмножество всего API asyncio.

В данном разделе мы собираемся взглянуть на эти составляющие ядро функции и рассмотреть как вдарить по почве выполнения в цикле с помощью основанного на событиях программирования в Python.

Вам потребуются базовые знания сопрограмм (представленные в разделе, идущим вслед за этим), но помимо этого, если вы хотите иметь возможность использовать обсуждаемую библиотеку asyncio в качестве разработчика конечного приложения (а не как проектировщика инструментальных средств), вот моменты, которые вам необходимо знать в крошечном примере:

Пример 3-1. "Hello World" для Asyncio

# quickstart.py
import time
import asyncio

async def main():
    print(f'{time.ctime()} Hello!')
    await asyncio.sleep(1.0)
    print(f'{time.ctime()} Goodbye!')
    loop.stop()⁽⁵⁾

loop = asyncio.get_event_loop()⁽¹⁾
loop.create_task(main())⁽²⁾
loop.run_forever()⁽³⁾
pending = asyncio.Task.all_tasks(loop=loop)
group = asyncio.gather(*pending, return_exceptions=True)⁽⁴⁾
loop.run_until_complete(group)⁽³⁾
loop.close()⁽⁵⁾

Вывод:
$ python quickstart.py
Sun Sep 17 14:17:37 2017 Hello!
Sun Sep 17 14:17:38 2017 Goodbye!

⁽¹⁾ loop = asyncio.get_event_loop()

Вам необходим некий экземпляр цикла прежде чем вы можете запустить какие бы то ни было сопрограммы, а это именно он и есть. На самом деле, где бы вы его не вызывали, get_event_loop() предоставит вам всякий раз тот же самый экземпляр loop, раз уж вы используете только один единственный поток. [API asyncio позволяет вам делать много безумных вещей с несколькими экземплярами и потоками циклов, но данная книга не является самой подходящей книгой для данной цели. 99% общего времени вы будете использовать некий единичный, основной поток для своей прикладной программы, как это показано тут.]

⁽²⁾ task = loop.create_task(coro)

В приведённом выше коде loop.create_task(main()) является неким особенным вызовом. Ваша функция сопрограммы не будет исполняться пока вы не сделает его. Мы говорим, что create_task() планирует исполнение вашей сопрограммы в данном цикле. Возвращаемый объект task может применяться для отслеживания текущего состояния данной задачи, например, исполняется ди она ещё или уже выполнена, а помимо этого может также применяться для получения некоторого результирующего значения от вашей завершённой сопрограммы. Вы также можете завершать данную задачу при помощи task.cancel(). [с другой стороны: вы можете заметить в этой строке кода что основным параметром в таком вызове функции для create_task() является coro. Именно это соглашение используется в большей части документации API, которую вы обнаружите, и он ссылается на некую coroutine; т.е., строго говоря, собственно результат вызова некоторой функции async def, а не саму эту функцию.]

⁽³⁾ loop.run_until_complete(coro) и loop.run_forever()

Это два способа запуска данного цикла. Они оба заблокируют данный текущий поток, которым обычно является самый основной поток {main}. Отметим, что run_until_complete() сохранит данный цикл исполняемым до тех пор, пока не завершится заданный coro - но все прочие запланированные задачи в этом цикле также будут исполняться при выполнении данного цикла.

⁽⁴⁾ group = asyncio.gather(task1, task2, task3)

Типичная манера для большинства программ будет состоять в том, чтобы начаться с loop.run_forever() для самой "главной" части ваше программы {main} , а затем, когда получен некий сигнал процесса, остановить данный цикл, собрать всё ещё приостановленные задачи, а затем воспользоваться loop.run_until_complete() до тех пор, пока эти задачи не выполнятся. именно этот метод служит для выполнения сбора. В более общем плане он также может применяться для сбора множества сопрограмм воедино и ожидания (при помощи await!) пока все собранные задачи не завершатся.

⁽⁵⁾ loop.stop() и loop.close()

Как уже описано выше, они применяются для постепенного останова некоторой программы. stop() обычно вызывается как следствие получения некоторого сигнала выключения, а close() обычно является самым последним действием: и оно очистит все очереди и остановит Исполнителя {Executor}. "Остановленный" цикл может быть запущен вновь, а "закрытый" цикл исчезает навсегда.

[Предостережение]
Предостережение

Предыдущий пример является слишком упрощённым чтобы применяться в практических настройках. Требуется дополнительная информация относительно обработки правильного останова. Основной целью данного примера было просто представить большую часть важнейших функций и методов из asyncio. Дополнительная практическая информация по обработке останова представлена позднее в данной книге.

asyncio в Python представляет множество базовых механизмов вокруг основного цикла событий - и требует чтобы вы были осведомлены о таких вещах, как сам цикл событий и управление им на протяжении его времени жизни. Именно это является отличием, например, от Node.js, который также содержит некий цикл событий, но сохраняет его где- то вовне в скрытом виде. Однако, как только вы начнёте понемногу работать с asyncio, вы начнёте замечать, что ваш шаблон запуска и останова основного цикла событий не отходит страшно далеко от приведённого выше кода. А в оставшейся части данной книги мы рассмотрим более подробно некоторые нюансы относительно времени жизни цикла.

В приведённом выше примере я кое- что упустил. Самый последний элемент базовой функциональности, о котором вам следует знать, состоит в том как запускать блокирующие функции. Основной момент относительно кооперативной многозадачности состоит в том, что вам требуется все связанные с вводом/ выводом функции..., ну да, кооперировать, а это означает допуск переключения некоторого контекста обратно в данный цикл при помощи особого ключевого слова await. Большая часть имеющегося кода Python, доступного на текущий момент в диком виде, не делает этого, а вместо этого полагается на вас для запуска таких функций в потоках. До тех пор, пока не будет более широко распространённой поддержки функций async def, вы обнаружите, что применение таких библиотек с блокировкой неизбежно.

Для этого asyncio предоставляет некий API, который очень похож на API из пакета concurrent.futures. Данный пакет предоставляет некий ThreadPoolExecutor и какой- тоProcessPoolExecutor. По умолчанию он основан на потоке, но запросто заменяется на базирование на процессе. Я опустил это в своём предыдущем примере, так как это скрыло бы то описание, как подгоняются друг к другу все фундаментальные части. Теперь, когда они были обсуждены, мы можем взглянуть непосредственно на исполнителя.

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

Пример 3-2. Основной интерфейс исполнителя

# quickstart_exe.py
import time
import asyncio

async def main():
    print(f'{time.ctime()} Hello!')
    await asyncio.sleep(1.0)
    print(f'{time.ctime()} Goodbye!')
    loop.stop()

def blocking():⁽¹⁾
    time.sleep(0.5)⁽²⁾
    print(f"{time.ctime()} Hello from a thread!")

loop = asyncio.get_event_loop()

loop.create_task(main())
loop.run_in_executor(None, blocking)⁽³⁾

loop.run_forever()

pending = asyncio.Task.all_tasks(loop=loop)⁽⁴⁾
group = asyncio.gather(*pending)
loop.run_until_complete(group)
loop.close()

Вывод:
$ python quickstart_exe.py
Sun Sep 17 14:17:38 2017 Hello!
Sun Sep 17 14:17:38 2017 Hello from a thread!
Sun Sep 17 14:17:39 2017 Goodbye!

⁽¹⁾ blocking() обычно вызывает внутри себяtime.sleep(), что привело бы к блокировке вашего основного потока и предотвращало бы исполнение вашего цикла событий. Это означает, что вам не следует делать такую функцию сопрограммой, но что ещё более серьёзно, вы не можете вызывать эту функцию из любого места в своём основном потоке, который является именно тем местом, в котором исполняется основной цикл asyncio. Мы решаем данную проблему запуская эту функцию в некотором исполнителе (executor).

⁽²⁾ Безотносительно к данному разделу, но нечто, что следует держать на уме далее в этой книге: отметим, что значение времени сна с блокировкой (0.5 секунды) короче чем значение засыпания без блокировки (1 секунда) в вашей сопрограмме main(). Это делает данный код примера изящным и опрятным. В своём следующем разделе мы объясним что бы произошло если бы функции исполнителя переживали бы свои асинхронные эквиваленты во время последовательности выключения.

⁽³⁾ await loop.run_in_executor(None, func)

Это самый последний момент в нашем списке существенных функций asyncio, о которых следует знать. Иногда вам требуется запускать вещи в некотором отдельном потоке, или даже в отдельном процессе: данный метод применяется именно для этого. Здесь мы передаём свою блокирующую функцию для запуска в определённом по умолчанию исполнителе. [к сожалению, самым первым параметром run_in_executor() является сам экземпляр Executor, который следует применять и вы обязаны передать None чтобы использовать установленный по умолчанию. Всякий раз, как я применяю это, я ощущаю как мой параметр "исполнитель" плачет, будучи вынужденным именоваться ключевым словом аргумента с определённым по умолчанию значением None.]

Отметим, что loop.run_in_executor() возвращает некое Future, что означает, что вы можете await {подождать} его при вызове в рамках функции другой сопрограммы.

⁽⁴⁾ Продолжаем отмечать вслед за элементом 2: наш набор задач в pending не содержит некоего элемента для того вызова blocking(), который делается в run_in_executor(). Это будет справедливо для любого вызова, который возвращает некое Future вместо какой- то Task. Имеющаяся документация достаточно хорошо определяет возвращаемые типы, поэтому об этом несложно знать; всего лишь просто запомните, что all_tasks() на самом деле возвращает только Task, и никаких Future.

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

Основная башня Asyncio

Как мы уже отметили в Быстром начале, существует лишь небольшое число команд, которые вам следует знать чтобы быть способным применять asyncio в качестве разработчика конечных приложений. К несчастью, имеющаяся документация для asyncio предоставляет гигантское число различных API, причём они представлены в очень "однообразном" формате, из которого очень трудно определить какие вещи предназначаются для общего применения, а какие стороны предоставляются для разработчиков инструментальных средств.

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

С этой точки зрения более полезно представлять себе модуль asyncio как отранжированный в виде некоей иерархии, вместо некоего плоского листа, причём всякий последующий уровень строится поверх спецификации предыдущего. К сожалению, это не совсем так, и я взял на себя некие вольности при выравнивании их в Таблице 3-1, но, надеюсь, это снабдит вас неким альтернативным представлением имеющегося API asyncio.

[Предостережение]
Предостережение

Таблица 3-1, а также представляемая здесь названия и нумерация "Уровней" являются исключительно моими собственным изобретением, которое имеет целью добавить некую небольшую структуру в помощь пояснению API asyncio. Это моя выдумка! Искушённый читатель может выстраивать вещи иным образом, и это тоже хорошо!

Таблица 3-1. Свойства asyncio, выстраиваемые в виде некоторой иерархии. Для разработчиков конечных приложений наиболее важные уровни отображены жирно

Уровень
Понятие
Htfkbpfwbz

Уровень 9

Сетевой: потоки

StreamReader & StreamWriter

Уровень 8

Сетевой: TCP & UDP

Protocol

Уровень 7

Сетевой: транспорт

BaseTransport

Уровень 6

Инструменты

asyncio.Queue

Уровень 5

подпроцессы и потоки

run_in_executor(), asyncio.subprocess

Уровень 4

задачи

asyncio.Task

Уровень 3

свойства

asyncio.Future

Уровень 2

цикл событий

BaseEventLoop

Уровень 1 (основной)

сопрограммы

async def & await

На самом основополагающем уровне, Уровне 1, у нас имеются сопрограммы (coroutines), которые вы уже видели ранее в этой книге. Это самый нижний уровень, с которого следует начинать размышлять о разработке стороннего инструментального средства, и, что удивительно, он стал чем- то популярным не у какого- то одного, а у двух таких из доступных на сегодняшний день в мире асинхронных инфраструктур: Curio и Trio. Они обе полагаются только на естественные сопрограммы в Python и больше ни на что иное из библиотечного модуля asyncio.

Нашим следующим уровнем является собственно цикл событий. Сопрограммы бесполезны сами по себе: они ничего не могут делать без некоего цикла, в котором они запускаются (более того, с необходимостью, Curio и Trio реализуют свои собственные циклы событий). asyncio предоставляет как некое определение цикла, Abstract​ EventLoop, так и какую- то реализацию, BaseEventLoop.

Чёткое отделение описания от реализации делает возможным для сторонних разработчиков выполнять альтернативные реализации своих циклов событий и это уже произошло в проекте uvloop, который предоставляет намного более быструю реализацию цикла чем та, что имеется в стандартном библиотечном модуле asyncio. Что важно: uvloop просто "встраивается" в имеющуюся иерархию и подменяет только саму часть цикла в общем стеке. Такая возможность выполнять подобные вещи по выбору именно то, для чего разрабатывался API asyncio, причём с ясным разделением между всеми различными подвижными частями.

Уровни 3 и 4 привносят нам свойства и задачи, которые очень тесно взаимосвязаны; они разделяются только по той причине, что Task является неким подклассом Future, но их запросто можно рассматривать как находящиеся на одном и том же уровне. Некий экземпляр Future представляет какой- то вид происходящего действия, которое возвратит некий результат посредством уведомления в свой цикл событий, в то время как Task представляет некую сопрограмму, исполняемую в этом цикле событий. Краткая история такова: некое свойство является "осведомлённым о цикле", в то время как конкретная задача одновременно "осведомлена о цикле" и "знает о сопрограмме". Выступая в роли разработчика конечного продукта, вы будете преимущественно работать с задачами, нежели со свойствами, однако для некоторого разработчика инструментальных средств данная пропорция может быть иной в зависимости от имеющихся подробностей.

Уровень 5 предоставляет возможности для запуска, а также снабжения await работ, которые должны исполняться либо в отдельном потоке, либо даже в отдельном процессе.

Уровень 6 представляет дополнительные осведомлённые об асинхронности инструмента, такие как asyncio.Queue. Нам можно было разместить этот уровень после всех сетевых уровне, но я полагаю, что более чётко будет поместить все относящиеся к осведомлённым о сопрограммах API вначале, прежде чем мы взглянем на имеющиеся уровни ввода/ вывода. Те Queue, которые производятся asyncio очень похожи на API имеющихся Queue безопасных потоков в модуле queue, за исключением то8о, что наша версия asyncio требует наличия ключевого слова await для get() и put(). Вы не можете использовать queue.Queue непосредственно внутри сопрограмм, поскольку его get() заблокируют ваш основной поток {main}.

Наконец, у нас имеются уровни сетевого ввода/ вывода с 7 по 9. Для вас, как разработчика конечного продукта, большая часть подходящего вам для работы API это API "потоков" на уровне 9. Я представил этот API потоков на самом верхнем уровне абстракции в нашей башне. Непосредственно под ним расположен уровень "протоколов" (Уровень 8), является более тонко настраиваемым API по сравнению с "потоками"; вы можете применять "протоколы" во всех экземплярах, в которых вы можете применять уровень "потоков", но "потоки" проще. Наконец, вам скорее всего никогда не придётся работать непосредственно с транспортным уровнем (Уровень 7), если только вы не создаёте некое инструментальное средство для других, чтобы применять его и вам необходимо персонализировать настройку имеющихся обменов {Прим. пер.: например, вы пожелаете предоставить потоки на основе протокола, применяющего обмен RDMA для увеличения пропускной способности и снижения латентности, см. например, Рекомендации по разработке высокопроизводительных систем RDMA и Ceph поверх RDMA.}

Выводы

D Быстром начале мы рассмотрели абсолютно необходимый минимум, который следует знать чтобы начать работу с библиотекой asyncio. Теперь, после того как мы взглянули на собранную воедино библиотеку API asyncio в целом, я бы хотел повторно вернуться к короткому перечню свойств и повторно выделить те части, которые вам скорее всего следует изучить.

Для написания сетевых приложений вам следует сосредоточиться на изучении следующих наиболее важных уровней для применения библиотечного модуля asyncio;

Уровень 1: существенным является понимание того как писать функции async def и применять await для вызова и исполнения прочих сопрограмм.

Уровень 2: важным является знакомство с тем как выполнять запуск, останов и взаимодействовать с имеющимся циклом событий.

Уровень 5: для применения кода с блокировкой в вашем асинхронном приложении требуются исполнители, а к тому же текущая реальность состоит в том, что большинство сторонних библиотек пока ещё не совместимы с asyncio. Хорошим примером этого является ORM {Object Relational Mapper} библиотека базы данных SQLAlchemy, у которой в настоящее время нет никакой альтернативы для asyncio. {Прим. пер.: строго говоря, это не совсем так, см gino.}

Уровень 6: если вам требуется кормить данными одну или более длительно исполняемыми сопрограммами, самым лучшим способом для этого будет применение asyncio.Queue. Это в точности та же самая стратегия, которая применяется в queue.Queue для распределении данных между потоками. Версия asyncio Queue применяет те же самые API что и стандартная библиотека модуля queue, но при этом применяет сопрограммы вместо блокирующих методов, подобных get().

Уровень 9: API Потоков (Streams) предоставляет вам самый простой способ обработки взаимодействия с сокетом поверх сетевой среды, а также именно тем местом, с которого вам следует начинать создание прототипов идей для сетевых приложений. Вы можете обнаружить, что вам требуется более тонкое управление, и тогда вы имеете возможность переключиться на API Протоколов (Protocols), однако - для большинства проектов - обычно лучше делать вещи более простыми пока вы в точности не поймёте какую именно проблему вы пытаетесь решить.

Конечно, если вы применяете некую совместимую с asyncio библиотеку стороннего разработчика, которая обрабатывает всё взаимодействие с нужным вам сокетом, скажем, aiohttp, вам вовсе не требуется напрямую работать с asyncio. В этом случае вам следует в основном полагаться на предоставляемую этими библиотеками документацию.

Это подводит нас к завершению данного раздела. Библиотека asyncio пытается предоставлять достаточную функциональность как для разработчиков конечных приложений, так и для разработчиков инструментальных средств. К сожалению, это означает, что весь API asyncio может показаться слишком размашистым. Я надеюсь, что данный раздел смог снабдить вас сырой "дорожной картой" чтобы помочь вам уцепить только те части, которые вам требуются.

В наших последующих частях мы собираемся рассмотреть необходимые вам составляющие части из моего приведённого выше короткого списка более подробно.

[Совет]
Совет

Сайт pythonsheets предоставляет некое более глубокое резюме (или "шпаргалку", если хотите) крупных фрагментов имеющегося API asyncio, где все концепции снабжены короткими отрывками исходного кода. Данная презентация является краткой, поэтому я не рекомендую её начинающим, но если вы опытны в Python и вы из тех людей, которым требуется только "ухватить" новую информацию о программировании в представленном коде, несомненно, это будет полезный вам ресурс.

Сопрограммы

Давайте начнём с самого начала: что такое сопрограмм?

Я собираюсь позволить вам быстро заглянуть под капот и увидеть некоторые части того механизма, который вы обычно не видите, даже когда применяете на протяжении повседневного программирования. Все приводимые далее примеры могут быть целиком воспроизведены в обычном интерпретаторе Python в интерактивном режиме и я призываю вас поработать с ними самостоятельно набирая их вручную, отслеживая их вывод и, возможно, экспериментируя различными путями с async и await.

[Предостережение]
Предостережение

asyncio впервые был добавлен в Python 3.4, однако применяемый нами новый синтаксис для сопрограмм с использованием async def и await был добавлен только в Python 3.5. Как люди делают что- то с asyncio в Python 3.4? Они применяют генераторы (generator) очень специальными способами для действия таким образом, как если бы они были сопрограммами. В некоторых более старых базовых кодах вы обнаружите функции генераьора, декорированные @asyncio.coroutine и содержащими операторы yield from. Создаваемые с помощью async def сопрограммы теперь именуются как "натуральные", так как они построены на том же самом языке, что и сопрограммы и ни на чём ином. Данная книга целиком игнорирует более старые сопрограммы, основанные на генераторе.

Ключевое слово нового async def

Давайте начнём с самой простой из возможных вещей:

Пример 3-3. Самый первый сюрприз

>>> async def f():⁽¹⁾
... return 123
...
>>> type(f)⁽²⁾
<class 'function'>
>>> import inspect⁽³⁾
>>> inspect.iscoroutinefunction(f)⁽⁴⁾
True

⁽¹⁾ loop = asyncio.get_event_loop()

Это самый простой способ объявления некоторой сопрограммы: она выглядит как какая- то обычная функция, за исключением того что начинается с определённых ключевых слов async def.

⁽²⁾ Сюрприз! В точности тип f не является coroutine, а всего лишь некая ординарная функция. Это является обычной практикой ссылаться на такие функции "async def" функции как на "сопрограммы", даже хотя - строго говоря - они рассматриваются Python как являющиеся функциями сопрограмм. Такое поведение в точности идентично тому, как функции генератора работают в Python:

>>> def g():
... yield 123
...
>>> type(g)
<class 'function'>
>>> gen = g()
>>> type(gen)
<class 'generator'>

Даже хотя g порой неверно именуется как "генератор", она остаётся некоей функцией и возвращается собственно генератор только когда эта функция вычисляется (evaluated). Функции сопрограмм работают в точности так же: вам требуется вызвать эту функцию async def для получения необходимой сопрограммы.

⁽³⁾ Наш модуль inspect из имеющейся стандартной библиотеки может предоставить намного лучшую ретроспективу возможностей нежели использованная встроенная функция type().

⁽⁴⁾ Имеется некая функция iscoroutinefunction(), которая позволяет вам делать отличие некоей обычной функции от функции сопрограммы.

Возвращаясь к нашей функции async def f(), что же происходит когда мы её вызываем?

Пример 3-4. Некая функция async def возвращает... сопрограмму!

>>> coro = f()
>>> type(coro)
<class 'coroutine'>
>>> inspect.iscoroutine(coro)
True

Итак, теперь вопрос звучит следующим образом: что же такое в точности "сопрограмма"? Сопрограммы очень походи на генераторы. В самом деле, прежде чем с помощью ключевых слов async def и await в Python 3.5 были введены натуральные сопрограммы, они уже были доступны к применению в библиотеке Asyncio Python 3.4 с использованием обычных генераторов при особых декораторах. [Более того, именно таким образом библиотеки открытого исходного кода, такие как Twisted и Tornado выставляли поддержку асинхронности в недалёком прошлом.] Не является неожиданным, что наши новые функции async def (и те сопрограммы, которые они возвращают) ведут себя аналогично генераторам.

Мы можем ещё немного поиграть с сопрограммами, чтобы посмотреть как Python их применяет. Что ещё более важно, мы желаем увидеть как Python способен "переключать " исполнение между сопрограммами. Давайте вначале взглянем на то как может быть получено само значение return.

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

>>> async def f():
... return 123
>>> coro = f()
>>> try:
... coro.send(None)⁽¹⁾
... except StopIteration as e:
... print('The answer was:', e.value)⁽²⁾
...
The answer was: 123

⁽¹⁾ Некая сопрограмма инициализируется "отправкой" ей None. Рассматривая изнутри, именно это собирается делать имеющийся цикл событий с вашими бесценными сопрограммами. Вам никогда не придётся делать это вручную. Все создаваемые вами сопрограммы будут исполняться либо с loop.create_task(coro), или при помощи await coro. И именно этот loop, который делает данный .send(None) находится за сценой.

⁽²⁾ Когда ваша сопрограмма выполняет возврат, возбуждается некий особый вид прерывания, именуемый StopIteration. Отметим, что мы можем получить доступ к самому возвращаемому значению данной сопрограммы через значение атрибута value самой этой исключительной ситуации. Опять же, вам нет нужды знать что это работает именно так: с вашей точки зрения функции async def просто возвращают некое значение при помощи соответствующего оператора return, в точности как самая обычная функция.

Эти два момента, т.е. send() и StopIteration определяют соответственные начало и окончание самого исполнения сопрограммы. До сих пор это выглядело просто как в действительности запутанный способ запуска некоей функции, но всё хорошо: наш цикл событий будет отвечать за продвижение сопрограмм по этим внутренним особенностям нижнего уровня. Со своей точки зрения вы просто планируете сопрограммы к исполнению в имеющемся цикле, а они исполняются сверху вниз почти как обычные функции. [⁷]

Нашим следующим шагом будет рассмотреть как само исполнение такой сопрограммы может быть подвешено.

Ключевое слово нового await

Данное новое ключевое слово, await, всегда берёт некий параметр и будет принимать единственную вещь, называемую ожидаемой (awaitable), которая определяется как одна из следующих (исключительно!):

Некую сопрограмму (т.е. конкретный результат какой- то вызываемой функции async def [Также допустимо некое наследование, сопрограмма на основе генератора, которая является некоторой функцией генератора, которая декорирована @types.coroutine и применяет внутри себя ключевое слово yield from для подвешивания. Мы собираемся в данной книге полностью игнорировать наследуемые сопрограммы. Забудьте про них!]

Любой объект, реализующий особый метод __await__(). Этот специализированный метод обязан возвращать некий итератор.

Второй из доступных случаев ожиданий выходит за рамки данной книги (он вам никогда не понадобится в повседневном программировании asyncio), тем не менее, самый первый пример достаточно очевиден:

Пример 3-5. Применение await в какой- то сопрограмме

async def f():
    await asyncio.sleep(1.0)
    return 123

async def main():
    result = await f()⁽¹⁾
    return result

⁽¹⁾ Вызов f производит некую сопрограмму; это означает, что мы позволяем ей выполнять await. Получаемое значение такой переменной result будет равняться 123 после завершения f().

Прежде чем мы завершим данный раздел и приступим к нашему циклу событий, полезно взглянуть на то, как могут подаваться исключительные ситуации, которые наиболее часто применяются для завершения: когда вы вызываете task.​cancel(), ваш цикл событий внутренне применит coro.throw() для возбуждения asyncio.CancelledError внутри вашей сопрограммы:

Пример 3-6. Применение coro.throw() для инъекции исключений в некую сопрограмму

>>> coro = f()⁽¹⁾
>>> coro.send(None)
>>> coro.throw(Exception, 'blah')⁽²⁾
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in f
Exception: blah
blah

⁽¹⁾ Как и ранее, функцией сопрограммы f() создаётся некая новая сопрограмма.

⁽²⁾ Вместо выполнения иного send(), мы делаем вызов throw() и предоставляем некий класс исключений, а также какое- то значение. Это возбуждает некую исключительную ситуацию внутри нашей сопрограммы, в точке await.

Метод throw() применяется (внутренним образом в asyncio) для прекращения задачи, что мы также достаточно просто можем продемонстрировать. Мы даже собираемся забежать вперёд и обработать данное завершение внутри некоторой новой сопрограммы, которая и обработает это:

Пример 3-7. Прекращение сопрограммы при помощи CancelledError

>>> import asyncio
>>> async def f():
...     try:
...         while True: await asyncio.sleep(0)
...     except asyncio.CancelledError:⁽¹⁾
...         print('I was cancelled!')⁽²⁾
...     else:
...         return 111
>>> coro = f()
>>> coro.send(None)
>>> coro.send(None)
>>> coro.throw(asyncio.CancelledError)⁽³⁾
I was cancelled!⁽⁴⁾
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration5)

⁽¹⁾ Наша функция сопрограммы теперь обрабатывает некую исключительную ситуацию: фактически, это особый тип исключений, применяемый на протяжении всей библиотеки asyncio для прекращения задачи: asyncio.CancelledError. Заметим, что данная исключительная ситуация внедряется в сопрограмму извне, т.е. самим циклом событий, который мы всё ещё пока имитируем вручную при помощи команд send() и throw(). В реальном коде, который мы увидим позднее, при прекращении задач, CancelledError возбуждается изнутри самой обёртывающей задачу сопрограммы, как это показано выше.

⁽²⁾ Некое простое сообщение чтобы сказать что мы выполнили прекращение. Отметим, что в результате обработки данной исключительной ситуации она более не распространяется и наша сопрограмма выполнит return.

⁽³⁾ Здесь мы выполняем throw() своей исключительной ситуации CancelledError.

⁽⁴⁾ Как и ожидалось, мы видим выведенным наше завершающее сообщение.

⁽⁵⁾ А наша сопрограмма выходит обычным способом. (Повторный вызов такой исключительной ситуации StopIteration является обычным способом, при помощи которого сопрограмма выполняет выход.

Просто чтобы указать, что прекращение задачи не является ничем иным как обычным возбуждением исключительной ситуации (и её обработкой), вот некий пример, в котором поглощаем прекращение и переходим к некоторой иной сопрограмме:

>>> async def f():
...     try:
...         while True: await asyncio.sleep(0)
...     except asyncio.CancelledError:
...         print('Nope!')
...         while True: await asyncio.sleep(0)⁽⁶⁾
...     else:
...         return 111
>>> coro = f()
>>> coro.send(None)
>>> coro.throw(asyncio.CancelledError)⁽⁷⁾
Nope!
>>> coro.send(None)⁽⁸⁾

⁽⁶⁾ Вместо того чтобы печатать некое сообщение, что произойдёт если после прекращения мы просто вернёмся обратно подождать иное ожидание?

⁽⁷⁾ Неудивительно, наша внешняя сопрограмма продолжает существовать и она немедленно подвешивается вновь внутри данной новой сопрограммы.

⁽⁸⁾ Всё выполняется должным образом и наша сопрограмма продолжает быть вывешенной и ожидаемо возобновляется.

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

На данный момент мы, естественно, очень устали выступать в роли некоего цикла событий вручную, выполняя все вызовы .send(None), поэтому вместо этого мы привнесём соответствующий цикл, предоставляемый asyncio и очистим предыдущий пример надлежащим образом:

>>> async def f():
...     await asyncio.sleep(0)
...     return 111
>>> loop = asyncio.get_event_loop()⁽⁹⁾
>>> coro = f()
>>> loop.run_until_complete(coro)⁽¹⁰⁾
111

⁽⁹⁾ Получаем некий цикл.

⁽¹⁰⁾ Запускаем завершение данной сопрограммы. Внутренним образом это выполняет все такие вызовы метода .send(None) для нас и определяет завершение наших сопрограмм при помощи исключительной ситуации StopIteration, которая также содержит наше возвращаемое значение.

Цикл событий

В своём предыдущем разделе мы показали как методы send() и throw() могут взаимодействовать с некоторой сопрограммой, но это было сделано только для понимания того как структурированы сами по себе сопрограммы. Именно цикл событий в asyncio обрабатывает все переключения между сопрограммами, а также перехватывает необходимые исключительные ситуации StopIteration, и многое другое, например, выполняет ожидание в сокетах и файловых дескрипторах на предмет событий.

Вы можете получить некий цикл событий посредством вызова get_event_loop(), которая является весьма интересной функцией:

Пример 3-8. Получение при каждом обращении одного и того же цикла событий

>>> loop = asyncio.get_event_loop()
>>> loop2 = asyncio.get_event_loop()
>>> loop is loop2⁽¹⁾
True

⁽¹⁾ Оба идентификатора, и loop, и loop2, ссылаются на один и тот же экземпляр.

Это означает, что если вы внутри некоторой функции сопрограммы, а у вас имеется потребность в доступе к своему экземпляру цикла, будет достаточно вызвать get_event_loop() для его получения. Вам не требуется передавать какой- то параметр loop в явном виде во все свои функции. Эта ситуация меняется коренным образом если вы разработчик инструментального средства: раз так, было бы лучше спроектировать ваши функции на приём некоторого параметра loop, просто на тот случай, когда ваши пользователи делают нечто необычное с политиками цикла событий. Политики выходят за рамки данной книги, и мы больше не будем обсуждать их.

[Совет]
Совет

Метод get_event_loop() работает только в пределах того же самого потока: на самом деле, get_event_loop() выдаст отказ в случае вызова внутри какого- то нового потока, если вы только не создадите намеренно некий новый цикл при помощи new_event_loop() и установите такой новый экземпляр в качестве "основного" цикла для этого потока через вызов set_event_loop(). Большинству из нас никогда не понадобится (и не захочется!) иметь некий отдельный экземпляр цикла запущенным в каом- то отдельном потоке. В первую очередь это касается практически всего асинхронного программирования.

Давайте исследуем некий пример: рассмотрим некую функцию сопрограммы, внутри которой создаются некие дополнительные задачи, причём без ожидания:

async def f():
    # Создадим какие- то задачи!
    loop = asyncio.get_event_loop()
    for i in range():
    loop.create_task(<some other coro>)

В этом примере замысел состоит в запуске полностью новых задач внутри данной сопрограммы. Без выполнения их ожидания, они запускаются независимо от своего контекста внутри функции сопрограммы f(). На самом деле, f() осуществит выход до того как завершатся запускаемые ею задачи.

Для ситуации, подобной приводимой, вам порой следует посмотреть код, в котором переменная значения цикла передаётся в качестве какого- то параметра, просто чтобы сделать её доступной для возможности вызова соответствующего метода create_task(). Определённой альтернативой является применение неудачно именованного asyncio.ensure_future(), который делает всё то же самое что и create_task(), но не требует некоторой переменной локального цикла:

async def f():
    # Создадим какие- то задачи!
    for i in range():
        asyncio.ensure_future(<some other coro>)

Если вы пожелаете, вы можете создать свою собственную вспомогательную функцию, которая также не требует некоего параметра loop, но имеет лучшее название:

def create_task(coro):
    return asyncio.get_event_loop().create_task(coro)

async def f():
    # Создадим какие- то задачи!
    for i in range():
        create_task(<some other coro>)

Единственная разница между loop.create_task() и asyncio​.ensure_future() состоит в заголовке и введении в заблуждение вновь прибывших. Мы поясним эти различия в нашем следующем разделе.

Задачи и фьючерсы

В своём предыдущем разделе мы обсудили сопрограммы и как они должны запускаться в некотором цикле чтобы они стали полезными. Здесь мы кратко поговорим об API Task и Future. То, с чем вы будете работать наиболее часто, это Task, поскольку большинство вашей работы будет содержать выполнение сопрограмм при помощи метода loop.create_task(), точно так же, как это было установлено ранее в разделе Быстрое начало. Класс Future на самом деле является суперклассом Task и предоставляет всю функциональность для работы с основным циклом.

Проще всего это воображать себе так: Future (Фьючерс) представляет состояние завершения некоторого действия и управляется самим циклом, в то время как Task в точности то же самое и то, где специфическим "действием" является некая сопрограмма; возможно, одна из созданных вами при помощи async def функция плюс loop.create_task().

Класс Future представляет некое состояние чего- то, что взаимодействует с циклом. Это описание слишком расплывчатое чтобы быть полезным, поэтому представляйте себе вместо этого некий экземпляр Future как такое: это именно то, что выступает переключателем для состояния завершения. Когда некий экземпляр Future создан, такой переключатель "ещё пока не завершён"; однако через некоторое время спустя он завершится. На самом деле некий экземпляр Future имеет метод с названием done():

>>> from asyncio import Future
>>> f = Future()
>>> f.done()
False

Некий экземпляр Future также может:

иметь некий набор значений "результата" (.set_result(value) и .result() для его получения)

завершаться с помощью .cancel() (и выполнять проверку завершения посредством .cancelled())

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

Даже несмотря на то, что Tasks более распространены, вы не можете полностью избежать Future: например, запуск некоей функции в каком- то исполнителе возвратит экземпляр Future, а не Tasks. {Прим. пер.: как определяет словарь, фьючерс- программная конструкция, указывающая на то, что результат некоего действия будет использоваться в программе позже, но само вычисление может планироваться системой в любой произвольный момент времени.} Давайте быстро взглянем на код примера чтобы получить представление о том, как работать с неким экземпляром Future напрямую:

>>> import asyncio
>>> async def main(f: asyncio.Future):⁽¹⁾
...     await asyncio.sleep(1)
...     f.set_result('I have finished.')⁽²⁾
>>> loop = asyncio.get_event_loop()
>>> fut = asyncio.Future()⁽³⁾
>>> print(fut.done())⁽⁴⁾
False
>>> loop.create_task(main(fut))⁽⁵⁾
<Task pending coro=<main() running at <ast>:4>>
>>> loop.run_until_complete(fut)⁽⁶⁾
'I have finished.'
>>> print(fut.done())
True
>>> print(fut.result())⁽⁷⁾
I have finished.

⁽¹⁾ Создаём некий образей основной функции. Это даёт нам нечто для запуска, немного выжидаем, а затем устанавливаем какой- то результат в этом Future, f.

⁽²⁾ Устанавливаем необходимый результат.

⁽³⁾ Вручную создаём некий экземпляр фьючерса. Отметим, что этот экземпляр (по умолчанию) связан с нашим loop, однако он не подключён, и не будет в дальнейшем подключаться к какой бы то ни было сопрограмме (то, для чего и создаются задачи).

⁽⁴⁾ Прежде чем что- то делать, проверяем что данный фьючерс ещё не сделан.

⁽⁵⁾ Планируем необходимую сопрограмму main(), передаём данный фьючерс. Помните, всё что делает сопрограмма main() это то, что она выполняет ожидание, а затем переключает необходимый экземпляр фьючерса. (Отметим, что наша сопрограмма main() ещё пока не запущена: сопрограммы запускаются только после запуска их цикла.)

⁽⁶⁾ run_until_complete() Future Task [В этом месте в документации имеется ошибка: Подпись выдаётся как AbstractEventLoop​ .run_until_complete(future), но на самом деле должно быть AbstractEventLoop​ .run_until_complete(coro_or_future), так как применяется то же самое правило.] Теперь, когда основной цикл запущен, наша сопрограмма main() начинает исполняться.

⁽⁷⁾

В конце концов, когда результат фьючерса установлен, он завершается. После завершения можно осуществить доступ к полученному результату.

Конечно, маловероятно что вы будете работать с Future напрямую, как мы показали это выше. Данный пример кода приводится исключительно для обучения. Большая часть ваших контактов с asyncio будет происходить через экземпляры Task.

Один из самых последних примеров, чтобы доказать что некая Task на самом деле какое- то небольшое декорирование в Future: мы можем повторить точно тот же самый пример что и выше, но вместо этого воспользовавшись Task:

>>> import asyncio
>>> async def main(f: asyncio.Future):
...     await asyncio.sleep(1)
...     f.set_result('I have finished.')
>>> loop = asyncio.get_event_loop()
>>> fut = asyncio.Task(asyncio.sleep(1_000_000))⁽¹⁾
>>> print(fut.done())
False
>>> loop.create_task(main(fut))
<Task pending coro=<main() running at <ast>:4>>
>>> loop.run_until_complete(fut)
'I have finished.'
>>> print(fut.done())
True
>>> print(fut.result())
I have finished.

⁽¹⁾ Одно единственное отличие: некий экземпляр Task вместо Future. Конечно, соответствующий API Task требует от нас предоставления какой- то сопрограммы, поэтому мы просто применяем sleep(), так как это подходит нам, и мы применяем некую комично большую продолжительность чтобы подчеркнуть некую потенциальную догадку: данная Task завершится после установки некоторого результата, вне зависимости от того будет ли завершена или нет лежащая в основе сопрограмма.

Данный пример работает в точности как это было до сих пор и показывает что даже некая задача может быть завершена ранее вручную путём установки в ней результата, в точности так же, как некий экземпляр Future может осуществить завершение. И опять- таки, это не очень востребовано на практике - вместо этого вы обычно выполняете ожидание завершения задачи или её прекращения в явном виде - но, надеюсь, это было полезно для понимания того что задачи и фьючерсы очень близки к тому чтобы быть синонимами: единственное отличие состоит в том, что задача ассоциируется с какой- то сопрограммой.

Создать задачу? Обеспечить фьючерс? Прими решение!

В Быстром начале мы сказали, что верный путь для запуска сопрограмм состоит в использовании loop.create_task(coro). Это возвращает нас к тому, что этого же можно достичь другой функцией уровня модуля: asyncio.ensure_future().

В процессе исследований для данной книги я убедился, что данный метод asyncio.ensure_future() несёт ответственность за большую часть распространённого недоразумения относительно самой библиотеки asyncio. Основная часть имеющегося API является достаточно ясной, однако имеется пара в которых можно споткнуться при обучении, и именно это один из них. Когда вы сталкиваетесь с ensure_future(), ваше сознание очень усердно работает над тем как интегрировать его в умственное построение модели того как следует применять asyncio - и терпит неудачу!

Основная проблема с ensure_future() лучше всего высвечивается этим имеющим теперь дурную репутацию пояснением из документации asyncio самого Python:

asyncio.ensure_future(coro_or_future, *, loop=None)

Планирование собственно исполнения некоторого объекта сопрограммы: его обёртывание в фьючерсе. Возвращает некий объект Task. Если в качестве аргумента выступает Future, он возвращается непосредственно.

-- Документация Python 3.6 {Прим. пер.: см. изменения в Python 3.7}

Что-а!? К счастью, кот более ясное описание ensure_future():

Если вы передаёте некую сопрограмму, будет произведён некий экземпляр Task (и ваша сопрограмма будет запланирована на исполнение в соответствующем цикле событий). Это идентично вызову loop.create_task(coro) и возврату получаемого нового экземпляра Task. {Прим. пер., Python 3.7: для проверки применяется вначале isfuture(), а затем iscoroutine() и возвращается обёрнутый объект Task в случае сопрограммы на входе или подобный Future объект без изменений - см. следующий пункт! Если же объект Task пребывает в состоянии ожидания - что проверяется при помощи inspect.isawaitable() - возвращается некий объект Task, который выполняет ожидание полученного в качестве аргумента объекта.}

Если вы пробрасываете некий экземпляр Future (который содержит экземпляры Task, так как Task является субклассом Future), вы получаете возвращаемой ту же самую вещь, неизменной! Да, на самом деле!

Давайте подробнее рассмотрим как это работает:

import asyncio

async def f():⁽¹⁾
    pass

coro = f()⁽²⁾
loop = asyncio.get_event_loop()⁽³⁾

task = loop.create_task(coro)⁽⁴⁾
assert isinstance(task, asyncio.Task)⁽⁵⁾

new_task = asyncio.ensure_future(coro)⁽⁶⁾
assert isinstance(new_task, asyncio.Task)

mystery_meat = asyncio.ensure_future(task⁽⁷⁾
assert mystery_meat is task⁽⁸⁾

⁽¹⁾ Простая функция сопрограммы без действий. Нам просто требуется нечто чтобы создать какую- то сопрограмму.

⁽²⁾ Здесь мы заставляем данный объект сопрограммы вызывать свою функцию напрямую. Ваш код будет редко делать такое, но я хочу сделать это явном виде (через несколько строк), чтобы мы передавали некий объект сопрограммы в каждый из create_task и ensure_future().

⁽³⁾ Получаем необходимый цикл.

⁽⁴⁾ С самого начала мы применяем loop.create_task() для планирования своей сопрограммы в нашем цикле и мы получаем некий новый экземпляр Task обратно.

⁽⁵⁾ Здесь мы проверяем полученный тип. До сих пор ничего примечательного.

⁽⁶⁾ Здесь мы показываем как asyncio.ensure_future() может применяться для выполнения токо же самого действия, которое совершает create_task: мы передаём некую сопрограмму и получаем обратно какой- то экземпляр Task (а наша сопрограмма оказывается запланированной для исполнения в основном цикле)! Если вы передаёте некую сопрограмму, не имеется разницы между loop.create_task() и asyncio.ensure_future().

⁽⁷⁾ Но что произойдёт если мы передадим некий экземпляр Task чтобы ensure_future()…? Отметим, что мы передаём уже созданный экземпляр задачи, которая была создана loop.create_task() на шаге 4.

⁽⁸⁾ Мы получаем обратно в точности тот же самый экземпляр Task что мы и передали: он передаётся в неизменном виде. {Прим. пер.: только если эта задача не находится в ожидании - см нашу сноску выше с цитированием документации Python 3.7 - в этом случае возвращается новая Task, ожидающая полученный в качестве аргумента объект в состоянии ожидания.}

Итак: что именно является непосредственным пунктом передачи экземпляров Future? И зачем делать два разных дела в одной функции? Ответ состоит в том, что данная функция, ensure_future(), предназначается для применения авторами инструментальных средств для предоставления API разработчиком конечных приложений и которая может обрабатывать оба вида параметров {даже три!}. Не верите мне? Вот что следует от самого Благословенного Диктатора Всея Жизни (BDFL):

Основной момент ensure_future() состоит в том, что если у вас есть нечто, что может быть либо сопрограммой, либо неким Future (и этот последний содержит некую Task, поскольку она является субклассом Future), и вы желаете иметь возможность вызывать некий метод в нём, который определён только в Future (скорее всего, единственным полезным примером является cancel()). Когда это уже Future (или Task) не делается ничего; когда это некая сопрограмма, она обёртывается некоторой Task.

Если вы знаете что у вас имеется некая сопрограмма и вы желаете её запланировать, более верным API для применения будет create_task(). Единственный случай, в котором вам следует вызывать ensure_future() это когда вы предоставляете некий API (как это делает большая часть владельцев API), который принимает либо некую сопрограмму, либо какой- то Future и вам требуется делать нечто с этим, и что требует чтобы у вас имелся Future[10].

-- Гвидо ван Россум, github.com/python/asyncio/issues/477

Итак, суммируем: наш API asyncio.ensure_future() является некоторой вспомогательной функцией предназначенной для разработчиков инструментальных средств. Она проще всего поясняется по аналогии с более распространённым видом функции: если у вас есть за плечами пять лет опыта программирования, вы вы уже могли видеть функции подобные приводимой ниже listify():

def listify(x: Any) > List:
    """ Try hard to convert x into a list """
    if isinstance(x, (str, bytes)):
        return [x]

    try:
        return [_ for _ in x]
    except TypeError:
        return [x]

Эта функция пытается преобразовать полученный аргумент в некий перечень, вне зависимости от того что она получает. Такой вид функция часто применяется в API и инструментальных средствах для принуждения вводов в некий известный тип, что упрощает последующий код, так как вы знаете что этот параметр (выводимый из listify()) всегда будет неким списком.

Если я переименую свою функцию listify() в ensure_list(), то вы должны начать замечать определённую параллель с asyncio.ensure_future(), не так ли? Он всегда пытается принудительно преобразовывать получаемый аргумент в некий тип Future. Эта утилитарная функция чтобы делать жизнь более простой для разработчиков инструментальных средств, а не для разработчиков окончательных продуктов, коими являемся вы и я.

И на самом деле, даже стандартная библиотека asyncio сама по себе применяет ensure_future() в точности для этой цели. Когда вы в следующий раз будете просматривать API, вы повсеместно будете видеть некий параметр функции, описываемый как coro_or_future, и именно его, скорее всего, ensure_future() будет применять внутри себя для принудительного преобразования данного параметра. Например, имеющаяся функция asyncio.gather() имеет такуб отличительную особенность:

asyncio.gather(*coros_or_futures, loop=None, ...)

Внутри себя gather() применяет using ensure_future() для принудительного приведения типа и это делает для вас возможным передавать ему сопрограммы, задачи или фьючерсы.

Основным ключевым моментом здесь является: поскольку вы некий разработчик конечного приложения, вам никогда не приходится применять asyncio.ensure_future. Этот инструмент больше предназначен для разработчиков инструментальных средств. Если вам требуется спланировать некую сопрограмму в своём цикле событий, просто делайте это напрямую с помощью loop.create_task().

К сожалению, это не самый конец данной истории. Прямо сейчас некоторые разработчики конечных продуктов предпочитают применять asyncio.ensure_future() вместо loop.create_task() по одной простой и очень прагматичной причине: так меньше работы! Чтобы вызвать create_task() вам либо требуется некий экземпляр loop, доступный в вашем локальном пространстве имён, в котором вы пишите код, или же вам необходим некий дополнительный вызов asyncio.get_event_loop() для получения соответствующей ссылки на loop; в противоположность этому, ensure_future() может вызываться как есть.

В приведённом выше примере у нас имелось три функции сопрограмм внутри которых будут запущены некие фоновые сопрограммы. Основная цель данного упражнения состояла в сопоставлении эстетических преимуществ применения create_task() по отношению к ensure_future() при планировании сопрограмм в основном цикле.

Пример 3-9. Сопоставление применения create_task() и ensure_future()

import asyncio

async def background_job():
    pass

async def option_A(loop):⁽¹⁾
    loop.create_task(background_job())

async def option_B():⁽²⁾
    asyncio.ensure_future(background_job())

async def option_C():⁽³⁾
    loop = asyncio.get_event_loop()
    loop.create_task(background_job())

loop = asyncio.get_event_loop()

loop.create_task(option_A(loop))⁽¹⁾
loop.create_task(option_B)⁽²⁾
loop.create_task(option_C)⁽³⁾

⁽¹⁾ В этом первом варианте, после создания необходимого цикла обычным образом мы планируем ту сопрограмму, которая возвращается из option_A(). Внутри создаётся некая новая задача для данного фонового задания. Поскольку мы не ожидаем данную фоновую задачу, наша сопрограмма option_A() выполнит выход; однако это не самое интересное здесь. Чтобы запланировать данную фоновую задачу при помощи вызова create_task(), было необходимо иметь доступным соответствующий объект loop. В данном случае мы передаём его когда создавалась сопрограмма option_A().

⁽²⁾ В нашем втором случае мы уже располагаем некоторой спланированной задачей, однако не требовалось передавать соответствующий экземпляр loop, так как наша функция ensure_future() доступна непосредственно в самом модуле asyncio. И это существенный момент: некоторые люди применяют ensure_future() не из- за её способности принудительного приведения типов к экземплярам Future, а вместо того чтобы не пришлось передавать идентификаторы loop.

⁽³⁾ И наш окончательный вариант, в нём также требуется передавать некий экземпляр цикла. В этом случае мы получаем текущий цикл событий [в этом случае "текущий" означает тот экземпляр цикла событий, который связан с данным действующим потоком] путём вызова get_event_loop(), а затем мы вновь способны делать "все правильные вещи" и вызвать loop.create_task(). Это аналогично тому что делает внутри себя ensure_future().

Что в действительности следует иметь доступным, так это вспомогательную функцию asyncio.create_task​ (coro), которая делает в точности то же самое что и наш "Вариант C"? приведённый выше, но предопределённой в имеющейся стандартной библиотеке. Это низложит удобство ensure_future() и сохранит ясность loop.create_task(). Эта потребность не замечалась командой разработки Python, но я счастлив сообщить, что asyncio.create_task() в конечном счёте будет доступна в Python 3.7!

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

Диспетчеры контекста Async: async with

Сопровождение контекста диспетчеров оказывается исключительно удобным. Она действительно имеет смысл, так как многие варианты требуют чтобы сетевые ресурсы - например, соединения - должны открываться и закрываться в определённой надлежащим образом области.

Основным ключом к пониманию async with является осознание того, что определённая операция некоторого диспетчера контекста управляется вызовами метода; с последующим рассмотрением соображения: а что если бы такие методы были бы функциями сопрограмм? И на самом деле, это в точности так и работает. Вот псевдокод, демонстрирующий всё это:

Пример 3-10. Диспетчер контекста Async

class Connection:
    def __init__(self, host, port):
        self.host = host
        self.port = port
    async def __aenter__(self):⁽¹⁾
        self.conn = await get_conn(self.host, self.port)
        return conn
    async def __aexit__(self, exc_type, exc, tb):⁽²⁾
        await self.conn.close()

async with Connection('localhost', 9001) as conn:
    <do stuff with conn>

⁽¹⁾ Вместо специального метода __enter__() для синхронизации диспетчеров контекста применяется особый новый метод __aenter__().

⁽²⁾ Более того, вместо __exit__() используется __aexit__(). Все прочие параметры идентичны параметрам __exit__() и заполняются если возбуждается некая исключительная ситуация в самом теле диспетчера контекста.

[Предостережение]
Предостережение

просто потому что вы должны применять asyncio в своей программе, это не означает, что все ваши диспетчеры контекста обязаны синхронизироваться именно так! Такой порядок полезен только если вам требуется выполнять await чего- то внутри своих методов enter и exit. Если нет никаого кода ввода/ вывода с блокировкой, просто применяйте диспетчеры контекста.

А теперь - только между нами - мне не сильно нравится такой стиль применения диспетчера контекста в явном виде, в то время как имеется великолепный декоратор @contextmanager в соответствующем модуле contextlib стандартной библиотеки! Как вы можете догадаться, имеется и некая асинхронная версия, @asynccontextmanager, но, к сожалению, она доступна только начиная с Python 3.7, который ещё не был доступен на момент написания данной книги {Прим. пер.: На момент перевода, всё уже хорошо, Python 3.7 доступен.} Тем не менее, достаточно просто скомпилировать Python из исходного кода и в нашем следующем разделе покажем как @asynccontextmanager будет работать в Python 3.7.

Вариант contextlib

Он аналогичен декоратору @contextmanager из соответствующего модуля contextlib стандартной библиотеки. Для краткого повтора, давайте вначале рассмотрим соответствующий вариант с блокировкой:

Пример 3-11. Вариант с блокировкой

from contextlib import contextmanager

@contextmanager⁽¹⁾
def web_page(url):
    data = download_webpage(url)⁽²⁾
    yield data
    update_stats(url)⁽³⁾

with web_page('google.com') as data:⁽⁴⁾
    process(data)⁽⁵⁾

⁽¹⁾ Соответствующий декоратор contextmanager преобразовывает некую функцию генератора в какой- то диспетчер контекста.

⁽²⁾ Этот вызов функции, (который я изготовил специально для данного примера) выглядит подозрительно похоже на конкретный вид вещей, которые желают применять некий сетевой интерфейс, который на много порядков величины медленнее "обычного" кода, ограниченного ЦПУ. Такой диспетчер контекста должен применяться в некотором выделенном потоке, в противном случае вся программа будет приостановлена в ожидании данных.

⁽³⁾ Представим что мы обновляем какие- то статистические данные всякий раз, когда мы обрабатываем данные из некоторого URL, например, общее число раз, которое URL был выгружен. Опять- же с точки зрения одновременного исполнения, нам придётся знать будет ли эта функция вовлечена в ввод/ вывод внутренним образом, например подобно записи в некую базу данных поверх сетевой среды. Если это так, update_stats() также является неким блокирующим вызовом.

⁽⁴⁾ Здесь применяется наш диспетчер контекста. Особенно отметим как этот сетевой вызов (для download_webpage()) скрывается внутри соответствующей конструкции нашего диспетчера контекста.

⁽⁵⁾ Данный вызов функции, process(), также может быть блокирующим. Нам придётся рассмотреть что делает данная функция. Чтобы предоставить вам общий взгляд на различные подлежащие рассмотрению вопросы, которые служат для принятия решения будет ли данный вызов функции выполнять "блокировку", либо же он будет "неблокирующим" перечислим те, которые могут ими служить:

безобидные без блокировки (быстрые и ограниченный ЦПУ)

с мягкой блокировкой (быстрой и ограниченной вводом/ выводом, возможно, нечто подобное быстрому доступу к диску вместо сетевого ввода/ вывода)

блокирующие (медленный, ограниченные вводом/ выводом)

демонические (медленные, ограниченные ЦПУ)

С целью упрощения данного примера, давайте предположим, что данный вызов process() является быстрой, ограниченной ЦПУ операцией и, следовательно, не имеющей блокировки.

Давайте сопоставим в точности тот же самый пример, но на этот раз и применением новой вспомогательной функции, осведомлённой об асинхронности, привносимой Python 3.7:

Пример 3-12. Вариант без блокировки

from contextlib import asynccontextmanager

@asynccontextmanage⁽¹⁾
async def web_page(url):⁽²⁾
    data = await download_webpage(url)⁽³⁾
    yield data⁽⁴⁾
    await update_stats(url)⁽⁵⁾

async with web_page('google.com') as data:⁽⁶⁾
    process(data)

⁽¹⁾ Наш новый asynccontextmanager применяется точно так же.

⁽²⁾ Тем не менее, он требует, чтобы функция декорирующего генератора объявлялась с применением async def.

⁽³⁾ Как и ранее, прежде чем сделать данные доступными в самом теле нашего диспетчера контекста, мы выбираем эти данные из необходимого URL. В данном случае я добавил необходимое ключевое слово await, которое сообщает нам что эта сопрограмма позволит соответствующему циклу событий запускать прочие задачи пока мы ожидаем завершения своего сетевого вызова.

Заметим, что мы не можем просто прибить своё ключевое слово await куда пожелаем. Данное изменение также предполагает, что мы также способны изменить саму свою функцию download_webpage() и преобразовать её в некую сопрограмму, которая совместима с этим ключевым словом await. Для тех случаев, когда невозможно изменять данную функцию, необходим иной подход и мы обсудим его в своём следующем примере.

⁽⁴⁾ Как и ранее, все данные делаются доступными самому телу нашего диспетчера контекста. Я пытаюсь сохранять данный код простым, и поэтому я опускаю обработчики try/finally, которые обычно вам следует писать для рассмотрения исключительных ситуаций, возбуждаемых в теле вызывающей стороны.

Отметим: Собственно присутствие yield является именно тем, что преобразует некую функцию в функцию генератора; дополнительно присутствие соответствующих ключевых слов async def в пункте 1 превращает её в асинхронную функцию генератора. При своём вызове она будет возвращать некий асинхронный генератор. Наш модуль inspect имеет две функции, которые могут проверить это: соответственно, isasyncgenfunction() и isasyncgen().

⁽⁵⁾ Тут мы предполагаем, что мы также преобразовали свой код внутри функции update_stats() чтобы позволить ему производить сопрограммы. Затем мы можем применить ключевое слово await, которое допустит некое переключение в нашем цикле событий пока мы ожидаем завершение работы, ограниченной соответствующим вводом/ выводом.

⁽⁶⁾ Для применения самого диспетчера контекста требовалось другое изменение: нам требовалось применять async with вместо with.

К счастью, данный пример показывает, что наш новый @asynccontextmanager в точности аналогичен имевшемуся ранее декоратору @contextmanager.

В сноске 3 предыдущего примера я сказал, что было необходимо изменить некоторые функции для возврата сопрограмм; ими были download_webpage() иupdate_stats(). Обычно это не так просто сделать, так как необходимо добавить поддержку асинхронности на нижнем уровне сокета.

Основной фокус предыдущего примера был нацелен на то что бы просто показать наш новый @asynccontextmanager, а не показывать как преобразовывать функции с блокировкой в неблокирующие. Более общий случай состоит в том, что вам может понадобиться применять в своей программе некую функцию с блокировкой, но не будет возможности изменять имеющийся в таких функциях код.

Такая ситуация часто происходит с библиотеками сторонних разработчиков и прекрасным примером может послужить соответствующая библиотека requests, которая внутри себя применяет вызовы с блокировкой [Может оказаться весьма сложным добавление поддержки Async в некое имеющееся инструментальное средство по данной причине, так как могут потребоваться большие структурные изменения. Это обсуждается в эссе для Requests github]. Хорошо, раз вы не можете изменить подлежащий вызову код, имеется иной путь, и это удобное место показать как именно для этого можно применять Исполнитель (executor):

Пример 3-13. Вариант без блокировки с небольшой вспомогательной функцией от моих друзей

from contextlib import asynccontextmanager

@asynccontextmanager
async def web_page(url):⁽¹⁾
    loop = asyncio.get_event_loop()
    data = await loop.run_in_executor(
        None, download_webpage, url)⁽²⁾
    yield data
    await loop.run_in_executor(None, update_stats, url)⁽³⁾

async with web_page('google.com') as data:
    process(data)

⁽¹⁾ В данном примере мы предполагаем, что у нас нет возможности изменить свой код для двух вызовов с блокировкой download_webpage() и update_stats(), то есть мы не можем модифицировать их в функции сопрограмм. Это плохо, ибо самый серъёзный грех основанного на событиях программирования нарушает то правило, которое не следует нарушать и ни при каких обстоятельствах не мешать основному циклу событий обрабатывать события.

Чтобы обойти данную проблему, мы будем применять некий Исполнитель для запуска вызовов с блокировкой в каком- то отдельном потоке. Такой Исполнитель предоставляется еам в виде некоего атрибута самого основного цикла событий .

⁽²⁾ Здесь мы вызываем своего Исполнителя. Его отличительной особенностью является AbstractEvent Loop.run_in_executor(executor, func, *args) и, если вы желаете применять определяемый по умолчанию Исполнитель (которым является ThreadPoolExecutor) тогда вам следует передать None в качестве аргумента для соответствующего "Исполнителя". [Это очень раздражает. Всякий раз, когда я применяю данный вызов, я не могу задаваться вопросом, почему бы не предпочесть в качестве аргумента некоего ключевого слова идиому использования executor=None.]

⁽³⁾ Как и в случае вызова download_webpage(), мы также можем запустить другой блокирующий вызов к update_stats() в некотором Исполнителе. Отметим, что вы обязаны применять ключевое слово await спереди этого вызова. Если вы его забудете, получаемое исполнение вашего асинхронного генератора (то есть вашего асинхронного диспетчера контекста) не будет выполнять ожидания необходимого вызова на завершение перед обработкой.

Скорее всего асинхронные диспетчеры контекста станут широко применяться во многих кодах, основанных на asyncio, следовательно достаточно важно иметь о них хорошее представление. Вы можете прочесть дополнительные подробности о новом асинхронном декораторе диспетчера контекста в документации Python 3.7.

[Замечание]
Замечание

asyncio всё ещё находится в стадии активной разработки командой разработчиков Python и в Python 3.7 доступны некие прочие небольшие, но существенные улучшения помимо asynccontextmanager, включая следующие небольшие примеры:

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

asyncio.create_task(): создаёт некую задачу без необходимости указания в явном виде идентификатора цикла.

AbstractEventLoop.sock_sendfile(): отправляет некий файл поверх сокета TCP с применением высокопроизводительного API os.sendfile().

AbstractEventLoop.start_tls(): обновляет некое имеющееся соединение до безопасного транспортного уровня (TLS).

asyncio.Server.serve_forever(): API очистки для запуска сетевых серверов asyncio.

Итераторы Async: async for

Помимо async def и await имеется ещё несколько расширений в синтаксисе языка Python. Первым из них является асинхронная версия для цикла "for". Проще всего понять как это работает если вы вначале поймёте что простая итерация - так же как и многие другие функции языка программирования - реализуется при помощи специальных методов, отличающих их двойными подчёркиваниями в соответствующих названиях.

Например, вот как некий стандартный (не асинхронный) итератор определяется посредством применения методов __iter__() и __next__():

>>> class A:
...     def __iter__(self):⁽¹⁾
...         self.x = 0⁽²⁾
...         return self⁽³⁾
...     def __next__(self):⁽⁴⁾
...         if self.x > 2:
...             raise StopIteration⁽⁵⁾
...         else:
...             self.x += 1
...             return self.x⁽⁶⁾
>>> for i in A():
...     print(i)
123

⁽¹⁾ Некий Итератор обязан реализовывать соответствующие специальный метод __iter__().

⁽²⁾ Инициализируем некое состояние в его "начальном" значении состояния.

⁽³⁾ Наш особый метод __iter__() должен возвращать нечто итерируемое, т.е. какой- то объект, который реализует специальный метод __next__(). В данном случае, тот же самый экземпляр, так как A сам по себе также реализует соответствующий особый метод __next__().

⁽⁴⁾ Метод __next__() определён. Он буде вызываться для каждого последующего шага в итерациях, до тех пор пока ...

⁽⁵⁾ ... не будет возбуждена StopIteration.

⁽⁶⁾ Здесь вырабатываются возвращаемые значения для каждой итерации.

Теперь вы зададите справедливый вопрос: что произойдёт если вы объявите свой особый метод __next__() в качестве некоторой функции сопрограммы async def? Это позволит ему выполнять await некоторого вида операций, ограниченных вводом/ выводом; а также это во многом в точности повторяет то, как работает async for, за исключением маленьких деталей вокруг именований. Имеющаяся спецификация (в PEP 492) показывает, что чтобы применять async for в качестве какого- то асинхронного итератора в самом этом асинхронном итераторе требуются определённые моменты:

Вам требуется реализовать def __aiter__() (Отмечаем: без) async def!)

__aiter__() должен возвращать некий объект, который реализует async def __anext__()

__aiter__() должен возвращать некое значение для каждой итерации и при завершении должен возбуждать StopAsyncIteration

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

import asyncio
from aioredis import create_redis

async def main():⁽¹⁾
    redis = await create_redis(('localhost', 6379))⁽²⁾
    keys = ['Americas', 'Africa', 'Europe', 'Asia']⁽³⁾

    async for value in OneAtATime(redis, keys):⁽⁴⁾
        await do_something_with(value)⁽⁵⁾

class OneAtATime:
    def __init__(self, redis, keys):⁽⁶⁾
        self.redis = redis
        self.keys = keys
    def __aiter__(self):⁽⁷⁾
        self.ikeys = iter(self.keys)
        return self
    async def __anext__(self):⁽⁸⁾
        try:
            k = next(self.ikeys)⁽⁹⁾
        except StopIteration:⁽¹⁰⁾
            raise StopAsyncIteration

        value = await redis.get(k)⁽¹¹⁾
        return value

asyncio.get_event_loop().run_until_complete(main())

⁽¹⁾ Это основная функция: вы запускаем её при помощи run_until_complete() в нижней части своего примера кода.

⁽²⁾ Для получения соединения мы применяем интерфейс верхнего уровня из aioredis.

⁽³⁾ Представим себе, что каждое из связанных с этими ключами значение достаточно большое и хранится в соответствующем экземпляре Redis.

⁽⁴⁾ Здесь мы применяем async for: основной момент состоит в том, что итерация сама по себе способна подвешивать себя пока не дождётся появления своего следующего значения.

⁽⁵⁾ Для полноты допустим что мы также выполняем некие действия, связанные с вводом/ выводом для своей выборки данных; может происходить обмен данными и затем они отправляются другому получателю.

⁽⁶⁾ Собственно инициализатор данного класса достаточно простой: мы сохраняем необходимое подключение к экземпляру Redis и соответствующий перечень ключей, по которым требуется выполнять итерацию.

⁽⁷⁾ В точности как и для предыдущего примера кода с __iter__(), мы используем __aiter__() для настройки необходимых для итерации моментов. Мы создаём некий обычный итератор по имеющимся ключам, self.ikeys, а также return self, так как OneAtATime также реализует соответствующие метод __anext__() сопрограммы.

⁽⁸⁾ Отметим, что метод __anext__()объявляется с применением async def, в то время как метод __aiter__() объявляется только при помощи def.

⁽⁹⁾ Для каждого ключа выполняем выборку его значения из Redis: self.ikeys является обычным итератором по имеющимся ключам, поэтому мы применяем next() для перемещения по ним.

⁽¹⁰⁾ Когда израсходованы self.ikeys, обрабатываем соответствующий StopIteration и просто возвращаем его в StopAsyncIteration! Именно таким образом вы сигнализируете об останове изнутри некоего асинхронного итератора.

⁽¹¹⁾ Наконец - вся цель данного примера - мы можем получить необходимые данные из Redis при помощи данного ключа. Мы можем выполнить await для этих данных, что означает, что другой код вашего цикла событий может выполняться пока мы дожидаемся сетевого ввода/ вывода.

Надеемся, что этот пример является понятным: async for предоставляет возможность оставаться в удобном простом цикле for, даже при итерации данных при которых сама по себе итерация выполняет ввод/ вывод. Основное преимущество от этого состоит в том что вы можете обрабатывать гигантские объёмы данных, причём всё это делать в отдельном цикле, так как вам придётся всякий раз иметь дело только с каждым фрагментом в небольших партиях.

Генераторы Async: yield внутри async def функций

Асинхронные генераторы отвечают на законный вопрос: "Что произойдёт если применить yield внутри естественной функции сопрограммы async def?" Эта концепция может вводить в заблуждение если у вас имеется некий опыт применения генераторов как если бы они были сопрограммами, так как это происходит в инструментальном средстве Twisted или в инфраструктуре Tornado, или даже как в случае yield from из asyncio Python 3.4.

Следовательно, прежде чем мы продолжим данный раздел, было бы лучше, чтобы вы убедили себя что:

Сопрограммы и генераторы совершенно разные понятия.

Асинхронные генераторы ведут себя во многом подобно обычным генераторам.

Для итераций вы применяете async for для асинхронных генераторов вместо обычного for в обычных генераторах.

Наш пример, использованный в предыдущем разделе для демонстрации какого- то асинхронного итератора при итерациях по Redis оказывается намного проще если мы установим некий асинхронный генератор:

Пример 3-14. С асинхронным генератором проще

import asyncio
from aioredis import create_redis

async def main():⁽¹⁾
    redis = await create_redis(('localhost', 6379))
    keys = ['Americas', 'Africa', 'Europe', 'Asia']

    async for value in one_at_a_time(redis, keys):⁽²⁾
        await do_something_with(value)

async def one_at_a_time(redis, keys):⁽³⁾
    for k in keys:
        value = await redis.get(k)⁽⁴⁾
        yield value⁽⁵⁾

asyncio.get_event_loop().run_until_complete(main())

⁽¹⁾ Самая основная функция main() идентична той, которая была в нашем коде Итераторы Async: async for.

⁽²⁾ Ладно, почти идентична: мне пришлось изменить её название с варианта написания верблюдом, на змеиный регистр (с подчёркиванием).

⁽³⁾ Наша функция теперь объявляется с помощью async def, превращая её в некую функцию сопрограммы, а так как эта функция также содержит ключевое слово yield, мы именуем её асинхронной функцией генератора.

⁽⁴⁾ Нам не требуется выполнять путанные действия, требовавшиеся в нашем предыдущем примере с помощью self.ikeys: здесь мы просто обходим циклом по имеющимся ключам напрямую, получая необходимые значения...

⁽⁵⁾ ... и собирая их для вызывавшей стороны, в точности как в обычном генераторе.

Если вы новичок в данном вопросе, всё это может показаться слишком сложным, но я призываю вас поиграть со всеми этими примерами самостоятельно. Достаточно быстро вы начнёте ощущать это естественным. Асинхронные генераторы скорее всего станут очень популярными при кодировании на основе asyncio, поскольку они привносят в точности те же самые преимущества, что и обычные генераторы: делают код короче и проще.

Сообразительность Async

Теперь, когда мы рассмотрели как Python поддерживает асинхронные итерации, следующим естественным вопросом будет спросить понимает ли он также списки в for - и наш ответ ДА! Данная поддержка была введена в PEP 530 и я рекомендую вам просмотреть этот PEP самостоятельно. Он достаточно короткий и хорошо читаемый.

Пример 3-15. Async достаточно искушён чтобы работать со списками, словарями и наборами

>>> import asyncio
>>> async def doubler(n):
...     for i in range(n):
...         yield i, i * 2⁽¹⁾
...         await asyncio.sleep(0.1)⁽²⁾
>>> async def main():
...     result = [x async for x in doubler(3)]⁽³⁾
...     print(result)
...
...     result = {x: y async for x, y in doubler(3)}⁽⁴⁾
...     print(result)
...
...     result = {x async for x in doubler(3)}⁽⁵⁾
...     print(result)
>>> asyncio.get_event_loop().run_until_complete(main())
[(0, 0), (1, 2), (2, 4)]
{0: 0, 1: 2, 2: 4}
{(1, 2), (0, 0), (2, 4)}

⁽¹⁾ doubler() является очень простым асинхронным генератором: задавая некое верхнее значение он итеративно проходит по простому диапазону, принося кортежи из смого значения и его дублёра.

⁽²⁾ Небольшой сон, просто чтобы подчеркнуть что это некая асинхронная функция.

⁽³⁾ Широта охвата некоторого асинхронного списка: обратите внимание как async for применяется вместо обыкновенного for. Это отличие в точности то же, что а в Итераторы Async: async for.

⁽⁴⁾ Выразительность Async; все обычные трюки срабатывают, например, распаковка полученного кортежа в x и y с тем, чтобы они смогли запитать охват синтаксиса словаря.

⁽⁵⁾ Широта охвата асинхронного множества работает в точности так, как вы могли бы ожидать.

Обратной стороной нашей монеты, как выводит PEP 530, является применение await внутри выразительных средств. Но это не совсем так: await <coro> является обычным выражением и оно может применяться в большинстве мест, в которых вы это можете ожидать.

Именно async for является тем, что превращает выразительность в асинхронную выразительность, а не наличие await. Всё что вам требуется для допустимости await (внутри некоторого поглощения), это то, что вы находитесь внутри самого тела какой- то функции сопрограммы, то есть некоторой функции, объявленной с помощью async def. Поэтому хотя применение await и async for внутри того же самого поглощаемого списка в действительности сочетает два различных подхода, давайте сделаем это в любом случае чтобы продолжить снижать порого чувствительности вхождения в комфортное состояние от применения синтаксиса асинхронного языка программирования:

Пример 3-16. Собираем всё в кучу

>>> import asyncio
>>> async def f(x):⁽¹⁾
...   await asyncio.sleep(0.1)
...   return x + 100
>>> async def factory(n):⁽²⁾
...   for x in range(n):
...     await asyncio.sleep(0.1)
...     yield f, x⁽³⁾
>>> async def main():
...   results = [await f(x) async for f, x in factory(3)]⁽⁴⁾
...   print('results = ', results)
>>> asyncio.get_event_loop().run_until_complete(main())
results = [100, 101, 102]

⁽¹⁾ Очень простая функция сопрограммы: небольшое засыпание с последующим возвратом увеличенного на 100 значения параметра.

⁽²⁾ Именно этот асинхронный генератор мы будем вызывать внутри некоторого охватывающего асинхронного списка слегка ниже, применяя async for для продвижения по итерациям.

⁽³⁾ Этот асинхронный генератор будет производить некий кортеж из f и значения переменной итерации ч. Собственно возвращаемым значением f является некая функция сопрограммы, кщё пока не сопрограмма.

⁽⁴⁾ Наконец, собственно асинхронный охват (выразительность). Данный пример был слегка натянут чтобы продемонстрировать некую выразительность, которая включает в себя одновременно и async for, и await. Давайте разберём что происходит внутри данного охвата: вызов нашего factory(3) возвращает некий асинхронный генератор, который должен управляться итерациями. Так как он является неким асинхронным генератором, вы не можете просто применять for; вы обязаны воспользоваться async for.

Далее, те значения, которые производятся данным асинхронным генератором являются кортежем из функции сопрограммы f и некоторого int. Вызов данной функции сопрограммы f() производит некую сопрограмму, которая должна вычисляться при помощи await.

Заметим, что внутри данного охвата использование await не имеет ничего общего с применением async for: они выполняют совершенно различные вещи и действуют на целиком различные объекты.

Запуск и останов (Аккуратные!)

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

Из этих двоих, запуск намного проще. Самый стандартный способ запуска некоторого приложения asyncio состоит в создании какой- то задачи, с последующим вызовом loop.run_forever(), как это было показано в нашем примере Hello World из раздела Быстрое начало.

Единственным исключением может быть случай когда вам требуется запустить некий сервер ожидания. В таком случае запуск обычно является процесом из двух этапов:

Вначале создаёте некую сопрограмму только для самой "запускающей" фазы такого сервера, а затем воспользуйтесь run_until_complete() в данной инициализированной сопрограмме для запуска самого сервера.

Во- вторых, продолжите в свой обычной "главной" {main} части приложения посредством вызова loop.run_forever().

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

Останов является намного более интригующим.

Для останова вначале мы рассмотрим соответствующий танец, который следует за тем, когда нечто останавливает имеющийся цикл событий. Когда некий исполняемый цикл останавливается, соответствующий вызов run_forever() выполняет снятие блокировки и выполняется появляющийся после этого код. В этот момент вы обязаны:

собрать все всё ещё приостановленные объекты задач (если таковые имеются)

прекратить эти задачи (что возбудит CancelledError внутри каждой исполняемой сопрограммы, которые вы можете выбрать на обработку в try/except внутри самого тела данной функции сопрограммы

собрать все эти задачи в некую "группу" задач

к этой "группе" задач применить run_until_complete() чтобы дождаться их завершения, т.е. позволить такой исключительной ситуации CancelledError возбудиться и подвергнуться обработке

Только после этого останов завершится.

Обряд посвящения в построение ваших первых нескольких прикладных программ asyncio будет состоять из попыток избавления от сообщений подобных Task was destroyed but it is pending! {Задача была уничтожена, но она приостановлена!} на протяжении останова. Это происходит по причине пропуска одного или более перечисленных выше этапов. Вот пример данной ошибки:

Пример 3-17. Уничтожение приостановленных задач

# taskwarning.py
import asyncio

async def f(delay):
    await asyncio.sleep(delay)

loop = asyncio.get_event_loop()
t1 = loop.create_task(f(1))⁽¹⁾
t2 = loop.create_task(f(2))⁽²⁾
loop.run_until_complete(t1)⁽³⁾
loop.close()

⁽¹⁾ Задача 1 будет исполняться 1 секунду.

⁽²⁾ Задача 2 будет исполняться 2 секунды.

⁽³⁾ Исполняется пока не завершится задача 1.

Вывод:

$ python taskwarning.py
Task was destroyed but it is pending!
task: <Task pending coro=<f() done, defined at [...snip...]>>

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

Давайте рассмотрим более подробно код из своего примера Быстрого начала и рассмотрим эти фазы вновь. Мы проделаем это через мини- обучение с помощью эхо сервера на основе telnet.

На Рисунке 3-1 наш сервер запущен с левой стороны панели. Затем, справа, некий сеанс telnet выполняет подключение к нашему серверу. Мы набираем несколько команд, которые возвращаются к нам обратно (все приведённые в верхний регистр) и после этого мы отключаемся. Теперь мы рассмотрим тот код, который управляет данной программой.

Рисунок 3-1

Взаимодействие Telnet при помощи эхо сервера

Пример 3-18. Жизненный цикл приложения Asyncio (на основе представленного в документации Python эхо сервера)

from asyncio import (⁽¹⁾
    get_event_loop, start_server, CancelledError,
    StreamReader, StreamWriter, Task, gather)

async def echo(reader: StreamReader, writer: StreamWriter):⁽²⁾
    print('New connection.')
    try:
        while True:⁽³⁾
            data: bytes = await reader.readline()⁽⁴⁾
            if data in [b'', b'quit']:
                break
            writer.write(data.upper())⁽⁵⁾
            await writer.drain()
        print('Leaving Connection.')
    # except CancelledError:⁽⁶⁾
    # writer.write_eof()
    # print('Cancelled')
    finally:
        writer.close()

loop = get_event_loop()
coro = start_server(echo, '127.0.0.1', 8888, loop=loop)⁽⁷⁾
server = loop.run_until_complete(coro)⁽⁸⁾

try:
    loop.run_forever()⁽⁹⁾
except KeyboardInterrupt:
    print('Shutting down!')

server.close()⁽¹⁰⁾
loop.run_until_complete(server.wait_closed())⁽¹¹⁾

tasks = Task.all_tasks()⁽¹²⁾
for t in tasks:
    t.cancel()
group = gather(*tasks, return_exceptions=True)⁽¹³⁾
loop.run_until_complete(group)⁽¹⁴⁾
loop.close()

⁽¹⁾ Импортируем кучу вещей из пространства имён asyncio - для данной книги мне приходится контролировать длин строки!

⁽²⁾ Данная функция сопрограммы echo() будет применена (нашим сервером) для создания какой- то сопрограммы для каждого выполненного соединения. Эта сопрограмма применяет API streams для сетевого обмена с asyncio.

⁽³⁾ Для поддержки подключения у нас будет некий бесконечный цикл ожидающий сообщения.

⁽⁴⁾ Ожидаем некую строку данных с другой стороны.

⁽⁵⁾ Возвращаем полученный данные обратно, но целиком в верхнем регистре.

⁽⁶⁾ Я интенсивно комментирую данный блок чтобы привлечь внимание к тому, что случится если ваша сопрограмма не отработает должным образом прекращение. Если вы действительно обработаете CancelledError, вы получите некий шанс выполнить всю очистку, особенную для сценария останова.

⁽⁷⁾ Именно это является запускающей фазой нашей программы: наш сервер требует некоторого отдельного шага из имеющегося "основного" этапа run_forever(). Наша функция start_server() возвратит какую- то сопрограмму и она должна быть run_until_complete(). Отметим как передаётся наша функция сопрограммы echo: она будет использоваться в качестве некоторой фабрики, которая производит новую сопрограмму для каждого нового подключения.

⁽⁸⁾ Исполним эту сопрограмму для запуска своего сервера TCP.

⁽⁹⁾ Только теперь мы начинаем свою основную, "ожидающую" часть программы. Начиная с этого момента всякое выполненное для нашего сервера подключение TCP породит некую сопрограмму из нашей функции сопрограммы echo. Единственная вещь, которая может остановить наш цикл событий это KeyboardInterrupt, которое является тем же самым, чем является SIGINT для систем Unix. (В некоторой промышленной системе вы будете применять особые обработчики сигналов, отличающиеся от KeyboardInterrupt; это представлено далее в разделе Сигналы.) .

⁽¹⁰⁾ По достижению этого места мы знаем что был инициирован останов, например, при помощи Ctrl-C. Самая первая вещь, которую необходимо сделать это предотвратить наш сервер от приёма дополнительных новых подключений. Это требует двух шагов: во- первых вызываем server.close()...

⁽¹¹⁾ ... а затем запускаем wait_closed() в определённом цикле для закрытия всех сокетов, которые всё ещё ожидают подключений, но которые ещё не соединились. Отметим, что все действующие подключения (например, порождённые из asyncio) с активными подключениями не подвергнутся воздействию: эти соединения останутся работающими.

⁽¹²⁾ Теперь мы можем начать останавливать задачи. Данная формирующая единое целое последовательность должна собрать все приостановленные в данный момент задачи в рассматриваемом цикле событий, прекратить их и затем собрать их в некую отдельную задачу группы.

⁽¹³⁾ Отметьте данный параметр return_exceptions. Он достаточно важен и отдельно обсуждается в нашем следующем разделе.

⁽¹⁴⁾ Как и ранее, для завершения запускаем полученную задачу группы.

Надеюсь, вы начинаете различать знакомый шаблон.

[Совет]
Совет

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

И вот почему: тот вызов all_tasks(), который выдаётся в 12 не собирается быть информированным о каких- либо новых задачах, создаваемых на протяжении данной фазы run_until_complete() при вызове 14 - что происходит когда данный код внутри ваших обработчиков прекращения когда они будут исполняться. Поэтому основное правило гласит: никаких новых задач внутри обработчиков исключительной ситуации CancelledError, если только вы также не снабжаете их await внутри какого- то обработчика исключительной ситуации CancelledError.

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

Для чего предназначено return_exceptions=True

Вы могли заметить, что я установил значение аргумента с ключевым словом return_exceptions=True в своём вызове gather() при исполнении 13 в нашем предыдущем примере кода. Я также это делал ранее в Быстром начале, причём я очень подло не сказал ничего об этом в тот раз. Настал момент для пояснений.

Значением по умолчанию является gather(..., return_exceptions=False). Такое значение по умолчанию является проблематичным для нашего процесса останова. Это слегка сложно объяснить напрямую; вместо этого давайте пошагово пройдём некую последовательность фактов,которая сделает более простым понимание этого:

run_until_complete() воздействует на некий фьючерс; на протяжении останова именно этот фьючерс возвращается gather.

Если этот фьючерс возбуждает некую исключительную ситуацию, эта исключительная ситуация также будет возбуждена из run_until_complete(), что означает, что данный цикл будет остановлен..

Если в некотором фьючерсе "группы" применяется run_until_complete(), любая возбуждаемая внутри всех его подзадач исключительная ситуация также будет возбуждена и в самом фьючерсе "группы", если она не обрабатывается в данной подзадаче. Отметим, что они также включают в себя и CancelledError.

Если только некоторые задачи обрабатывают CancelledError, а прочие нет, те из них, что не делают этого вызовут останов своего цикла. Это означает, что данный цикл будет остановлен прежде чем все задачи сделают это.

В режиме останова нам в действительности не желательно такое поведение. Мы хотим завершить run_until_complete() только когда все задачи в данной группе на самом деле завершены, вне зависимости от того возбуждали некие иные задачи исключительные ситуации или нет.

Следовательно, нам приходится выполнять gather(*, return_exceptions=True): такая установка заставляет имеющийся фьючерс "групп" трактовать исключительные ситуации из своих подзадач как возвращаемые значения, поэтому они не всплывают и не выступают помехой для run_until_complete().

И здесь у вас именно это: необходимая взаимосвязь между return_exceptions=True и run_until_complete()!

Нежелательная последовательность перехватываемых исключительных ситуаций, исполняемых таким образом, может ускользнуть от вашего внимания поскольку они теперь (в действительности) подлежат обработке внутри имеющейся задачи "группы". Если это вызывает беспокойство, вы можете получить вывод из run_until_complete() и просканировать его относительно любых подклассов Exception; а затем записать протокол сообщений для вашей ситуации. Вот быстрый взгляд на то что вы получите:

Пример 3-19. Все имеющиеся задачи завершатся

import asyncio

async def f(delay):
    await asyncio.sleep(1 / delay)⁽¹⁾
    return delay

loop = asyncio.get_event_loop()
for i in range(10):
    loop.create_task(f(i))
pending = asyncio.Task.all_tasks()
group = asyncio.gather(*pending, return_exceptions=True)
results = loop.run_until_complete(group)
print(f'Results: {results}')
loop.close()

⁽¹⁾ Было бы ужасно если бы некто передал ноль...

Вывод:

$ python alltaskscomplete.py
Results: [6, 9, 3, 7, ...
          ZeroDivisionError('division by zero',), 4, ...
          8, 1, 5, 2]

Без return_exceptions=True, соответствующая ZeroDivisionError была бы возбуждена из run_until_complete(), останавливая весь цикл и таким образом не позволяя завершиться прочим задачам.

В нашем следующем разделе мы рассмотрим обработку сигналов (помимо Keyboard​Interrupt), но прежде чем мы перейдём к этому, было бы неплохо принять во внимание, что аккуратный останов является одной из самых сложных сторон сетевого программирования и это остаётся справедливым и для asyncio. Информация в этом разделе - это только начало. Я призываю вас провести особые проверки для чистого останова в ваших собственных автоматизированных комплектах. Различные приложения часто требуют различных стратегий.

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

Сигналы

В своём предыдущем примере мы показали как наш цикл событий будет остановлен с помощью KeyboardInterrupt, например, нажатием Ctrl-C. Такое возбуждение KeyboardInterrupt действенно снимет блокировку имеющегося вызова run_forever() и позволит произойти дальнейшей последовательности останова.

KeyboardInterrupt соответствует сигналу SIGINT. Для сетевых служб более распространёнными в действительности являются сигнал для обработки завершения SIGTERM, и именно он также является установленным по умолчанию сигналом,который вы применяете в соответствующей команде kill() из оболочки UNIX.

[Совет]
Совет

Данная команда kill() в системах Unix имеет обманчивое название: всё что она делает, это отправка сигналов в некий процесс! Без аргументов, $ kill <PID> отправит некий сигнал TERM: ваш процесс может получить этот сигнал и аккуратно выполнить останов или просто проигнорировать его! Однако, это плохая идея, так как если ваш процесс не может быть остановлен в конце концов, следующий момент который выполняет данный убивец, это $ kill s KILL <PID>, который отправляет соответствующий сигнал KILL. Вот он уже остановит вас и ваша программа ничего с этим не сможет поделать.

asyncio имеет встроенную поддержку для обработки сигналов процессов, однако имеется удивительная степень сложности относительно обработки сигналов в целом (безотносительно к asyncio). Мы не можем охватить всё что имеется, но мы можем взглянуть на некоторые из самых основных вопросов, которым следует уделить внимание. Наш следующий образец кода произведёт такой вывод:

$ python shell_signal01.py
<Your app is running>
<Your app is running>
<Your app is running>
<Your app is running>
^CGot signal: SIGINT, shutting down.

Для останова данной программы я нажал Ctrl-C, что видно из последней строки. Вот сам код:

Пример 3-20. Памятка по применению KeyboardInterrupt в качестве обработчика SIGINT

# shell_signal01.py
import asyncio

async def main():⁽¹⁾
    while True:
        print('')
        await asyncio.sleep(1)

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.create_task(main())⁽²⁾
    try:
        loop.run_forever()
    except KeyboardInterrupt:⁽³⁾
        print('Got signal: SIGINT, shutting down.')
    tasks = asyncio.Task.all_tasks()
    for t in tasks:
        t.cancel()
    group = asyncio.gather(*tasks, return_exceptions=True)
    loop.run_until_complete(group)
    loop.close()

⁽¹⁾ Это основная часть нашего приложения. Чтобы сделать вещи более простыми мы просто собираемся заснуть в некотором бесконечном цикле.

⁽²⁾ Данная последовательность запуска и останова покажется знакомой вам по нашим предыдущим разделам. Мы спланируем main(), вызовем run_forever(), и подождём чего- то чтобы остановить этот цикл.

⁽³⁾ В данном случае только Ctrl-C остановит наш цикл, а затем мы обработаем KeyboardInterrupt и выполним все необходимые кусочки очистки, котоые мы рассмотрели в своих предыдущих разделах.

До сих пор всё было достаточно прямолинейным. Теперь мы собираемся усложнить действия:

Один из ваших коллег обратился к вам с просьбой обрабатывать SIGTERM в дополнение к SIGINT в качестве сигнала останова.

В вашем реальном приложении вам требуется обрабатывать CancelledError внутри своей сопрограммы main(), а необходимый код очистки внутри соответствующего обработчика исключительной ситуации собирается занять несколько секунд до завершения (представим, что вам придётся взаимодействовать с одноранговой сетевой средой и закрывать пачку соединений сокетов).

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

asyncio предоставляет достаточную степень детализации в своём API для обработки подобных ситуаций. Я изменил свой код приведённого ранее примера для ввода этих моментов:

Пример 3-21. Обработка как SIGINT, так и SIGTERM, но выполнение останова только один раз.

# shell_signal02.py
import asyncio
from signal import SIGINT, SIGTERM⁽¹⁾

async def main():
    try:
        while True:
            print('<Your app is running>')
            await asyncio.sleep(1)
    except asyncio.CancelledError:⁽²⁾
        for i in range(3):
            print('<Ваша прикладная программа завершается...>')
            await asyncio.sleep(1)

def handler(sig):⁽³⁾
    loop.stop()⁽⁴⁾
    print(f'Got signal: {sig!s}, shutting down.')
    loop.remove_signal_handler(SIGTERM)⁽⁵⁾
    loop.add_signal_handler(SIGINT, lambda: None)⁽⁶⁾

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    for sig in (SIGTERM, SIGINT):⁽⁷⁾
        loop.add_signal_handler(sig, handler, sig)
    loop.create_task(main())
    loop.run_forever()⁽⁸⁾
    tasks = asyncio.Task.all_tasks()
    for t in tasks:
        t.cancel()
    group = asyncio.gather(*tasks, return_exceptions=True)
    loop.run_until_complete(group)
    loop.close()

⁽¹⁾ Импортируем все значения сигналов из стандартной библиотеки signal.

⁽²⁾ На этот раз наша сопрограмма main() собирается выполнять некую очистку внутри себя. После получения сигнала прекращения (инициируемого прекращением каждой из соответствующих задач), будет иметься промежуток времени в три секунды пока main() продолжит исполнение на протяжении фазы "run_until_complete()" данного процесса останова. Я напечатаю "Ваша прикладная программа завершается..." .

⁽³⁾ Это обработчик обратного вызова для того случая, когда мы получили некий сигнал. Он настроен в данном цикле через вызов к add_signal_handler() немного дальше.

⁽⁴⁾ Первейшая цель данного обработчика состоит в остановке основного цикла: это снимет блокировку вызова loop.run_forever() и сделает возможным сбор приостановленных задач, прекращение и собственно run_complete() для останова.

⁽⁵⁾ Так как мы теперь пребываем в режиме останова, нам не нужны иные SIGINT или SIGTERM для переключения данного обработчика снова: это вызвало бы loop.stop в процессе соответствующей фазы run_until_complete(), что оказало бы воздействие на наш процесс останова. По этой причине мы удаляем данный обработчик сигнала для SIGTERM из своего цикла.

⁽⁶⁾ Попались! Мы не можем просто удалить данный обработчик для SIGINT, так как это повлечёт что KeyboardInterrupt опять станет соответствующим обработчиком для SIGINT, точно так же, как если бы он добавлялся ранее нашим собственным обработчиком! Вместо этого мы устанавливаем некую пустую функцию lambda в качестве текущего обработчика. Это означает, что мы избегаем KeyboardInterrupt и SIGINT (а также Ctrl-C) не имеют эффекта. [add_signal_handler() скорее всего следует именовать как set_signal_handler(), так как у вас может иметься только один обработчик для каждого типа сигнала, а повторный вызов add_signal_handler() для того же самого сигнала подменит предыдущий обработчик для данного сигнала, если таковой имеется.]

⁽⁷⁾ В этом месте наши обработчики сигналов подключаются к основному циклу. Отметим, что уже обсуждалось ранее, после что установки некоего обработчика SIGINT, KeyboardInterrupt более не будет возбуждаться по SIGINT. Такое возбуждение KeyboardInterrupt является установленным "по умолчанию" обработчиком для SIGINT и является предварительной настройкой в Python пока вы не предпримете чего бы то ни было для изменения такого обработчика, что мы и делаем здесь.

⁽⁸⁾ Как обычно, исполнение блокирует run_forever() пока что- то не остановит данный цикл. В таком случае основной цикл будет остановлен внутри handler() если в наш процесс буде отправлен либо SIGINT, либо SIGTERM. Остаток кода тот же самый что и прежде..

Вывод:

$ python shell_signal02.py
<Your app is running>
<Your app is running>
<Your app is running>
<Your app is running>
<Your app is running>
^CGot signal: Signals.SIGINT, shutting down.
<Your app is shutting down...>
^C<Your app is shutting down...>⁽¹⁾
^C<Your app is shutting down...>

⁽¹⁾ Я много раз нажал CtrlC в процессе данной фазы останова, но, как и ожидалось, ничего не произошло пока в конце концов не завершилась сопрограмма main().

Ожидание исполнителя в процессе останова

Возвращаясь к Быстрому началу, в котором мы представили основы интерфейса Исполнителя в Примере 2. В Примере 2 мы указали, что соответствующий вызов time.sleep() был короче чем соответствующий вызов asyncio.sleep() - к счастью для нас - так как это означает, что собственная задача Исполнителя завершается быстрее чем его сопрограмма main() и в следствии этого данная программа останавливается корректно.

В этом разделе мы изучим что происходит в процессе останова когда задания Исполнителя требуют больше времени чем все приостановленные экземпляры Task. Самый короткий ответ таков: без вмешательства будьте готовы получить такой вид ошибок:

Пример 3-22. Исполнитель требует слишком долгого исполнения до завершения

# quickstart.py
import time
import asyncio

async def main():
    print(f'{time.ctime()} Hello!')
    await asyncio.sleep(1.0)
    print(f'{time.ctime()} Goodbye!')
    loop.stop()

def blocking():
    time.sleep(1.5)⁽¹⁾
    print(f"{time.ctime()} Hello from a thread!")

loop = asyncio.get_event_loop()

loop.create_task(main())

loop.run_in_executor(None, blocking)
loop.run_forever()
tasks = asyncio.Task.all_tasks(loop=loop)
group = asyncio.gather(*tasks, return_exceptions=True)
loop.run_until_complete(group)
loop.close()

⁽¹⁾ Этот пример кода в точности то же что и пример из Быстрого начала, за исключением того, что значение времени сна в нашей функции с блокировкой теперь длиннее чем соответствующее значение в асинхронной части.

Пример 3-23. Вывод

Fri Sep 15 16:25:08 2017 Hello!
Fri Sep 15 16:25:09 2017 Goodbye!
exception calling callback for <Future at [...snip...]>
Traceback (most recent call last):

<big nasty traceback>

RuntimeError: Event loop is closed
Fri Sep 15 16:25:09 2017 Hello from a thread!

Что здесь происходит, так это то, что за сценой, run_in_executor() не создаёт некий экземпляр Task, он возвращает некий Future. Это означает, что он не содержится в наборе "активных задач", возвращаемых из asyncio.Task.all_tasks(), и таким образом, run_until_complete() не ждёт завершения задачи своего Исполнителя!

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

Самая первая идея состоит в самая простая реализация состоит в постоянном выполнении await некоторой задачи Исполнителя внутри какой- то сопрограммы:

Пример 3-24. Вариант A: обёртка вызова соответствующего исполнителя внутри какой- то сопрограммы

# quickstart.py
import time
import asyncio

async def main():
    print(f'{time.ctime()} Hello!')
    await asyncio.sleep(1.0)
    print(f'{time.ctime()} Goodbye!')
    loop.stop()

def blocking():
    time.sleep(2.0)
    print(f"{time.ctime()} Hello from a thread!")

async def run_blocking():⁽¹⁾
    await loop.run_in_executor(None, blocking)

loop = asyncio.get_event_loop()
loop.create_task(main())
loop.create_task(run_blocking())⁽²⁾
loop.run_forever()
tasks = asyncio.Task.all_tasks(loop=loop)
group = asyncio.gather(*tasks, return_exceptions=False)
loop.run_until_complete(group)
loop.close()

⁽¹⁾ Основная идея имеет целью исправление того недостатка, что run_in_executor() возвращает только некий экземпляр Future, а не какую- то задачу. Мы не можем перехватывать все задания в all_tasks(), но мы можем применять для своего фьючерса await. Чтобы сделать это мы создаём такую новую функцию сопрограммы run_blocking(), а внутри неё мы не делаем ничего кроме того, что мы осуществляем await получаемого результата из вызова run_in_executor(). Такая новая функция сопрограммы run_blocking() будет спланирована в некую Task и таким образом наш процесс останова включит её в необходимую группу.

⁽²⁾ Для запуска такой новой функции сопрограммы run_blocking() мы применяем create_task в точности так же, как планировался запуск нашей сопрограммы main().

Приведённый выше код выглядит великолепно, за исключением одного момента: он не способен обрабатывать прекращение! Если вы посмотрите повнимательнее, вы увидите, что я опустил тот цикл прекращения задач, который появлялся в наших предыдущих примерах. Если вы вернёте его обратно, мы получим jib,rb "Event loop is closed" {Цикл событий закрыт}, как и ранее. Мы даже не можем обработать CancelledError внутри run_blocking() чтобы попытаться повторно ожидать данный фьючерс. Вне зависимости от того что мы пытаемся сделать, та задача, которая обёртывает run_blocking() не может быть прекращена, однако соответствующее задание Исполнителя будет исполняться пока не завершится его внутренний time.sleep(). Давайте перейдём в нашей следующей идее.

Следующая, приводимая ниже идея, является слегка более хитрой. Основная стратегия состоит в сборе всех приостановленных задач, с последующим прекращением только их, однако перед этим мы вызываем run_until_complete(), который мы добавили в соответствующий фьючерс из run_in_executor().

Пример 3-25. Вариант B: добавление соответствующего Future Исполнителя для всех собираемых задач

# quickstart.py
import time
import asyncio

async def main():
    print(f'{time.ctime()} Hello!')
    await asyncio.sleep(1.0)
    print(f'{time.ctime()} Goodbye!')
    loop.stop()

def blocking():
    time.sleep(2.0)
    print(f"{time.ctime()} Hello from a thread!")

loop = asyncio.get_event_loop()
loop.create_task(main())
future = loop.run_in_executor(None, blocking)⁽¹⁾
loop.run_forever()
tasks = asyncio.Task.all_tasks(loop=loop)⁽²⁾
for t in tasks:
    t.cancel()⁽³⁾
group_tasks = asyncio.gather(*tasks, return_exceptions=True)
group = asyncio.gather(group_tasks, future)⁽⁴⁾
loop.run_until_complete(group)
loop.close()

⁽¹⁾ В точности как и в первоначальном примере с блокировкой, мы вызываем напрямую run_in_executor(), но мы гарантированно назначаем полученный результат идентификатору future. В скорости этот идентификатор будет применён.

⁽²⁾ Основной цикл остановлен, и теперь мы пребываем в соответствующей фазе останова. Для начала мы получаем все задачи. Отметим, что они не содержат само задание Исполнителя, так как run_in_executor() не создал какой- то задачи.

⁽³⁾ Прекращаем все задачи.

⁽⁴⁾ Это основная уловка: мы создаём некую новую группу, которая объединяет все задачи и соответствующий фьючерс Исполнителя. Таким образом, наш Исполнитель завершится нормально, пока все задачи всё ещё претерпевают обычный процесс прекращения.

Это решение ведёт себя лучшим образом в процессе останова, но всё ещё имеет недостатки. В целом, будет достаточно неудобно собирать все имеющиеся фьючерсы Исполнителя на протяжении всей вашей программы в некую коллекцию, которую вы можете применять в своей последовательности останова; затем создавать некую группу задачи- плюс- фьючерсы; а потом запускать это вплоть до завершения. Это работает, но имеется лучший способ.

Пример 3-26. Вариант C: используйте ваш собственный Исполнитель и ждите

# quickstart.py
import time
import asyncio

from concurrent.futures import ThreadPoolExecutor as Executor

async def main():
    print(f'{time.ctime()} Hello!')
    await asyncio.sleep(1.0)
    print(f'{time.ctime()} Goodbye!')
    loop.stop()

def blocking():
    time.sleep(2.0)
    print(f"{time.ctime()} Hello from a thread!")

loop = asyncio.get_event_loop()
executor = Executor()⁽¹⁾
loop.set_default_executor(executor)⁽²⁾
loop.create_task(main())
future = loop.run_in_executor(None, blocking)⁽³⁾
loop.run_forever()
tasks = asyncio.Task.all_tasks(loop=loop)
for t in tasks:
    t.cancel()
group = asyncio.gather(*tasks, return_exceptions=True)
loop.run_until_complete(group)
executor.shutdown(wait=True)⁽⁴⁾
loop.close()

⁽¹⁾ На этот раз мы создаём свой собственный экземпляр Исполнителя.

⁽²⁾ Вам придётся настроить свой собственный индивидуальный Исполнитель в качестве установленного по умолчанию для основного цикла. Это означает, что делая вызов run_in_executor() из любого места своего кода, он применяться ваш индивидуальный экземпляр.

⁽³⁾ Как и ранее, запускаем соответствующую функцию с блокировкой.

⁽⁴⁾ Наконец, мы можем в явном виде выполнять ожидание для всех своих заданий Исполнителя для завершения до закрытия основного цикла. Это исключит все видимые нами ранее сообщения "Event loop is closed". Мы можем сделать это потому что у нас имеется доступ к соответствующему объекту Исполнителя; установленный по умолчанию Исполнитель теперь не выставлен в соответствующем API asyncio и по этой причине мы не можем вызывать останов в нём.

Наконец, у нас есть некая стратегия с общей приемлемостью: вы можете вызвать run_in_executor() где угодно, а ваша программа всё равно будет останавливаться чисто.

Я настоятельно рекомендую вам поэкспериментировать со всеми показанными здесь примерами кода и испытайте различные стратегии для создания задач и заданий Исполнителя, заменяйте их поочерёдно во времени попытайтесь выполнять завершения чисто.

Тестирование с помощью asyncio

В одном из предыдущих разделов я сказал, что вам не следует передавать некие переменные цикла внутри своей программы (если вам требуется доступ к самому циклу), а вы просто можете применять asyncio.get_event_loop() для получения данного цикла всякий раз как он вам потребуется.

Однако, при написании проверок элементов, вы приходится работать со множеством циклов событий, так как каждая проверка элемента обычно требует некоторого нового экземпляра цикла. Вновь возникает основной вопрос: следует ли вам писать код, который требует передачи циклов событий в параметрах функций для поддержки проверок элементов?

Давайте рассмотрим некий пример, применяющий великолепные инструментальные средства проверки pytest. Для начала взглянем на ту функцию, которую мы хотим проверить:

Пример 3-27. Иногда вам приходится применять call_soon()

import asyncio
from typing import Callable

async def f(notify: Callable[[str], None]):⁽¹⁾
    # <...some code...>
    loop = asyncio.get_event_loop()⁽²⁾
    loop.call_soon(notify, 'Alert!')⁽³⁾
    # <...some code...>

⁽¹⁾ Представим себе некую функцию сопрограммы f, внутри которой необходимо применить loop.call_soon() для того чтобы активировать другую функцию оповещения. (Она может выполнять протоколирование, записывать сообщения в Slack, короткие запоминания, либо что- то ещё, что вы можете придумать!)

⁽²⁾ В этой функции мы не получаем значение цикла через соответствующие параметры функции, поэтому они получаются через get_event_loop(); помните, данный вызов всегда возвращает тот цикл, который ассоциирован с данным текущим потоком.

⁽³⁾ Некий основной вызов предупреждения.

Лучший способ для переброски pytest в ваш код asyncio состоит в предоставлении некоторого цикла событий через какое- то приспособление. Pytest встраивает такое в каждый ваш тест в виде некоего параметра функции. Это звучит более ложно, чем есть на самом деле, поэтому вот пример приспособления для предоставления некоторого цикла событий:

Пример 3-28. Приспособление Pytest для предоставления какого- то цикла событий - с некоторой ошибкой!

# conftest.py⁽¹⁾
import pytest

@pytest.fixture(scope='function')⁽²⁾

def loop():
    loop = asyncio.new_event_loop()⁽³⁾
    try:
        yield loop
    finally:
        loop.close()⁽⁴⁾

⁽¹⁾ Pytest, по определению, автоматически будет импортирован и сделан доступным для всех ваших модулей проверок, причём все они определены в некотором файле с названием conftest.py.

⁽²⁾ Это создаёт некое приспособление. Установленный аргумент "scope" сообщает Pytest когда данное приспособление должно быть завершено и сделано некое новое. Для сферы "function", как и выше, каждый отдельный тест будет иметь изготовленным некий новый цикл.

⁽³⁾ Создайте некий совершенно новый экземпляр цикла. Отметим, что мы не запускаем данный цикл. Мы оставляем это для каждого теста в котором будет использовано данное приспособление.

⁽⁴⁾ Когда данное приспособление будет завершено (в нашем случае после каждого теста), закрываем основной цикл.

Приведённый выше код вызывает некую проблему, так что не применяйте его. Эта проблема едва уловима, но она также является основной сутью данного раздела, поэтому в ближайшее время мы обсудим её подробно. Вначале давайте рассмотрим все проверки для функции сопрограммы f:

Пример 3-29. Соответствующие проверки

from somewhere import f⁽¹⁾

def test_f(loop):⁽²⁾

    collected_msgs = []⁽³⁾

    def dummy_notify(msg):⁽⁴⁾
        collected_msgs.append(msg)

    loop.create_task(f(dummy_notify))⁽⁵⁾
    loop.call_later(1, loop.stop)⁽⁶⁾
    loop.run_forever()

    assert collected_msgs[0] == 'Alert!'⁽⁷⁾

⁽¹⁾ Рассматривайте этот образец кода как псевдокод - нет никакого модуля somewhere! Здесь f ссылается на функцию сопрограммы, определяемой далее.

⁽²⁾ Pytest распознаёт здесь соответствующий аргумент "loop", отыскивает это имя в соответствующем списке приспособлений и затем вызывает наш приведённый выше test_f() совместно с этим новым экземпляром цикла, выпускаемым из данного приспособления.

⁽³⁾ Мы собираемся передать некий обратный вызов "пустышки" notify() в f, данный перечень соберёт все получаемые предупреждения (а также наш тест проверяет что мы получаем правильные сообщения обратно).

⁽⁴⁾ Это поддельная функция notify(), требующаяся f.

⁽⁵⁾ Планируем некую сопрограмму из f, передавая свой поддельный обратный вызов notify().

⁽⁶⁾ Для запуска своего цикла мы применяем run_forever(), следовательно это гарантирует нам что данный цикл будет на самом деле остановлен. В качестве альтернативы мы можем применять run_until_complete(f(...)) без некоторого call_later(); однако что вы выберете зависит от того что вы проверяете. Для эффектов стороны проверки, как и в приводимом тесте , я обнаружил, что проще применять run_forever(). С другой стороны, когда вы проверяете возвращаемые значения из сопрограмм, лучше воспользоваться run_until_complete(). [А ещё один вариант может быть очень полезным для ускорения проверок: я также могу вызывать loop.stop() изнутри fake_notify(), непосредственно сразу после сбора всех msg в свой перечень. Это сберегает время, поскольку данный вызов run_forever() немедленно снимает блокировку и мы не выполняем ожидание для f() чтобы производить любую дальнейшую обработку. Конечно, это может означать что вы получаете предупредительный вывод об "отложенных задачах" когда данное приспособление вызывает loop.close() в процессе завершения. Тестирование является хитроумным искусством и всякая ситуация имеет свои отличительные особенности.]

⁽⁷⁾ Наконец, проверяем!

Помните я сказал, что у ас была некая проблема? Вот она: внутри функции сопрограммы f наш возвращаемый из get_event_loop() экземпляр цикла является иным по отношению к тому циклу событий, который был предоставлен нам нашим приспособлением. Данная проверка завершается по этой причине неудачей; на самом деле данный цикл из get_event_loop() даже никогда не будет запущен.

Наше решение этой дилеммы состоит в добавлении некоторого параметра loop в явном виде в нашу функцию сопрограмму f. Затем вы всегда можете удостовериться что был использован правильный экземпляр цикла. На практике, однако, это может вызывать страшную головную боль, так как большие базовые коды потребуют гигантского числа функций для передачи по имеющемуся циклу.

Вот некий лучший способ: когда в игре некий новый цикл, установите этот цикл чтобы он был "тем самым" циклом связанным с нашим текущим потоком, что означает что всякий вызов к get_event_loop() возвратит совершенно новый образец. Это работает очень хорошо для тестирования. Всё что вам требуется сделать, так это слегка изменить наше приспособление pytest:

Пример 3-30. Более удобное приспособление для pytest

# conftest.py
import pytest

@pytest.fixture(scope='function')
def loop():
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)⁽¹⁾
    try:
        yield loop
    finally:
        loop.close()

⁽¹⁾ Это та самая магическая строка: после вызова set_event_loop(), всякий последующий вызов asyncio.get_event_loop() будет возвращать тот экземпляр цикла, который создаётся в данном приспособлении, а вам не придётся в явном виде передавать такой экземпляр цикла через все вызовы вашей функции.

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

Глава 6. Дополнения

Содержание

Глава 6. Дополнения

Краткая история поддержки Async в Python

В самом начале был asyncore

Путь к естественным сопрограммам

Пример Столовых приборов с использованием Asyncio

Вспомогательные материалы для вытаскивания новостей с вебсайтов

Вспомогательные материалы для примера ZeroMQ

Обработка переключателя базы данных для примера asyncpg

Вспомогательные материалы для примера Sanic: aelapsed и aprofiler

Краткая история поддержки Async в Python

Несмотря на то, что модуль asyncore долгое время был частью стандартной библиотеки PythonВ, он страдает фундаментальными недостатками, проистекающими из не гибкого API, который не соответствует ожиданиям современного асинхронного сетевого модуля.

Более того, его подход черезчур упрощён для того чтобы предоставлять разработчикам все инструменты, необходимые им для применения полного потенциала построения асинхронных сетей.

Самое популярное решение в наши дни состоит в использовании при производстве библиотек сторонних производителей. Они зачастую предоставляют удовлетворительные решения, но между этими библиотеками отсутствует совместимость, что приводит к тому, что кодовые базы очень тесно переплетены с теми библиотеками, которые они применяют.[1]

-- Лоуренс ван Хутвен, PEP3153 (май 2012), Поддержка асинхронного ввода/ вывода

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

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

В самом начале был asyncore

[При сопоставлении с asyncore,] Twisted лучше практически во всех возможных отношениях. Он более портативный, более функциональный, проще, более масштабируемый, облалает лучшим сопровождением, лучше документирован и он способен делать деликатесные омлет. Asyncore, по сути, устарел.[2]

-- Glyph ca. 2010

asyncore следует рассматривать как нечто исторически ископаемое, причём он на самом деле никогда не применялся.[3]

-- Жан Поль Кальдероун ca. 2013 в StackOverflow

Поддержка так называемой "асинхронной функциональности" была добавлена в Python давным- давно, в модуле asyncore. Как вам это могут подсказать цитаты к данной главе, приём asyncore был равнодушным, а использование низким. Что удивляет, по крайней мере автора этих строк, так это когда это было сделано: в Python 1.5.2! Вот что говорится в заголовке Lib/asyncore.py из исходного кода CPython:

# * Mode: Python *
# Id: asyncore.py,v 2.51 2000/09/07 22:29:26 rushing Exp
# Author: Sam Rushing <rushing@nightmare.com>

# =============================================================
# Copyright 1996 by Sam Rushing

Более того, самый первый параграф документации Python для asyncore говорит следующее, и это запросто может появиться и в документации asyncio в наши дни:

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

Существует всего два способа иметь некую программу в единственном процессоре выполняющей "более чем одну вещь в некий момент времени". Многопоточное программирование является самым простым и наиболее популярным способом осуществления этого, но существует и другая, очень отличающаяся техника, которая позволяет вам иметь почти все преимущества многопоточности без реального применения множества потоков. Это на самом деле представляет практическую ценность только если ваша программа в основном связана с вводом/ выводом. Если ваша программа привязана к процессору, тогда предварительно запланированные потоки, скорее всего, являются именно тем что вам требуется. Однако сетевые серверы редко привязаны к процессору.

1996, я не ослышался? Ясно, что в 1996 в Python уже можно было управлять множеством событий сокета в каком- то отдельном потоке (причём, на самом деле, намного раньше чем в прочих языках программирования). Так что изменилось за последние 20 лет, что делает Asyncio особенным теперь?

Ответом является синтаксис языка. Мы намерены рассмотреть это более пристально в своём следующем разделе, но прежде чем мы закроем это окно в прошлое, будет не лишним отметить некую небольшую деталь в документации для asyncore в наши дни:

Исходный код: Lib/asyncore.py

Заявлена устаревшей начиная с версии 3.6: Применяйте, пожалуйста, вместо него asyncio.[4]

-- Официальная документация стандартной библиотеки Python ca. декабрь 2016

Путь к естественным сопрограммам

Напомним, что мы используем термин "Asyncio" как для ссылки на собственно изменения в синтаксисе языка Python, так и для самого нового модуля asyncio в его стандартной библиотеке [asyncio был добавлен в Python 3.4]. Давайте слегка больше погрузимся в это отличие.

В наши дни поддержка асинхронного программирования в Python имеет три отличающихся компонента, причём изучая их также интересно наблюдать когда они были добавлены в Python:

Синтаксис языка: Генераторы

Ключевое слово yield, добавленное в Python 2.2 (2001) в PEP 255 и расширенное в Python 2.5 (2005) в PEP 342 при помощи методов send() и throw() в объектах генератора, что впервые сделало возможным использование генераторов в виде сопрограмм.

Ключевое слово yield from, добавленное в Python 3.3 (2009) в PEP 380 чтобы сделать намного более простым работу с вложенными выдач (yields) генераторов, в частности, в том случае, когда генераторы применяются как сопрограммы временной замены [makeshift: прилагательное, "дейстующее как промежуточное или временное средство"].

Синтаксис языка: Сопрограммы

Ключевые слова async и await, добавленные в Python 3.5 (2015) в PEP 492, которые дали первоклассную поддержку сопрограммам в качестве собственного самостоятельного языкового свойства. Это также означало, что генераторы могут снова применяться в качестве генераторов даже внутри функций сопрограмм.

Библиотечный модуль: asyncio добавленные в Python 3.4 (2012) в PEP 3156, предоставил поддержку "всё включено" как для проектировщиков инфраструктур, так и для конечных пользователей для работы напрямую с сопрограммами и сетевым вводом, выводом. Критически важно что сама архитектура имеющегося в asyncio цикла событий была предназначена для предоставления общей основы поверх которой могут быть приведены к стандарту прочие имеющиеся сторонние производители инфраструктур, таких как Tornado и Twisted.

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

Воздействие нового синтаксиса для async и await является существенным, причём оно оказало влияние также и на прочие языки программирования, такие как JavaScript, C#, Scala, Kotlin и Dart.

Тысячи вовлечённых в проект Python программистов потратили много времени, чтобы донести до нас этот момент.

Пример Столовых приборов с использованием Asyncio

В своём Примере: Роботы и Столовые приборы мы проанализировали ошибку условий состязательности вызываемую тем, что множество потоков изменяют записи столовых приборов в общем глобальном экземпляре объекта "кухня". Для придания законченного вида здесь приводится некая версия того как мы могли бы создать некий вариант asyncio той же самой задачи.

Имеется особый момент, который я бы хотел подчеркнуть, относительно наблюдаемости распараллеливания при таком подходе asyncio:

Пример 6-1. Управление столовыми приборами при помощи asyncio

import asyncio

class CoroBot(): ⁽¹⁾
  def __init__(self):
    self.cutlery = Cutlery(knives=0, forks=0)
    self.tasks = asyncio.Queue() ⁽²⁾

  async def manage_table(self):
    while True:
      task = await self.tasks.get() ⁽³⁾
      if task == 'prepare table':
        kitchen.give(to=self.cutlery, knives=4, forks=4)
      elif task == 'clear table':
        self.cutlery.give(to=kitchen, knives=4, forks=4)
      elif task == 'shutdown':
        return

from attr import attrs, attrib

@attrs
class Cutlery:
    knives = attrib(default=0)
    forks = attrib(default=0)

    def give(self, to: 'Cutlery', knives=0, forks=0):
        self.change(knives, forks)
        to.change(knives, forks)

    def change(self, knives, forks):
        self.knives += knives
        self.forks += forks

kitchen = Cutlery(knives=100, forks=100)
bots = [CoroBot() for i in range(10)]

import sys
for b in bots:
    for i in range(int(sys.argv[1])):
        b.tasks.put_nowait('prepare table')
        b.tasks.put_nowait('clear table')
    b.tasks.put_nowait('shutdown')

print('Kitchen inventory before service:', kitchen)

loop = asyncio.get_event_loop()
tasks = []
for b in bots:
    t = loop.create_task(b.manage_table())
    tasks.append(t)

task_group = asyncio.gather(*tasks)
loop.run_until_complete(task_group)
print('Kitchen inventory after service:', kitchen)

⁽¹⁾ Вместо ThreadBot у нас теперь есть CoroBot. И это на самом деле бот! В данном примере кода имеется только один поток, причём этот поток будет управлять всеми десятью отдельными экземплярами "CoroBot", по одному для каждого стола в нашем ресторане.

⁽²⁾ Вместо queue.Queue мы используем очередь с включённым asyncio.

⁽³⁾ Именно это самый важный момент: единственным местом, в котором исполнение может переключаться между различными экземплярами CoroBot, это то, в котором появляется ключевое слово await. Не возможно иметь некое переключение контекста на протяжении всего остатка данной функции и именно по этой причине нет никакого условия состязательности на протяжении данного изменения инвентаризации кухонных столовых приборов.

Само присутствие ключевых слов await делает переключения контекста отслеживаемым. Это делает гораздо более простым делать заключение относительно потенциального условия состязательности в приложениях с одновременным исполнением. Для приведённого выше кода CoroBot, наш тест всегда выполняется вне зависимости от того сколько задач назначать:

$ python cutlery_test_corobot.py 100000
Kitchen inventory before service: Cutlery(knives=100, forks=100)
Kitchen inventory after service: Cutlery(knives=100, forks=100)

Это на самом деле совсем не производит никакого впечатления: это абсолютно предсказуемый результат и он основан на том факте, что очевидно, что в данном коде отсутствует условие состязательности. И вся суть именно в этом.

Вспомогательные материалы для вытаскивания новостей с вебсайтов

Для запуска нашего кода в Примере: Вытаскивание новостей требуется такой файл index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF8">
    <title>The News</title>
    <style>
        .wrapper {
            display: grid;
            gridtemplatecolumns: 300px 300px 300px;
            gridgap: 10px;
            width: 920px;
            margin: 0 auto;
        }

        .box {
            borderradius: 40px;
            padding: 20px;
            border: 1px solid slategray;
        }

        .cnn {
            backgroundcolor: #cef;
        }

        .aljazeera {
            backgroundcolor: #fea;
        }

        h1 {
            textalign: center;
            fontsize: 60pt;
        }

        a {
            color: black;
            textdecoration: none;
        }

        span {
            textalign: center;
            fontsize: 15pt;
            color: black;
        }
    </style>
</head>
<body>
<h1>The News</h1>
<div class="wrapper">
    $body
</div>
</body>
</html>

Это достаточно базовый шаблон с элементарными стилями.

Вспомогательные материалы для примера ZeroMQ

В разделе ØMQ, в Пример: APM (мониторинг производительности приложения), я упоминал, что вам потребуется соответствующий файл HTML, который обслуживает показ диаграмм измерений. Вот этот файл, charts.html, приводимый ниже. Вам следует получить некий URL для smoothie.min.js с smoothiecharts.org или из своей CDN и применить этот URL в качестве соответствующего атрибута src вместо применяемого:

Пример 6-2. charts.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF8">
    <title>Server Performance</title>
    <script src="smoothie.min.js"></script>
    <script type="text/javascript">
        function createTimeline() {
            var cpu = {}; ⁽¹⁾
            var mem = {};

            var chart_props = {
                responsive: true,
                enableDpiScaling: false,
                millisPerPixel:100,
                grid: {
                    millisPerLine: 4000,
                    fillStyle: '#ffffff',
                    strokeStyle: 'rgba(0,0,0,0.08)',
                    verticalSections: 10
                },
                labels:{fillStyle:'#000000',fontSize:18},
                timestampFormatter:SmoothieChart.timeFormatter,
                maxValue: 100,
                minValue: 0
            };

            var cpu_chart = new SmoothieChart(chart_props); ⁽²⁾
            var mem_chart = new SmoothieChart(chart_props);

            function add_timeseries(obj, chart, color) { ⁽³⁾
                obj[color] = new TimeSeries();
                chart.addTimeSeries(obj[color], {
                    strokeStyle: color,
                    lineWidth: 4
                })
            }

            var evtSource = new EventSource("/feed"); ⁽⁴⁾
            evtSource.onmessage = function(e) {
                var obj = JSON.parse(e.data); ⁽⁵⁾
                if (!(obj.color in cpu)) {
                    add_timeseries(cpu, cpu_chart, obj.color);
                }
                if (!(obj.color in mem)) {
                    add_timeseries(mem, mem_chart, obj.color);
                }
                cpu[obj.color].append(
                    Date.parse(obj.timestamp), obj.cpu); ⁽⁶⁾
                mem[obj.color].append(
                    Date.parse(obj.timestamp), obj.mem);
            };

            cpu_chart.streamTo(
                document.getElementById("cpu_chart"), 1000
            );
            mem_chart.streamTo(
                document.getElementById("mem_chart"), 1000
            );
        }
    </script>
    <style>
        h1 {
            textalign: center;
            fontfamily: sansserif;
        }
    </style>
</head>
<body onload="createTimeline()">
    <h1>CPU (%)</h1>
    <canvas id="cpu_chart" style="width:100%; height:300px">
    </canvas>
    <hr>
    <h1>Memory usage (MB)</h1>
    <canvas id="mem_chart" style="width:100%; height:300px">
    </canvas>

⁽¹⁾ Каждый из cpu и mem соответствует объектам TimeSeries(). Каждое свойство будет выделено неким цветом, а его значением будет экземпляр TimeSeries().

⁽²⁾ Один экземпляр диаграммы создаётся для использования ЦПУ, а второй для оперативной памяти.

⁽³⁾ Мы создаём экземпляр TimeSeries() внутри соответствующего события onmessage() для текущего экземпляра EventSource. Это означает, что для каждых приходящих новых данных (например, для различных названий цветов) будет автоматически получаться новая временная последовательность, созданная под него. Наша функция add_timeseries() одновременно и создаёт соответствующий экземпляр временной последовательности, и добавляет его к заданному экземпляру chart.

⁽⁴⁾ Создаём новый экземпляр EventSource в соответствующем URL /feed. Наш браузер подключится к этой оконечной точке в нашем сервере, metricserver.py. Отметим, что сам браузер попытается автоматически выполнить повторное подключение в случае его утраты. SSE (Serversent events, отправляемые сервером события) часто остаются недооценёнными, однако имеется множество ситуаций при которых внутренняя простота SSE более предпочтительна чем веб сокеты.

⁽⁵⁾ Соответствующее событие onmessage() зажигается всякий раз, когда сервер отправляет данные. В ланном месте выполняется разбор полученных данных в виде JSON.

⁽⁶⁾ Повторный вызов данного идентификатора cpu устанавливает некое соответствие цвета экземпляру TimeSeries(). В этом месте мы получаем такую временную последовательность и добавляем её в конец. Такде мы получаем необходимый временной штамп и выполняем его разбор для получения правильного формата, требуемого со стороны данной диаграммы.

Обработка переключателя базы данных для примера asyncpg

В разделе asyncpg, Пример: несостоятельность кэширования, в интересах экономии места был опущен один из необходимых исходных файлов Python. Это файл представлен ниже с пояснениями:

Пример 6-3. triggers.py

# triggers.py
from asyncpg.connection import Connection ⁽¹⁾

async def create_notify_trigger( ⁽²⁾
        conn: Connection,
        trigger_name: str = 'table_update_notify',
        channel: str = 'table_change') > None:
    await conn.execute(
        'CREATE EXTENSION IF NOT EXISTS hstore') ⁽³⁾
    await conn.execute(
            SQL_CREATE_TRIGGER.format(
                trigger_name=trigger_name,
                channel=channel)) ⁽⁴⁾

async def add_table_triggers( ⁽⁵⁾
        conn: Connection,
        table: str,
        trigger_name: str = 'table_update_notify',
        schema: str = 'public') > None:
    templates = (SQL_TABLE_INSERT, SQL_TABLE_UPDATE,
                 SQL_TABLE_DELETE) ⁽⁶⁾
    for template in templates:
        await conn.execute(
            template.format(
                table=table,
                trigger_name=trigger_name,
                schema=schema)) ⁽⁷⁾

SQL_CREATE_TRIGGER = """\
CREATE OR REPLACE FUNCTION {trigger_name}()
  RETURNS trigger AS $$
DECLARE
  id integer;  or uuid
  data json;
BEGIN
  data = json 'null';
  IF TG_OP = 'INSERT' THEN
    id = NEW.id;
    data = row_to_json(NEW);
  ELSIF TG_OP = 'UPDATE' THEN
    id = NEW.id;
    data = json_build_object(
      'old', row_to_json(OLD),
      'new', row_to_json(NEW),
      'diff', hstore_to_json(hstore(NEW)  hstore(OLD))
    );
  ELSE
    id = OLD.id;
    data = row_to_json(OLD);
  END IF;
  PERFORM
    pg_notify(
      '{channel}',
      json_build_object(
        'table', TG_TABLE_NAME,
        'id', id,
        'type', TG_OP,
        'data', data
      )::text
    );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
""" ⁽⁸⁾

SQL_TABLE_UPDATE = """\
DROP TRIGGER IF EXISTS
  {table}_notify_update ON {schema}.{table};
CREATE TRIGGER {table}_notify_update
  AFTER UPDATE ON {schema}.{table}
    FOR EACH ROW
      EXECUTE PROCEDURE {trigger_name}();
""" ⁽⁹⁾

SQL_TABLE_INSERT = """\
DROP TRIGGER IF EXISTS
  {table}_notify_insert ON {schema}.{table};
CREATE TRIGGER {table}_notify_insert
  AFTER INSERT ON {schema}.{table}
    FOR EACH ROW
      EXECUTE PROCEDURE {trigger_name}();
"""

SQL_TABLE_DELETE = """\
DROP TRIGGER IF EXISTS
  {table}_notify_delete ON {schema}.{table};
CREATE TRIGGER {table}_notify_delete
  AFTER DELETE ON {schema}.{table}
    FOR EACH ROW
      EXECUTE PROCEDURE {trigger_name}();
"""

⁽¹⁾ Данная функция требует asyncpg, хотя данный импорт используется только чтобы разрешить применение Connection в типе аннотаций.

⁽²⁾ Эта функция сопрограммы create_notify_trigger() создаст необходимую функцию триггера саму по себе в нашей базе данных. Такая функция триггера будет содержать необходимое название канала, в который будут отправляться обновления. Необходимый код для самой функуии содержится в идентификаторе SQL_CREATE_TRIGGER и он устанавливается как некая форматированная строка.

⁽³⁾ Вспоминаем из своего образца примера, что уведомления изменения содержат некий раздел "diff" в котором отображается само отличие между старыми и новыми данными. Мы применяем свойство hstore PostgreSQL для вычисления данного отличия. Оно представляет нечто близкое к семантике наборов. Требуемое расширение hstore не включено по умолчанию, поэтому мы разрешаем его здесь.

⁽⁴⁾ В данный шаблон подставляются желаемые названия триггера и канала, а затем он исполняется.

⁽⁵⁾ Наша вторая функция, add_table_triggers(), подключит полученную функцию триггера к таким событиям таблицы как вставка, обновление и удаление.

⁽⁶⁾ Имеются три формата строк для каждого из трёх методов.

⁽⁷⁾ Желаемый переменные подставляются в соответствующие шаблоны и после этого исполняются.

⁽⁸⁾ Зтот SQL отнял у меня слегка больше времени чем ожидалось, прежде чем стал в точности правильным! Данная процедура PostgreSQL вызывается для вставки, обновления и удаления; тот способ, которым мы определяем что именно проверяется в соответствующей переменной TG_OP. Если данной операцией является INSERT, тогда будет определён NEW (а OLD не будет задан). Для DELETE будет определён OLD, а не NEW. Для UPDATE определены оба, что позволяет нам вычислить их разницу. Мы также можем воспользоваться встроенной поддержкой PostreSQL для JSON при помощи функций row_to_json() и hstore_to_json(): это означает, что наш обработчик обратного вызова получит допустимый JSON.

Наконец, наш вызыов соответствующей функции pg_notify() именно то, что на самом деле отправляет необходимое событие. Все подписчики на {channel} получат необходимое уведомление.

⁽⁹⁾ Это достаточно стандартныц код триггера: он просту устанавливает некий триггер для вызова какой- то определённой процедуры {trigger_name}() в случае возникновения определённого события, например, INSERT или UPDATE.

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

Вспомогательные материалы для примера Sanic: aelapsed и aprofiler

Пример 6-4. perf.py

# perf.py
import logging
from time import perf_counter
from inspect import iscoroutinefunction

logger = logging.getLogger('perf')

def aelapsed(corofn, caption=''): ⁽¹⁾
    async def wrapper(*args, **kwargs):
        t0 = perf_counter()
        result = await corofn(*args, **kwargs)
        delta = (perf_counter()  t0) * 1e3
        logger.info(
            f'{caption} Elapsed: {delta:.2f} ms')
        return result
    return wrapper

def aprofiler(cls, bases, members): ⁽²⁾
    for k, v in members.items():
        if iscoroutinefunction(v):
            members[k] = aelapsed(v, k)
        return type.__new__(type, cls, bases, members)

⁽¹⁾ Наш декоратор aelapsed() запишет значение времени, которое заняло исполнение обёрнутой им сопрограммы.

⁽²⁾ Наш метакласс aprofiler()обеспечит, что все участники этого класса, а именно функции сопрограмм, получат обёртку в соответствующем декораторе aelapsed().

Глава 4. 20 библиотек Asyncio, которые вы не применяете (Но... Да ладно, не берите в голову)

Содержание

Глава 4. 20 библиотек Asyncio, которые вы не применяете (Но... Да ладно, не берите в голову)

Streams (Стандартная библиотека)

Пример: Очередь сообщений

Пример: Улучшенная очередь сообщений

Twisted

Очередь Janus

aiohttp

Пример: Hello World

Пример: Вытаскивание новостей

ØMQ (ZeroMQ)

Пример: множественные сокеты

Пример: APM (мониторинг производительности приложения)

asyncpg и Sanic

Пример: несостоятельность кэширования

Прочие библиотеки и ресурсы

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

Название этой главы, 20 библиотек Asyncio, которые вы не применяете..., обыгрывает заголовок моей предыдущей книги 20 библиотек Python, которые вы не применяете (а следовало бы). Многие из этих библиотек также будут полезны вам в приложениях на основе asyncio, однако в данной главе мы будем применять библиотеки, намеренно спроектированные под обсуждаемую новую функциональность Python.

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

По этой причине большинство примеров из этой главы будут чем- то большим в терминах строк кода, чем это бывает обычно в подобных книгах. Моя цель состоит в применении данного подхода состояла в том, чтобы сделать данные примеры более полезными, предоставляя вам "полное представление" какой- то асинхронной программы вместо того чтобы оставить вас с необходимостью самостоятельно размышлять как соединять фрагменты воедино.

[Замечание]
Замечание

Порой образцы кода в этой главе будут компромиссом со стилем кода для экономии места. Мне нравится PEP8, так же как и Pythonista, но практичность одерживает верх над безукоризненностью!

Streams (Стандартная библиотека)

Прежде чем пускаться во все тяжкие со сторонними библиотеками, давайте начнём со стандартной. Streams API является интерфейсом верхнего уровня предлагаемого для асинхронного программирования сокетов и, как покажет наш следующий пример, его достаточно просто применять; тем не менее, проектирование приложения остаётся сложным просто из- за самой природы этой области.

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

Пример: Очередь сообщений

Некая служба очередей сообщений является промежуточным ПО, ориентированным на сообщения (MOM, messageoriented middleware), развёртываемом в вычислительном облачном решении при помощи программного обеспечения в виде модели службы. Подписавшиеся на службу осуществляют доступ к очередям или темам для обмена данными применяя топологию точка- точка или шаблоны издателя и подписчика. [1].

-- Wikipedia: "Message queuing service"

Я только что работал в некотором проекте, который привлекал использование ActiveMQ в качестве посредника обмена сообщениями для взаимодействия микрослужб. На базовом уровне такой брокер (сервер):

поддерживал постоянные соединения со множеством клиентов,

получал сообщения от клиентов с каким- то целевым "названием канала",

доставлял эти сообщения всем прочим клиентам, подписанным на канал с тем же самым названием.

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

Каналы с префиксом /topic в названии, например, /topic/customer/registration управляются при помощи шаблона Издатель-подписчик (все подписчики канала получают все сообщения)

Каналы именуемые с префиксом /queue обрабатываются в соответствии с моделью точка-точка {P2P, одноранговой}, при которой сообщения в канале распространяются между подписчиками канала неким карусельным манером: каждый подписчик получает какое- то уникальное сообщение.

В нашем примере мы сконструируем игрушечного брокера сообщений при помощи таких базовых функций. Самой первой проблемой, которую нам предстоит решить, является то, что TCP не является протоколом, основанным на сообщениях: мы просто получаем потоки байт в какой- то сети. Нам требуется создать свой собственный протокол для требуемой структуры сообщений, причём самым простым протоколом является установка префикса с неким размером заголовка, за которым следует какая- то полезная нагрузка с этим размером. Для такого рода сообщений приводимая ниже утилита библиотеки предоставляет read и write:

Пример 4-1. Протокол обмена сообщениями: чтение и запись

# msgproto.py
import asyncio
from asyncio import StreamReader, StreamWriter

async def read_msg(reader: StreamReader) > bytes:
    # Возбуждает asyncio.streams.IncompleteReadError
    size_bytes = await reader.readexactly(4) ⁽¹⁾
    size = int.from_bytes(size_bytes, byteorder='big') ⁽²⁾
    data = await reader.readexactly(size) ⁽³⁾
    return data

async def send_msg(writer: StreamWriter, data: bytes):
    writer.write(len(data).to_bytes(4, byteorder='big')) ⁽⁴⁾
    writer.write(data) ⁽⁵⁾
    await writer.drain() ⁽⁶⁾

def run_server(client, host='127.0.0.1', port=25000): ⁽⁷⁾
    loop = asyncio.get_event_loop()
    coro = asyncio.start_server(client, '127.0.0.1', 25000)
    server = loop.run_until_complete(coro)
    try:
        loop.run_forever()
    except KeyboardInterrupt:
        print('Bye!')
    server.close()
    loop.run_until_complete(server.wait_closed())
    tasks = asyncio.Task.all_tasks()
    for t in tasks:
        t.cancel()
    group = asyncio.gather(*tasks, return_exceptions=True
    loop.run_until_complete(group)
    loop.close()

⁽¹⁾ Получаем самые первые четыре байта. Это префикс размера.

⁽²⁾ Эти четыре байта необходимо преобразовать в целое значение.

⁽³⁾ Теперь, когда мы знаем размер полезной нагрузки, считываем её из имеющегося потока.

⁽⁴⁾ Запись обратна к чтению: вначале мы отправляем значение длины своих данных, кодируя их в 4 байтах.

⁽⁵⁾ Затем отправляем собственно данные.

⁽⁶⁾ drain() гарантирует то что необходимые данные отправлены полностью. Без drain() какие- то данные всё ещё могут дожидаться своей очереди в имеющемся буфере отправки в то время как выполнен выход из данной сопрограммы.

⁽⁷⁾ Это здесь не существенно, но я снабдил функцию под запуск некоего сервера TCP. Данная последовательность останова уже обсуждалась ранее в нашем предыдущем разделе и я включаю это здесь исключительно для экономии места в последующих примерах кода. Останов сервера начинается через SIGINT или Ctrl-C.

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

Пример 4-2. Некий прототип в 35 строк

# mq_server.py
import asyncio
from asyncio import StreamReader, StreamWriter, gather
from collections import deque, defaultdict
from typing import Deque, DefaultDict
from msgproto import read_msg, send_msg, run_server ⁽¹⁾

SUBSCRIBERS: DefaultDict[bytes, Deque] = defaultdict(deque) ⁽²⁾

async def client(reader: StreamReader, writer: StreamWriter):
    peername = writer.transport.get_extra_info('peername') ⁽³⁾
    subscribe_chan = await read_msg(reader) ⁽⁴⁾
    SUBSCRIBERS[subscribe_chan].append(writer) ⁽⁵⁾
    print(f'Remote {peername} subscribed to {subscribe_chan}')
    try:
      while True:
        channel_name = await read_msg(reader) ⁽⁶⁾
        data = await read_msg(reader) ⁽⁷⁾
        print(f'Sending to {channel_name}: {data[:19]}...')
        writers = SUBSCRIBERS[channel_name]
        if writers and channel_name.startswith(b'/queue'):
            writers.rotate() ⁽⁸⁾
            writers = [writers[0]] ⁽⁹⁾
        await gather(*[send_msg(w, data) for w in writers]) ⁽¹⁰⁾
    except asyncio.CancelledError: ⁽¹¹⁾
      print(f'Remote {peername} closing connection.') ⁽¹²⁾
      writer.close()
    except asyncio.streams.IncompleteReadError:
      print(f'Remote {peername} disconnected')
    finally:
      print(f'Remote {peername} closed')
      SUBSCRIBERS[subscribe_chan].remove(writer) ⁽¹³⁾

if __name__ == '__main__':
    run_server(client)

⁽¹⁾ Импорт из нашего модуля msgproto.py.

⁽²⁾ Некая общая коллекция активных в настоящее время подписчиков. При каждом подключении какого- то клиента, тот обязан вначале отправить некое название канала, на который он подписывается. Очередь с двусторонним доступом (deque) удержит всех имеющихся подписчиков в каком- то определённом канале.

⁽³⁾ Данная функция сопрограммы client() произведёт какую- то долгоживущую сопрограмму для каждого нового соединения. Представляйте себе её как некий обратный вызов того сервера TCP, который запускается в run_server(). В этой строке я показал как можно получить адрес хоста и порт удалённого однорангового партнёра, например, для регистрации.

⁽⁴⁾ Наш протокол для клиентов состоит в следующем:

При первом подключении некий клиент обязан отправить какое- то сообщение, содержащее тот канал, на который он подписывается (в данном случае, subscribe_chan).

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

⁽⁵⁾ Добавляем необходимый экземпляр StreamWriter в имеющуюся общую коллекцию подписчиков.

⁽⁶⁾ В бесконечном цикле ожидаем данные от этого клиента. Самое первое сообщение от какого- то клиента должно быть названием целевого канала.

⁽⁷⁾ Далее переходим к реальным данным для распространения в конкретном канале.

⁽⁸⁾ Получаем определённую очередь с двусторонним доступом (deque) подписчиков в своём целевом канале.

⁽⁹⁾ Некая особая обработка если данное название канала начинается с магического слова "/queue": в таком случае мы отправляем свои данные только одному из всех подписчиков, а не всем им. Это можно применять для разделения работы между каким- то пакетом исполнителей, вместо того чтобы следовать обычной схеме уведомлений pub-sub, при которой все подписчики в некотором канале получают все сообщения.

⁽¹⁰⁾ Именно по этой причине мы используем двустороннюю очередь, а не список: вращение по нашей deque это то как мы отслеживаем своего следующего клиента в строке для распространения "/queue". Это кажется затратным, пока вы не поймёте, что однократное обращение по двусторонней очереди является операцией O(1).

⁽¹¹⁾ Целевым является всякий идущий первым клиент; это изменяется после каждого кругового обхода.

⁽¹²⁾ Создаём некий список сопрограмм для отправки данного сообщения каждому записывающему, а затем распаковываем его в gather() с тем, чтобы мы могли дождаться завершения всех этих отсылок.

[Замечание]
Замечание

Эта строка является негодной брешью в нашей программе, но может быть не очевидно почему: хотя может быть и правильным дождаться отправки всем подписчикам, которая происходит одновременно, что произойдёт если у нас имеется очень медленный клиент? В таком случае наш gather() завершится только когда самый тормозной подписчик получит свои данные. Мы не можем получать никакие иные данные от отправляющих их клиентов пока не завершатся все эти send_msg(). Это замедляет распространение всех сообщений до значения скорости самого медленного подписчика.

⁽¹³⁾ Когда мы покидаем сопрограмму client(), не забудьте удалить себя из имеющейся общей коллекции SUBSCRIBERS. К сожалению, это операция с O(n), которая может быть затратной при больших значениях n. Некая иная структура исправит это, но в данный момент мы утешаем себя пониманием того что соединения рассматриваются как обладающие большим временем жизни, следовательно события отключения единичны; а n вряд ли будет слишком большим (скажем ~ 10 000 в качестве грубой оценки); и в таком коде достаточно просто разобраться!

Итак, у нас есть сервер; теперь нам нужны клиенты, а после этого мы сможем продемонстрировать некий вывод. Для целей демонстрации мы создадим два вида клиентов: "отправителя" и "получателя". Наш сервер не изменяется? для него клиенты одни и те же. Данное отличие между поведением "отправителя" и "получателя" служит лишь цели обучения.

Пример 4-3. Сторона ожидания: некий инструментарий для ожидания сообщений в нашем брокере сообщений

# mq_client_listen.py
import asyncio
import argparse, uuid
from msgproto import read_msg, send_msg

async def main(args):
  me = uuid.uuid4().hex[:8] ⁽¹⁾
  print(f'Starting up {me}')
  reader, writer = await asyncio.open_connection(
    args.host, args.port) ⁽²⁾
  print(f'I am {writer.transport.get_extra_info("sockname")}')
  channel = args.listen.encode() ⁽³⁾
  await send_msg(writer, channel) ⁽⁴⁾
  try:
    while True:
      data = await read_msg(reader) ⁽⁵⁾
      if not data:
          print('Connection ended.')
         break
      print(f'Received by {me}: {data[:20]}')
  except asyncio.streams.IncompleteReadError:
    print('Server closed.')

if __name__ == '__main__':
  parser = argparse.ArgumentParser() ⁽⁶⁾
  parser.add_argument('host', default='localhost')
  parser.add_argument('port', default=25000)
  parser.add_argument('listen', default='/topic/foo')
  loop = asyncio.get_event_loop()
  try:
      loop.run_until_complete(main(parser.parse_args()))
  except KeyboardInterrupt:
      print('Bye!')
  loop.close()

⁽¹⁾ Наш стандартный модуль uuid является удобным способом создания какй- то "уникальности" для данного ожидающего. Если вы при помощи этого вы запускаете множество экземпляров, каждый из них будет иметь свй собственный идентификатор и у вас будет возможность отслеживать в соответствующих журналах что происходит.

⁽²⁾ Открываем соединение со своим сервером.

⁽³⁾ Название канала для подписки является неким входным параметром, которые выбирается из args.listen. Перед отправки кодируем его в bytes.

⁽⁴⁾ По правилам нашего протокола (которые описаны в анализе кода нашего брокера ранее), самое первое, что мы должны сделать сразу после подключения, так это отправит то название канала, на который требуется подписка.

⁽⁵⁾ Этот цикл ничего не делает кроме того что ждёт появления данных в своём сокете.

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

Структура нашего другого клиента, программы "отправителя", аналогична уже обсуждённому модулю стороны ожидания.

Пример 4-4. Наилучшая практика использования потоков

# mq_client_sender.py
import asyncio
import argparse, uuid
from itertools import count
from msgproto import send_msg

async def main(args):
  me = uuid.uuid4().hex[:8] ⁽¹⁾
  print(f'Starting up {me}')
  reader, writer = await asyncio.open_connection(
    host=args.host, port=args.port) ⁽²⁾
  print(f'I am {writer.transport.get_extra_info("sockname")}')

  channel = b'/null' ⁽³⁾
  await send_msg(writer, channel) ⁽⁴⁾

  chan = args.channel.encode() ⁽⁵⁾
  for i in count(): ⁽⁶⁾
    await asyncio.sleep(args.interval) ⁽⁷⁾
    data = b'X'*args.size or f'Msg {i} from {me}'.encode()
    try:
        await send_msg(writer, chan)
        await send_msg(writer, data) ⁽⁸⁾
    except ConnectionResetError:
        print('Connection ended.')
        break
    writer.close()

if __name__ == '__main__':
  parser = argparse.ArgumentParser() ⁽⁹⁾
  parser.add_argument('host', default='localhost')
  parser.add_argument('port', default=25000, type=int)
  parser.add_argument('channel', default='/topic/foo')
  parser.add_argument('interval', default=1, type=float)
  parser.add_argument('size', default=0, type=int)
  loop = asyncio.get_event_loop()
  try:
       loop.run_until_complete(main(parser.parse_args()))
  except KeyboardInterrupt:
       print('Bye!')
  loop.close()

⁽¹⁾ Как и для стороны ожидания, требуется некая идентичность.

⁽²⁾ Как и для стороны ожидания, добиваемся выполнения подключения.

⁽³⁾ В соответствии с правилами нашего протокола, самый первый момент, который следует выполнить сразу после подключения, состоит в получении самого названия канала для подписки; однако, так как мы являемся стороной отправки, мы на самом деле не озабочены подпиской на какие- либо каналы; тем не менее, так как наш протокол того требует, поэтому просто предоставляем некий нулевой канал для подписки (на самом деле нам не требуется ничего ожидать).

⁽⁴⁾ Отправляем значение канала для подписки.

⁽⁵⁾ Получаемый из командной строки параметр args.channel предоставляет тот канал, в который мы желаем отправлять сообщения. Отметим, что его следует преобразовать в байты перед отправкой.

⁽⁶⁾ Применение itertools.count() похоже на цикл while True, за исключением того что вы получаете для использования некую переменную итераций. Мы применяем это при отладке обмена сообщениями, так как таким образом проще отслеживать какое сообщение откуда отправлено.

⁽⁷⁾ Значение задержки между отправляемыми сообщениями является неким получаемым на входе параметром, args.interval. Наша следующая строка вырабатывает полезную нагрузку данного сообщения. Это либо строка байт предписанного размера (args.size), либо некое описательное сообщение. Такая гибкость служит исключительно для проверки.

⁽⁸⁾ Отправляем! Заметим, что здесь присутствуют два сообщения: самым первым является название целевого канала, а второе представляет собственно полезную нагрузку.

⁽⁹⁾ Как и в случае стороны ожидания, имеется некий пакет параметров командной строки для регулировок нашего отправителя: "channel" определяет целевой канал для отправки, в то время как "interval" управляет значение задержки между отправками. Значение параметра "size" управляет размером полезной нагрузки каждого сообщения.

Теперь у нас имеются некие брокер, сторона ожидания и отправитель; настало время ознакомиться с выводом. Для того чтобы воспроизвести следующие фрагменты кода я запустил свой сервер, после этого двух клиентов ожидания и затем отправителя; а после этого были отправлены несколько сообщений, я остановил свой сервер при помощи Ctrl-C:

Пример 4-5. Вывод брокера обмена сообщениями (сервера)

$ python mq_server.py
Remote ('127.0.0.1', 55382) subscribed to b'/queue/blah'
Remote ('127.0.0.1', 55386) subscribed to b'/queue/blah'
Remote ('127.0.0.1', 55390) subscribed to b'/null'
Sending to b'/queue/blah': b'Msg 0 from 6b5a8e1d'...
Sending to b'/queue/blah': b'Msg 1 from 6b5a8e1d'...
Sending to b'/queue/blah': b'Msg 2 from 6b5a8e1d'...
Sending to b'/queue/blah': b'Msg 3 from 6b5a8e1d'...
Sending to b'/queue/blah': b'Msg 4 from 6b5a8e1d'...
Sending to b'/queue/blah': b'Msg 5 from 6b5a8e1d'...
^CBye!
Remote ('127.0.0.1', 55382) closing connection.
Remote ('127.0.0.1', 55382) closed
Remote ('127.0.0.1', 55390) closing connection.
Remote ('127.0.0.1', 55390) closed
Remote ('127.0.0.1', 55386) closing connection.
Remote ('127.0.0.1', 55386) closed

Пример 4-6. Вывод отправителя (клиента)

$ python mq_client_sender.py channel /queue/blah
Starting up 6b5a8e1d
I am ('127.0.0.1', 55390)
Connection ended.

Пример 4-7. Вывод получателя 1 (клиента)

$ python mq_client_listen.py listen /queue/blah
Starting up 9ae04690
I am ('127.0.0.1', 55382)
Received by 9ae04690: b'Msg 1 from 6b5a8e1d'
Received by 9ae04690: b'Msg 3 from 6b5a8e1d'
Received by 9ae04690: b'Msg 5 from 6b5a8e1d'
Server closed.

Пример 4-8. Вывод получателя 2 (клиента)

$ python mq_client_listen.py listen /queue/blah
Starting up bd4e3baa
I am ('127.0.0.1', 55386)
Received by bd4e3baa: b'Msg 0 from 6b5a8e1d'
Received by bd4e3baa: b'Msg 2 from 6b5a8e1d'
Received by bd4e3baa: b'Msg 4 from 6b5a8e1d'
Server closed.

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

Основная проблема в том, что для какого- то определённого клиента мы отправляем сообщения подписчикам в той же самой подпрограмме, в которой мы получили новые сообщения. Это означает, что если какой- то подписчик является медленным для того чтобы потреблять то, что ему отсылается, он может тратить много времени на выполнение строки await gather(...) и мы не можем ничего более получать и обрабатывать пока не дождёмся его.

Вместо этого нам требуется отвязать получение сообщений от отправки сообщений. В своём следующем примере мы переработаем наш код именно с этой целью.

Пример: Улучшенная очередь сообщений

В этом примере мы изменим имеющуюся архитектуру своего игрушечного брокера обмена сообщениями. Наши программы "стороны ожидания" и "отправителя" останутся прежними. Основная цель для этого нового проекта посредника состоит в разъединении отправки и приёма. Данный код слегка длиннее, но также не страшный.

Пример 4-9. Брокер обмена сообщениями: улучшенная архитектура

# mq_server_plus.py
import asyncio
from asyncio import StreamReader, StreamWriter, Queue
from collections import deque, defaultdict
from contextlib import suppress
from typing import Deque, DefaultDict, Dict
from msgproto import read_msg, send_msg, run_server

SUBSCRIBERS: DefaultDict[bytes, Deque] = defaultdict(deque)
SEND_QUEUES: DefaultDict[StreamWriter, Queue] = defaultdict(Queue)
CHAN_QUEUES: Dict[bytes, Queue] = {} ⁽¹⁾

async def client(reader: StreamReader, writer: StreamWriter):
peername = writer.transport.get_extra_info('peername')
  subscribe_chan = await read_msg(reader)
  SUBSCRIBERS[subscribe_chan].append(writer) ⁽²⁾
  loop = asyncio.get_event_loop()
  send_task = loop.create_task(
    send_client(writer, SEND_QUEUES[writer])) ⁽³⁾
  print(f'Remote {peername} subscribed to {subscribe_chan}')
  try:
    while True:
      channel_name = await read_msg(reader)
      data = await read_msg(reader)
      if channel_name not in CHAN_QUEUES: ⁽⁴⁾
        CHAN_QUEUES[channel_name] = Queue(maxsize=10) ⁽⁵⁾
        loop.create_task(chan_sender(channel_name)) ⁽⁶⁾
      await CHAN_QUEUES[channel_name].put(data) ⁽⁷⁾
  except asyncio.CancelledError:
    print(f'Remote {peername} connection cancelled.')
  except asyncio.streams.IncompleteReadError:
    print(f'Remote {peername} disconnected')
  finally:
    print(f'Remote {peername} closed')
    await SEND_QUEUES[writer].put(None) ⁽⁸⁾
    await send_task ⁽⁹⁾
    del SEND_QUEUES[writer] ⁽¹⁰⁾
    SUBSCRIBERS[subscribe_chan].remove(writer)

async def send_client(writer: StreamWriter, queue: Queue): ⁽¹¹⁾
    while True:
        with suppress(asyncio.CancelledError):
            data = await queue.get()
            if not data:
                writer.close()
                break
            await send_msg(writer, data)

async def chan_sender(name: bytes):
    with suppress(asyncio.CancelledError):
        while True:
            writers = SUBSCRIBERS[name]
            if not writers:
                await asyncio.sleep(1)
                continue ⁽¹²⁾
            if name.startswith(b'/queue'): ⁽¹³⁾
                writers.rotate()
                writers = [writers[0]]
            msg = await CHAN_QUEUES[name].get() ⁽¹⁴⁾
            if not msg:
                break
            for writer in writers:
                if not SEND_QUEUES[writer].full():
                    print(f'Sending to {name}: {msg[:19]}...')
                    await SEND_QUEUES[writer].put(msg) ⁽¹⁵⁾

if __name__ == '__main__':
    run_server(client)

⁽¹⁾ В нашей предыдущей реализации имелся только один SUBSCRIBERS; теперь в глобальном наборе присуствуют SEND_QUEUES и CHAN_QUEUES. Именно это является результатом полного разделения приёма и отправления данных. SEND_QUEUES имеет одну запись очереди для каждого соединения с клиентом: все подлежащие отправке этому клиенту данные должны помещаться в эту очередь. (Если вы быстро заглянете вперёд, сопрограмма send_client() выгрузит SEND_QUEUES и отправит её.)

⁽²⁾ До текущего момента в нашей функции сопрограммы client() весь код был в точности тем же, что и в нашем образце сервера: принималось название канала подписчика и мы добавляли соответствующий экземпляр StreamWriter для такого нового клиента в свою глобальную коллекцию SUBSCRIBERS.

⁽³⁾ А вот это новинка: мы создаём некую задачу с долгим временем жизни которая будет выполнять всю отправку данных такому клиенту. Эта задача будет исполняться независимо в виде какой- то отдельной сопрограммы и будет выгружать для отправки сообщения поддерживаемой ею очереди, SEND_QUEUES[writer]

⁽⁴⁾ Теперь мы находимся внутри того цикла, в котором мы принимаем данные. Помните, что мы всегда получаем два сообщения: одно для своего названия целевого канала, а второе собственно для данных. Мы намерены создать каую- то новую, выделенную Queue для каждого целевого канала, и именно этому служит CHAN_QUEUES: всякий раз, когда клиент желает поместить данные в какой- то канал, мы обираемся размещать такие данные в соответствующей очереди, а затем немедленно возвращаемся к ожиданию дополнительных данных. Такой подход отсоединяет имеющееся распределение обмена сообщениями от необходимого приёма сообщений этим же клиентом.

⁽⁵⁾ Если уже имеется некая очередь для данного целевого канала, пользуемся ею.

⁽⁶⁾ Для этого канала создаём некую выделенную задачу с длительным временем жизни. Данная сопрограмма, chan_sender(), будет отвечать за забор данных из очереди этого канала и распространения этих данных подписчикам.

⁽⁷⁾ Помещаем свои вновь полученные данные в очередь некого специфического канала. Отметим, что если данная очередь заполнится, мы будем выполнять здесь ожидание пока не освободится место для наших новых данных. В процессе ожидания в этом месте мы не будем считывать никакие новые данные из своего сокета, что означает, что нашему клиенту придётся со своей стороны выполнять ожидание отправки новых данных в соответствующий сокет. Это не обязательно плохой момент, так как он сообщает данному клиенту о так называемом противодавлении - back pressure. (В противном случае вы можете здесь выбрать сброс сообщений, если это подходит для вашего варианта применения.)

⁽⁸⁾ После того как это соединение закрывается, самое время для его очистки! Та долгоиграющая задача, которую мы создавали для отправки данных этому клиенту, send_task, может быть остановлена помещением None в её очередь, SEND_QUEUES[writer] (проверьте соответствующий код для send_client()). Существенно применять какое- то значение в данной очереди вместо того чтобы полностью прекращать её, так как в этой очереди уже могут иметься данные и мы хотим отправки этих данных прежде чем завершить send_client().

⁽⁹⁾ Ожидаем завершения данной задачи отправителя.

⁽¹⁰⁾ Удаляем соответствующую запись в своей коллекции SEND_QUEUES (а в своей следующей строке мы также удаляем соответствующий sock из своей коллекции SUBSCRIBERS, как и ранее).

⁽¹¹⁾ Наша функция сопрограммы send_client() очень похожа на пример из текста данной книги по извлечению исполнителя из очереди. Отметим как данная сопрограмма выполнит выход если поместить None в соответствующую очередь. Также укажем на то как мы гасим CancelledError внутри данного цикла: именно поэтому мы желаем чтобы данная задача закрывалась исключительно по получению None в своей очереди. Таким образом все придерживаемые в этой очереди данные могут быть отправлены до её останова.

⁽¹²⁾ chan_sender() является некоторой необходимой логикой распространения для какого- то канала: она отправляет данные из какого- то выделенного экземпляра Queue канала всем своим подписчикам в этом канале. Но что произойдёт если пока ещё нет никаких подписчиков для этого канала? Мы всего лишь немного подождём и повторим попытку вновь (Заметим, что соответствующая очередь этого канала, то есть CHAN_QUEUES[name], тем не менее продолжит заполняться.)

⁽¹³⁾ Как и ранее в своей предыдущей реализации брокера, мы делаем нечто особенное для каналов, название которых начинается с "/deque": мы вращаем свою deque (двустороннюю очередь, колоду) и отправляем только самую первую запись. Это действует как некая недоделанная система балансировки нагрузки, так как каждый подписчик получает различные сообщения из той же самой очереди. Для всех прочих каналов все подписчики получают все имеющиеся сообщения.

⁽¹⁴⁾ Здесь мы выполняем ожидание данных этой очереди. В своей следующей строке мы выполняем выход в случае получения None. В настоящее время это не является предметом переключения где бы то ни было (следовательно, данная сопрограмма chan_sender() живёт постоянно); однако если впоследствии была добавлена логика очистки задач этого канала, скажем, в случае отсутствия активности на протяжении какого- то времени, именно здесь это следует делать.

⁽¹⁵⁾ Данные были получены, поэтому настало время их отправки подписчикам. Отметим что мы здесь не выполняем отправку: мы помещаем соответствующие данные в собственную очередь отправки каждого подписчика. Дакое разъединение необходимо чтобы гарантировать то, что какой- то медленный подписчик не будет замедлять получение данных кому- то ещё. И более того, если соответствующий подписчик настолько медленный, что его очередь отправки переполняется, мы не помещаем эти данные в его очередь, т.е. они теряются.

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

Эти два варианта применения показывают некий прогресс в понимании относительно необходимой архитектуры распределённой системы обмена сообщениями. Неким ключевым моментом была та реализация, при которой отправка и получение данных могут лучше обрабатываться в отдельных сопрограммах в зависимости от варианта применения. При таких обстоятельствах для перемещения данных между различными сопрограммами, а также для предоставления буферизации с целью их разделения могут очень полезными оказаться очереди.

Самой важной целью данных примеров было показать то как API Streams из asyncio упрощает построение приложений на основе сокетов.

Twisted

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

Отметим, что asyncio не заменяет Twisted[2]. Twisted содержит реализации высочайшего уровня гигантского числа протоколов Internete, включающих в себя не только HTTP, но также и XMPP, NNTP, IMAP, SSH, IRC и FTP (как серверы, так и клиенты). А в последнее время также ждите прибавок: DNS? Проверьте. SMTP? Проверьте. POP3? Проверьте.

На уровне самого кода основное различие между Twisted и asyncio, помимо истории и исторического контекста, состоит в том, что на протяжении длительного времени Python испытывал недостаток языковой поддержки для сопрограмм, а это означало, что Twisted и подобные ему проекты вынуждены были предлагать пути работы с асинхронностью, которые бы работали с имеющимся стандартным синтаксисом Python. На протяжении большей части истории Twisted обратные вызовы (callback) выступали тем средством, при помощи которого выполнялось асинхронное программирование, причём со всею нелинейной сложностью, которую они влекли за собой.

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

@defer.inlineCallbacks ⁽¹⁾
def f():
    yield
    defer.returnValue(123) ⁽²⁾

@defer.inlineCallbacks
def my_coro_func():
    value = yield f() ⁽³⁾
    assert value == 123

⁽¹⁾ Первоначально Twisted требовал создания экземпляров Deferred, а также добавления обратных вызовов к такому экземпляру в качестве своего метода построения асинхронных программ. Несколько лет назад был добавлен соответствующий декоратор @inlineCallbacks, который видоизменил генераторы в качестве сопрограмм.

⁽²⁾ Хотя @inlineCallbacks позволил нам писать код, который был линейным при своём возникновении (в отличии от обратных вызовов), всё же требовался некий хак, например, вызов defer.returnValue(), который был именно тем способом, которым возвращались значения из сопрограмм @inlineCallbacks.

⁽³⁾ Здесь мы можем обнаружить yield (оператор производства), который делает эту функцию неким генератором. Для того чтобы работал @inlineCallbacks, должен иметься по крайней мере один yield для осуществления декорации данной функции.

После появления в Python 3.5 сопрограмм, команда Twisted (в частности, Amber Brown) выполнила работу по добавлению поддержки в запущенном Twisted соответствующего цикла событий asyncio.

Эти усилия предпринимаются и в настоящее время и моя цель в данном разделе не в том чтобы убедить вас создавать все ваши приложения в виде некоего гибрида Twistedasyncio, а вместо этого ознакомит вас с тем, что такая работа в настоящее время выполняется с тем, чтобы предоставить значительное взаимодействие между Twisted и asyncio.

Для тех из вас, кто обладает существенным опытом относительно Twisted, следующий пример может вызвать раздражение:

Пример 4-10. Наилучшая практика использования потоков

from time import ctime
from twisted.internet import asyncioreactor
asyncioreactor.install() ⁽¹⁾
from twisted.internet import reactor, defer, task ⁽²⁾

async def main(): ⁽³⁾
    for i in range(5):
        print(f'{ctime()} Hello {i}')
        await task.deferLater(reactor, 1, lambda: None) ⁽⁴⁾

defer.ensureDeferred(main()) ⁽⁵⁾
reactor.run()  ⁽⁶⁾

⁽¹⁾ Именно таким образом вы сообщаете Twisted о необходимости применять соответствующий цикл событий asyncio в качестве его основного reactor. Отметим, что данная строка обязана предшествовать самому импорту reactor из twisted.internet в нашей следующей строке.

⁽²⁾ Всякий знакомый с программированием Twisted распознает эти импорты. У нас нет места пояснять их здесь, но в своей сердцевине наш reactor является соответствующей версией Twisted необходимого цикла asyncio, а defer и task являются пространствами имён для инструментов по работе с составлением расписаний сопрограмм.

⁽³⁾ Увидеть async def здесь, в некоторой программе Twistes, выглядит как ужасно неуместное, но это именно то, что в конечном итоге даёт нам наша новая поддержка для async/await: возможность применять естественные сопрограммы непосредственно в программах Twisted!

⁽⁴⁾ В нашм более древнем мире @inlineCallbacks, вам пришлось бы применять здесь yield, но теперь мы можем тут воспользоваться await, точно также как и в коде asyncio. Оставшаяся часть данной строки, deferLater, является неким альтернативным способом того же самого, чем является asyncio.sleep(1). Мы выполняем await некоего фьючерса, в котором через 1 секунду будет зажжён некий ничего не выполняющий обратный вызов.

⁽⁵⁾ ensureDeferred() выступает в роли версии Twisted планирования какой- то сопрограммы. Это аналогично loop.create_task() или asyncio.ensure_future().

⁽⁶⁾ Запуск необходимого reactor в точности тот же что и для loop.run_forever() в asyncio.

Вывод:

$ python twisted_asyncio.py
Mon Oct 16 16:19:49 2017 Hello 0
Mon Oct 16 16:19:50 2017 Hello 1
Mon Oct 16 16:19:51 2017 Hello 2
Mon Oct 16 16:19:52 2017 Hello 3
Mon Oct 16 16:19:53 2017 Hello 4

Имеется намного больше тем для изучения Twisted и в особенности стоит потратить своё время на просмотр перечня реализованных в Twisted сетевых протоколов. Ещё имеется определённая работа, которую следует осуществить, но общее будущее выглядит очень ярким для взаимодействия Twisted м asyncio.

Имеющаяся архитектура asyncio была настроена таким образом, что мы имеем возможность заглянуть в будущее, в котором будет возможен объединённый код множества различных асинхронных инфраструктур, таких как Twisted и Tornado в некотором отдельном приложении, причём весь код запускается в одном и том же цикле событий.

Очередь Janus

Наша Очередь Janus (устанавливаемая при помощи pip install janus) предоставляет некое решение для взаимодействия между потоками и сопрограммами. В нашей стандартной библиотеке существуют такие виды очередей:

queue.Queue: некая очередь с ",блокировкой", обычно применяемая для взаимодействия и буферизации между потоками (threads)

asyncio.Queue: некая совместимая с asyncio очередь, обычно используемая при взаимодействии и буферизации между сопрограммами (coroutines)

К несчастью ничего нет полезного для взаимодействия между потоками и сопрограммами! Именно тут на помощь приходит Janus: это некая отдельная Очередь, которая выставляет оба API: один с блокировкой, и второй асинхронный. В нашем следующем образце кода данные вырабатываются внутри некоего потока, помещаются в какую- то очередь, а заетм потребляются из некоторой сопрограммы.

Пример 4-11. Наилучшая практика использования потоков

import asyncio, time, random, janus

loop = asyncio.get_event_loop()
queue = janus.Queue(loop=loop) ⁽¹⁾

async def main():
    while True:
        data = await queue.async_q.get() ⁽²⁾
        if data is None:
            break
        print(f'Got {data} off queue') ⁽³⁾
    print('Done.')

def data_source():
    for i in range(10):
        r = random.randint(0, 4)
        time.sleep(r) ⁽⁴⁾
        queue.sync_q.put(r) ⁽⁵⁾
    queue.sync_q.put(None)

loop.run_in_executor(None, data_source)
loop.run_until_complete(main())
loop.close()

⁽¹⁾ Создаём некую очередь Janus. Отметим, что точно так же как и в случае с asyncio.Queue, очередь Janus будет связана с неким определённым циклом событий. Как обычно, если вы не предоставляете предписанный параметр loop, внутренним образом будет применяться соответствующий стандартный вызов get_event_loop().

⁽²⁾ Наша функция сопрограммы main() просто выполняет ожидание данных в какой- то очереди. Данная строка подвешивается до тех пор, пока нет данных, в точности как и для asyncio.Queue. Данный объект очереди имеет два "лица" {в точном соответствии с названием}: это именуется async_q, и оно предоставляет API очереди, совместимый с асинхронными приложениями.

⁽³⁾ Выводим некое сообщение.

⁽⁴⁾ Внутри нашей функции data_source() вырабатывается некое случайное целое значение, которое применяется как некая продолжительность сна, а также как какое- то значение данных. Отметим, что вызов time.sleep() является блокирующим, поэтому данная функция должна исполняться в каком- то потоке.

⁽⁵⁾ Помещаем полученные данные в имеющуюся очередь Janus. Это отображает второе "лицо" нашей очереди Janus: sync_q, которое предоставляет наш стандартный API Queue с блокировкой.

Вывод:

Got 2 off queue
Got 4 off queue
Got 4 off queue
Got 2 off queue
Got 3 off queue
Got 4 off queue
Got 1 off queue
Got 1 off queue
Got 0 off queue
Got 4 off queue
Done.

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

aiohttp

aiohttp привносит в asyncio все моменты HTTP, в том числе поддержку для клиентов и серверов HTTP, а также сопровождение вебсокетов. Давайте перепрыгнем сразу в примеры кодирования начиная далее с простейшего самого по себе: &qout;hello world&qout;:

Пример: Hello World

Следующий пример демонстрирует минимальный веб сервер при помощи aiohttp:

from aiohttp import web

async def hello(request):
    return web.Response(text="Hello, world")

app = web.Application() ⁽¹⁾
app.router.add_get('/', hello) ⁽²⁾
web.run_app(app, port=8080) ⁽³⁾

⁽¹⁾ Создан некий экземпляр Application.

⁽²⁾ Создан какой- то корень, причём соответствующая целевая сопрограмма hello() определяется как соответствующий обработчик.

⁽³⁾ Запускается веб приложение.

Обратим внимание что в данном коде не упоминаются никакие циклы, задачи или фьючерсы : разработчики инфраструктуры aiohttp скрыли всё это от нас, оставив очень чистый API. Такой подход рассматривается как общий в большинстве инфраструктур поверх asyncio, который разрабатывается с тем, чтобы позволить проектировщикам инфраструктур выбирать только самое существенное из того что необходимо и заключать это в предпочитаемом ими API.

Пример: Вытаскивание новостей

aiohttp может применяться как в качестве сервера, так и как какая- то библиотека клиента, как и очень популярная (на применяющая блокировку!) библиотека requests. Я бы хотел показать демонстрацию aiohttp применяя некий пример, который объединяет обе функциональности.

В данном примере мы реализуем некий вебсайт, который за сценой выполняет вытаскивание веб содержимого (scraping). Это приложение будет выполнять выскребание новостей с двух вебсайтов и объединять полученные заголовки в одну страницу результатов. Вот наша стратегия:

Некий клиент браузера выполняет веб запрос к http://localhost:8080/news

Наш веб сервер получает этот запрос, а затем в своём сервере делает выборку данных с множества новостных вебсайтов

Со всех страниц данных вытаскиваются заголовки

Полученные заголовки сортируются и форматируются в соответствующий HTML отклика, который отправляется обратно в соответствующий клиент браузера

Рисунок 4-1 отображает наш вывод:

Рисунок 4-1

Окончательная продукция нашей программы вытаскивания новостей: заголовки из CNN на синем, из Al Jazeera на жёлтом.

Выдёргивание веб содержимого в наши дни достаточно сложное, так как многие вебсайты интенсивно применяют JavaScript для загрузки своего содержимого. К примеру, если вы попробуете requests.get('http://edition.cnn.com'), вы обнаружите что этот отклик содержит очень мало полезных данных! Становится со всё возрастающей необходимостью иметь способность исполнять JavaScript локально чтобы получать данные, так как многие сайты применяют JavaScript для загрузки своего реального содержимого. Сам процесс исполнения такого JavaScript для воспроизводства такого окончательного вывода HTML именуется рендерингом (построением образа).

Для осуществления построения образа мы применяем отличный проект с названием Splash, который описывает самого себя как "Службу рендеринга JavaScript". Он способен запускаться в каком- то контейнере docker и предоставлять некий API для построения образа прочих сайтов. Внутри себя он применяет некий механизм WebKit (JavaScript- совместимый) для полной выгрузки и построения образа какого- то вебсайта. Именно его мы будем применять для получения данных вебсайта. Наш сервер aiohttp будет вызывать этот API Splash для получения данных необходимой страницы.

[Совет]
Совет

Чтобы получить и запустить необходимый контейнер Splash, в своей оболочке выполните следующие команды:

$ docker pull scrapinghub/splash
$ docker run rm p 8050:8050 scrapinghub/splash

Наша серверная основа будет вызывать API Splash в http://localhost:8050.

from asyncio import get_event_loop, gather
from string import Template
from aiohttp import web, ClientSession
from bs4 import BeautifulSoup

async def news(request): ⁽¹⁾
    sites = [
        ('http://edition.cnn.com', cnn_articles), ⁽²⁾
        ('http://www.aljazeera.com', aljazeera_articles),
    ]
    loop = get_event_loop()
    tasks = [loop.create_task(news_fetch(*s)) for s in sites] ⁽³⁾
    await gather(*tasks) ⁽⁴⁾

    items = { ⁽⁵⁾
        text: ( ⁽⁶⁾
            f'<div class="box {kind}">'
            f'<span>'
            f'<a href="{href}">{text}</a>'
            f'</span>'
            f'</div>'
        )
        for task in tasks for href, text, kind in task.result()
    }
    content = ''.join(items[x] for x in sorted(items))

    page = Template(open('index.html').read()) ⁽⁷⁾
    return web.Response(
        body=page.safe_substitute(body=content), ⁽⁸⁾
        content_type='text/html',
    )

async def news_fetch(url, postprocess):
    proxy_url = (
        f'http://localhost:8050/render.html?' ⁽⁹⁾
        f'url={url}&timeout=60&wait=1'
    )
    async with ClientSession() as session:
        async with session.get(proxy_url) as resp: ⁽¹⁰⁾
            data = await resp.read()
            data = data.decode('utf8')
    return postprocess(url, data) ⁽¹¹⁾

def cnn_articles(url, page_data): ⁽¹²⁾
    soup = BeautifulSoup(page_data, 'lxml')
    def match(tag):
        return (
            tag.text and tag.has_attr('href')
            and tag['href'].startswith('/')
            and tag['href'].endswith('.html')
            and tag.find(class_='cd__headlinetext')
        )
        headlines = soup.find_all(match) ⁽¹³⁾
        return [(url + hl['href'], hl.text, 'cnn')
            for hl in headlines]

def aljazeera_articles(url, page_data): ⁽¹⁴⁾
    soup = BeautifulSoup(page_data, 'lxml')
    def match(tag):
        return (
            tag.text and tag.has_attr('href')
            and tag['href'].startswith('/news')
            and tag['href'].endswith('.html')
        )
    headlines = soup.find_all(match)
    return [(url + hl['href'], hl. text, 'aljazeera')
    for hl in headlines]

app = web.Application()
app.router.add_get('/news', news)
web.run_app(app, port=8080)

⁽¹⁾ Наша функция news() является соответствующим обработчиком для URL /news в нашем сервере. Она возвращает окончательную страницу HTML, отображающую все имеющиеся заголовки.

⁽²⁾ Здесь у нас имеется толлько два новостных вебсайта для выдёргивания: CNN и Al Jazeera. Легко можно добавить дополнительные, но затем также потребуется добавить дополнительные обработчики данных, подобные представленным функциям cnn_articles() и aljazeera_articles(), каждая из которых имеет индивидуальные настройки извлечения данных заголовков.

⁽³⁾ Для каждого новостного сайта мы создаём некую задачу для извлечения и обработки данных соответствующей страницы HTML для их передовиц. Отметим, что мы распаковываем соответствующие кортежи ((*s)), так как наша функция сопрограммы news_fetch() получает оба URL и функцию обработки данных в качестве параметров. Каждый news_fetch() будет возвращать перечень кортежей в виде (<article URL>, <article title>).

⁽⁴⁾ Все задачи собираются воедино в отдельный Future (gather() возвращает некий фьючерс, представляющий значение состояния всех подлежащих выработке задач), а затем мы немедленно выполняем await относительно завершения такого фьючерса. Данная строка будет в подвешенном состоянии пока не завершится соответствующий фьючерс.

⁽⁵⁾ Так как все имеющиеся задачи news_fetch() теперь завершены, мы собираем все полученные результаты в некий словарь. Отметим как вложенные содержания понятий применяются для выполнения итераций по задачам, а затем и по списку кортежей, возвращаемых каждой задачей. Мы также используем f- строки для непосредственной подстановки данных, в том числе и сам "вид" страницы, что будет применяться в CSSS для обозначения цветом фона соответствующего div.

⁽⁶⁾ В этом словаре значением ключа является сам заголовок, а его значением некая строка HTML для какого- то div, которая будет отображаться в нашей странице результата.

⁽⁷⁾ Наш веб сервер собирается вернуть HTML. Мы выгружаем данные HTML из некоторого локального файла с названием index.html, этот файл предоставляется в наших Дополнениях, если вы пожелаете восстановить данный пример самостоятельно.

⁽⁸⁾ Мы подставляем собранные DIV заголовков в общий шаблон и возвращаем полученную страницу в браузер своего клиента. Созданная страница отображена на Рисунке 4-1.

⁽⁹⁾ Здесь, внутри соответствующей функции сопрограммы news_fetch(), у нас имеется крошечный шаблон для попадания в свой API Splash (в моём случае он исполняется в некотором локальном контейнере docker с портом 8050). Здесь мы демонстрируем как aiohttp может применяться в качестве некоторого клиента HTPP.

⁽¹⁰⁾ Самый стандартный путь состоит в создании некоего экземпляра ClientSession() и последующем применении метода get() в имеющемся экземпляре сеанса для выполнения необходимого вызова REST. В нашей следующей строке получаются данные отклика. Отметим, что поскольку мы всегда оперируем в сопрограммах, причём применяя async with и await, эти сопрограммы никогда не выполняют блокировку: у нас будет иметься возможность обрабатывать многие тысячи таких запросов, даже хотя эти операции, т.е. news_fetch(), могут быть относительно медленными, поскольку мы выполняем веб вызовы внутренним образом.

⁽¹¹⁾ После получения необходимых данных вызываем требуемую функцию обработки данных. Возвращаясь назад, вспоминаем, что для CNN это будет cnn_articles(), а для Al Jazeera aljazeera_articles().

⁽¹²⁾ У нас очень мало места для краткого обзора обработки данных. После получения данных соответствующей страницы мы применяем библиотеку Beautiful Soup 4 для выделения заголовков.

⁽¹³⁾ Наша функция match() возвратит все имеющие соответствие теги (я вручную проверил соответствующие исходные коды HTML исходные коды этих новостных вебсайтов чтобы вывести какие комбинации фильтров выделяют наилучшие теги), а затем мы возвращаем какой- то список кортежей, соответствующий установленному формату (<article URL>, <article title>).

⁽¹⁴⁾ Это аналогично обработке данных для Al Jazeera. Соответствующее условие match() слегка другое, но иным способом выдаёт то же самое из CNN.

В общем случае вы обнаружите, что aiohttp имеет достаточно простой API и "остаётся на вашем пути" при разработке вами своих приложений.

В своём следующем разделе мы рассмотрим применение ZeroMQ с помощью asyncio, который имеет достаточно приятным свой любопытный эффект программирования сокета.

ØMQ (ZeroMQ)

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

-- Питер Хинтджентс, ZeroMQ: Обмен сообщениям для множества приложений

ZeroMQ (or even ØMQ!) является популярной безразличной к языкам программирования библиотекой для сетевых приложений: она предоставляет "умные" сокеты. Когда вы создаёте сокеты ZeroMQ в виде кода они напоминают обычные сокеты с хорошо узнаваемыми названиями методов наподобие recv() и send() и тому подобных, однако внутри себя эти сокеты обрабатывают некоторые из наиболее раздражающих и утомительных задач, требующихся при работе с обычными сокетами.

Одной из таких функций является управление передачей сообщений, следовательно вам не приходится внедрять свой собственный протокол и отсчитывать байты в кабеле для определения того когда доставлены все необходимые байты для конкретного сообщения - вы просто отправляете нечто, что вы рассматриваете как служащее неким "сообщением" и вся эта штука появляется неповреждённой на другой стороне!

Другим величайшим свойством является автоматическое распознавание логики. Если ваш сервер падает и возвращается обратно в строй позднее, ваш клиент сокета ØMQ автоматически восстановит соединение. И, что ещё лучше, отправленные вашим кодом в этот сокет сообщения будут собираться в буфер на протяжении всего периода отключения, следовательно все они всё же будут отправлены когда их сервер вернётся к жизни. Имеется ряд причин, по которым ØMQ порой именуется как обмен сообщениями без брокера[3]: он предоставляет некоторые из необходимых функций программного обеспечения посредника обмена сообщениями непосредственно в самих своих объектах сокетов.

Сокеты ØMQ уже реализуются внутри самих себя как асинхронные (поэтому они могут поддерживать многие тысячи одновременных соединений, даже при использовании в коде с потоками), однако это скрыто от нас за API ØMQ; тем не менее в привязку Python PyZMQ к библиотеке ØMQ была добавлена поддержка Asyncio и в данном разделе мы намерены взглянуть на некоторые примеры того как эти умные сокеты могут встраиваться в ваши приложения Python.

Пример: множественные сокеты

Относительно вытаскивания заголовков: если ØMQ предоставляет сокеты, которые уже являются асинхронными неким способом, который применяется с потоками, в чём соль применения ØMQ совместно с Code? Ответ такой: код очистки.

Для демонстрации давайте взглянем на некий крошечный пример в котором вы используете множество сокетов ØMQ в одном и том же приложении. Для начала мы покажем версию с блокировкой (этот пример взят из the zguide, официального руководства по ØMQ):

Пример 4-12. Традиционный подход

# poller.py
import zmq

context = zmq.Context()
receiver = context.socket(zmq.PULL) ⁽¹⁾
receiver.connect("tcp://localhost:5557")

subscriber = context.socket(zmq.SUB) ⁽²⁾
subscriber.connect("tcp://localhost:5556")
subscriber.setsockopt_string(zmq.SUBSCRIBE, '')

poller = zmq.Poller() ⁽³⁾
poller.register(receiver, zmq.POLLIN)
poller.register(subscriber, zmq.POLLIN)

while True:
    try:
        socks = dict(poller.poll()) ⁽⁴⁾
    except KeyboardInterrupt:
        break
    if receiver in socks:
        message = receiver.recv_json()
        print(f'Via PULL: {message}')

if subscriber in socks:
    message = subscriber.recv_json()
    print(f'Via SUB: {message}')

⁽¹⁾ Сокеты ØMQ имеют типы! Это некий сокет PULL. Вы можете представлять себе его как некий вид сокета "доступного только на получение", который будет запитываться неким иным сокетом "только для отправки", который будетсокетом типа PUSH.

⁽²⁾ Данный сокет с типом SUB является иным видом сокета "доступного только на получение", и он будет питаться сокетом типа PUB, который служит "только для отправки".

⁽³⁾ Если вам необходимо перемещать данные между множеством сокетов в каком- то приложении ØMQ потока, вам понадобится некое средство опроса (poller). Это происходит по причине того, что такие сокеты не сберегают поток, поэтому вы не можете выполнять recv() в различных сокетах из разных потоков [4: На самом деле вы можете делать это пока ваши подлежащие использованию в различных потоках сокеты создаются, применяются и целиком уничтожаются в своих собственных потоках. Это возможно, но трудно сделать, причём многие люди пытаются добиться этого. Именно по этой причине настолько сильна рекомендация применять единый поток и механизм опроса.]

⁽⁴⁾ Это работает аналогично системному вызову select(). Данная система опроса не будет блокирована когда имеются данные готовыми на приём в одном из своих зарегистрированных сокетов и затем она поднимается для вас чтобы выгрузить эти данные и сделать с ними что- то. Этот большой блок if является тем как вам приходится обнаруживать правильный сокет.

Применение некоего цикла опроса плюс какой- то блок выбора сокета в явном виде делают его выглядящим слегка неуклюжим. Иным вариантом может быть .recv() в каждом сокете в различных потоках - но тогда вам придётся иметь дело с большим числом потенциальных проблем относительно сохранности потоков. К примеру: сокеты ØMQ не сохраняют потоки и поэтому один и тот же сокет не должен применяться в различных потоках. Отображённый выше код является намного более безопасным, так как вам не приходится беспокоиться о каких бы то ни было проблемах сохранности потока.

Так или иначе, прежде чем мы продолжим обсуждение, давайте рассмотрим код своего сервера и некий небольшой вывод:

Пример 4-13. Код сервера

# poller_srv.py
import zmq, itertools, time

context = zmq.Context()
pusher = context.socket(zmq.PUSH)
pusher.bind("tcp://*:5557")

publisher = context.socket(zmq.PUB)
publisher.bind("tcp://*:5556")

for i in itertools.count():
    time.sleep(1)
    pusher.send_json(i)
    publisher.send_json(i)

Данный код сервера не важен для нашего обсуждения, но вкратце: имеется некий сокет PUSH и какой- то сокет PUB, как мы и говорили ранее, а также цикл внутри, который отправляет данные в оба сокета каждую секунду. Вот некий вывод из poller.py (Отметим, должны быть запущены обе программы):

$ python poller.py
Via PULL: 0
Via SUB: 0
Via PULL: 1
Via SUB: 1
Via PULL: 2
Via SUB: 2
Via PULL: 3
Via SUB: 3

Наш код работает. Однако наш интерес здесь не в том работает ли этот код, а в том что может предложить asyncio для самой структуры этого кода poller.py. Ключевым моментом для понимания является то, что наш код asyncio предназначен для запуска в некотором отдельном потоке, что означает, что будет луше обрабатывать различные сокеты в разных сопрограммах - и действительно, именно это мы и сделаем.

Конечно, кому- то пришлось взять на себя тяжёлый труд добавления поддержки сопрограмм в pyzmq (нашей библиотеке клиента Python для ØMQ) самостоятельно выполнив эту работу, так что это было не бесплатно! Но теперь, когда этот тяжкий труд выполнен, мы можем слегка улучшить структуру своего "традиционного" кода:

[Замечание]
Замечание

Для последующих примеров кода необходимо использовать pyzmq >- 17.0.0. На момент написания верия 17 еще не была выпущена, поэтому вам может потребоваться установить самую последнюю бета версию pyzmq с главной версией 17. {Прим. пер.: На момент перевода последним выпуском является 17.1.12, так что нет нужды применять бета версию.}

Пример 4-14. Чистое разделение с asyncio

# poller_aio.py
import asyncio
import zmq
from zmq.asyncio import Context

context = Context()

async def do_receiver():
    receiver = context.socket(zmq.PULL) ⁽¹⁾
    receiver.connect("tcp://localhost:5557")
    while True:
        message = await receiver.recv_json() ⁽²⁾
        print(f'Via PULL: {message}')

async def do_subscriber():
    subscriber = context.socket(zmq.SUB) ⁽³⁾
    subscriber.connect("tcp://localhost:5556")
    subscriber.setsockopt_string(zmq.SUBSCRIBE, '')
    while True:
        message = await subscriber.recv_json() ⁽⁴⁾
        print(f'Via SUB: {message}')

loop = asyncio.get_event_loop()
loop.create_task(do_receiver()) ⁽⁵⁾
loop.create_task(do_subscriber())
loop.run_forever()

⁽¹⁾ Этот образец кода делает то же самое что и ранее, за исключением того, что теперь мы пользуемся преимуществами сопрограмм чтобы поменять всё. Теперь мы можем иметь дело с каждым сокетом по отдельности. Мы создали две функции сопрограмм, одну для каждого сокета, а вторую для имеющегося сокета PULL.

⁽²⁾ В pyzmq мы пользуемся поддержкой asyncio, что означает, что все send() и recv() обязаны применять соответствующее ключевое слово await. Наш Poller больше не появляется где бы то ни было, так как он интегрирован в сам цикл событий asyncio.

⁽³⁾ Именно это обработчик для нашего сокета SUB. Его структура очень похожа на соответствующий обработчик сокета PULL, но тот не является предметом в этом случае. Если бы потребовалась более сложная логика, мы могли бы запросто добавить его тут, причём полностью заключив только внутри кода обработчика данного SUB.

⁽⁴⁾ И вновь: совместимые с asyncio сокеты требуют такого ключевого слова await при отправке и приёме.

⁽⁵⁾ Для запуска необходимого цикла событий asyncio и создания задач для каждого сокета требуются эти дополнительные строки. Я срезал здесь несколько углов и опустил всю обработку ошибо и очистку, так как хочу выделить само воздействие на компоновку кода в дальнейшем.

Получаемый вывод в точности тот же, что был показан ранее.

Такое использование сопрограмм, по моему мнению, оказывает невероятно положительное воздействие на структуру кода в данных примерах. В реальном промышленном коде с большим числом сокетов ØMQ, такие сопрограммы обработчики для каждого могут также даже располагаться в отдельных файлах, предоставляя дополнительные возможности для лучшей структуры кода. И даже для программ с единственным сокетом чтения- записи очень просто применять отдельные сопрограммы для чтения и записи в случае такой потребности.

Наш улучшенный код выглядит во многом похожим на код потока и действительно, для данного конкретного отображённого выше примера тот же самый refactor будет работать и для потоков: запустите функции do_receiver() и do_subscriber() с блокировкой в отдельных потоках. Но вы и правда желаете иметь дело даже с потенциальными условиями для состязательности, в особенности когда ваше приложение растёт со временем в отношении особенностей и сложности?

Здесь ешё множество возможностей для изучения и, как я уже говорил ранее, эти волшебные сокеты полны потех на забаву! В своём следующем примере мы рассмотрим более практичное применение ØMQ нежели то, которое преложено выше.

Пример: APM (мониторинг производительности приложения)

В современной, упакованной в контейнеры, базирующейся на развёртывании микрослужб практике сегодняшнего для некоторые моменты, применение которых обязано быть тривиальным, например мониторинг применения вашими прикладными программами ЦПУ и памяти стало чем- то более сложным нежели простой запуск top. Для заполнения этой пустоты на протяжении последних нескольких лет появился ряд коммерческих продуктов, однако остаётся тот вариант, когда стоимость может выступать запретом для небольших команд стартапов и тех для кого это составляет хобби.

В данном примере мы приспособим ØMQ и asyncio для построения игрушечного прототипа распределённого мониторинга приложений. Наш проект имеет три части:

Уровень приложения

Этот уровень содержит все наши приложения. Примерами могут служить "индивидуальные" микрослужбы, микрослужбы "резервирования", некая микруслужба "электронной почты" и тому подобное. Мы добавляем некий "передающий" сокет ØMQ в каждое из своих приложений. Этот сокет будет отправлять измерения производительности в какой- то центральный сервер.

Уровень сбора информации

Наш центральный сервер выставит какой- то сокет ØMQ для сбора необходимых данных от всех запущенных экземпляров приложений. Этот сервер также будет обслуживать некуб веб страницу для показа графиков производительности во времени, а также наш сервер будет в реальном времени выдавать потоком те данные, которые приходят к нему!

Уровень виртуализации

Это сама веб страница подлежащая обслуживанию. Мы будем отображать все собираемые данные в каком- то наборе графиков и эти диаграммы будут в живую обновляться в реальном масштабе времени. Для упрощения примеров нашего кода мы воспользуемся библиотекой JavaScript Smoothie Charts, которая предоставляет все необходимые функции стороны клиента.

Пример 4-15. Уровень приложения: производство измерений

import argparse
from asyncio import get_event_loop, gather, sleep, CancelledError
from random import randint, uniform
from datetime import datetime as dt
from datetime import timezone as tz
from contextlib import suppress
import zmq, zmq.asyncio, psutil
from signal import SIGINT

# zmq.asyncio.install() ⁽¹⁾
ctx = zmq.asyncio.Context()

async def stats_reporter(color: str): ⁽²⁾
    p = psutil.Process()
    sock = ctx.socket(zmq.PUB) ⁽³⁾
    sock.setsockopt(zmq.LINGER, 1)
    sock.connect('tcp://localhost:5555') ⁽⁴⁾
    with suppress(CancelledError): ⁽⁵⁾
        while True: ⁽⁶⁾
            await sock.send_json(dict( ⁽⁷⁾
                color=color,
                timestamp=dt.now(tz=tz.utc).isoformat(), ⁽⁸⁾
                cpu=p.cpu_percent(),
                mem=p.memory_full_info().rss / 1024 / 1024
            ))
            await sleep(1)
    sock.close() ⁽⁹⁾

async def main(args):
    leak = []
    with suppress(CancelledError):
        while True:
            sum(range(randint(1_000, 10_000_000))) ⁽¹⁰⁾
            await sleep(uniform(0, 1))
            leak += [0] * args.leak

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('color', type=str) ⁽¹¹⁾
    parser.add_argument('leak', type=int, default=0)
    args = parser.parse_args()
    loop = get_event_loop()
    loop.add_signal_handler(SIGINT, loop.call_soon, loop.stop) ⁽¹²⁾
    tasks = gather(main(args), stats_reporter(args.color)) ⁽¹³⁾
    loop.run_forever()
    print('Leaving...')
    for t in asyncio.Task.all_tasks():
        t.cancel() ⁽¹⁴⁾
    loop.run_until_complete(tasks)
    ctx.term() ⁽¹⁵⁾

⁽¹⁾ В версиях pyzmq, предшествующих 17.0.0, требовалось применять в явном виде данную команду zmq.asyncio.install() чтобы разрешить поддержку Asyncio. На момент написания версия 17 находилась в бета версии {Прим. пер.: однако на момент перевода всё уже хорошо.}

⁽²⁾ Эта функция сопрограммы запустит некую сопрограмму с долгим временем жизни, последовательно отправляющую данные в свой процесс сервера.

⁽³⁾ Создаём некий сокет ØMQ! Имеются различные разновидности сокетов. Именно этот имеет тип PUB, и он допускает отправку сообщений в одном направлении в другой сокет ØMQ. Этот сокет имеет - как об этом гласит руководство ØMQ - суперсилу. Он будет автоматически обрабатывать все повторные подключения и буферировать логику для вас.

⁽⁴⁾ Подключаемся к своему серверу.

⁽⁵⁾ Наша последовательность останова управляется KeyboardInterrupt, впоследствии останавливаясь. Когда принимается соответствующий сигнал мы прекращаем все свои задачи. Здесь мы обрабатываем соответствующий возбуждённую CancelledError при помощи удобного диспетчера контекста suppress() из модуля стандартной библиотеки contextlib.

⁽⁶⁾ Выполняем итерации на постоянной основе, отправляя данные в свой сервер.

⁽⁷⁾ Так как ØMQ знает как работать с целыми сообщениями, а не просто с обрезками какого- то потока данных, он открывает свою дверь для стопки полезных обёрток вокруг обычной идиомы sock.send(): в данном случае мы применяем один из таких вспомогательных методов, send_json(), который автоматически выстраивает все аргументы в JSON. Это позволяет нам напрямую применять dict().

⁽⁸⁾ Надёжный способ передачи информации даты и времени состоит в следовании формата ISO 8601. Это в особенности справедливо если вы обмениваетесь данными даты и времени между программным обеспечением, написанным на различных языках программирования, так как подавляющее большинство реализаций языков способно работать с данным стандартом.

⁽⁹⁾ Чтобы закончить здесь мы должны получить исключительную ситуацию CancelledError, приводящую в результате к прекращению задачи. Наш сокет ØMQ должен быть закрыт чтобы позволить останов программы.

⁽¹⁰⁾ Эта функция main() символизирует самое настоящее приложение микрослужбы. Данное суммирование произвольных чисел производит поддельную работу, просто чтобы выдавать нам некие ненулевые данные для просмотра на нашем уровне виртуализации слегка позднее.

⁽¹¹⁾ Мы собираемся создать множество экземпляров данного приложения, поэтому будет удобно иметь возможность различать их (позднее на диаграммах) при помощи некоего параметра color.

⁽¹²⁾ При получении сигнала SIGINT (например, при нажатии CtrlC), планировщик вызывает останов данного цикла.

⁽¹³⁾ Создаём и собираем задачи для каждой из имеющихся функций сопрограммы.

⁽¹⁴⁾ Получив соответствующий сигнал останова прекращаем все задачи. Это возбуждает некую CancelledError внутри всех тех сопрограмм, которые представлены в нашей группе tasks. После завершения, всё ещё требуется запускать эти задачи для их окончания, предоставляя им возможность обработать надлежащим образом такое завершение. К примеру, мы обязаны закрыть имеющийся сокет ØMQ чтобы выполнить окончательное завершение.

⁽¹⁵⁾ Наконец может быть прекращён контекст соответствующего ØMQ.

Представляющей наибольший интерес является наша функция stats_reporter(). Именно она отправляет поток данных измерений (собираемых очень полезной библиотекой psutil). Оставшийся код предполагается выступающим в качестве обычного приложения микрослужбы.

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

Пример 4-16. Уровень сбора данных: этот сервер собирает результаты статистик

# metricserver.py
import asyncio
from contextlib import suppress
import zmq
import zmq.asyncio
import aiohttp
from aiohttp import web
from aiohttp_sse import sse_response
from weakref import WeakSet
import json

# zmq.asyncio.install()
ctx = zmq.asyncio.Context()
connections = WeakSet() ⁽¹⁾

async def collector():
    sock = ctx.socket(zmq.SUB) ⁽²⁾
    sock.setsockopt_string(zmq.SUBSCRIBE, '') ⁽³⁾
    sock.bind('tcp://*:5555') ⁽⁴⁾
    with suppress(asyncio.CancelledError):
        while True:
            data = await sock.recv_json() ⁽⁵⁾
            print(data)
            for q in connections:
                await q.put(data) ⁽⁶⁾
    sock.close()

async def feed(request): ⁽⁷⁾
    queue = asyncio.Queue()
    connections.add(queue) ⁽⁸⁾
    with suppress(asyncio.CancelledError):
        async with sse_response(request) as resp: ⁽⁹⁾
            while True:
                data = await queue.get() ⁽¹⁰⁾
                print('sending data:', data)
                resp.send(json.dumps(data)) ⁽¹¹⁾
    return resp

async def index(request): ⁽¹²⁾
    return aiohttp.web.FileResponse('./charts.html')

async def start_collector(app): ⁽¹³⁾
    app['collector'] = app.loop.create_task(collector())

async def stop_collector(app):
    print('Stopping collector...')
    app['collector'].cancel() ⁽¹⁴⁾
    await app['collector']
    ctx.term()

if __name__ == '__main__':
    app = web.Application()
    app.router.add_route('GET', '/', index)
    app.router.add_route('GET', '/feed', feed)
    app.on_startup.append(start_collector) ⁽¹⁵⁾
    app.on_cleanup.append(stop_collector)
    web.run_app(app, host='127.0.0.1', port=8088)

⁽¹⁾ Одна половина этой программы будет получать данные от прочих приложений, а другая половина будет предоставлять данные браузерам клиентов через SSE (serversent events , события отправки сервером). Мы применяем WeakSet() для отслеживания всех подключённых в настоящее время веб клиентов. Каждый подключённый клиент будет иметь некий связанный с ним экземпляр Queue(), поэтому такой идентификатор connections на самом деле некий набор очередей.

⁽²⁾ Повторно вызывая это на своём уровне приложения мы применяем некий сокет zmq.PUB; здесь, на уровне сбора информации мы пользуемся его партнёром, с соответствующим типом сокета zmq.PUB. Этот сокет ØMQ может выполнять только приём, и при этом не выполняет никакой отправки.

⁽³⁾ Для данного типа сокета zmq.SUB требуется предоставлять некое имя подписки, но для своих целей мы просто проверяем что всё идёт своим чередом, поэтому тут у нас пустой название темы.

⁽⁴⁾ Тут мы подвязываемся к своему сокету zmq.SUB. Задумайтесь на мгновение об этом! В конфигурациях "pubsub" вам обычно приходится делать сторону публикации своего сервера (bind()) и сторону подписки соответствующего клиента (connect()). ØMQ применяет иной подход: любая сторона может выступать в качестве такого сервера. Для нашего случая применения это важно, так как каждый из экземпляров нашего уровня приложения будет подключаться к одному и тому доменному имени сервера соединения и нет никакого иного обходного пути.

⁽⁵⁾ Имеющаяся в pyzmq поддержка asyncioпозволяет нам выполнять await данных со стороны наших подключённых прикладных программ. И не только это, так как все входящие данные будут автоматически распаковываться из JSON (да, это означает, что data представлены в виде dict()).

⁽⁶⁾ Повторный вызов этого нашего набора connections удержит какую- то очередь для каждого веб клиента? Теперь, когда данные были получены, самое время отправить их всем клиентам: эти данные помещаются в каждую из очередей.

⁽⁷⁾ Наша функция сопрограммы feed() создаст сопрограммы для каждого подключённого веб клиента. Для активной доставки данных соответствующему клиенту внутри применяются события отправки сервером.

⁽⁸⁾ Как уже описывалось ранее, каждый веб клиент будет иметь свой собственный экземпляр queue для приёма данных из соответствующей сопрограммы collector(). Такой экземпляр queue добавляется в имеющийся набор connections, но поскольку connections является неким неустойчивым набором, соответствующая запись будет автоматически удаляться из connections когда соответствующая очередь покидает сферу, т.е. когда отключается некий веб клиент. Неустойчивые ссылки (weakrefs) великолепны для упрощения таких видов задач резервирования.

⁽⁹⁾ Имеющийся пакет aiohttp_sse предоставляет необходимый диспетчер контекста sse_response(). Он предоставляет нам некую сферу внутри тех данных, для которых мы выполняем активную доставку своему веб клиенту.

⁽¹⁰⁾ Мы остаёмся подключёнными к соответствующему веб клиенту и ожидаем данные для очереди этого конкретного клиента.

⁽¹¹⁾ Как только поступают необходимые данные (внутри collector()), они отправляются необходимому подключённому веб клиенту. Отметим, что здесь мы повторно инициализируем соответствующий dict данных. Некая оптимизация в показанном здесь коде позволит избежать распаковки JSON в (collector() и вместо этого воспользоваться (sock.recv_string чтобы обойти потребность упорядочения в обо конца. Конечно, в каком- то реальном сценарии вы можете пожелать в любом случае выполнять распаковку в своём средстве сбора информации и выполнять некую проверку этих данных прежде чем отправлять их в браузер своего клиента. Так много возможностей!

⁽¹²⁾ Оконечная точка index() является самой первой загружаемой страницей и здесь мы обслуживаем некий файл со статистическими данными с названием charts.html.

⁽¹³⁾ Наша библиотека aiohttp предоставляет возможности для вас подхватывать дополнительные сопрограммы с длительным временем жизни, которые вам могут понадобиться. При помощи соответствующей сопрограммы collector() у нас имеется именно такое положение дел, поэтому мы создаём некую сопрограмму запуска start_collector() и какую- то сопрограмму останова. Они будут вызываться на протяжении определённых этапов последовательностей запуска и останова aiohttp. Отметим, что мы добавили необходимую задачу средства сбора информации в саму нашу app, которая реализует какой- то протокол установки соответствия с тем, чтобы вы могли применять его в виде dict.

⁽¹⁴⁾ Здесь вы можете увидеть что мы получаем свою сопрограмму collector() из соответствующего идентификатора app и вызываем в ней cancel().

⁽¹⁵⁾ Наконец, вы можете увидеть где прицепляются необходимые индивидуальные сопрограммы запуска и останова: соответствующий экземпляр app предоставляет особые точки входа дополнительного ПО, к которым в конец могут быть добавлены ваши персональные сопрограммы.

Всё что нам осталось, это соответствующий уровень виртуализации. Мы применяем библиотеку Smoothie Charts для выработки диаграмм с прокруткой и законченным HTML для нашей основной (и не только!) веб страницы, charts.html, который представлен в наших Дополнениях целиком. В нём имеется слишком много HTML, CSS и JavaScript чтобы представлять их в данном разделе, но я на всё же хочу выделить несколько моментов относительно того как наши события отправки сервером (SSE) обрабатываются в JavaScript соответствующего клиента браузера.

Пример 4-17. Уровень визуализации, который является воображаемым способом высказываться "соответствующему браузеру"

<обрез>
var evtSource = new EventSource("/feed"); ⁽¹⁾
evtSource.onmessage = function(e) {
    var obj = JSON.parse(e.data); ⁽²⁾
    if (!(obj.color in cpu)) {
        add_timeseries(cpu, cpu_chart, obj.color);
    }
    if (!(obj.color in mem)) {
        add_timeseries(mem, mem_chart, obj.color);
    }
    cpu[obj.color].append(
        Date.parse(obj.timestamp), obj.cpu); ⁽³⁾
    mem[obj.color].append(
        Date.parse(obj.timestamp), obj.mem);
};
<обрез>

⁽¹⁾ Создаём некий новый экземпляр EventSource в соответствующем URL /feed. Соответствующий браузер подключится к /feed в нашем сервере, metricserver.py. Заметим, что этот браузер автоматически попытается выполнить повторное подключение в случае утраты такого подключения. События отправки сервером (SSE) часто остаются недооценёнными, но имеется множество случаев в которых такая простота SSE может быть предпочтительной для веб сокетов.

⁽²⁾ Это событие onmessage() зажигается всякий раз когда наш сервер отправляет данные. Здесь наши данные подвергаются синтаксическому разбору в качестве JSON.

⁽³⁾ Повторный вызов данного идентификатора cpu является некоторой установкой соответствия цвета экземпляру TimeSeries(). В данном случае мы получаем некую временную последовательность и помещаем в её конец данные. Мы также получаем соответствующий временной штамп и выполняем его разбор для получения правильного формата, необходимого нашей диаграмме.

Теперь мы собираемся запустить свой код. Чтобы получить всё отображение в движении требуется некий пакет инструкций командной строки:

Пример 4-18. Запуск нашего средства сбора информациии

$ python metricserver.py
======== Running on http://127.0.0.1:8088 ========
(Press CTRL+C to quit)

Это стартует наш коллектор. Своим следующим шагом мы запускаем все необходимые экземпляры микрослужб. Они отправляют свои измерения ЦПУ и памяти в наше средство сбора информации. Каждое будет снабжено идентификацией отличительного цвета, что определяется в соответствующей командной строке:

Пример 4-19. Запуск необходимых приложений мониторинга

$ python backendapp.py color red &
$ python backendapp.py color blue leak 10000 &
$ python backendapp.py color green leak 100000 &

Рисунок 4-2 отображает наш окончательный продукт! Вы должны поверить мне на слово, что графики на самом деле оживают. Вы можете отметить в приведённом выше листинге, что я добавил некую утечку памяти для синего и ещё более высокую для зелёного. Мне даже пришлось перезапускать свою зелёную службу несколько раз чтобы предотвратить её восхождение выше 100 МБ.

Рисунок 4-2

Для зелёного нам необходимо вызвать инженера по качеству продукта КАК МОЖНО СКОРЕЕ!.

Что особенно интересно относительно данного проекта, так это: любой из запущенных экземпляров в любой части данного стека может быть перезапущен и не требуется никакой код обработки повторного подключения! Наши сокеты ØMQ совместно с имеющимся экземпляром JavaScript EventSource в соответствующем браузере, волшебным образом выполняет повтороное соединение и подхватывается в том месте, в котором оно было утрачено.

В своём следующем разделе мы переключим своё внимание на базы данных и то как asyncio может применяться при проектировании системы прекращения кэширования.

asyncpg и Sanic

Библиотека asyncpg предоставляет клиентский доступ к базе данных PostgreSQL, но отличается сама по себе от прочих библиотек клиентов Postgres тем что сосредоточена на скорости. asyncpg является авторским продуктом Юрия Селиванова, одного из коренных разработчиков asyncio, который также является автором проекта uvloop. Кроме того, asyncpg не имеет зависимостей от сторонних разработок, хотя всё же требуется Cython, если вы выполняете установку из исходного кода.

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

Мы рассмотрим пример использования asyncpg для признания недействительным кэширования, но для начала было бы полезным получить базовые знания о том что предоставляет имеющийся API asyncpg. Для всего кода в данном разделе нам потребуется запущенный экземпляр PostgreSQL и проще всего это сделать при помощи Docker:

Пример 4-20. Запуск PostgreSQL в контейнере Docker

$ docker run d rm p 55432:5432 postgres

Отмечу, что я выставил порт 55432 вместо устанавливаемого по умолчанию, 5432, просто на тот случай если у вас уже и меется некий запущенный экземпляр такой базы данных с этим портом по умолчанию. Приводимый ниже код демонстрирует каким образом asyncpg общается с PostgreSQL.

Пример 4-21. Базовая демонстрация asyncpg

import asyncio
import asyncpg
import datetime
from util import Database ⁽¹⁾

async def main():
    async with Database('test', owner=True) as conn: ⁽²⁾
        await demo(conn)

async def demo(conn: asyncpg.Connection):
    await conn.execute('''
        CREATE TABLE users(
            id serial PRIMARY KEY,
            name text,
            dob date
        )'''
    ) ⁽³⁾

    pk = await conn.fetchval( ⁽⁴⁾
        'INSERT INTO users(name, dob) VALUES($1, $2) '
        'RETURNING id', 'Bob', datetime.date(1984, 3, 1)
    )

    async def get_row(): ⁽⁵⁾
        return await conn.fetchrow( ⁽⁶⁾
            'SELECT * FROM users WHERE name = $1',
            'Bob'
        )
    print('After INSERT:', await get_row()) ⁽⁷⁾

    await conn.execute(
        'UPDATE users SET dob = $1 WHERE id=1',
        datetime.date(1985, 3, 1) ⁽⁸⁾
    )
    print('After UPDATE:', await get_row())
    await conn.execute(
        'DELETE FROM users WHERE id=1'
    )
    print('After DELETE:', await get_row())

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())

⁽¹⁾ Я скрыл некоторые инструменты подготовки в небольшом модуле util для упрощения вещей и сохранении самых центральных сообщений.

⁽²⁾ Наш класс Database даёт нам некий диспетчер контекста, который создаст для нас некую новую базу данных - в данном случае с наименованием test - и уничтожит эту базу данных при покидании этого диспетчера контекста. Это становится очень полезным при экспериментах с идеями в коде. Так как никакое состояние не переносится между экспериментами, вы всякий раз начинаете с чистой базой данных. Отметим, что данный диспетчер контекста является async with; мы рассмотрим это подробнее впоследствии, но в данный момент основной областью нашего фокуса в данной демонстрации является то, что происходит внутри нашей сопрограммы demo().

⁽³⁾ Yfi lbcgtnxth rjyntrcnf Database снабжает нас неким экземпляром Connection, который незамедлительно применяется для создания новой таблицы, users.

⁽⁴⁾ Вставляем новую запись. Хотя мы и можем применять для выполнения данной вставки .execute(), основным преимуществом использования fetchval() является то, что мы можем получать соответствующий id вновь вставляемой записи, который будет сохраняться в нашем идентификаторе pk.

[Замечание]
Замечание

Мы применяем параметры ($1 и $2) для проброса данных в свой запрос SQL. Никогда не пользуйтесь интерполяцией строк или конкатенацией при построении запросов, так как это создаёт риск для безопасности!

⁽⁵⁾ При извлечении данных, намного более полезным будет применять методы на основе fetch, так как они возвратят объекты Record. asyncpg автоматически приводит в соответствие большинство типов данных в надлежащие типы данных для Python.

⁽⁶⁾ Мы немедленно применяем вспомогательную функцию get_row() для отображения вновь вставленной записи.

⁽⁷⁾ Мы изменили данные воспользовавшись соответствующей командой UPDATE для SQL. Это достаточно незначительная модификация: значение года в дате рождения изменяется на один год. Как и ранее, это осуществляется при помощи метода соединения execute(). Оставшаяся часть данного кода демонстрирует далее те же самые структуры, которые мы видели ранее, а также DELETE, за которым следует другой print(), происходящий несколькими строками позднее.

⁽⁸⁾

Он производит следующий вывод:

$ python asyncpgbasic.py
After INSERT: <Record id=1 name='Bob' dob=datetime.date(1984, 3, 1)>
After UPDATE: <Record id=1 name='Bob' dob=datetime.date(1985, 3, 1)>
After DELETE: None

В этом выводе отметим как мы выполняем выборку необходимого значения даты из своего объекта Record, подлежащего преобразованию в некий объект date Python: asyncpg автоматически преобразует этот тип данных из типа SQL в свои замены в Python. Имеется большая таблица преобразования типов в документации asyncpg, которая описывает все имеющиеся соответствия типов, которые уже встроены в asyncpg.

Приведённый выше код является очень простым; возможно даже слишком грубым, поэтому, если вы если вы применяете удобство ORM (objectrelational mapper, соответствий реляционных объектов), таких как SQLAlchemy или веб инфраструктуру Django со встроенным ORM. В конце данной главы я упомяну некоторые библиотеки сторонних разработчиков, которые предоставляют доступ к ORM или функциональности подобной ORM для asyncpg.

Давайте быстро взглянем на мой инструментарий готовки объекта Database в секретном модуле utils; вы можете найти его полезным для изготовления нечто подобного в своих собственных экспериментах:

Пример 4-22. Полезный инструментарий для экспериментов с asyncpg

# util.py
import argparse, asyncio, asyncpg
from asyncpg.pool import Pool
DSN = 'postgresql://{user}@{host}:{port}'
DSN_DB = DSN + '/{name}'
CREATE_DB = 'CREATE DATABASE {name}'
DROP_DB = 'DROP DATABASE {name}'

class Database:
    def __init__(self, name, owner=False, **kwargs):
        self.params = dict(
            user='postgres', host='localhost',
            port=55432, name=name) ⁽¹⁾
        self.params.update(kwargs)
        self.pool: Pool = None
        self.owner = owner
        self.listeners = []

    async def connect(self) > Pool:
        if self.owner:
            await self.server_command(
                CREATE_DB.format(**self.params)) ⁽³⁾

        self.pool = await asyncpg.create_pool( ⁽⁴⁾
            DSN_DB.format(**self.params))
        return self.pool

    async def disconnect(self):
        """Destroy the database"""
        if self.pool:
            releases = [self.pool.release(conn)
                        for conn in self.listeners]
            await asyncio.gather(*releases)
            await self.pool.close() ⁽⁵⁾
        if self.owner:
            await self.server_command( ⁽⁶⁾
                DROP_DB.format(**self.params))

    async def __aenter__(self) > Pool: ⁽²⁾
        return await self.connect()

    async def __aexit__(self, *exc):
        await self.disconnect()

    async def server_command(self, cmd): ⁽⁷⁾
        conn = await asyncpg.connect(
            DSN.format(**self.params))
        await conn.execute(cmd)
        await conn.close()

    async def add_listener(self, channel, callback): ⁽⁸⁾
        conn: asyncpg.Connection = await self.pool.acquire()
        await conn.add_listener(channel, callback)
        self.listeners.append(conn)

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('cmd', choices=['create', 'drop'])
    parser.add_argument('name', type=str)
    args = parser.parse_args()
    loop = asyncio.get_event_loop()
    d = Database(args.name, owner=True)
    if args.cmd == 'create':
        loop.run_until_complete(d.connect())
    elif args.cmd == 'drop':
        loop.run_until_complete(d.disconnect())
    else:
        parser.print_help()

⁽¹⁾ Наш класс Database всего лишь некий предполагаемый диспетчер контекста для создания и удаления какой- то базы данных из экземпляра PostgreSQL. Необходимое название базы данных передаётся в имеющийся конструктор.

⁽²⁾ (Примечание: Данная последовательность сносок в коде намеренно отличается от данного списка.) Данные диспетчер контекста является именно асинхронным. Вместо обычных методов __enter__() и __exit__() у нас имеются их заменители __aenter__() и __aexit__(). Здесь, на стороне входа, мы создадим необходимую базу данных и вернём подключение к этой новой базе данных.

⁽³⁾ server_command() является другим вспомогательным методом, определяемым несколькими строками позднее. Мы применяем его для запуска необходимых команд при создании своей новой базы данных.

⁽⁴⁾ Для вновь созданной базы данных делается некое соединение. Отметим, что я жёстко закодировал определённые подробности о данном подключении: это сделано умышленно, так как я стараюсь сохранять данный образец кода небольшим. Вы запросто можете сделать его универсальным введя такие поля как имя пользователя, название хоста и номер порта.

⁽⁵⁾ На стороне выхода из своего диспетчера контекста мы закрываем текущее подключение и ...

⁽⁶⁾ ... уничтожаем базу данных.

⁽⁷⁾ Для завершённости решения, именно этот метод утилиты служит для запуска команд в самом сервере PostgreSQL. Для данной цели он создаёт некое подключение, запускает все определённые команды и выполняет выход.

⁽⁸⁾ Это сюрприз и он будет гвоздём программы в нашем следующем примере!

[Предостережение]
Предостережение

В приводимом выше пункте 8 мы создали некое выделенное соединение для каждого канала, в котором мы желаем выполнять ожидание. Это очень затратно, так как это означает, что некий исполнитель PostgreSQL будет целиком привязан к каждому каналу, в котором он выполняет ожидание. Намного лучший подход применял бы одно подключение ко множеству каналов. После того как вы проработаете данный пример, попробуйте изменить данный код для использования единого соединения для ожидания во множестве каналов!

Теперь, когда у нас имеется базовое понимание имеющихся строительных блоков asyncpg, мы можем изучить реально замечательный пример: использование встроенной поддержки PostgreSQL для отправки уведомлений осуществления выполнения несостоятельности кэширования!

Пример: несостоятельность кэширования

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

-- Фил Карлтон

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

Именно по этой причине распространённой практикой является рассмотрение вариантов архитектуры, которые могут ограничить избыточное общение с общей базой данных. Наиболее распространённым вариантом является применение кэширования для "запоминания" выбранных ранее результатов базы данных и повторного воспроизведения при их запросе с тем чтобы избегать последовательных вызовов имеющейся DB относительно одной и той же информации.

Однако: что произойдёт если одна из экземпляров ваших прикладных программ запишет новые данные в эту базу данных, в то время как другой экземпляр прикладного приложения всё ещё возвращает свои старые, устаревшие данные из их внутреннего кэша? Именно это составляет классическую задачу несостоятельности кэширования (cache invalidation - устаревания кэширования), причём это очень сложно решить каким- то надёжным путём.

Наша стратегия наступления такова:

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

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

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

Данный пример выделяет как встроенная поддержка обновления событий PostreSQL посредством своих команд LISTEN и NOTIFY может просто сообщать нам о том когда изменены данные.

asyncpg уже имеет поддержку для такого API LISTEN/NOTIFY. Данная функциональность PostgreSQL позволяет вашей прикладной программе подписаться на события в каком- то именованном канале, а также отправлять события в именованные каналы. Это практически аналогично тому что PostreSQL становится облегчённой версией RabbitMQ или ActiveMQ!

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

Наше прикладное приложение предоставляет некий сервер API на основе JSON для управления любимыми блюдами покровителей в нашем роботизированном ресторане. Наша лежащая в основе база данных будет иметь всего одну таблицу, patron, причём всего с двумя полями: name и fav_dish. Наш API делает возможными для нас обычный набор из четырёх операций: create, read, update и delete (CRUD).

Вот как выглядит взаимодействие с нашим API при помощи curl для создания какого- то нового элемента в нашей базе данных:

Пример 4-23. Создание записи нового покровителя

curl d '{"name": "Carol", "fav_dish": "SPAM Bruschetta"}' \
    H "ContentType: application/json" \
    X POST \
    http://localhost:8000/patron

Вывод:

{"msg":"ok","id":37}

Значение параметра d предназначено для данных (Здесь можно найти рецепт этого блюда и рецепты для прочих замечательных трат на основе SPAM), значение H служит для необходимых заголовков HTTP, а X для соответствующих методов HTTP запросов (альтернативами являются GET, DELETE и PUT, а также несколько прочих), а URL предназначен для нашего сервера API. Мы вскорости получим код для этого.

Из своего вывода мы видим, что наше создание было "OK", и что в качестве первичного ключа этой новой записи в нашей базе данных был возвращён соответствующий id.

В наших последующих нескольких фрагментах мы проследуем запуску трёх прочих операций: read, update и delete.

Пример 4-24. Чтение записи покровителя

curl X GET http://localhost:8000/patron/37

Вывод:

{"id":37,"name":"Carol","fav_dish":"SPAM Bruschetta"}

Чтение имеющихся данных достаточно прямолинейное и заметим, что в его URL должен быть представлен id нужной нам записи.

Пример 4-25. Обновление записи какого- то имеющегося покровителя (а также некая проверка!)

curl d '{"name": "Eric", "fav_dish": "SPAM Bruschetta"}' \
    H "ContentType: application/json" \
    X PUT \
    http://localhost:8000/patron/37

curl X GET http://localhost:8000/patron/37

Вывод:

{"msg":"ok"}
{"id":37,"name":"Eric","fav_dish":"SPAM Bruschetta"}

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

Методом запроса HTTP (X) является PUT, а не POST.

Наш URL теперь нуждается в соответствующем поле id.

Нам также придётся вызвать другой GET сразу после чтобы убедиться в применении своего изменения. Наконец, удаление:

Пример 4-26. Удаление записи какого- то имеющегося покровителя (а также некая проверка!)

curl X DELETE http://localhost:8000/patron/37
curl X GET http://localhost:8000/patron/37

Вывод:

{"msg":"ok"}
null

Этот приведённый выше пример также показывает возврат null при попытке получения несуществующей записи.

До сих пор всё выглядело достаточно обычным; однако нашим предметом является не только сделать некий API CRUD - мы желаем посмотреть на признание недейственным кэширования, поэтому давайте переключим наше внимание на само кэширование. Теперь, когда у нас имеется понимание API наших прикладных программ, мы можем взглянуть на журнал регистраций приложения чтобы посмотреть на данные времён для каждого из запросов: это сообщит нам какие какие приложения были кэшированы, а какие попадали в саму DB.

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

curl X GET http://localhost:8000/patron/29
curl X GET http://localhost:8000/patron/29

Вывод:

{"id":29,"name":"John Cleese","fav_dish":"Gravy on Toast"}
{"id":29,"name":"John Cleese","fav_dish":"Gravy on Toast"}

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

$ python sanic_demo.py
20170929 16:20:33  (sanic)[DEBUG]:

                ▄▄▄▄▄
          ▀▀▀██████▄▄▄     _______________
        ▄▄▄▄▄ █████████▄ /                 \
     ▀▀▀▀█████▌ ▀▐▄ ▀▐█ |   Gotta go fast!  |
   ▀▀█████▄▄ ▀██████▄██ | _________________/
   ▀▄▄▄▄▄ ▀▀█▄▀█════█▀  |/
        ▀▀▀▄ ▀▀███ ▀ ▄▄
     ▄███▀▀██▄████████▄   ▄▀▀▀▀▀▀█▌
   ██▀▄▄▄██▀▄███▀   ▀▀████      ▄██
  ▄▀▀▀▄██▄▀▀▌████▒▒▒▒▒▒███     ▌▄▄▀
  ▌    ▐▀████▐███▒▒▒▒▒▐██▌
  ▀▄▄▄▄▀ ▀▀████▒▒▒▒▄██▀
          ▀▀█████████▀
        ▄▄██▀██████▀█
      ▄██▀     ▀▀▀  █
     ▄█             ▐▌
 ▄▄▄▄█▌              ▀█▄▄▄▄▀▀▄
▌     ▐                ▀▀▄▄▄▀
 ▀▀▄▄▀
20170929 16:20:33 (sanic): Goin' Fast @ http://0.0.0.0:8000
20170929 16:20:33 (sanic): Starting worker [10366]) ⁽¹⁾
20170929 16:25:27 (perf): id=37 Cache miss ⁽²⁾
20170929 16:25:27 (perf): get Elapsed: 4.26 ms ⁽³⁾
20170929 16:25:27 (perf): get Elapsed: 0.04 ms ⁽⁴⁾

⁽¹⁾ Всё вплоть до этой строки является установленным по умолчанию запуском регистрации сообщений sanic.

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

⁽³⁾ Это получено из curl X GET. Я добавил некую функциональность определения времён в свои оконечные точки API. В данном случае соответствующая обработка данного запроса GET требует ~4 мс.

⁽⁴⁾ Второй GET возвращает данные из своего кэша, и намного быстрое время, ~100х, указывает что эти данные теперь возвращаются из кэша.

До сих пор ничего необычного. Многие веб страницы применяют кэширование таким образом.

Давайте запустим некое второе приложение с портом 8001 (первый экземпляр работал по порту 8000):

$ python sanic_demo.py port 8001
<разрыв>
20171002 08:09:56  (sanic): Goin' Fast @ http://0.0.0.0:8001
20171002 08:09:56  (sanic): Starting worker [385]

Оба экземпляра, конечно, подключены к одной и той же базе данных. Теперь, когда запущены оба экземпляра сервера API, давайте свои видоизменим имеющиеся данные для своего попечителя Джона, который предпочитает, ясен пень, достаточно Спама в своей диете. Мы сделаем это выполнив некий UPDATE, допустим, в первом экземпляре своей прикладной программы по порту 8000:

curl d '{"name": "John Cleese", "fav_dish": "SPAM on toast"}' \
    H "ContentType: application/json" \
    X PUT \
    http://localhost:8000/patron/29

{"msg":"ok"}

Сразу после данного события обновления только в одном экземпляре прикладной программы, оба сервера API, 8000 и 8001, сообщают следующие события в их журналах:

Пример 4-27. Сообщения в журналах обоих серверов, 8000 и 8001

20171002 08:35:49  (perf)[INFO]: Got DB event:
{
    "table": "patron",
    "id": 29,
    "type": "UPDATE",
    "data": {
        "old": {
            "id": 29,
            "name": "John Cleese",
            "fav_dish": "Gravy on Toast"
        },
        "new": {
            "id": 29,
            "name": "John Cleese",
            "fav_dish": "SPAM on toast"
        },
        "diff": {
            "fav_dish": "SPAM on toast"
        }
    }
}

Наша база данных сообщила имеющиеся события обновлений обратно в наши экземпляры прикладного приложения! Мы даже ещё не сделали никаких запросов к экземпляру 8001 прикладного приложения. Означает ли это что наши новые данные уже кэшированы здесь?

Чтобы проверить это, теперь мы делаем некий GET в своём втором сервере 8001, и информация о времени показывает что мы на самом деле получаем свои данные напрямую из имеющегося кэша, даже хотя никакой GETне вызывался ранее в этом экземпляре прикладной программы:

Пример 4-28. Самый первый GET для id=29 в сервере 8001

curl X GET http://localhost:8001/patron/29
{"id":29,"name":"John Cleese","fav_dish":"SPAM on toast"}

Пример 4-29. Журнал регистрации сервера 8001: Самый первый GET производящий кэшируемые данные

 20171002 08:46:45  (perf)[INFO]: get Elapsed: 0.04 ms

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

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

Основа проекта данного кода заключается в следующем:

Некий простой веб API, использующий наш новый, совместимый с asyncio веб- инструментарий Sanic.

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

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

Все серверы прикладного приложения будут подписаны на события через asyncpg в конкретных таблицах нашей DB и будут получать уведомления об обновлениях когда соответствующие данные в производной таблице DB были изменены. Это делает возможным для имеющихся серверов прикладного приложения обновлять их персональные кэши в оперативной памяти.

Пример 4-30. API при помощи Sanic

# sanic_demo.py
import argparse
from sanic import Sanic
from sanic.views import HTTPMethodView
from sanic.response import json
from util import Database ⁽¹⁾
from perf import aelapsed, aprofiler ⁽²⁾
import model

app = Sanic() ⁽³⁾

@aelapsed
async def new_patron(request): ⁽⁴⁾
    data = request.json ⁽⁵⁾
    id = await model.add_patron(app.pool, data) ⁽⁶⁾
    return json(dict(msg='ok', id=id)) ⁽⁷⁾

class PatronAPI(HTTPMethodView, metaclass=aprofiler): ⁽⁸⁾
    async def get(self, request, id):
        data = await model.get_patron(app.pool, id) ⁽⁹⁾
        return json(data)

    async def put(self, request, id):
        data = request.json
        ok = await model.update_patron(app.pool, id, data)
        return json(dict(msg='ok' if ok else 'bad')) ⁽¹⁰⁾

    async def delete(self, request, id):
        ok = await model.delete_patron(app.pool, id)
        return json(dict(msg='ok' if ok else 'bad'))

@app.listener('before_server_start') ⁽¹¹⁾
    async def db_connect(app, loop):
    app.db = Database('restaurant', owner=False) ⁽¹²⁾
    app.pool = await app.db.connect() ⁽¹³⁾
    await model.create_table_if_missing(app.pool) ⁽¹⁴⁾
    await app.db.add_listener('chan_patron', model.db_event) ⁽¹⁵⁾

@app.listener('after_server_stop') ⁽¹⁶⁾
async def db_disconnect(app, loop):
    await app.db.disconnect()

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument('port', type=int, default=8000)
    args = parser.parse_args()
    app.add_route(
        new_patron, '/patron', methods=['POST']) ⁽¹⁷⁾
    app.add_route(
        PatronAPI.as_view(), '/patron/') ⁽¹⁸⁾
    app.run(host="0.0.0.0", port=args.port)

⁽¹⁾ Вспомогательная утилита Database описывалась ранее. Она предоставляет те методы, которые требуются для подключения к нашей базе данных.

⁽²⁾ Два дополнительных инструмента я на скорую руку смастерил воедино для регистрации значения прошедшего времени для каждого оконечного пункта API. Мы применяли его в предыдущем обсуждении при выявлении того, когда некий GET возвращался из своего кэша. Сами реализации для aelapsed() и aprofiler() не важны для данного примера, но вы можете получить их в наших Дополнениях.

⁽³⁾ Создан необходимый основной экземпляр прикладного приложения Sanic.

⁽⁴⁾ Данная функция сопрограммы служит для создания новых записей покровителей. В некотором вызове add_route(), далее вниз по нашему коду, new_patron() связывается с соответствующей оконечной точкой /patron, причём только для надлежащего метода POST. Наш декоратор @aelapsed не является частью основного API Sanic: он является моим собственным новшеством, в основном с целью регистрации значений времён для каждого вызова.

⁽⁵⁾ Sanic предоставляет немедленный разбор получаемых данных .json при помощи соответствующего атрибута в получаемом объекте request.

⁽⁶⁾ Наша модель model, которую мы импортировали ранее, является основной моделью для нашей таблицы покровителей в общей базе данных. Мы рассмотрим её подробнее в своём следующем листинге кода. А на текущий момент просто уясните, что все наши запросы и SQL к базе данных находятся в этом модуле model. Здесь мы пробрасываем соответствующий пул соединений для своей базы данных, и именно они являются одним и тем же шаблоном для всех наших операций с нашей моделью базы данных в данной функции и в нашем классе PatronAPI ниже.

⁽⁷⁾ Будет создан новый первичный ключ, id, и именно он возвращается обратно вызывающей стороне в виде JSON.

⁽⁸⁾ В то время как создание обрабатывается в нашей функции new_patron(), все прочие взаимодействия обрабатываются в данном представлении на основе класса, который удобно предоставляется Sanic. Все имеющиеся в данном классе методы ассоциируются с одним и тем же URL, /patron/<id:int>, который вы можете увидеть далее вниз по коду в соответствующей add_route() ближе к концу. Отметим, что необходимый параметр URL id будет передаваться во всякий вызываемый метод, причём этот параметр требуется для всех трёх оконечных точек.

Вы можете не опасаться проигнорировать соответствующий аргумент metaclass: всё что он выполняет, это обёртка вокруг каждого метода при помощи декоратора @aelapsed с тем чтобы значения времени выводились в нашем журнале регистраций. Это не является частью API Sanic, и опять- таки является моим собственным нововведением для регистрации данных времени.

⁽⁹⁾ Как и ранее, модель взаимодействия выполняется внутри нашего модуля model.

⁽¹⁰⁾ Если наша модель выдаёт отчёт об отказе для осуществления необходимого обновления, мы изменяем соответствующие данные отклика. Я включил это для тех читателей, которые ещё пока не знакомы с версией трёхместного оператора Python.

⁽¹¹⁾ Применяемые декораторы @app.listener являются предоставляемыми Sanic точками входа, которые дают вам какое- то место для добавления дополнительных действий в процессе последовательностей запуска и останова. Конкретно эта, before_server_start, вызывается перед тем как будет запущен наш сервер API. Она выглядит как подходящее место для инициализации нашего подключения к базе данных.

⁽¹²⁾ Мы применяем своего помощника Database для создания какого- то соединения с нашим экземпляром PostgreSQL. Той DB, к которой мы выполняем подключение, является restaurant.

⁽¹³⁾ Получаем некий пул подключений к нашей базе данных.

⁽¹⁴⁾ Применяем свою модель (к соответствующей таблице patron) чтобы создать необходимую таблицу если она отсутствует.

⁽¹⁵⁾ Применяем свою модель для создания некоей выделенной стороны ожидания для событий базы данных, причём ожидание мы выполняем в канале chan_patron. Соответствующей функцией обратного вызова для данных событий является model.db_event(), которую мы изучим в своём следующем листинге. Этот обратный вызов будет вызываться всякий раз, когда наша база данных обновляет соответствующий канал.

⁽¹⁶⁾ after_server_stop является той точкой входа, которая должна появиться в процессе останова. В этом месте мы отключаемся от своей базы данных.

⁽¹⁷⁾ Этот add_route() отправляет запросы POST для соответствующего URL /patron в нашу функцию сопрограммы new_patron().

⁽¹⁸⁾ Данный add_route() отправляет все запросы для соответствующего URL /patron/<id:int>> в представление на основе класса PatronAPI. Все названия методов в этом классе определяют именно то что они вызывают. Поэтому некий запрос GET вызовет соответствующий метод PatronAPI.get() и так далее.

Приводимый выше код содержит всю необходимую обработку HTTP для нашего сервера, а также задачи запуска и останова в виде настройки некоего пула соединений с нашей базой данных, а также, что является критически важным, установку стороны ожидания событий DB в имеющемся канале chan_patron в нашем сервере DB.

Теперь мы намерены рассмотреть свою модель для обсуждаемой таблицы patron из общей базы данных:

Пример 4-31. Модель DB для таблицы "patron"

# model.py
import logging
from json import loads, dumps
from triggers import (
    create_notify_trigger, add_table_triggers) ⁽¹⁾
from boltons.cacheutils import LRU ⁽²⁾

logger = logging.getLogger('perf')

CREATE_TABLE = ('CREATE TABLE IF NOT EXISTS patron(' ⁽³⁾
                'id serial PRIMARY KEY, name text, '
                'fav_dish text)')
INSERT = ('INSERT INTO patron(name, fav_dish) '
          'VALUES ($1, $2) RETURNING id')
SELECT = 'SELECT * FROM patron WHERE id = $1'
UPDATE = 'UPDATE patron SET name=$1, fav_dish=$2 WHERE id=$3'
DELETE = 'DELETE FROM patron WHERE id=$1'
EXISTS = "SELECT to_regclass('patron')"

CACHE = LRU(max_size=65536) ⁽⁴⁾

async def add_patron(conn, data: dict) > int: ⁽⁵⁾
    return await conn.fetchval(
        INSERT, data['name'], data['fav_dish'])

async def update_patron(conn, id: int, data: dict) > bool:
    result = await conn.execute( ⁽⁶⁾
       UPDATE, data['name'], data['fav_dish'], id)
    return result == 'UPDATE 1'

async def delete_patron(conn, id: int): ⁽⁷⁾
    result = await conn.execute(DELETE, id)
    return result == 'DELETE 1'

async def get_patron(conn, id: int) > dict: ⁽⁸⁾
    if id not in CACHE:
        logger.info(f'id={id} Cache miss')
        record = await conn.fetchrow(SELECT, id) ⁽⁹⁾
        CACHE[id] = record and dict(record.items())
    return CACHE[id]

def db_event(conn, pid, channel, payload): ⁽¹⁰⁾
    event = loads(payload) ⁽¹¹⁾
    logger.info('Got DB event:\n' + dumps(event, indent=4))
    id = event['id']
    if event['type'] == 'INSERT':
        CACHE[id] = event['data']
    elif event['type'] == 'UPDATE':
        CACHE[id] = event['data']['new'] ⁽¹²⁾
    elif event['type'] == 'DELETE':
        CACHE[id] = None

async def create_table_if_missing(conn): ⁽¹³⁾
    if not await conn.fetchval(EXISTS):
        await conn.fetchval(CREATE_TABLE)
        await create_notify_trigger(
            conn, channel='chan_patron')
        await add_table_triggers(
            conn, table='patron')

⁽¹⁾ Чтобы быть способными получать уведомления при изменениях базы данных вам придётся добавить триггеры в свою базу данных. Я разработал эти полезные вспомогательные функции для создания необходимых функций триггера самих по себе (при помощи create_notify_trigger), а также для добавления такого триггера в некую определённую таблицу (посредством add_table_triggers). Необходимый для этого язык SQL является чем- то выходящим за рамки данной книги, тем не менее, это критически важно для понимания того как работает данный пример. Я включил соответствующий код с аннотациями для данных триггеров в соответствующее Дополнение.

⁽²⁾ Пакет стороннего разработчика boltons предоставляет некую пачку полезных инструментов, не последним из которых является собственно кэш LRU, более многостороннюю версию нежели наш декоратор @lru_cache из стандартного библиотечного модуля functools [Получите boltons воспользовавшись pip install boltons].

⁽³⁾ Данный блок текста содержит все необходимые SQL для наших стандартных операций CRUD. Отметим, что мы применяем стандартный синтаксис PostgreSQL для его параметров:$1, $2 и так далее. Здесь нет никакой новизны, ну и нечего обсуждать более.

⁽⁴⁾ Создаём необходимый кэш для данного экземпляра прикладного приложения.

⁽⁵⁾ Данная функция сопрограммы add_patron() является тем, что мы вызываем из своего модуля Sanic внутри соответствующей оконечной точки new_patron() для добавления новых покровителей. Внутри этой функции мы применяем для вставки новых данных метод fetchval() почему "fetchval", а не "fetchval"? Потому что "fetchval" возвращает значение первичного ключа вновь вставляемой записи! [Тем не менее, вам также потребуется соответствующая часть SQL RETURNING id!]

⁽⁶⁾ Обновляем некую имеющуюся запись. В случае успеха PostgreSQL возвратит UPDATE 1, следовательно мы используем это для проверки успешности данного обновления.

⁽⁷⁾ Удаление очень похоже на обновление.

⁽⁸⁾ Это операция "чтения". Именно она является той частью нашего интерфейса CRUD, которая требует позаботиться относительно состояния кэша. Задумайтесь об этом на мгновение: мы не обновляем значение кэша при осуществлении вставки, обновления или удаления. Это происходит потому, что мы полагаемся на существующие асинхронные уведомления от своей базы данных (через установленные триггеры) относительно обновлений значения кэширования в том случае когда изменяются любые данные.

⁽⁹⁾ Безусловно, мы всё ещё желаем применять кэширование после самого первого GET.

⁽¹⁰⁾ Наша функция db_event() является необходимым обратным вызовом, который asyncpg будет осуществлять когда имеются события в нашем канале уведомлений DB, chan_patron. Этот особый параметр списка требуемый со стороны asyncpg.conn является тем подключением, в которое были отправлены необходимые события, pid является значением идентификатора самого процесса того экземпляра PostgreSQL, который отправил данное событие, channel представляет собственно название нашего канала (и в нашем случае это chan_patron), а payload являются подлежащими отправке в этом канале данными.

⁽¹¹⁾ Выполняем разбор полученных данных JSON в некий dict.

⁽¹²⁾ Само заполнение кэша является достаточно прямолинейным, однако заметим, что события "обновления" содержат как новые, так и старые данные, поэтому нам необходимо гарантировать, что мы выполняем кэширование только новых данных.

⁽¹³⁾ Это небольшая функция утилиты, которую я создал для простого повторного создания таблицы при её отсутствии. Это действительно полезно ксли вам требуется часто выполнять данную операцию - например в случае написания примеров кода для данной книги! Именно в этом месте также создаются соответствующие коды триггеров уведомления нашей базы данных и добавляются в нашу таблицу patron. Относительно листинга этих функция с пояснениями обратитесь к нашему Дополнению.

Это подводит нас к самому концу данного примера. Мы рассмотрели как Sanic делает очень простым создание некоего сервера API, а также мы ознакомились с тем как применять asyncpg для осуществления запросов через некий пул подключений, а также как использовать функциональность асинхронных уведомлений PostgerSQL для приёма обратных вызовов поверх некоего выделенного подключения к базе дынных с длительным временем жизни.

Многие люди предпочитают применять для работы с базами данных ORM (objectrelational mapper - соответствий реляционных объектов), и это именно та отрасль, в которой лидером выступает SQLAlchemy. Существует растущая поддержка применения SQLAlchemy совместно с asyncpg в библиотеках сторонних разработчиков, таких как asyncpgsa и GINO. Другой популярный ORM, peewee поддерживается под asyncio через пакет aiopeewee.

Прочие библиотеки и ресурсы

Для asyncio существует множество прочих библиотек, не охватываемых данной книгой. Для дополнительного поиска вы можете воспользоваться проектом aio-libs, который управляет примерно сорока различными библиотеками, а также проектом Awesome asyncio, который сопровождает закладки большого числа прочих проектов для asyncio.

Одна из тех библиотек, которую будет не лишним отметить: aiofiles. Если вы вернётесь к более раннему обсуждению, в котором мы сказали, что для достижения выской степери параллельности в asyncio жизненно важно чтобы наш цикл никогда не "блокировался". В данном контексте мы концентрируемся на блокирующих операциях, которые исключительно относились к вводу/ выводу на сетевой основе, но в свою очередь и доступ к дискам также некая операция с блокировкой, которая оказывает воздействие на производительность на очень высоких уровнях распараллеливания. Решением для этого являются aiofiles, которые предоставляют удобную обёртку для выполнения доступа к диску в некотором потоке. Это работает потому что Python высвобождает GIL [global interpreter lock] в процессе файловых операций, следовательно ваш основной поток (исполняющий соответствующий цикл asyncio) не затрагивается.

Наиболее важной областью для asyncio намерена выступать сетевое программирование. По этой причине неплохой мыслью будет слегка подучить программирование сокета, и даже по прошествии лет Socket Programming HOWTO Гордона МакМиллана, включённый в стандартную документацию Python является одним из самых лучших введений в эту тему из тех что можно найти.

Я изучал asyncio из очень разнообразных источников, которые я уже упоминал в боле ранних разделах. Люди по разному обучаются из различных источников, а мои учебные материалы, которые я ещё не упоминал такие:

Безусловно, наилучший рассказ относительно asyncio, с которым мне удалось познакомиться на YouTube, это Getting To Grips With Asyncio Роберта Смолшира, представленного на NDC в январе 2017 в Лондоне. Это выступление может быть достаточно сложным для начинающих, но на самом деле представляет ясное описание того как спроектирован asyncio.

Слайды Николая Новика, представленные на PyCon UA 2016: Building Apps With Asyncio. Эта информация очень плотная, но в этих слайдах содержится гигантский практический опыт.

Нескончаемые сеансы в Python REPL, в которых выполняются пробы м "наблюдается что происходит"!

Наконец, я побуждаю вас продолжать обучение и если какое- то понятие не "клеится", продолжать искать новые источники, пока вы не отыщете объяснение, которое сработает в вашем случае.

Глава 5. Заключительные соображения

Содержание

Глава 5. Заключительные соображения

Заключительные соображения

Заключительные соображения

Когда в Python появились существенная новая функциональность появилась в Python, она оказалась новой для всех: даже несмотря на то, что я имею опыт работы с Python почти в два десятилетия, я обнаружил, что Asyncio существенно отличается от имеющихся у меня знаний, даже принимая во внимание то, что я уже работал с Twisted и Tornado в предыдущих проектах, и поэтому программирование на основе событий не было новым для меня. Я обнаружил, что API asyncio оказался гораздо более сложным, чем я ожидал. Частично это было вызвано отсутствием документации верхнего уровня.

Теперь, по прошествии определённого времени для изучения того как применять Asyncio в Python, я ощущаю себя с ним намного более комфортно и такой прогресс, видимо, будет также и у вас. За архитектурой этого API имеются отчётливые структуры и цель и, надеюсь, эта книга сделает для вас это изучение гораздо более простым, нежели это было для меня. Обладая неким базовым пониманием, я нахожу что достаточно просто писать новый код на основе asyncio без необходимости постоянно обращаться к документации: а это очень хороший знак и, естественно, это не так для всех прочих модулей стандартных библиотек.

Тем не менее, всё ещё имеются шероховатости.

Стандартная библиотека asyncio по- прежнему будет иметь большой, подробный API, так как она обслуживает как разработчиков инфраструктур, так и разработчиков приложений конечных пользователей. Это означает, что мы - выступая как разработчики конечного продукта - далжны будем узнать какие именно части имеющегося API применимы для нас, а какие нет. Со временем, по мере развития и возмужания экосистемы библиотек сторонних разработчиков для asyncio, мы, скорее всего, будем работать именно с API этих библиотек, вместо того чтобы работать с сырым API стандартной библиотеки asyncio. Хорошими примерами этого являются такие имеющиеся библиотеки как aiohttp и Sanic.

API asyncio сам по себе также продолжит улучшаться, по мере того как всё больше людей приобретут опыт работы с ним. Как уже упоминалось ранее в этой книге, в Python 3.7 появилось множество улучшений для asyncio, а а настоящее время ведутся работы по ещ1 более существенным улучшениям, например, Tasklocal storage {Прим. пер.: также см. PEP 550, PEP 555.}, аналог хранилища локальных потоков для потокового программирования, но вместо этого для контекстов asyncio Task.

Проходя этот путь нашлись и неожиданные открытия для меня: по счастливой случайности, так оказалось, что мне потребовалось (наконец!) изучать ZeroMQ примерно в то же самое время, когда писалась эта книга. И я обнаружил, что asyncio в сочетании с pyzmq превращает сетевое программирование в радость!

Наконец, моими рекомендациями для наилучшего изучения Asyncio являются: экспериментировать, пробовать и получать удовольствие!

Указатель

Содержание

Указатель