Говорити про це 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. Події в часі
Найпростіша подія, яку можна впоратися, це досягнення часу. Це також фундаментальний блок потокового коду: потік повторно sleeps, поки умова не відповідає дійсності. Однак регулярне 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/…