Моделируем тиканье бомбы

В статье про корутины мы разобрали основные понятия, встречающиеся в асинхронном программировании. В этой статье мы воспользуемся этими понятиями для решения конкретной задачи: смоделировать бомбу.

Постановка задачи

Любая бомба делает три вещи:

  • ведёт обратный отсчёт до взрыва
  • издаёт устрашающее «тик-тик»
  • взрывается, когда доходит до 0

Чтобы бомбу смоделировать аккуратно, нам понадобится асинхронное программирование. Бомб может быть несколько, работать они будут независимо друг от друга, и взрываться они будут независимо друг от друга. В общем, самое оно, чтобы углубить понимание корутин.

Тиканье через корутину

Начнём с того, что напишем простую корутину: бомба должна пять раз тикнуть и взорваться.

import asyncio

async def do_ticking():
  amount_of_ticks = 5
  for _ in range(amount_of_ticks):
    print('tick')
    await asyncio.sleep(0)

Ключевое слово async используется, чтобы показать, что функция будет асинхронной. Строка await asyncio.sleep(0) означает «верни управление вызывающей функции до повторного вызова coroutine.send(None)».

Если вы знакомы с итераторами, то заметите: await чем-то напоминает yield . В примере выше прямо хочется написать

# ...
    print('tick')
    await

Но синтаксис не позволит написать просто await. Поэтому после await мы вставляем пустую операцию — асинхронный sleep на 0 секунд.

Теперь включим тиканье бомбы:

ticking = do_ticking()
while True:
  try:
    ticking.send(None)
  except StopIteration:
    break  # корутина истощилась, прерываем event loop
print('boom')

На первой строке мы создаём корутину, дальше вызываем её до истощения и взрываем бомбу. Цикл, в котором мы вызываем асинхронный метод раз за разом, называется event loop.

Но теперь проблема: как вставить интервал длиной в секунду между двумя тиками? Попробуйте сами:

Казалось бы, заменить await asyncio.sleep(0) на await asyncio.sleep(1), и всё должно сработать. На деле мы получаем ошибку:

AssertionError: yield from wasn't used with future

Проблема в том, что мы используем свой event loop, а не тот, который предоставляет нам asyncio. Почему asyncio требует свой event loop? Как sleep вообще может быть асинхронным?

Сейчас мы напишем свою версию команды sleep, совместимую с нашим циклом. Так мы лучше поймём устройство и возможности asyncio.

Создаём Sleep

Итак, создадим awaitable сущность. В документации awaitable определён как «класс, имеющий метод __await__, возвращающий итератор». В коде это выглядит так:

class EventLoopCommand():

    def __await__(self):
        return (yield self)

Наша команда Sleep — это дочерний класс EventLoopCommand. Он хранит количество секунд:

class Sleep(EventLoopCommand):

  def __init__(self, seconds):
      self.seconds = seconds

Теперь мы можем использовать наш собственный Sleep на месте asyncio.sleep:

async def do_ticking():
  amount_of_ticks = 5
  for _ in range(amount_of_ticks):
    print('tick')
    await Sleep(1)

Обрабатываем Sleep

Теперь, когда наша корутина вызывает Sleep, наш event loop должен обработать эту команду. С помощью магии генераторов, наша функция возвращает Sleep после каждого запуска:

  try:
    sleep_command = ticking.send(None)
    print(sleep_command.seconds)
  except StopIteration:
    break

Итак, наш цикл знает, сколько секунд ему надо подождать, перед тем как сделать ещё один тик. Этот же цикл и обеспечит ожидание:

ticking = do_ticking()
iterations_before_tick = 0
while True:
  try:
    if iterations_before_tick <= 0:
      sleep_command = ticking.send(None)
      seconds_to_sleep = sleep_command.seconds
      ticks_to_sleep = convert_seconds_to_iterations(
        seconds_to_sleep
      )
      iterations_before_tick = ticks_to_sleep

    iterations_before_tick -= 1
  except StopIteration:
    break

Этот цикл на каждой итерации проверяет, не пора ли тикнуть ещё раз. Как только «итераций до тика» становится 0, цикл делает тик и отсчитывает заново.

Чтобы углубить понимание алгоритма, добавьте вторую асинхронно тикающую бомбу:

А что же await?

Слово await до сих пор встречалось только в двух местах: в методе __await__ и при вызове await Sleep(1). А всё потому, что оно и нужно в двух местах. С помощью __await__ мы сообщаем объектам класса Sleep, что делать, когда вызывается await Sleep(...). В нашем примере объект класса Sleep возвращает сам себя, поэтому ticking.send(None) возвращает его же.

Наш цикл событий знает, как обрабатывать await Sleep(1), потому что мы прямо написали, как это делать. asyncio.sleep была несовместима с нашим циклом событий потому, что он неправильно её обрабатывает. Для правильной обработки пришлось бы воспользоваться инструментарием asyncio.

Концепция awaitable-объектов тесно связана с механизмом итерирования, о котором мы рассказываем в статье про внутренности итераторов.

Изолируем бомбу

Наконец, модель была бы неполной без качественной декомпозиции. Сейчас она у нас никудышная: бомба тикает в одном месте, а взрывается за пределами цикла. Соберём всё, что относится к бомбе, в одном месте:

async def bang_the_bomd():
  clock = do_ticking()
  await clock
  print('boom')

Обратите внимание, что функция по-прежнему возвращает объект класса Sleep:

Добавляем другие бомбы

Теперь не составит труда добавить несколько бомб:

А чтобы бомбы различались, параметризируем нашу корутину:

Если вы запустите пример выше, то увидите, как одновременно тикают три разные бомбы, и что первая взорвавшаяся останавливает весь цикл.

Обратите внимание, что в коде все бомбы обрабатываются похожим образом. Теперь ничто не мешает сложить их в словарь и упростить код:

waiting_bombs = {
  bang_the_bomd(amount_of_ticks=9, sound='click'): 0,
  bang_the_bomd(amount_of_ticks=5, sound='chick'): 0,
  bang_the_bomd(amount_of_ticks=3): 0,
}

# event loop
while waiting_bombs:
  for bomb in waiting_bombs:
    try:
      if waiting_bombs[bomb] <= 0:
        timeout = bomb.send(None).seconds
        ticks_to_sleep = convert_seconds_to_iterations(timeout)
        waiting_bombs[bomb] = ticks_to_sleep

      waiting_bombs[bomb] -= 1
    except StopIteration:
      del waiting_bombs[bomb]

Альтернативные источники

Если мы не смогли объяснить материал, напишите нам или попробуйте альтернативные источники по теме: