Говорити про це 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
- рекурсивно виконувати перше твердження та переходити до наступного
- раз на a
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
- раз на a
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
не знаєте один про одного. Це робить супроводи набагато більш чистими, ніж зворотні дзвінки. Супротивники призупиняють дію та продовжують весь існуючий стек виконання до чергового пункту виклику.
Зокрема, root
можна було б відновити довільну кількість процедур. Тим не менш, він ніколи не може відновити більше, ніж один одночасно. Розміщення одного кореня одночасно, але не паралельно!
1.5. Пітона 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
s, поки умова не відповідає дійсності. Однак регулярне sleep
виконання блоків саме по собі - ми хочемо, щоб інші спроби не були заблоковані. Замість цього ми хочемо сказати циклу подій, коли він повинен відновити поточний стек кореневих програм.
2.1.2. Визначення події
Подія - це просто цінність, яку ми можемо ідентифікувати - будь то через enum, тип чи іншу ідентичність. Ми можемо визначити це за допомогою простого класу, який зберігає наш цільовий час. Крім зберігання інформації про подію, ми можемо дозволити 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.
Тривіальна реалізація не потребує ніяких передових концепцій. 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], [])
Після вибору повернення 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
: github.com/python/cpython/blob/…