Корутины

Когда говорят «написать корутину», обычно подразумевают асинхронную функцию. Корутины можно ставить на паузу, чтобы дать другим функциям немного поработать. В этом заключается принцип асинхронности. О нём мы рассказывали в этой статье.

Давайте сразу рассмотрим пример асинхронной функции:

import asyncio

async def count_to_three():
    print("Веду отсчёт. 1")
    await asyncio.sleep(0)
    print("Веду отсчёт. 2")
    await asyncio.sleep(0)
    print("Веду отсчёт. 3")

Очень похоже на обычную функцию, однако здесь есть два новых слова: async и await.

async говорит Питону о том, что мы пишем не просто функцию, а асинхронную функцию. Просто добавили async и всё, функция теперь асинхронная.

Второе слово — await. Оно прерывает исполнение функции, и возвращает управление программой наружу. После этого корутину можно запустить повторно, а затем еще и еще, и каждый раз она будет продолжать работу с того await, на котором прервалась ранее. Например, в функции count_to_three команда await встречается два раза, значит корутину можно вызвать трижды.

Нельзя делать await None или await "Hello, World!". Можно await только то, что так и называют — «awaitable».

await asyncio.sleep(0) — это команда корутине
«Дай поработать другим!»

Сразу покажем, как это выглядит на практике:

coroutine_counter = count_to_three()
print(coroutine_counter)  # <coroutine object count_to_three at 0x7f5a58486a98>
coroutine_counter.send(None)  # Выведет "Веду отсчёт. 1"
coroutine_counter.send(None)  # Выведет "Веду отсчёт. 2"
coroutine_counter.send(None)  # Выведет "Веду отсчёт. 3"

Мы вызываем асинхронную функцию count_to_three, однако она не выводит на экран цифру 1, а возвращает корутину. Все асинхронные функции так делают. Это сделано для того, чтобы у вас был объект этой корутины в переменной. Теперь корутину можно запускать раз за разом, а она раз за разом будет делать кусочек и останавливаться на следующем await.

Чтобы запустить корутину, используют метод send(). При каждом запуске корутины этим методом она продолжает исполняться с последнего await, на котором она остановилась. Поэтому при новом запуске той же корутины срабатывает не тот же print, а следующий.

Нельзя просто .send(). Всегда нужно передавать какое-то значение. Об этом тоже расскажем позже. Пока что воспринимайте .send(None) как команду «продолжи выполнять корутину».

sample text

Когда корутина закончится?

Она остановится навсегда, когда закончатся все await или встретится return. Когда корутина заканчивается — она истощается и вызов .send() выдаёт ошибку:

coroutine_counter = count_to_three()
coroutine_counter.send(None)  # Выведет "Веду отсчёт. 1"
coroutine_counter.send(None)  # Выведет "Веду отсчёт. 2"
coroutine_counter.send(None)  # Выведет "Веду отсчёт. 3"
coroutine_counter.send(None)  # Выбросит ошибку StopIteration

Если мы хотим запустить наш счётчик сначала, придётся создать новую корутину, вызвав count_to_three():

coroutine_counter = count_to_three()
coroutine_counter.send(None)  # Выведет "Веду отсчёт. 1"
coroutine_counter.send(None)  # Выведет "Веду отсчёт. 2"
coroutine_counter_new = count_to_three()
coroutine_counter_new.send(None)  # Снова выведет  "Веду отсчёт. 1", новая корутина

Обычно заранее не известно сколько await будет до момента «истощения», поэтому исключение приходится «перехватывать»:

coroutine_counter = count_to_three()
while True:
    try:
        coroutine_counter.send(None)  # В четвёртый раз здесь вылетит StopIteration
    except StopIteration:
        break

Исключение StopIteration возникает всего один раз. Если после него попробовать запустить корутину ещё раз, то поднимется другое исключение — RuntimeError, и оно уже будет считаться ошибкой. О том как работать с исключениями читайте в статье про try except.

Нельзя запускать истощённую корутину.

Добиваемся асинхронности

С корутинами разобрались, останавливать их научились. А зачем?..

Корутины позволят вашему коду работать асинхронно, т.е. делать несколько вещей одновременно. Допустим, вы решили скачать несколько файлов. Обычный, синхронный код скачивает файлы по-очереди. Сначала первый файл целиком, затем второй, тоже целиком. Асинхронный код качает файлы одновременно, по кусочкам. Приведём пример скачивания двух файлов:

async def download_file(url):
    # здесь происходит какая-то логика со скачиванием файла


image_downloader = download_file('https://www.some-images.com/image1.jpg')
music_downloader = download_file('https://www.music-site.com/artist/album/song5.mp3')

coroutines = [music_downloader, image_downloader]

while True:
    for coroutine in coroutines:
        try:
            coroutine.send(None)
        except StopIteration:
            coroutines.remove(coroutine)
     if len(coroutines) == 0:
        break

Разберём как работает код:

  1. Мы создали 2 корутины: image_downloader и music_downloader. Первая качает картинку по ссылке https://www.some-images.com/image1.jpg, вторая — музыку по ссыке https://www.music-site.com/artist/album/song5.mp3.

  2. Мы положили их в список coroutines

  3. В бесконечном цикле мы по очереди запускаем все корутины из списка. Если вышла ошибка StopIteration — корутина истощилась, т.е. файл скачан. Убираем её из списка, корутина больше запускаться не будет.

  4. Если список с корутинами закончился (его длинна равна нулю), пора заканчивать и бесконечный цикл, потому что все файлы скачаны.

Передать параметры в асинхронную функцию

В плане аргументов асинхронные функции ничем не отличаются от обычных. Доработаем пример со счетчиком и вместо async def count_to_three напишем универсальную функцию async def count:

import asyncio

async def count(limit=3):
    for step in range(1, limit+1):
        print("Веду отсчёт.", step)
        await asyncio.sleep(0)

coroutine = count(5)

while True:
    coroutine.send(None)

Программа выведет:

Веду отсчёт. 1
Веду отсчёт. 2
Веду отсчёт. 3
Веду отсчёт. 4
Веду отсчёт. 5
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
StopIteration