Хотя асинхронное программирование (AP) является стандартным явлением в JavaScript и встроено в Go как горутины, многие программисты Python не знакомы с этой концепцией. Основная идея - добиться одновременного выполнения нескольких функций в одном программном потоке. Пока одна функция ожидает внешнего события, такого как получение ввода-вывода из Интернета или базы данных, выполняется другая функция, чтобы заполнить потерянное в противном случае время.

Некоторые функции AP были доступны начиная с Python 3.5, однако ключевые функции, такие как asyncio.run, доступны только в версии 3.7 (выпущенной в июне 2018 г.), поэтому установите их, если хотите продолжить.

Мотивация # 1

Пакетная загрузка веб-страниц для парсинга - обычная задача в Python, но очень медленная. Например, мой проект planetmoney-rss загружает каждую страницу эпизода Planet Money (их более 1000!) И собирает их в RSS-канал для использования в проигрывателе подкастов. Ранее извлеченные данные, конечно, кэшируются, но первоначальная загрузка занимает более 15 минут - неудобно, если вы пытаетесь проверить или отладить их.

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

Как вы можете видеть на рисунке, когда точка доступа не используется, мы должны ждать, пока каждый запрос завершит свой круговой обход до сервера и обратно; за это время наша программа не может сделать ничего полезного. С AP наш код для запроса №1 добровольно отказывается от управления до тех пор, пока не будет готов его ответ, позволяя выполнить код для запроса №2, а затем - по запросу №3. Эта разница проявляется в общем времени, затрачиваемом каждой программой: около 1 RTT (однократное время приема-передачи) вместо 3 RTT.

Этот парень сократил время загрузки статистики игроков НБА с 12 минут до 22 секунд, используя AP. Я определенно перепишу planetmoney-rss, чтобы теперь использовать AsyncIO!

Мотивация # 2

Допустим, у нас есть бот-дискорд, и мы хотим проводить некоторое обслуживание бота каждые 24 часа (в моем случае очищаю словарь, чтобы он не увеличивался бесконечно, но это также может быть ежедневное утреннее сообщение / сообщение спокойной ночи).

Наивная реализация

while True:
    time.sleep(24*60*60)  # sleep 24 hours
    send_message(‘Another day has passed!’)

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

Как будет выглядеть реализация на базе AP?

Эта реализация заставляет бот выводить сообщение «Прошел еще один день!» каждые 24 часа, но по-прежнему сохраняет реакцию бота, о чем свидетельствует ответ «Я все еще могу отвечать на ваши сообщения!» Каждый раз, когда какой-либо пользователь отправляет сообщение.

Параллелизм в одном потоке

Так как же работает AP? Ключевой концепцией здесь является * инверсия управления * - за это отвечает не ваш код, а нечто, называемое циклом событий. Цикл событий постоянно зацикливается и вызывает ваши функции (называемые сопрограммами в контексте AP), которые выполняются до тех пор, пока они не завершатся или не завершатся. Завершение происходит, когда мы достигаем конца сопрограммы, return или генерируем исключение. Что касается уступки, давайте посмотрим на await coroutine () - он выглядит как любой другой вызов подпрограммы и точно так же, как они добавляются в стек вызовов. Когда синхронный код встречает блокирующий вызов, мы ждем (с неизменным стеком вызовов), пока не вернется результат. Когда асинхронный код встречает обычно блокирующий вызов, мы вместо этого разворачиваем стек вызовов и возвращаемся обратно в цикл событий - мы уступаем.

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

Но для программиста все это выглядит как любой другой вызов подпрограммы, за исключением того, что вы вызываете его с помощью оператора await - это сделано намеренно. Вызов может даже вернуть значение. Думайте о await как о том, что «я выполняю здесь обычный вызов функции, но где-то в глубине стека вызовов мы можем дать результат» - и цикл обработки событий позаботится о возобновлении всего за вас.

Давайте посмотрим на бота Discord сверху в качестве примера:

on_ready вызывается, когда бот запускается, но мы немедленно нажимаем asyncio.sleep - * неблокирующий сон *, который возвращает управление обратно цикл событий, пока не пройдут 24 часа. Если какие-либо события on_message происходят в течение временного окна, цикл событий может вскоре отправить сопрограмму on_message с небольшой задержкой. По истечении 24 часов цикл обработки событий снова «вызовет» on_ready, но мы продолжаем выполнение с той строки, на которой остановились, и выводим сообщение на терминал.

await message.channel.send подготавливает запрос к отправке (поиск IP-адреса, создание пакета и все такое), отправляет его и передает управление циклу обработки событий. Тем временем запрос отправляется на серверы Discord, обрабатывается и подтверждается там, а подтверждение отправляется обратно, аналогично нашему первому мотивирующему примеру выше. Как только цикл событий переходит к проверке сокетов, наш await может завершиться.

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

Задачи и точки входа

Взгляните на эти две функции «использования»:

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

* create_task * (до 3.7: sure_future) работает по-другому - это добавляет задачу в цикл событий (здесь task_a), но выполнение может продолжаться без привязки - не требуется ожидания перед запуском task_b. await в этом контексте блокирует выполнение до завершения уже запущенной задачи. В этом случае task_b завершается первым и выводит «привет», затем task_a, после чего use_tasks может продолжить. await task_b выполняется, но игнорируется, поскольку task_b уже завершено, в результате чего в общей сложности остается 3 секунды. По сути, все три функции выполнялись одновременно.

Ловушка здесь заключается в том, чтобы запустить задачу, но не ждать ее, прежде чем использовать ее результаты - это случилось со мной, и я не заметил, поскольку код просто использовал результаты последнего вызова. Еще одна ловушка - забыть обернуть сопрограмму в задачу: task = coroutine (…); await task (неверно) вместо task = asyncio.create_task (coroutine (…)); ожидание задачи.

* точка входа * необходима для вызова асинхронного кода из кода синхронизации, настройки и входа в цикл событий. Это демонстрируется в последних двух строках с использованием asyncio.run - по замыслу, мы могли бы использовать здесь также цикл обработки событий любой другой библиотеки. Циклы событий знакомы по видеоиграм или программированию графического интерфейса пользователя - вы определяете обработчики событий для клавиатуры и других событий, которые затем вызываются циклом событий.

Пакетная загрузка

Давайте посмотрим на код AP для пакетной загрузки аватаров Discord по умолчанию:

Это вводит две новые концепции, которые, однако, в основном являются синтаксическим сахаром.

await asyncio.wait (aws) (и аналогичный asyncio.gather) используется для ожидания в списке ожидаемых вместе и примерно эквивалентен для aw в aws: await aw - выполнение будет продолжено только после того, как все ожидающие выполнения будут завершены. Еще лучше: если ожидаемый return возвращает что-то, asyncio.gather соберет возвращаемые значения в список! (list = asyncio.gather (aws))

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

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

Справочник по синтаксису

Давайте вспомним новые ключевые слова Python, представленные для AP:

  • ожидание - выполните операцию и продолжайте только после того, как будет готов результат. Операция может передать управление циклу обработки событий. Операция должна быть «ожидаемой», обычно это сопрограмма (async def), задача или будущее.
  • async def - любая функция, зарегистрированная в цикле событий, должна быть определена как async def, помечая ее как * сопрограмму *.
  • async for - перебирает итератор, который может ждать на любой итерации. Мне нравится думать об этом как о for await - на самом деле, именно так это называется в JavaScript. Пример Discord: async для сообщения в channel.history (limit = 500), который извлекает историю с помощью асинхронного HTTP-запроса. И да, async for работает при составлении списков.
  • асинхронно с - используйте диспетчер контекста, который может ожидать во время входа и / или выхода.

Любая функция, содержащая await, async for или async with, должна быть сопрограммой (async def).

Против нескольких потоков

Теперь мы понимаем, как писать асинхронные программы на Python, и все хорошо и хорошо, правда? «Почему бы не использовать многопоточность?» - слышу вы.

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

Обычно многопоточность позволяет использовать несколько ядер ЦП, но поскольку интерпретатор CPython поддерживает GIL (глобальную блокировку интерпретатора), одновременно может выполняться только один собственный поток, поэтому производительность в любом случае не будет превышать асинхронный код. Библиотека multiprocessing может запускать несколько независимых интерпретаторов Python одновременно, но запуск медленный и ресурсоемкий по сравнению с использованием сопрограмм, а обмен данными между ними затруднен.

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

По сравнению с генераторами

Если вы думаете, что слышали концепцию удержания состояния в функции и добровольно отказывались от управления в Python раньше, вы не ошибаетесь! Генераторы используются в Python с версии 2.2. Они генерируют по одному объекту за раз, который предназначен для использования в цикле, поэтому они представляют собой особый вид итератора. Простая версия обычного объекта range может быть реализована следующим образом:

def range(start, stop, step):
    while start < stop:
        yield start
        start += step

Фактически, генераторы также называются полупрограммами, и yield from использовался вместо await в реализации сопрограмм Python 3.4. yield from отправляет на один вызов глубже в стек вызовов генератора, а yield сохраняет стек и выполняет резервное копирование на исходный сайт вызова, не являющийся генератором - точные две операции, которые мы выполняем в AP . Однако одно отличие от AP заключается в том, что yield возвращает управление сайту вызова до тех пор, пока next не будет вызван снова, а не цикл событий.

Заключение

AP может быть мощным инструментом для ускорения программ, особенно тех, которые сильно загружают операции ввода-вывода, например, из Интернета или большой базы данных. AP может быть сложным для понимания, но оно того стоит. К сожалению, Python слишком стар, чтобы AP использовался по умолчанию, как в Go, что приводит к несколько загроможденному синтаксису await / async и ограниченному взаимодействию между AP и не-AP кодом. Если что-то все еще неясно, я рекомендую этот вопрос о StackOverflow (ответы от 4 июля 18 г. и 27 февраля 18 г.).