Изящное закрытие асинхронных сопрограмм

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

#!/usr/bin/env python
# -*- coding: UTF-8 -*-
import asyncio
import time
import functools
import signal


class DummyProtocol(asyncio.Protocol):

    def __init__(self, *args, **kwargs):
        self._shutdown = asyncio.Event()
        self._response = asyncio.Queue(maxsize=1)
        super().__init__(*args, **kwargs)

    def connection_made(self, transport):
        self.transport = transport

    def close(self):
        print("Closing protocol")
        self._shutdown.set()

    def data_received(self, data):

        #data = b'OK MPD '

        # Start listening for commands after a successful handshake
        if data.startswith(b'OK MPD '):
            print("Ready for sending commands")
            self._proxy_task = asyncio.ensure_future(self._send_commands())
            return

        # saving response for later consumption in self._send_commands
        self._response.put_nowait(data)

    async def _send_commands(self):

        while not self._shutdown.is_set():

            print("Waiting for commands coming in ...")

            command = None

            # listen for commands coming in from the global command queue. Only blocking 1sec.
            try:
                command = await asyncio.wait_for(cmd_queue.get(), timeout=1)
            except asyncio.TimeoutError:
                continue

            # sending the command over the pipe
            self.transport.write(command)

            # waiting for the response. Blocking until response is complete.
            res = await self._response.get()
            # put it into the global response queue
            res_queue.put_nowait(res)


async def connect(loop):
    c = lambda: DummyProtocol()
    t = asyncio.Task(loop.create_connection(c, '192.168.1.143', '6600'))
    try:
        # Wait for 3 seconds, then raise TimeoutError
        trans, proto = await asyncio.wait_for(t, timeout=3)
        print("Connected to <192.168.1.143:6600>.")
        return proto
    except (asyncio.TimeoutError, OSError) as e:
        print("Could not connect to <192.168.1.143:6600>. Trying again ...")
        if isinstance(e, OSError):
            log.exception(e)


def shutdown(proto, loop):
    # http://stackoverflow.com/a/30766124/1230358
    print("Shutdown of DummyProtocol initialized ...")
    proto.close()
    # give the coros time to finish
    time.sleep(2)

    # cancel all other tasks
    # for task in asyncio.Task.all_tasks():
    #    task.cancel()

    # stopping the event loop
    if loop:
        print("Stopping event loop ...")
        loop.stop()

    print("Shutdown complete ...")    


if __name__ == "__main__":

    loop = asyncio.get_event_loop()

    cmd_queue = asyncio.Queue()
    res_queue = asyncio.Queue()

    dummy_proto = loop.run_until_complete(connect(loop))

    for signame in ('SIGINT','SIGTERM'):
        loop.add_signal_handler(getattr(signal, signame), functools.partial(shutdown, dummy_proto, loop))

    try:
        loop.run_forever()
    except KeyboardInterrupt:
        pass
    finally:
        loop.close()

что дает мне следующий результат, если нажать CTRL-C:

Connected to <192.168.1.143:6600>.
Ready for sending commands
Waiting for commands coming in ...
Waiting for commands coming in ...
Waiting for commands coming in ...
Waiting for commands coming in ...
^CShutdown of DummyProtocol initialized ...
Closing protocol
Stopping event loop ...
Shutdown complete ...
Task was destroyed but it is pending!
task: <Task pending coro=<DummyProtocol._send_commands() running at ./dummy.py:45> wait_for=<Future pending cb=[Task._wakeup()]>>
Task was destroyed but it is pending!
task: <Task pending coro=<Queue.get() running at /usr/local/Cellar/python3/3.5.1/Frameworks/Python.framework/Versions/3.5/lib/python3.5/asyncio/queues.py:168> wait_for=<Future pending cb=[Task._wakeup()]> cb=[_release_waiter(<Future pendi...sk._wakeup()]>)() at /usr/local/Cellar/python3/3.5.1/Frameworks/Python.framework/Versions/3.5/lib/python3.5/asyncio/tasks.py:344]>
Exception ignored in: <generator object Queue.get at 0x10594b468>
Traceback (most recent call last):
  File "/usr/local/Cellar/python3/3.5.1/Frameworks/Python.framework/Versions/3.5/lib/python3.5/asyncio/queues.py", line 170, in get
  File "/usr/local/Cellar/python3/3.5.1/Frameworks/Python.framework/Versions/3.5/lib/python3.5/asyncio/futures.py", line 227, in cancel
  File "/usr/local/Cellar/python3/3.5.1/Frameworks/Python.framework/Versions/3.5/lib/python3.5/asyncio/futures.py", line 242, in _schedule_callbacks
  File "/usr/local/Cellar/python3/3.5.1/Frameworks/Python.framework/Versions/3.5/lib/python3.5/asyncio/base_events.py", line 447, in call_soon
  File "/usr/local/Cellar/python3/3.5.1/Frameworks/Python.framework/Versions/3.5/lib/python3.5/asyncio/base_events.py", line 456, in _call_soon
  File "/usr/local/Cellar/python3/3.5.1/Frameworks/Python.framework/Versions/3.5/lib/python3.5/asyncio/base_events.py", line 284, in _check_closed
