Як насправді працює асинціо?


120

Це питання мотивоване моїм іншим питанням: як чекати в cdef?

В Інтернеті є багато статей і публікацій блогу asyncio, але всі вони дуже поверхневі. Я не зміг знайти будь-якої інформації про те, як asyncioреально реалізується і що робить введення / виведення асинхронним. Я намагався прочитати вихідний код, але це тисячі рядків не найвищого класу коду С, багато з яких стосується допоміжних об'єктів, але найважливіше, що важко зв’язати між синтаксисом Python і тим, що C код він би перекладав в.

Власна документація Asycnio є ще менш корисною. Там немає інформації про те, як це працює, лише деякі вказівки щодо їх використання, які також іноді вводяться в оману / дуже погано написані.

Я знайомий із впровадженням процедур Go, і сподівався, що Python зробив те саме. Якби це було так, код, який я з'явився у пості, пов’язаній вище, працював би. Оскільки цього не сталося, я зараз намагаюся з'ясувати, чому. Моя найкраща здогадка поки що така, будь ласка, виправте мене, де я помиляюся:

  1. Визначення форми форми async def foo(): ...фактично інтерпретується як методи успадкування класу coroutine.
  2. Можливо, async defнасправді розбивається на кілька методів за допомогою awaitвисловлювань, де об’єкт, за яким ці методи називаються, здатний відслідковувати прогрес, який він досяг за час виконання.
  3. Якщо вищезазначене відповідає дійсності, то, по суті, виконання програмної програми зводиться до виклику методів об'єкта кореневої програми деяким глобальним менеджером (циклом?).
  4. Глобальний менеджер якимось чином (як?) Усвідомлює, коли операції вводу / виводу виконуються кодом Python (тільки?) І здатний обрати один із очікуваних методів підпрограми, який слід виконати після того, як поточний метод виконання відмовився від керування (натисніть на awaitоператор ).

Іншими словами, ось моя спроба "знеструмити" якийсь asyncioсинтаксис у щось більш зрозуміле:

async def coro(name):
    print('before', name)
    await asyncio.sleep()
    print('after', name)

asyncio.gather(coro('first'), coro('second'))

# translated from async def coro(name)
class Coro(coroutine):
    def before(self, name):
        print('before', name)

    def after(self, name):
        print('after', name)

    def __init__(self, name):
        self.name = name
        self.parts = self.before, self.after
        self.pos = 0

    def __call__():
        self.parts[self.pos](self.name)
        self.pos += 1

    def done(self):
        return self.pos == len(self.parts)


# translated from asyncio.gather()
class AsyncIOManager:

    def gather(*coros):
        while not every(c.done() for c in coros):
            coro = random.choice(coros)
            coro()

Якщо мої здогадки виявляться правильними: тоді у мене є проблема. Як насправді відбувається введення / виведення у цьому сценарії? В окрему нитку? Чи весь перекладач призупинено, і введення / виведення відбувається поза перекладачем? Що саме мається на увазі введення / виведення? Якщо моя процедура python називається процедурою C open(), і вона, в свою чергу, надсилає переривання до ядра, передаючи йому управління, то як інтерпретатор Python знає про це і чи здатний продовжувати виконувати якийсь інший код, тоді як код ядра робить фактичний ввід / вивід і поки це прокидає процедуру Python, яка спочатку послала переривання? Як в принципі інтерпретатор Python може знати про це?


2
Більшість логік обробляється реалізацією циклу подій. Подивіться, як реалізується CPython BaseEventLoop: github.com/python/cpython/blob/…
Blender

@ Blender добре, я думаю, що нарешті знайшов те, що хотів, але тепер я не розумію, чому код був написаний таким, яким він був. Чому _run_once, що насправді єдина корисна функція у всьому цьому модулі, стає "приватною"? Реалізація жахлива, але це менше проблем. Чому єдина функція, яку ви хотіли б зателефонувати в циклі подій, позначена як "не дзвоніть мені"?
wvxvw

Це питання для списку розсилки. Який випадок використання вимагатиме від вас _run_onceв першу чергу?
Блендер

8
Але це насправді не відповідає на моє запитання. Як би ви вирішили будь-яку корисну проблему за допомогою просто _run_once? asyncioє складним і має свої недоліки, але будь ласка, тримайте дискусію цивільною. Не бідайте розробникам за кодом, який ви самі не розумієте.
Блендер

