От переводчика: Это перевод статьи
https://snarky.ca/how-the-heck-does-async-await-work-in-python-3-5/.
Оригинальная статья показалась мне очень полезной и, как мне кажется, определенно заслуживает внимания, если вы до сих пор плохо представляете, как работает асинхронное программирование в Python.
Все ссылки на сторонние ресурсы, встречающиеся в оригинальном тексте, сохранены как есть. Очень советую прочитать информацию по этим ссылка, в особенности различные PEP, тогда многое встанет на свои места.
Перевод в некоторых местах является достаточно вольным, многие выражения переведены не дословно, но с сохранением основного смысла. Все же русский и английский — разные языки и дословный перевод не всегда лучше и понятнее.
Некоторые термины имеют оригинальное написание рядом в скобках. Это сделано с целью сохранения изначального смысла и для возможности сопоставления с оригинальными техническими терминами в документации на английском языке.
У меня получилось перевести не все термины, некоторые можно перевести по-разному. Если вы знаете, как точно переводится тот или иной термин, прошу указывать это в комментариях. Если такой перевод сделает смысл более понятным, я с удовольствием его добавлю.
Если после прочтения у вас останутся вопросы или вы заметили неточность, обязательно напишите об этом в комментариях.
Приятного чтения!
Являясь разработчиком ядра Python мне всегда хотелось понять, как на самом деле функционирует этот язык. Я понимаю, что всегда найдутся такие закоулки, где я не буду знать всех тонкостей, но, чтобы иметь возможность помогать с решением вопросов и с дизайном языка Python в целом, мне кажется, я должен понимать его базовые семантики и как это все работает "под капотом".
Но до недавнего момента я не представлял, как async
/await
работает в Python 3.5. Я знал, что yield from
в Python 3.3 в купе с asyncio
в Python 3.4 привел к появлению этого нового синтаксиса. Но тот факт, что мне не часто приходилось писать код, работающий с сетью (на чем asyncio
как раз и фокусируется, но не ограничивается), вылился в итоге в то, что я мало внимания уделял теме async
/await
. Я конечно знал, что
yield from iterator
в целом эквивалентен конструкции
for x in iterator:
yield x
Я также знал, что asyncio
— это event loop фреймворк, который используется для асинхронного программирования, и что в реальности означают эти слова. Но, никогда особо не погружаясь в синтаксис async
/await
для понимания, как это все работает вместе, я чувствовал, что не понимаю, как устроено асинхронное программирование в Python, и это меня беспокоило. Поэтому я решил все же найти время и разобраться, как же, черт побери, это все устроено. И, поскольку я слышал от различных людей, что они тоже не понимают, как этот новый мир асинхронного программирования функционирует, я решил написать это эссе (да, этот пост занял так много времени и содержит так много слов, что моя жена в итоге окрестила его эссе).
Но, поскольку я хотел иметь наиболее полное понимание, как работает этот новый синтаксис, это эссе содержит некоторые низкоуровневые технические детали, связанные с работой CPython. Не переживайте, если подробностей больше, чем вам необходимо, или есть вещи, которые вы не полностью понимаете, поскольку я не планирую объяснять все нюансы реализации CPython, чтобы не допустить превращения этого эссе в целую книгу (к примеру, если вы не знаете, что объекты, представляющие собой код, имеют флаги, не заморачивайтесь насчет этого, ничего страшного, вам необязательно это знать, чтобы получить необходимую информацию из этого эссе). Также я попытался написать наиболее обобщенное резюме в конце каждого раздела, поэтому, если сам раздел покажется вам слишком избыточным, вы можете перейти сразу к резюме.
Исторический урок о сопрограммах в Python
Согласно Википедии, "Сопрограммы (coroutines) (прим. переводчика: вот ссылка на определение этого термина в русской версии Википедии, далее идет выжимка именно оттуда) — методика связи программных модулей друг с другом по принципу кооперативной многозадачности: модуль приостанавливается в определённой точке, сохраняя полное состояние (включая стек вызовов и счётчик команд), и передаёт управление другому. Тот, в свою очередь, выполняет задачу и передаёт управление обратно, сохраняя свои стек и счётчик". Это упрощенно можно перефразировать так "сопрограммы — это функции, выполнение которых вы можете приостанавливать". И если вы скажете "дак это то, как работают генераторы", вы будете абсолютно правы.
Еще в Python 2.2 генераторы впервые были представлены в PEP 255 (они также назывались генераторами-итераторами поскольку реализовывали интерфейс итератора). Первоначально вдохновленные на создание языком программирования Icon, генераторы позволяли создать итератор в наиболее простой форме, который не расходовал бы память при вычислении следующего значения в процессе итерации по нему (вы конечно же могли бы создать целый класс, который реализует функции __iter__()
и __next__()
и не хранит каждое значение итератора, но на это потребуется больше времени). Например, если бы вы захотели создать свой вариант range()
, то вы могли бы реализовать его вот таким очень затратным по памяти способом, создав просто список с целыми числами:
def eager_range(up_to):
"""Создает список чисел от 0 до значения в up_to, исключая последнее."""
sequence = []
index = 0
while index < up_to:
sequence.append(index)
index += 1
return sequence
Проблема в том, что несмотря на все же реализованную последовательность целых чисел от 0 до 1,000,000, вам пришлось создать в памяти очень большой список из 1,000,000 чисел. Но после того, как в язык были добавлены генераторы, у вас появилась возможность с минимальными затратами создать итератор, которому не нужно генерировать всю последовательность за раз. Вместо этого достаточно иметь память для хранения только одного числа в каждый момент времени.
def lazy_range(up_to):
"""Генератор возвращает последовательность целых чисел от 0 до значения в up_to, исключая последнее."""
index = 0
while index < up_to:
yield index
index += 1
Возможность останавливать выполнение функции в любой момент времени, когда в коде встречается выражение yield
(до версии Python 2.5 эта конструкция была оператором), и позже возобновлять выполнение функции с этого места оказалась очень полезной в контексте более эффективного использования памяти, позволяя реализовать концепцию бесконечных последовательностей и другие похожие вещи.
Но, как вы уже успели заметить, генераторы — это все же итераторы. Иметь возможность простого создания итераторов — это конечно замечательно (и вполне очевидно, когда достаточно просто определить метод __iter__()
в объекте, который используется в качестве генератора), но люди знали, что если взять из генераторов механизм "постановки на паузу" и добавить к нему возможность "отправки чего-либо обратно" в генератор, внезапно Python получил бы возможность использовать идею сопрограмм (но пока я не сказал обратное, считайте, что это всего лишь идея в Python, сами сопрограммы обсуждаются далее по тексту). И такая возможность отправки чего-либо обратно в остановленный генератор была добавлена в Python 2.5 благодаря PEP 342. Помимо других вещей, PEP 342 добавил в генераторы метод send()
. Это уже позволило не только останавливать генераторы, но и отправлять значения обратно в генератор в место останова. Взяв наш пример выше с функцией range()
, вы можете добавить возможность перемещения по последовательности вперед и назад на некоторое количество шагов:
def jumping_range(up_to):
"""Генератор возвращает последовательность целых чисел от 0 до значения в up_to, исключая последнее.
Отправка значения в генератора сдвинет последовательность на указанное количество значений.
"""
index = 0
while index < up_to:
jump = yield index
if jump is None:
jump = 1
index += jump
if __name__ == '__main__':
iterator = jumping_range(5)
print(next(iterator)) # 0
print(iterator.send(2)) # 2
print(next(iterator)) # 3
print(iterator.send(-1)) # 2
for x in iterator:
print(x) # 3, 4
Генераторы больше не трогали до Python 3.3, в котором в рамках PEP 380 добавили выражение yield from
. Строго говоря, это выражение дает возможность выполнить более чистый рефакторинг генераторов, позволяя одной простой конструкцией выдавать каждое значение из итератора (которым обычно оказывается генератор):
def lazy_range(up_to):
"""Генератор возвращает последовательность целых чисел от 0 до значения в up_to, исключая последнее."""
index = 0
def gratuitous_refactor():
nonlocal index
while index < up_to:
yield index
index += 1
yield from gratuitous_refactor()
Сделав рефакторинг более простым, yield from
также позволил создавать цепочку из генераторов таким образом, что значения могли всплывать и опускаться обратно по стэку вызовов без использования всякого дополнительного кода.
def bottom():
# Возврат значения с помощью yield позволяет ему
# подняться вверх по стэку и затем спуститься обратно вниз.
return (yield 42)
def middle():
return (yield from bottom())
def top():
return (yield from middle())
# Получаем генератор.
gen = top()
value = next(gen)
print(value) # Выводит '42'.
try:
value = gen.send(value * 2)
except StopIteration as exc:
value = exc.value
print(value) # Выводит '84'.
Резюме
Генераторы, добавленные в Python 2.2, позволили останавливать выполнение кода. С появлением функционала отправки значения обратно в остановленный генератор, который внедрили в Python 2.5, в нем появилась возможность реализации идеи сопрограмм. А добавление yield from
в Python 3.3 не только упростило рефакторинг генераторов, но и позволило выстраивать их в цепочки.
Что такое event loop?
Если вы хотите разобраться с async
/await
, важно понимать, что такое event loop, и как он позволяет сделать асинхронное программирование возможным. Если вы ранее участвовали в создании графического интерфейса пользователя (GUI) (сюда входит и клиентская вэб-разработка), тогда у вас уже должен быть опыт работы с event loop. Но, поскольку идея асинхронного программирования в Python, выраженная в этой языковой конструкции, появилась достаточно недавно, это вполне нормально, что вы не знаете, что такое event loop.
Обращаясь к Википедии, event loop — это "программная конструкция, которая ожидает и генерирует события или сообщения в программе". По сути event loop позволяет реализовать логику "когда случилось A, выполнить В". Возможно, наиболее простым примером этой идеи является event loop в JavaScript, который присутствует в любом браузере. В момент, когда вы нажали что-то на странице ("когда случилось А"), событие нажатия попадает в event loop, который в свою очередь проверяет, была ли ранее зарегистрирована функция обратного вызова (callback) на событие onclick
для того, чтобы обработать это нажатие ("выполнить В"). Если какой-либо обработчик был зарегистрирован для этого события, он вызывается и получает объект с информацией о событии. Event loop (прим. переводчика: этот термин можно дословно перевести, как "петля событий") называется так, потому что он постоянно собирает события и, перебирая их, пытается выяснить, как обработать каждое из этих событий.
В случае с Python, asyncio
был добавлен в стандартную библиотеку языка, чтобы обеспечить как раз реализацию идеи event loop. В asyncio
основной фокус сделан на сеть, что в случае с event loop подразумевает, что частным случаем "когда случилось А" может являться событие готовности сокета к чтению и/или записи (I/O) (посредством модуля selectors
). Помимо графического интерфейса пользователя (GUI) и сетевого ввода/вывода (I/O), event loop также часто используется для организации исполнения кода в отдельном потоке или дочернем процессе и выступает в качестве планировщика (кооперативная многозадачность). Если вы понимаете, как работает GIL в Python, event loop'ы могут быть очень кстати в ситуациях, когда отключение GIL в принципе возможно и это будет даже эффективно.
Резюме
Event loop позволяет организовать логику "когда произошло А, сделай В". Проше говоря, event loop наблюдает за тем, не произошло ли "что-то", за что он отвечает, и если это "что-то" случилось, он вызывает код, который должен обработать это событие. Python включил event loop в стандартную библиотеку в виде asyncio
начиная с версии Python 3.4.
Как работают async
и await
Как это было реализовано в Python 3.4
С нововведениями в работе генераторов, добавленных в Python 3.3, и появлением event loop в виде asyncio
Python 3.4 уже имел достаточно для поддержки асинхронного программирования в форме concurrent программирования (прим. переводчика. Термин "concurrent" условно можно перевести как "одновременный". Но "одновременное программирование" звучит странно и имеет совершенно другой смысл. Если кто-то подскажет, как лучше это перевести, буду очень признателен). Асинхронное программирование — это такое программирование, когда порядок выполнения неизвестен заранее (поскольку выполнение асинхронно, а не синхронно). Concurrent программирование — это написание такого кода, который может выполняться независимо от других частей, даже если они все выполняются в одном потоке (одновременное выполнение — это не параллелизм). К примеру, следующий код на Python 3.4 реализует таймер обратного отсчета с помощью двух асинхронных, одновременных вызовов одной и той же функции.
import asyncio
# Позаимствовано отсюда http://curio.readthedocs.org/en/latest/tutorial.html.
@asyncio.coroutine
def countdown(number, n):
while n > 0:
print('T-minus', n, '({})'.format(number))
yield from asyncio.sleep(1)
n -= 1
loop = asyncio.get_event_loop()
tasks = [
asyncio.ensure_future(countdown("A", 2)),
asyncio.ensure_future(countdown("B", 3))]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()
В Python 3.4 декоратор asyncio.coroutine
использовался для того, чтобы пометить функцию, которая является сопрограммой, и написана для использования с пакетом asyncio
и его реализацией event loop. Это обозначило первое определение сопрограмм в Python: это такой объект, который реализует методы, добавленные генераторам в рамках PEP 342, и предоставляемые абстрактным базовым классом collections.abc.Coroutine. А это в свою очередь означало, что все генераторы внезапно получили интерфейс сопрограмм, даже несмотря на то, что изначально их даже не планировалось использовать в таком ключе. Чтобы это исправить, asyncio
потребовал, чтобы все генераторы, являющиеся сопрограммами, были обернуты в декоратор asyncio.coroutine.
С таким однозначным обозначением сопрограммы (которое совпадало с интерфейсом генераторов) вы затем просто использовали yield from
с любым объектом asyncio.Future
, чтобы отправить его в event loop, останавливая выполнение сопрограммы до возникновения какого-либо события (реализация future-объекта описана в asyncio
и не особо важна в данном контексте). Когда future-объект попадал в event loop, он начинал проверяться на предмет завершения той задачи, за выполнение которой он отвечал. Как только его задача завершалась, event loop оповещался о завершении и остановленная ранее и ожидающая этот future-объект сопрограмма запускалась вновь с точки останова и посредством метода send()
в нее передавался результат выполнения future-объекта.
Возьмем пример, приведенный выше. Event loop запускает каждую сопрограмму countdown()
, выполняя код до выражения yield from
и функции asyncio.sleep()
в одной из сопрограмм. Это выражение возвращает объект asyncio.Future
, который отправляется в event loop и останавливает выполнение сопрограммы. Далее event loop наблюдает за этим future-объектом до его завершения, по факту пока не истечет секунда (также проверяя и другие объекты, такие как вторая сопрограмма). Как только секунда истекла, event loop берет остановленную сопрограмму countdown()
, которая ранее вернула future-объект, и отправляет результат завершения future-объекта в сопрограмму в то самое место, откуда она вернула future-объект, а затем сопрограмма вновь запускается. Так продолжается до тех пор, пока все запущенные сопрограммы countdown()
не завершатся и внутри event loop не останется объектов для наблюдения. Позже мы рассмотрим более полный пример того, как сопрограммы и event loop работают в связке, но для начала я расскажу, как работает async
и await
.
Переход от yield from
к await
в Python 3.5
Функция, которая помечалась как сопрограмма для использования в асинхронном программировании, в Python 3.4 выглядела так:
# Это работает и в Python 3.5.
@asyncio.coroutine
def py34_coro():
yield from stuff()
В Python 3.5 был добавлен декоратор types.coroutine
, который помечает генератор как сопрограмму аналогично asyncio.coroutine
. Также можно использовать конструкцию async def
, чтобы синтаксически обозначить функцию, являющуюся сопрограммой, несмотря на то, что она не может содержать выражения yield
, допускается использовать только return
и await
для возврата значения из такой сопрограммы.
async def py35_coro():
await stuff()
По сути async
и types.coroutine
сужают определение того, что считается сопрограммой. Это меняет определение сопрограмм от "просто интерфейс генератора", сделав различие между любым генератором и генератором, используемым как сопрограмма, намного более строгим (даже функция inspect.iscoroutine()
теперь выполняет более строгую проверку, сообщая, что для сопрограммы необходимо использовать async
).
Вы наверняка уже заметили, что в примере, написанном для Python 3.5, помимо async
также появилось выражение await
(которое, кстати, можно использовать только вместе с async def
). Несмотря на то, что await
работает аналогично yield from
, объекты, которые может принимать выражение await
, другие. Для использования в выражении await
конечно же разрешены сопрограммы, поскольку весь фундамент концептуально строится на базе сопрограмм. Но когда вы передаете в await
объект, с технической точки зрения достаточно, чтобы этот объект был объектом типа awaitable
— это такой объект, в котором определен метод __await__()
, возвращающий итератор, который в свою очередь не является сопрограммой. Сами сопрограммы также считаются awaitable
объектами (вот почему collections.abc.Coroutine
наследуется от collections.abc.Awaitable
). Такой подход следует уже известной традиции языка Python, когда большинство синтаксических конструкций "под капотом" транслируются в вызовы методов, например, a + b
это по сути вызов функции a.__add__(b)
или b.__radd(a)
.
Как же в итоге обыгрывается различие между yield from
и await
на более низком уровне (то есть, разница между генератором с декоратором types.coroutine
и определенным с помощью выражения async def
)? Чтобы это выяснить, давайте взглянем на байткод двух примеров, представленных выше. Байткод для функции py34_coro()
выглядит так:
>>> dis.dis(py34_coro)
2 0 LOAD_GLOBAL 0 (stuff)
3 CALL_FUNCTION 0 (0 positional, 0 keyword pair)
6 GET_YIELD_FROM_ITER
7 LOAD_CONST 0 (None)
10 YIELD_FROM
11 POP_TOP
12 LOAD_CONST 0 (None)
15 RETURN_VALUE
А байткод для функции py35_coro()
такой:
>>> dis.dis(py35_coro)
1 0 LOAD_GLOBAL 0 (stuff)
3 CALL_FUNCTION 0 (0 positional, 0 keyword pair)
6 GET_AWAITABLE
7 LOAD_CONST 0 (None)
10 YIELD_FROM
11 POP_TOP
12 LOAD_CONST 0 (None)
15 RETURN_VALUE
Игнорируя разницу в номерах строк, которая может возникнуть из-за того, что py34_coro()
обернут в декоратор, можно увидеть одно единственное отличие — это код операции GET_YIELD_FROM_ITER
в одном случае и GET_AWAITABLE
в другом. Обе функции помечены флагом, который определяет, что функции являются сопрограммами, так что в этой части различий нет. В случае же с GET_YIELD_FROM_ITER
, декоратор проверяет, является его аргумент генератором или сопрограммой, и, если это не так, он оборачивает аргумент в функцию iter()
(выражение yield from
принимает сопроцедуру только тогда, когда на ней установлен соответствующий код операции, что по сути и делает декоратор types.coroutine
, проставляя флаг CO_ITERABLE_COROUTINE
объекту кода генератора на уровне языка C).
Но GET_AWAITABLE
байткод обрабатывает по-другому. В то время как байткод принимает сопрограмму, как это происходит и в случае с GET_YIELD_FROM_ITER
, он не примет генератор, который не помечен как сопрограмма. Также помимо обычных сопрограмм байткод принимает awaitable
объект, рассмотренный ранее. Из этого следует, что оба выражения yield from
и await
принимают сопрограммы, но различаются тем, что первый также может принимать простые генераторы, а второй — объекты типа awaitable.
Вам наверняка интересно, зачем введено такое ограничение между типами объектов, которые может принимать async
-сопрограмма и сопрограмма на базе генератора в их выражении останова сопрограммы? Основная цель такого подхода в том, чтобы не дать вам случайно перепутать объекты, которые в лучших традициях Python просто могут иметь общий API. Поскольку генераторы по своей сути реализуют API сопрограмм, можно очень легко и совершенно случайно попытаться использовать генератор вместо предполагаемой сопрограммы. А поскольку не все генераторы написаны таким образом, что их можно использовать в качестве сопрограмм, необходимо полностью исключить такое неправильное использование генератора. И, поскольку Python не является статически компилируемым, лучшее, что может предложить язык в такой ситуации — это проверки на соответствие во время выполнения кода. Это значит, что даже если используется types.coroutine
, компилятор не в состоянии определить, как будет использоваться генератор: как сопрограмма или как обычный генератор (помните, что даже если синтаксически имеется функция с декоратором types.coroutine
, это не гарантирует, что кто-то ранее не сделал замену types = spam
). Поэтому различные коды операции, которые имеют отличные друг от друга ограничения, генерируются компилятором на базе той информации, которая у него есть в момент выполнения (прим. переводчика. Автор статьи хотел пояснить, что коды операции используются для однозначного разделения сопрограмм на базе генераторов, которые были до появления awaitable объектов, от родных сопрограмм, которые используют только awaitable объекты. А при одинаковых интерфейсах иначе, кроме как выставлением кода операции на самом объекте сопроцедуры, эту задачу не решить).
Хотелось бы сделать одно существенное замечание касательно разницы между сопрограммами на базе генераторов и сопрограммами с использованием async
. Только сопрограммы на базе генераторов могут действительно останавливать выполнение и отсылать что-либо в event loop. Как правило, вы не замечаете эту важную деталь, потому что обычно используете функции, которые существуют в экосистеме event loop, такие как asyncio.sleep()
, а поскольку event loop'ы реализуют свой собственный API, то о реализации этой важной детали заботятся сами функции из этой экосистемы. Большинство из нас чаще использует event loop нежели реализует его логику, поэтому, используя только сопрограммы на базе async
, вам по большому счету будет все равно, как они работают внутри. Но если вам ранее было интересно, почему вы не могли создать что-то похожее на asyncio.sleep()
, используя только сопрограммы на базе async
, то это как раз тот самый момент истины.
Резюме
Давайте подведем итог. Определение метода с использование async def
превращает его в сопрограмму. Другой способ определить сопрограмму — это пометить генератор с помощью types.coroutine
(с технической точки зрения, она выставит флаг CO_ITERABLE_COROUTINE
в качестве кода операции на объекте генератора) или сделать его подклассом collections.abc.Coroutine
. Остановить цикл выполнения сопрограммы можно только с помощью сопрограммы на базе генератора.
Объект awaitable — это либо сопрограмма, либо объект, реализующий метод __await__
(с технической точки зрения, это по сути интерфейс абстрактного класса collections.abc.Awaitable
), который возвращает итератор, не являющийся сопрограммой. Выражение await
по сути является выражением yield from
, но с ограничением использования только с объектами awaitable
(обычные генераторы не будут работать с выражением await
). Функция async
— это сопрограмма, в которой имеются операторы return
(сюда также входит неявный return None
, который по умолчанию присутствует в конце каждой функции в Python) и/или выражения await
(использование выражений yield
не допускается). Ограничения для async
функций введены для того, чтобы вы по ошибке не перепутали сопрограммы на базе генераторов с другими генераторами, поскольку ожидаемое использование обоих типов генераторов в корне отличается.
Думайте об async
/await
как о программном интерфейсе для асинхронного программирования
Я хочу отметить одну ключевую особенность, о которой я глубоко не задумывался, пока не посмотрел доклад Дэвида Бизли на Python Brazil 2015. В этом докладе Дэвид сделал акцент на том, что async
/await
на самом деле является программным интерфейсом для асинхронного программирования (о чем он упомянул еще раз в Twitter.) Дэвид считает, что люди не дожны думать об async
/await
как о синониме asyncio
, а вместо этого расценивать asyncio
как отдельный фреймворк, который может использовать программный интерфейс async
/await
для реализации асинхронного программирования.
На самом деле Дэвид настолько верит в идею того, что async
/await
— это всего лишь программный интерфейс для асинхронного программирования, что даже создал проект curio
, в котором реализовал свой собственный event loop. Это помогло мне прояснить тот факт, что async
/await
представляют в Python всего лишь строительные блоки для реализации асинхронного программирования, но без привязки к конкретной реализации event loop или другим низкоуровневым вещам (в отличие от других языков программирования, в которых event loop непосредственно интегрирован в сам язык). Это позволяет проектам вроде curio
не просто работать по-другому на более низком уровне (например, asyncio
использует future-объекты как API для взаимодействия со своим event loop, в то время как curio
использует для этого кортежи), но и иметь другие цели и иную производительность (например, asyncio
включает в себя целый фреймворк для реализации слоев транспорта и протокола, что делает его достаточно расширяемым, в то время как curio
сам по себе проще и перекладывает эту задачу на плечи разработчика, что в свою очередь позволяет ему работать быстрее).
Учитывая (короткую) историю асинхронного программирования в Python, становится понятно, почему люди могут считать, что async
/await
== asyncio
. Я имею ввиду, что asyncio
— это то, что позволило сделать возможным асинхронное программирование в Python 3.4 и было мотиватором для добавления async
/await
в Python 3.5. Но идея, заложенная в основе async
/await
, изначально такова, чтобы не требовать для работы сам asyncio
или каким-либо способом искажать архитектурные решения в пользу именно этого фреймворка. Другими словами, async
/await
продолжает традицию Python в создании вещей, как можно более гибких, но в то же время достаточно практичных в использовании (и реализации).
Пример реализации
После всего прочтенного ваша голова наверняка слегка переполнена новыми терминами и понятиями, затрудняя охват всех нюансов реализации асинхронного программирования. Чтобы помочь вам расставить точки над i, далее приведен всеобъемлющий (хотя и надуманный) пример реализации асинхронного программирования, начиная от event loop и заканчивая функциями, относящимися к пользовательскому коду. Пример содержит сопрограммы, представляющие собой отдельные таймеры обратного отсчета, но выполняющиеся совместно. Это и есть асинхронное программирование через одновременное выполнение, по сути 3 отдельных сопрограммы будут запущены независимо друг от друга и все это будет выполняться в одном единственном потоке.
import datetime
import heapq
import types
import time
class Task:
"""Представляет, как долго сопрограмма должна ждать перед возобновлением выполнения.
Операторы сравнения реализованы для использования в heapq.
К сожалению, кортеж с двумя элементами не работает, потому что,
когда экземпляры класса datetime.datetime равны, выполняется
сравнение сопрограмм, а поскольку они не имеют методом, реализующих
операции сравнения, возникает исключение.
Считайте класс подобием о asyncio.Task/curio.Task.
"""
def __init__(self, wait_until, coro):
self.coro = coro
self.waiting_until = wait_until
def __eq__(self, other):
return self.waiting_until == other.waiting_until
def __lt__(self, other):
return self.waiting_until < other.waiting_until
class SleepingLoop:
"""Event loop, сфокусированный на отложенном выполнении сопрограмм.
Считайте класс подобием asyncio.BaseEventLoop/curio.Kernel.
"""
def __init__(self, *coros):
self._new = coros
self._waiting = []
def run_until_complete(self):
# Запустить все сопрограммы.
for coro in self._new:
wait_for = coro.send(None)
heapq.heappush(self._waiting, Task(wait_for, coro))
# Не прерывать выполнение, пока есть выполняющиеся сопроцедуры.
while self._waiting:
now = datetime.datetime.now()
# Получаем сопрограмму с самым ранним временем возобновления.
task = heapq.heappop(self._waiting)
if now < task.waiting_until:
# Мы оказались здесь раньше, чем нужно,
# поэтому подождем, когда придет время возобновить сопрограмму.
delta = task.waiting_until - now
time.sleep(delta.total_seconds())
now = datetime.datetime.now()
try:
# Время возобновить выполнение сопрограммы.
wait_until = task.coro.send(now)
heapq.heappush(self._waiting, Task(wait_until, task.coro))
except StopIteration:
# Сопрограмма завершена.
pass
@types.coroutine
def sleep(seconds):
"""Останавливает сопрограмму на указанное количество секунд.
Считайте класс подобием asyncio.sleep()/curio.sleep().
"""
now = datetime.datetime.now()
wait_until = now + datetime.timedelta(seconds=seconds)
# Останавливаем все сопроцедуры в текущем стэке. Тут необходимо
# использовать ```yield```, чтобы создать сопрограмму на базе генератора,
# а не на базе ```async```.
actual = yield wait_until
# Возобновляем стэк выполнения, возвращая время,
# которое мы провели в ожидании.
return actual - now
async def countdown(label, length, *, delay=0):
"""Начинает обратный отсчет с секунд ```length``` и с задержкой ```delay```.
Это обычно то, что реализует пользователь.
"""
print(label, 'waiting', delay, 'seconds before starting countdown')
delta = await sleep(delay)
print(label, 'starting after waiting', delta)
while length:
print(label, 'T-minus', length)
waited = await sleep(1)
length -= 1
print(label, 'lift-off!')
def main():
"""Запустить event loop с обратным отсчетом 3 отдельных таймеров.
Это обычно то, что реализует пользователь.
"""
loop = SleepingLoop(countdown('A', 5), countdown('B', 3, delay=2),
countdown('C', 4, delay=1))
start = datetime.datetime.now()
loop.run_until_complete()
print('Total elapsed time is', datetime.datetime.now() - start)
if __name__ == '__main__':
main()
Как я и сказал, это надуманный пример, но если вы запустите этот код в Python 3.5, вы заметите, что все 3 сопрограммы выполняются независимо друг от друга в одном потоке, а также, что общее время выполнения составляет около 5 секунд. Вы можете расценивать Task
, SleepingLoop
и sleep()
как то, что уже реализовано в таких фреймворках как asyncio
и curio
. Для обычного же пользователя важен только код в функциях countdown()
и main()
. Можно заметить, что тут нет никакой магии относительно async
, await
или всего этого асинхронного программирования. Это всего лишь программный интерфейс, который Python предоставляет вам, как разработчику, чтобы сделать такие вещи более простыми в реализации.
Мои надежды и мечты относительно будущего
Теперь, когда я в полной мере понимаю, как работает асинхронное программирование в Python, у меня появилось желание использовать его постоянно! Это настолько крутая идея, что намного лучше подходит для реализации всего того, для чего вы бы ранее стали использовать отдельные потоки. Основная проблема в том, что Python 3.5 еще относительно свежий, как и async
/await
. А это значит, что пока существует не так много библиотек для поддержки такого рода асинхронного программирования. Например, чтобы реализовать выполнение HTTP-запросов, вам придется либо самому писать всю реализацию HTTP запроса (какая гадость), либо использовать такой фреймворк, как aiohttp
, который добавляет HTTP поверх другого event loop (в нашем случае это asyncio
), либо надеяться, что продолжат появляться такие проекты как библиотека hyper
для обеспечения абстракций таких вещей как HTTP, что позволит в конечном счете использовать любую библиотеку ввода/вывода (даже несмотря на то, что на данный момент hyper
поддерживает только HTTP/2).
Лично я надеюсь, что такие проекты как hyper
будут иметь успех, чтобы мы смогли получить четкое разделение логики получения бинарных данных из канала ввода/вывода и логики интерпретации этих самых данных. Такая абстракция очень важна, поскольку в большинстве библиотек ввода/вывода в Python присутствует достаточно сильная связность между реализацией канала ввода/вывода и обработкой данных, которые пришли из этого канала. Это основная проблема пакета http
стандартной библиотеки Python, поскольку в нем отсутствует отдельный HTTP-анализатор, а объект соединения выполняет всю работу по вводу/выводу и обработке. И если вы надеялись, что requests
получит поддержку асинхронного программирования, вашим надеждам не суждено сбыться, поскольку синхронный ввод/вывод является неотделимой частью общей архитектуры. Этот сдвиг и появление возможности использовать асинхронное программирование в Python предоставляет шанс сообществу Python исправить ситуацию с отсутствием необходимых абстракций на разных уровнях сетевого стэка. И теперь у нас есть преимущество в виде возможности написания асинхронного кода так, как будто это синхронный код, поэтому инструменты, заполняющие пустоту в области асинхронного программирования, могут использоваться в обоих мирах.
Я также надеюсь, что Python получит необходимую поддержку yield
в сопрограммах на базе async
. Это может потребовать введения еще одного ключевого слова в синтаксис (возможно, что-то типа anticipate
?), но сам факт того, что сейчас нельзя реализовать систему на базе event loop только с использованием сопрограмм async
, меня тревожит. К счастью, я такой не один и, поскольку автор PEP 492 солидарен со мной, есть шанс, что эта странность будет исправлена в будущем.
Заключение
По сути async
и await
являются модными генераторами, которые мы называем сопрограммами, вдобавок имеется дополнительная поддержка для вещей, называемых awaitable-объектами, и функционал для преобразования простых генераторов в сопрограммы. Все это вместе призвано обеспечить одновременное выполнение, чтобы мы имели наиболее полную поддержку асинхронного программирования в Python. Сопрограммы круче и намного проще в использовании, чем такие сравнимые по функционалу решения, как потоки (я ранее написал пример с использованием асинхронного программирования менее чем из 100 строк с учетом комментариев), в то же время это решение является достаточно гибким и быстрым с точки зрения производительности (FAQ проекта curio
содержит информацию, что curio
быстрее, чем twisted
на 30-40%, но медленнее gevent
всего на 10-15%, и это с учетом того, что он полностью написан на чистом Python, а учитывая тот факт, что Python 2 + Twisted может использовать меньше памяти и проще в отладке чем Go, только представьте, что вы можете сделать с его помощью!). Я очень рад, что это прижилось в Python 3 и я с радостью наблюдаю, как сообщество принимает это и начинает помогать с поддержкой в библиотеках и фреймворках, чтобы мы все в итоге только выиграли от появления более широкой поддержки асинхронного программирования в Python.
Автор: pesh1983