RuntimeError: Event loop is closed

Я не очень разбираюсь в асинчио, поэтому я уверен, что здесь что-то не хватает. То, что действительно дает мне головные боли, является частью результата после Shutdown complete .... Начиная с Task was destroyed but it is pending!, я должен признать, что я понятия не имею, что происходит. Я посмотрел на другие вопросы, но не мог заставить его работать. Итак, почему этот код выводит такие вещи, как Task was destroyed but it is pending! aso. и как можно закрыть закрытые сопрограммы?

Благодарим за помощь!

Ответ 1

Что делает Task was destroyed but it is pending! имею в виду?

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

Есть два распространенных способа решить эту проблему:

  1. Вы можете подождать, пока задачи закончатся
  2. Вы можете отменить задачи и ждать, пока они завершены

Asyncio и блокировка синхронных операций

Давай посмотрим на тебя код:

def shutdown(proto, loop):
    print("Shutdown of DummyProtocol initialized ...")
    proto.close()

    time.sleep(2)
    # ...

time.sleep(2) - эта строка не даст сопрограммам время закончиться. Он просто заморозит всю вашу программу на две секунды. Ничего не случится за это время.

Это происходит потому, что ваш цикл обработки событий выполняется в том же процессе, где вы вызываете time.sleep(2). Вы никогда не должны вызывать долго выполняющиеся синхронные операции таким образом в ваших программах asyncio. Пожалуйста, прочитайте этот ответ, чтобы увидеть, как работает асинхронный код.

Как мы можем ждать завершения задач

Давайте попробуем изменить функцию shutdown. Это не асинхронная функция, вы не можете await чего-то внутри нее. Чтобы выполнить некоторый асинхронный код, нам нужно сделать это вручную: остановить текущий запущенный цикл (поскольку он уже запущен), создать некоторую асинхронную функцию для ожидания завершения задач, передать эту функцию для выполнения в цикле обработки событий.

def shutdown(proto, loop):
    print("Shutdown of DummyProtocol initialized ...")

    # Set shutdown event: 
    proto.close()

    # Stop loop:
    loop.stop()

    # Find all running tasks:
    pending = asyncio.Task.all_tasks()

    # Run loop until tasks done:
    loop.run_until_complete(asyncio.gather(*pending))

    print("Shutdown complete ...")    

Вы также можете просто отменить задачи и ждать их завершения. Смотрите этот ответ для деталей.

Где разместить операции по очистке

Я не знаком с сигналами, но вам действительно нужно, чтобы ловить CTRL-C? Всякий раз, когда происходит KeyboardInterrupt, он будет переброшен по строке, где вы запускаете свой цикл обработки событий (в коде вы используете loop.run_forever()). Я могу ошибаться, но общий способ справиться с этой ситуацией - поместить все операции очистки в finally блокировку.

Например, вы можете увидеть, как aiohttp делает aiohttp:

try:
    loop.run_forever()
except KeyboardInterrupt:  # pragma: no branch
    pass
finally:
    srv.close()
    loop.run_until_complete(srv.wait_closed())
    loop.run_until_complete(app.shutdown())
    loop.run_until_complete(handler.finish_connections(shutdown_timeout))
    loop.run_until_complete(app.cleanup())
loop.close()