1
@ user8371915 Якщо ви вважаєте, що я нічого не висвітлював, ви можете додати або прокоментувати мою відповідь.
Bharel

Відповіді:


203

Як працює асинціо?

Перш ніж відповісти на це запитання, ми повинні зрозуміти кілька базових термінів, пропустити їх, якщо ви вже знаєте будь-який з них.

Генератори

Генератори - це об'єкти, які дозволяють призупинити виконання функції пітона. Користувачі, підготовлені користувачем, реалізуються за допомогою ключового слова yield. Створюючи звичайну функцію, що містить yieldключове слово, ми перетворюємо цю функцію в генератор:

>>> def test():
...     yield 1
...     yield 2
...
>>> gen = test()
>>> next(gen)
1
>>> next(gen)
2
>>> next(gen)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

Як бачите, виклик next()генератора змушує інтерпретатор завантажувати кадр тесту і повертає yieldзначення ed. next()Знову дзвонивши , змушуйте кадр знову завантажуватися в стек перекладача і продовжуйте використовувати yieldінше значення.

До третього часу next()викликали, наш генератор був закінчений, і StopIterationйого кинули.

Спілкування з генератором

Менш відомою особливістю генераторів є той факт, що ви можете спілкуватися з ними двома методами: send()і throw().

>>> def test():
...     val = yield 1
...     print(val)
...     yield 2
...     yield 3
...
>>> gen = test()
>>> next(gen)
1
>>> gen.send("abc")
abc
2
>>> gen.throw(Exception())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 4, in test
Exception

Після виклику gen.send()значення передається у вигляді зворотного значення з yieldключового слова.

gen.throw()з іншого боку, дозволяє кидати винятки всередині генераторів, за винятком, піднятим на тому самому місці yield.

Повернення значень від генераторів

Повертаючи значення з генератора, це призводить до того, що значення ставиться всередині StopIterationвинятку. Пізніше ми можемо відновити значення з винятку і використовувати його для наших потреб.

>>> def test():
...     yield 1
...     return "abc"
...
>>> gen = test()
>>> next(gen)
1
>>> try:
...     next(gen)
... except StopIteration as exc:
...     print(exc.value)
...
abc

Ось нове ключове слово: yield from

Python 3.4 прийшов з додаванням нового ключового слова: yield from. Те , що це ключове слово дозволяє нам зробити, це пройти по будь-якому next(), send()і throw()в внутреннепрізматіческій найбільш вкладений генератор. Якщо внутрішній генератор повертає значення, це також повернене значення yield from:

>>> def inner():
...     inner_result = yield 2
...     print('inner', inner_result)
...     return 3
...
>>> def outer():
...     yield 1
...     val = yield from inner()
...     print('outer', val)
...     yield 4
...
>>> gen = outer()
>>> next(gen)
1
>>> next(gen) # Goes inside inner() automatically
2
>>> gen.send("abc")
inner abc
outer 3
4

Я написав статтю для подальшої деталізації цієї теми.

Збираючи все це разом

Ввівши нове ключове слово yield fromв Python 3.4, ми змогли створити генератори всередині генераторів, які так само, як і в тунелі, передають дані туди-назад від самого внутрішнього до найбільшого зовнішнього генераторів. Це породило нове значення для генераторів - спрощення .

Супроводи - це функції, які можна зупинити та відновити під час виконання. У Python вони визначаються за допомогою async defключового слова. Як і генератори, вони теж використовують власну форму, yield fromяка є await. До asyncі awaitбули введені в Python 3.5, ми створили співпрограми в точно так же, генераторах були створені (з yield fromзамість await).

async def inner():
    return 1

async def outer():
    await inner()

Як і кожен ітератор або генератор, який реалізує __iter__()метод, виконуються спрощення, __await__()що дозволяє продовжувати їх щоразу await coro.

Всередині файлів Python є хороша діаграма послідовностей, яку ви повинні перевірити.

У асинціо, окрім функцій кореневих програм, ми маємо 2 важливих об’єкти: завдання та ф'ючерси .

Ф'ючерси

