Речь async/await
и asyncio
не об одном и том же. Первая - это фундаментальная низкоуровневая конструкция (сопрограммы), а вторая - это библиотека, использующая эти конструкции. И наоборот, нет однозначного ответа.
Ниже приводится общее описание того, как работают async/await
и- asyncio
подобные библиотеки. То есть, сверху могут быть другие уловки (есть ...), но они несущественны, если вы сами их не построите. Разница должна быть незначительной, если вы уже не знаете достаточно, чтобы не задавать такой вопрос.
1. Сопрограммы против подпрограмм в скорлупе
Точно так же, как подпрограммы (функции, процедуры, ...), сопрограммы (генераторы, ...) представляют собой абстракцию стека вызовов и указателя инструкций: существует стек выполняемых частей кода, и каждая из них находится в определенной инструкции.
Различие между def
противопоставлением async def
используется просто для ясности. Фактическая разница - return
против yield
. От этого await
или yield from
взять разницу от отдельных вызовов до целых стеков.
1.1. Подпрограммы
Подпрограмма представляет новый уровень стека для хранения локальных переменных и однократный обход ее инструкций для достижения конца. Рассмотрим такую подпрограмму:
def subfoo(bar):
qux = 3
return qux * bar
Когда вы его запускаете, это означает
- выделить пространство стека для
bar
иqux
- рекурсивно выполнить первый оператор и перейти к следующему оператору
- один раз
return
передайте его значение в стек вызовов
- очистить стек (1.) и указатель инструкции (2.)
Примечательно, что 4. означает, что подпрограмма всегда запускается в одном и том же состоянии. После завершения все, что относится только к самой функции, теряется. Функцию нельзя возобновить, даже если после нее есть инструкции return
.
root -\
: \- subfoo --\
:/--<---return --/
|
V
1.2. Сопрограммы как постоянные подпрограммы
Сопрограмма похожа на подпрограмму, но может завершиться без разрушения своего состояния. Рассмотрим такую сопрограмму:
def cofoo(bar):
qux = yield bar # yield marks a break point
return qux
Когда вы его запускаете, это означает
- выделить пространство стека для
bar
иqux
- рекурсивно выполнить первый оператор и перейти к следующему оператору
- один раз в a
yield
, поместите его значение в вызывающий стек, но сохраните указатель стека и инструкции
- после вызова
yield
, восстановить стек и указатель инструкции и передать аргументы вqux
- один раз
return
передайте его значение в стек вызовов
- очистить стек (1.) и указатель инструкции (2.)
Обратите внимание на добавление 2.1 и 2.2 - сопрограмма может быть приостановлена и возобновлена в заранее определенных точках. Это похоже на то, как подпрограмма приостанавливается во время вызова другой подпрограммы. Разница в том, что активная сопрограмма не привязана строго к своему стеку вызовов. Вместо этого приостановленная сопрограмма является частью отдельного изолированного стека.
root -\
: \- cofoo --\
:/--<+--yield --/
| :
V :
Это означает, что подвешенные сопрограммы можно свободно хранить или перемещать между стеками. Любой стек вызовов, имеющий доступ к сопрограмме, может решить возобновить ее.
1.3. Обход стека вызовов
Пока наша сопрограмма идет вниз по стеку вызовов только с yield
. Подпрограмма может перемещаться вниз и вверх по стеку вызовов с помощью return
и ()
. Для полноты сопрограмм также нужен механизм для подъема по стеку вызовов. Рассмотрим такую сопрограмму:
def wrap():
yield 'before'
yield from cofoo()
yield 'after'
Когда вы его запускаете, это означает, что он по-прежнему выделяет стек и указатель инструкций как подпрограмму. Когда он приостанавливается, это по-прежнему похоже на сохранение подпрограммы.
Однако yield from
делает и то , и другое . Он приостанавливает wrap
и запускает указатель стека и инструкции cofoo
. Обратите внимание, что wrap
остается приостановленным до cofoo
полного завершения. Всякий раз, когда cofoo
приостанавливается или что-то отправляется, cofoo
он напрямую подключается к вызывающему стеку.
1.4. Все сопрограммы вниз
Как установлено, yield from
позволяет соединить два прицела через другой промежуточный. При рекурсивном применении это означает, что вершина стека может быть соединена с нижней частью стека.
root -\
: \-> coro_a -yield-from-> coro_b --\
:/ <-+------------------------yield ---/
| :
:\ --+-- coro_a.send----------yield ---\
: coro_b <-/
Учтите, что root
и coro_b
друг о друге не знают. Это делает сопрограммы намного чище, чем обратные вызовы: сопрограммы по-прежнему построены на соотношении 1: 1, как подпрограммы. Сопрограммы приостанавливают и возобновляют весь свой существующий стек выполнения до обычной точки вызова.
Примечательно, что root
можно было возобновить произвольное количество сопрограмм. Тем не менее, он никогда не может возобновиться более чем по одному одновременно. Сопрограммы одного и того же корня параллельны, но не параллельны!
1.5. Python async
иawait
Объяснение до сих пор явно используется yield
и yield from
словарный запас генераторов - лежащий в основе функциональность та же. Новый синтаксис Python3.5 async
и await
существует в основном для ясности.
def foo(): # subroutine?
return None
def foo(): # coroutine?
yield from foofoo() # generator? coroutine?
async def foo(): # coroutine!
await foofoo() # coroutine!
return None
async for
И async with
утверждения необходимы , потому что вы бы разорвать yield from/await
цепь с голым for
и with
отчетностью.
2. Анатомия простого цикла событий
Сама по себе сопрограмма не имеет понятия о передаче управления другой сопрограмме. Он может передать управление только вызывающей стороне в нижней части стека сопрограмм. Затем этот вызывающий может переключиться на другую сопрограмму и запустить ее.
Этот корневой узел нескольких сопрограмм обычно представляет собой цикл событий : при приостановке сопрограмма выдает событие, на котором она хочет возобновить. В свою очередь, цикл событий может эффективно ожидать возникновения этих событий. Это позволяет ему решать, какую сопрограмму запускать следующей или как подождать перед возобновлением.
Такой дизайн подразумевает наличие набора заранее определенных событий, которые понимает цикл. Несколько сопрограмм await
друг друга, пока, наконец, не будет await
обработано событие . Это событие может напрямую связываться с циклом событий посредством yield
управления.
loop -\
: \-> coroutine --await--> event --\
:/ <-+----------------------- yield --/
| :
| : # loop waits for event to happen
| :
:\ --+-- send(reply) -------- yield --\
: coroutine <--yield-- event <-/
Ключ в том, что приостановка сопрограмм позволяет циклу событий и событиям напрямую взаимодействовать. Промежуточный стек сопрограмм не требует каких-либо знаний о том, в каком цикле он выполняется, или о том, как работают события.
2.1.1. События во времени
Самое простое событие для обработки - достижение определенного момента времени. Это также фундаментальный блок многопоточного кода: поток повторяется sleep
до тех пор, пока условие не станет истинным. Однако обычное sleep
выполнение блоков само по себе - мы хотим, чтобы другие сопрограммы не блокировались. Вместо этого мы хотим сообщить циклу событий, когда он должен возобновить текущий стек сопрограмм.
2.1.2. Определение события
Событие - это просто значение, которое мы можем идентифицировать - будь то через перечисление, тип или другой идентификатор. Мы можем определить это с помощью простого класса, который хранит наше целевое время. Помимо хранения информации о событии, мы можем await
напрямую разрешить класс.
class AsyncSleep:
"""Event to sleep until a point in time"""
def __init__(self, until: float):
self.until = until
# used whenever someone ``await``s an instance of this Event
def __await__(self):
# yield this Event to the loop
yield self
def __repr__(self):
return '%s(until=%.1f)' % (self.__class__.__name__, self.until)
Этот класс только сохраняет событие - он не говорит, как на самом деле его обработать.
Единственная особенность __await__
- это то, что await
ищет ключевое слово. Фактически, это итератор, но он недоступен для обычного итерационного механизма.
2.2.1. В ожидании события
Теперь, когда у нас есть событие, как на него реагируют сопрограммы? Мы должны иметь возможность выразить эквивалент sleep
посредством await
нашего события. Чтобы лучше увидеть, что происходит, мы ждем дважды половину времени:
import time
async def asleep(duration: float):
"""await that ``duration`` seconds pass"""
await AsyncSleep(time.time() + duration / 2)
await AsyncSleep(time.time() + duration / 2)
Мы можем напрямую создать и запустить эту сопрограмму. Как и в случае с генератором, при использовании coroutine.send
сопрограмма запускается до тех пор, пока не будет yield
получен результат.
coroutine = asleep(100)
while True:
print(coroutine.send(None))
time.sleep(0.1)
Это дает нам два AsyncSleep
события, а затем, StopIteration
когда сопрограмма завершена. Обратите внимание, что единственная задержка - это от time.sleep
цикла! Каждый AsyncSleep
хранит только смещение от текущего времени.
2.2.2. Событие + Сон
На данный момент в нашем распоряжении есть два отдельных механизма:
AsyncSleep
События, которые могут быть получены из сопрограммы
time.sleep
что может ждать, не влияя на сопрограммы
Примечательно, что эти двое ортогональны: ни один не влияет на другой и не запускает его. В результате мы можем придумать нашу собственную стратегию, sleep
чтобы противостоять задержке файла AsyncSleep
.
2.3. Наивный цикл событий
Если у нас несколько сопрограмм, каждая из них может сказать нам, когда она хочет, чтобы ее разбудили. Затем мы можем дождаться возобновления первого из них, затем следующего и так далее. Примечательно, что на каждом этапе нам важно только то, что будет следующим .
Это упрощает планирование:
- сортировать сопрограммы по желаемому времени пробуждения
- выбрать первого, кто хочет проснуться
- подожди до этого момента
- запустить эту сопрограмму
- повторять от 1.
Тривиальная реализация не требует каких-либо сложных концепций. A list
позволяет сортировать сопрограммы по дате. Ожидание обычное time.sleep
. Запуск сопрограмм работает так же, как и раньше, с coroutine.send
.
def run(*coroutines):
"""Cooperatively run all ``coroutines`` until completion"""
# store wake-up-time and coroutines
waiting = [(0, coroutine) for coroutine in coroutines]
while waiting:
# 2. pick the first coroutine that wants to wake up
until, coroutine = waiting.pop(0)
# 3. wait until this point in time
time.sleep(max(0.0, until - time.time()))
# 4. run this coroutine
try:
command = coroutine.send(None)
except StopIteration:
continue
# 1. sort coroutines by their desired suspension
if isinstance(command, AsyncSleep):
waiting.append((command.until, coroutine))
waiting.sort(key=lambda item: item[0])
Конечно, здесь есть много возможностей для улучшения. Мы можем использовать кучу для очереди ожидания или таблицу отправки для событий. Мы также можем получить возвращаемые значения из StopIteration
и назначить их сопрограмме. Однако основной принцип остается прежним.
2.4. Кооперативное ожидание
AsyncSleep
Событие и run
цикл обработки событий является полностью работоспособно осуществлением своевременных мероприятий.
async def sleepy(identifier: str = "coroutine", count=5):
for i in range(count):
print(identifier, 'step', i + 1, 'at %.2f' % time.time())
await asleep(0.1)
run(*(sleepy("coroutine %d" % j) for j in range(5)))
При этом происходит совместное переключение между каждой из пяти сопрограмм, каждая из которых приостанавливается на 0,1 секунды. Несмотря на то, что цикл обработки событий является синхронным, он по-прежнему выполняет работу за 0,5 секунды вместо 2,5 секунд. Каждая сопрограмма хранит состояние и действует независимо.
3. Цикл событий ввода-вывода
Поддерживаемый цикл событий sleep
подходит для опроса . Однако ожидание ввода-вывода для дескриптора файла может быть выполнено более эффективно: операционная система реализует ввод-вывод и, таким образом, знает, какие дескрипторы готовы. В идеале цикл событий должен поддерживать явное событие «готовность к вводу-выводу».
3.1. select
вызов
У Python уже есть интерфейс для запроса в ОС дескрипторов ввода-вывода для чтения. Когда вызывается с дескрипторами для чтения или записи, он возвращает дескрипторы, готовые к чтению или записи:
readable, writeable, _ = select.select(rlist, wlist, xlist, timeout)
Например, мы можем open
файл для записи и ждать, пока он будет готов:
write_target = open('/tmp/foo')
readable, writeable, _ = select.select([], [write_target], [])
После возврата select writeable
содержит наш открытый файл.
3.2. Базовое событие ввода / вывода
Подобно AsyncSleep
запросу, нам нужно определить событие для ввода-вывода. Согласно базовой select
логике событие должно относиться к читаемому объекту, например к open
файлу. Кроме того, мы храним, сколько данных нужно прочитать.
class AsyncRead:
def __init__(self, file, amount=1):
self.file = file
self.amount = amount
self._buffer = ''
def __await__(self):
while len(self._buffer) < self.amount:
yield self
# we only get here if ``read`` should not block
self._buffer += self.file.read(1)
return self._buffer
def __repr__(self):
return '%s(file=%s, amount=%d, progress=%d)' % (
self.__class__.__name__, self.file, self.amount, len(self._buffer)
)
Как и в случае с, AsyncSleep
мы в основном просто сохраняем данные, необходимые для основного системного вызова. На этот раз __await__
его можно возобновлять несколько раз - пока желаемое amount
не будет прочитано. Кроме того, мы получаем return
результат ввода-вывода, а не просто возобновляем.
3.3. Дополнение цикла событий чтением ввода-вывода
Основа для нашего цикла событий по-прежнему run
определена ранее. Во-первых, нам нужно отслеживать запросы на чтение. Это больше не отсортированное расписание, мы сопоставляем только запросы чтения с сопрограммами.
# new
waiting_read = {} # type: Dict[file, coroutine]
Поскольку select.select
принимает параметр тайм-аута, мы можем использовать его вместо time.sleep
.
# old
time.sleep(max(0.0, until - time.time()))
# new
readable, _, _ = select.select(list(reads), [], [])
Это дает нам все читаемые файлы - если они есть, мы запускаем соответствующую сопрограмму. Если таковых нет, значит, мы достаточно долго ждали запуска нашей текущей сопрограммы.
# new - reschedule waiting coroutine, run readable coroutine
if readable:
waiting.append((until, coroutine))
waiting.sort()
coroutine = waiting_read[readable[0]]
Наконец, мы должны фактически прослушивать запросы на чтение.
# new
if isinstance(command, AsyncSleep):
...
elif isinstance(command, AsyncRead):
...
3.4. Собираем все вместе
Вышесказанное было небольшим упрощением. Нам нужно кое-что переключить, чтобы не истощать спящие сопрограммы, если мы всегда можем читать. Нам нужно справиться с тем, что нечего читать или нечего ждать. Однако конечный результат по-прежнему соответствует 30 LOC.
def run(*coroutines):
"""Cooperatively run all ``coroutines`` until completion"""
waiting_read = {} # type: Dict[file, coroutine]
waiting = [(0, coroutine) for coroutine in coroutines]
while waiting or waiting_read:
# 2. wait until the next coroutine may run or read ...
try:
until, coroutine = waiting.pop(0)
except IndexError:
until, coroutine = float('inf'), None
readable, _, _ = select.select(list(waiting_read), [], [])
else:
readable, _, _ = select.select(list(waiting_read), [], [], max(0.0, until - time.time()))
# ... and select the appropriate one
if readable and time.time() < until:
if until and coroutine:
waiting.append((until, coroutine))
waiting.sort()
coroutine = waiting_read.pop(readable[0])
# 3. run this coroutine
try:
command = coroutine.send(None)
except StopIteration:
continue
# 1. sort coroutines by their desired suspension ...
if isinstance(command, AsyncSleep):
waiting.append((command.until, coroutine))
waiting.sort(key=lambda item: item[0])
# ... or register reads
elif isinstance(command, AsyncRead):
waiting_read[command.file] = coroutine
3.5. Кооперативный ввод / вывод
Реализации AsyncSleep
, AsyncRead
и run
теперь полностью функциональны для сна и / или чтения. Как и в случае sleepy
, мы можем определить помощника для проверки чтения:
async def ready(path, amount=1024*32):
print('read', path, 'at', '%d' % time.time())
with open(path, 'rb') as file:
result = return await AsyncRead(file, amount)
print('done', path, 'at', '%d' % time.time())
print('got', len(result), 'B')
run(sleepy('background', 5), ready('/dev/urandom'))
Запустив это, мы видим, что наш ввод-вывод чередуется с ожидающей задачей:
id background round 1
read /dev/urandom at 1530721148
id background round 2
id background round 3
id background round 4
id background round 5
done /dev/urandom at 1530721148
got 1024 B
4. Неблокирующий ввод-вывод
Хотя ввод-вывод в файлах передает эту концепцию, он не совсем подходит для библиотеки, например asyncio
: select
вызов всегда возвращается для файлов , и оба open
и read
могут блокироваться на неопределенное время . Это блокирует все сопрограммы цикла обработки событий - что плохо. Такие библиотеки, как aiofiles
использование потоков и синхронизации для имитации неблокирующего ввода-вывода и событий в файле.
Однако сокеты допускают неблокирующий ввод-вывод, а присущая им задержка делает его гораздо более важным. При использовании в цикле событий ожидание данных и повторная попытка могут быть заключены в оболочку, ничего не блокируя.
4.1. Неблокирующее событие ввода / вывода
Подобно нашему AsyncRead
, мы можем определить событие приостановки и чтения для сокетов. Вместо файла мы берем сокет, который должен быть неблокирующим. Кроме того, мы __await__
используем socket.recv
вместо file.read
.
class AsyncRecv:
def __init__(self, connection, amount=1, read_buffer=1024):
assert not connection.getblocking(), 'connection must be non-blocking for async recv'
self.connection = connection
self.amount = amount
self.read_buffer = read_buffer
self._buffer = b''
def __await__(self):
while len(self._buffer) < self.amount:
try:
self._buffer += self.connection.recv(self.read_buffer)
except BlockingIOError:
yield self
return self._buffer
def __repr__(self):
return '%s(file=%s, amount=%d, progress=%d)' % (
self.__class__.__name__, self.connection, self.amount, len(self._buffer)
)
В отличие от AsyncRead
, __await__
выполняет действительно неблокирующий ввод-вывод. Когда данные доступны, они всегда читаются. Когда данные недоступны, он всегда приостанавливается. Это означает, что цикл обработки событий блокируется только на время выполнения полезной работы.
4.2. Разблокирование цикла событий
Что касается цикла событий, то здесь особо ничего не меняется. Событие для прослушивания остается таким же, как и для файлов - файловый дескриптор, отмеченный как готовый select
.
# old
elif isinstance(command, AsyncRead):
waiting_read[command.file] = coroutine
# new
elif isinstance(command, AsyncRead):
waiting_read[command.file] = coroutine
elif isinstance(command, AsyncRecv):
waiting_read[command.connection] = coroutine
На этом этапе должно быть очевидно, что AsyncRead
и AsyncRecv
являются событиями того же типа. Мы могли бы легко реорганизовать их в одно событие с заменяемым компонентом ввода-вывода. По сути, цикл событий, сопрограммы и события четко разделяют планировщик, произвольный промежуточный код и фактический ввод-вывод.
4.3. Уродливая сторона неблокирующего ввода-вывода
В принципе, что вы должны сделать в этой точке повторить логику read
как recv
для AsyncRecv
. Однако сейчас это намного уродливее - вам нужно обрабатывать ранние возвраты, когда функции блокируются внутри ядра, но передают вам управление. Например, открытие соединения по сравнению с открытием файла намного дольше:
# file
file = open(path, 'rb')
# non-blocking socket
connection = socket.socket()
connection.setblocking(False)
# open without blocking - retry on failure
try:
connection.connect((url, port))
except BlockingIOError:
pass
Короче говоря, осталось несколько десятков строк обработки исключений. На этом этапе события и цикл событий уже работают.
id background round 1
read localhost:25000 at 1530783569
read /dev/urandom at 1530783569
done localhost:25000 at 1530783569 got 32768 B
id background round 2
id background round 3
id background round 4
done /dev/urandom at 1530783569 got 4096 B
id background round 5
Дополнение
Пример кода на github
BaseEventLoop
реализован CPython : github.com/python/cpython/blob/…