Майбутні - це об'єкти, у яких __await__()реалізований метод, і їхня робота полягає в утриманні певного стану та результату. Стан може бути одним із наступних:

  1. ПЕЧЕННЯ - майбутнє не встановлює жодного результату чи винятку.
  2. CANCELED - використання майбутніх було скасовано fut.cancel()
  3. ЗАКОНЧЕНО - майбутнє було закінчено або за допомогою набору результатів, fut.set_result()або за допомогою набору винятків із використаннямfut.set_exception()

Результат, як і ви здогадалися, може бути або об'єктом Python, який буде повернуто, або винятком, який може бути підвищений.

Ще одна важлива особливість futureоб’єктів - це те, що вони містять метод, який називається add_done_callback(). Цей метод дозволяє викликати функції, як тільки завдання виконано - чи підняли виняток, чи закінчили.

Завдання

Об'єкти завдань - це спеціальні ф'ючерси, які обертаються навколо супротивів і спілкуються з внутрішніми і зовнішніми найбільше корутинами. Кожен раз, коли програма працює awaitз майбутнім, майбутнє передається повністю до завдання (як і в yield from), і завдання його отримує.

Далі завдання пов'язує себе з майбутнім. Це робиться, закликаючи add_done_callback()майбутнє. Відтепер, якщо майбутнє коли-небудь буде здійснено, або його скасовуючи, передаючи виняток або передаючи об'єкт Python в результаті, буде викликано зворотний виклик завдання, і він повернеться до існування.

Асинціо

Останнім актуальним питанням, на яке ми повинні відповісти, є - як реалізується IO?

Заглибившись в асинціо, у нас є цикл подій. Петля подій завдань. Завдання циклу подій - викликати завдання щоразу, коли вони готові, і координувати всі ці зусилля в одну робочу машину.

Частина циклу подій IO побудована на одній важливій функції, яка називається select. Select - це функція блокування, реалізована під операційною системою внизу, яка дозволяє чекати в сокетах для вхідних або вихідних даних. Після отримання даних він прокидається і повертає розетки, які отримали дані, або сокети, готові до запису.

Коли ви намагаєтеся отримувати або надсилати дані через сокет через асинціо, то, що насправді відбувається нижче, - це спочатку перевірка сокета, чи є в ньому дані, які можна негайно прочитати або надіслати. Якщо його .send()буфер заповнений або .recv()буфер порожній, сокет реєструється у selectфункції (просто додавши його до одного зі списків, rlistза recvі wlistдля send), а відповідна функція - awaitновостворений futureоб'єкт, прив’язаний до цього сокета.

Коли всі доступні завдання чекають ф'ючерсів, цикл подій дзвонить selectі чекає. Коли в одному з сокетів є вхідні дані або його sendбуфер вичерпаний, асинчо перевіряє майбутній об'єкт, прив'язаний до цього сокета, і встановлює його на виконане.

Тепер вся магія відбувається. Майбутнє буде виконано, завдання, яке додалося раніше add_done_callback(), повертається до життя, і закликає .send()до програми, яка відновлює саму внутрішню програму (через awaitланцюг), і ви читаєте нещодавно отримані дані з сусіднього буфера було розлито до.

Знову ланцюг методів у випадку recv():

  1. select.select чекає.
  2. Готовий гніздо з даними повертається.
  3. Дані з сокета переміщуються в буфер.
  4. future.set_result() це називається.
  5. Завдання, яке додало себе add_done_callback(), тепер прокинулося.
  6. Завдання викликає .send()підпрограму, яка проникає в саму внутрішню програму і пробуджує її.
  7. Дані зчитуються з буфера і повертаються нашому скромному користувачеві.

Підсумовуючи це, asyncio використовує можливості генератора, які дозволяють призупиняти та відновити функції. Він використовує yield fromможливості, що дозволяють передавати дані туди-назад від внутрішнього самого генератора до зовнішнього самого. Він використовує всі ці для того, щоб зупинити виконання функції, поки він чекає завершення IO (за допомогою функції ОС select).

І найкраще? Хоча одна функція призупинена, інша може працювати і переплутатись з делікатною тканиною, яка є асинчо.


12
Якщо потрібно більше пояснень, не соромтесь прокоментувати. До речі, я не зовсім впевнений, чи повинен я писати це як статтю в блозі або відповідь у stackoverflow. На питання довго відповісти.
Bharel

1
У асинхронному сокеті, намагаючись надсилати або приймати дані, спочатку перевіряє буфер ОС. Якщо ви намагаєтеся отримати та немає даних у буфері, основна функція прийому поверне значення помилки, яке поширюватиметься як виняток у Python. Те саме з відправленням і повним буфером. Коли виняток піднімається, Python в свою чергу надсилає ці сокети до функції вибору, яка призупиняє процес. Але це не так, як працює асинціо, це те, як вибір і сокети працюють, що також дуже специфічно для ОС.
Бхарел

2
@ user8371915 Завжди тут, щоб допомогти :-) Майте на увазі, що для розуміння Асинчо ви повинні знати, як працюють і працюють генератори, комунікація генератора yield from. Однак я зверху зауважив, що це можна пропустити, якщо читач уже знає про це :-) Що ще ви вважаєте, що мені слід додати?
Bharel

2
Речі перед розділом " Асинсіо ", мабуть, є найбільш критичними, оскільки це єдине, що мова насправді робить сама. Це selectможе також бути кваліфікованим, оскільки це те, як неблокуючі системні виклики вводу / виводу працюють на ОС. Фактичні asyncioконструкції та цикл подій - лише код на рівні програми, побудований із цих речей.
МістерМіягі

3
У цій публікації є інформація про основу асинхронного вводу / виводу в Python. Дякую за таке ласкаве пояснення.
mjkim

83

Говорити про це 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

Коли ви запускаєте це, це означає

  1. виділити стековий простір для barіqux
  2. рекурсивно виконувати перше твердження та переходити до наступного
  3. раз на a return, натисніть його значення на стек виклику
  4. очистити стек (1.) та вказівник інструкції (2.)

Зокрема, 4. означає, що підпрограма завжди починається в одному стані. Все, що виключно для самої функції, втрачається після завершення. Функцію неможливо відновити, навіть якщо після цього є інструкції return.

root -\
  :    \- subfoo --\
  :/--<---return --/
  |
  V

1.2. Супроводи як стійкі підпрограми

Спрограма є як підпрограма, але може вийти, не руйнуючи її стану. Розглянемо наступну процедуру:

 def cofoo(bar):
      qux = yield bar  # yield marks a break point
      return qux

Коли ви запускаєте це, це означає

  1. виділити стековий простір для barіqux
  2. рекурсивно виконувати перше твердження та переходити до наступного
    1. раз на a yield, натисніть його значення на стек виклику, але зберігайте стек та вказівку на інструкції
    2. щойно зателефонувавши yield, відновіть стек та покажчик інструкцій та натисніть аргументи наqux
  3. раз на a return, натисніть його значення на стек виклику
  4. очистити стек (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. сортуйте супроводи за бажаним часом пробудження
  2. виберіть перше, що хоче прокинутися
  3. зачекайте до цього моменту
  4. запустіть цю програму
  5. повторити з 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


Використання yield selfв AsyncSleep дає мені Task got back yieldпомилку, чому це так? Я бачу, що код в asyncio.Futures використовує це. Використання голого врожаю працює чудово.
Рон Серруя

1
У циклі подій зазвичай очікуються лише власні події. Зазвичай ви не можете змішувати події та петлі подій у бібліотеках; показані тут події працюють лише із показаним циклом подій. Зокрема, асинціо використовує лише None (тобто голий вихід) як сигнал для циклу подій. Події безпосередньо взаємодіють із об’єктом циклу подій для реєстрації пробуджень.
MisterMiyagi

12

Ваше coroзневоднення є концептуально правильним, але трохи неповним.

awaitне призупиняється беззастережно, але лише якщо він стикається з блокувальним викликом. Звідки відомо, що дзвінок блокується? Це вирішується кодом, який очікується. Наприклад, очікувана реалізація зчитування сокета може бути обмежена таким чином:

def read(sock, n):
    # sock must be in non-blocking mode
    try:
        return sock.recv(n)
    except EWOULDBLOCK:
        event_loop.add_reader(sock.fileno, current_task())
        return SUSPEND

У реальному асинціо еквівалентний код змінює стан Futureзамість повернення магічних значень, але поняття те саме. Якщо це належним чином адаптовано до об'єкта, подібного до генератора, вищевказаний код можна awaitредагувати.

Що стосується абонента, коли ваша програма містить:

data = await read(sock, 1024)

Це дегустарує щось наближене до:

data = read(sock, 1024)
if data is SUSPEND:
    return SUSPEND
self.pos += 1
self.parts[self.pos](...)

Люди, знайомі з генераторами, як правило, описують вищесказане, з точки зору того, yield fromщо підвіска робить автоматично.

Ланцюг підвіски триває аж до циклу подій, який помічає, що супровід призупинено, видаляє його з набору для запуску та продовжує виконувати супроводи, які можна виконати, якщо такі є. Якщо підпрограми не виконуються, цикл чекає, select()поки будь-який дескриптор файлу, який зацікавлений у програмі, не стане готовим до IO. (Цикл подій підтримує відображення файлу-дескриптора до корекції.)

У наведеному вище прикладі, як тільки select()каже цикл подій, який sockможна прочитати, він буде знову coroдоданий до набору, який можна виконати, тому він буде продовжений з точки призупинення.

Іншими словами:

  1. Усе відбувається в одній темі за замовчуванням.

  2. Цикл подій несе відповідальність за планування процедур і пробудження їх, коли все, що їх чекає (як правило, виклик вводу-виводу, який зазвичай блокується, або тайм-аут) стає готовим.

Для ознайомлення з циклами подій, що керують процедурами, я рекомендую цю розмову Дейва Бізлі, де він демонструє кодування циклу подій з нуля перед активною аудиторією.


Дякую, це ближче до того, що я переживаю, але це все ще не пояснює, чому async.wait_for()не робить те, що належить ... Чому така велика проблема, щоб додати зворотний виклик до циклу подій і сказати це опрацювати скільки завгодно зворотних дзвінків, в тому числі і доданий вами? Моє розчарування asyncioпочасти - за того , що лежить в основі концепція є дуже простий, і, наприклад, Emacs Lisp мала реалізацію вікових груп, без використання слів ... (тобто create-async-processі accept-process-output- і це все , що потрібно ... (продовження)
wvxvw

10
@wvxvw Я зробив скільки завгодно, щоб відповісти на поставлене вами питання, наскільки це навіть можливо, враховуючи, що лише останній абзац містить шість питань. І тому ми продовжуємо - це не те, wait_for що не робить те, що належить (це робить, це супровід, якого ви повинні чекати), це те, що ваші очікування не відповідають тому, що система була розроблена і реалізована. Я думаю, що ваша проблема може бути узгоджена з асинсіо, якби цикл подій працював окремим потоком, але я не знаю деталей вашого випадку використання, і, чесно кажучи, ваше ставлення не дає вам великого задоволення.
user4815162342

5
@wvxvw My frustration with asyncio is in part due to the fact that the underlying concept is very simple, and, for example, Emacs Lisp had implementation for ages, without using buzzwords...- Ніщо не заважає тобі реалізувати цю просту концепцію без модних слів для Python :) Тоді чому ти взагалі використовуєш цю потворну асинцію? Реалізуйте своє з нуля. Наприклад, ви можете почати зі створення власної async.wait_for()функції, яка робить саме те, що вона повинна.
Михайло Герасимов

1
@MikhailGerasimov, здається, ти думаєш, що це риторичне питання. Але я хотів би розвіяти для вас таємницю. Мова покликана говорити іншим. Я не можу вибрати для інших, якою мовою вони говорять, навіть якщо я вважаю, що мова, якою вони говорять, є сміттям, найкраще, що я можу зробити, це спробувати переконати їх у тому. Іншими словами, якби я міг вільно обирати, я б ніколи не вибирав Python для початку, не кажучи вже про те asyncio. Але, в принципі, це не моє рішення приймати. Мене примушують використовувати мову сміття через en.wikipedia.org/wiki/Ultimatum_game .
wvxvw

4

Все це зводиться до двох головних проблем, з якими вирішує асинціо:

  • Як виконати кілька вводу / виводу в одному потоці?
  • Як реалізувати кооперативну багатозадачність?

Відповідь на перший пункт вже давно існує і називається циклом вибору . У python він реалізований у модулі селекторів .

Друге питання пов'язане з поняттям кореневої програми , тобто функцій, які можуть зупинити їх виконання та відновити згодом. У python реалізовані функції з використанням генераторів та вихід із заяви. Ось що ховається за синтаксисом асинхрон / очікування .

Більше ресурсів у цій відповіді .


РЕДАКТУВАННЯ: Адресуючи ваш коментар щодо городунів:

Найближчий еквівалент гороутину в асинціо - це насправді не ко-програма, а завдання (див. Різницю в документації ). У python, посібник (або генератор) нічого не знає про поняття циклу подій або вводу-виводу. Це просто функція, яка може зупинити її виконання yield, зберігаючи поточний стан, і згодом її можна буде відновити. yield fromСинтаксис дозволяє для побудови ланцюжка їх у прозорий спосіб.

Тепер, у рамках завдання з асинсіо, програма в самому низу ланцюга завжди закінчується і дає майбутнє . Потім це майбутнє перетворюється на цикл подій і інтегрується у внутрішню техніку. Коли майбутнє встановлено за допомогою іншого внутрішнього зворотного виклику, цикл подій може відновити завдання, відправивши майбутнє назад у ланцюг кореневих програм.


РЕДАКТУВАННЯ: Виправлення деяких питань у своєму дописі:

Як насправді відбувається введення / виведення у цьому сценарії? В окрему нитку? Чи весь перекладач призупинено, і введення / виведення відбувається поза перекладачем?

Ні, нічого не відбувається в нитці. I / O завжди керується циклом подій, переважно через дескриптори файлів. Однак реєстрація цих дескрипторів файлів, як правило, приховується високопрофільними процедурами, що робить брудну роботу для вас.

Що саме мається на увазі введення / виведення? Якщо моя процедура python називається процедурою C open (), і вона, в свою чергу, надсилає переривання до ядра, відмовляючись від нього контролю, то як інтерпретатор Python знає про це і може продовжувати виконувати інший код, тоді як код ядра робить фактичний I / O і поки вона не прокинеться процедурою Python, яка спочатку надіслала переривання? Як в принципі інтерпретатор Python може знати про це?

I / O - це будь-який дзвінок, що блокує. У системі asyncio всі операції вводу-виводу повинні проходити через цикл подій, тому що, як ви вже сказали, цикл подій не має можливості усвідомлювати, що виклик блокування виконується в якомусь синхронному коді. Це означає, що ви не повинні використовувати синхронний openв контексті програми. Натомість використовуйте виділену бібліотеку таких айофілів, яка забезпечує асинхронну версію open.


Сказати, що супроводи реалізовані за допомогою yield fromнасправді нічого не говорить. yield fromце лише синтаксична конструкція, це не фундаментальний будівельний блок, який можуть виконувати комп'ютери. Аналогічно для циклу вибору. Так, підпрограми Go також використовують цикл select, але те, що я намагався зробити, працювало б у Go, але не в Python. Мені потрібні більш детальні відповіді, щоб зрозуміти, чому це не спрацювало.
wvxvw

Вибачте ... ні, не дуже. "майбутнє", "завдання", "прозорий шлях", "вихід від" - це лише модні слова, вони не є об'єктами з області програмування. програмування має змінні, процедури та структури. Отже, сказати, що "goroutine - це завдання" - це лише кругова заява, яка задає питання. Зрештою, пояснення того asyncio, що для мене, зводиться до коду С, який ілюструє, до чого переведений синтаксис Python.
wvxvw

Для подальшого пояснення, чому ваша відповідь не відповідає на моє запитання: маючи всю надану вами інформацію, я не маю поняття, чому моя спроба з коду, який я опублікував у зв’язаному запитанні, не спрацювала. Я абсолютно впевнений, що я міг написати цикл подій таким чином, щоб цей код спрацював. Насправді це був би спосіб написання циклу подій, якби я мав його написати.
wvxvw

7
@wvxvw Я не згоден. Це не "голосні слова", а концепції високого рівня, що реалізовані у багатьох бібліотеках. Наприклад, завдання на асинціо, гевент-зелений і горутіна відповідають одному і тому ж: одиниці виконання, яка може одночасно працювати в одному потоці. Крім того, я не думаю, що С взагалі потрібен для розуміння асинціо, якщо ви не хочете потрапляти до внутрішніх функцій генераторів пітонів.
Вінсент

@wvxvw Дивіться мою другу редакцію. Це повинно усунути кілька хибних уявлень.
Вінсент
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.