1. Почему так сложно понять asyncio

Асинхронное программирование традиционно относят к темам для "продвинутых". Действительно, у новичков часто возникают сложности с практическим освоением асинхронности. В случае python на то есть весьма веские причины:

  1. Асинхронность в python была стандартизирована сравнительно недавно. Библиотека asyncio появилась впервые в версии 3.5 (то есть в 2015 году), хотя возможность костыльно писать асинхронные приложения и даже фреймворки, конечно, была и раньше. Соответственно у Лутца она не описана, а, как всем известно, "чего у Лутца нет, того и знать не надо".

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

  3. Официальная документация asyncio (разумеется, исчерпывающая и прекрасно организованная) рассчитана скорее на создателей фреймворков, чем на разработчиков пользовательских приложений. Там столько всего — глаза разбегаются. А между тем: "Вам нужно знать всего около семи функций для использования asyncio" (c) Юрий Селиванов, автор PEP 492, в которой были добавлены инструкции async и await

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

Утром меня поднимает с кровати будильник в телефоне. Я когда-то давно поставил его на 8:30 и с тех пор он исправно выполняет свою работу. Чтобы понять когда вставать, мне не нужно таращиться на часы всю ночь напролет. Нет нужды и периодически на них посматривать (скажем, с интервалом в 5 минут). Да я вообще не думаю по ночам о времени, мой мозг занят более интересными задачами — просмотром снов, например. Асинхронная функция "подъем" находится в режиме ожидания. Как только произойдет событие "на часах 8:30", она сама даст о себе знать омерзительным Jingle Bells.

Иногда по выходным мы с собакой выезжаем на рыбалку. Добравшись до берега, я снаряжаю и забрасываю несколько донок с колокольчиками. И... Переключаюсь на другие задачи: разговариваю с собакой, любуюсь красотами природы, истребляю на себе комаров. Я не думаю о рыбе. Задачи "поймать рыбу удочкой N" находятся в режиме ожидания. Когда рыба будет готова к общению, одна из удочек сама даст о себе знать звонком колокольчика.

Будь я автором самого толстого в мире учебника по python, я бы рассказывал читателям про асинхронное программирование уже с первых страниц. Вот только написали "Hello, world!" и тут же приступили к созданию "Hello, asynchronous world!". А уже потом циклы, условия и все такое.

Но при написании этой статьи я все же облегчил себе задачу, предположив, что читатели уже знакомы с основами python и им не придется втолковывать что такое генераторы или менеджеры контекста. А если кто-то не знаком, тогда сейчас самое время ознакомиться.

Пара слов о терминологии

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

Внимание! Все примеры отлажены в консольном python 3.10. Вероятно в ближайших последующих версиях также работать будут. Однако обратной совместимости со старыми версиями не гарантирую. Если у вас что-то пошло не так, попробуйте, установить 3.10 и/или не пользоваться Jupyter.

2. Первое асинхронное приложение

Предположим, у нас есть две функции в каждой из которых есть некая "быстрая" операция (например, арифметическое вычисление) и "медленная" операция ввода/вывода. Детали реализации медленной операции нам сейчас не важны. Будем моделировать ее функцией time.sleep(). Наша задача - выполнить обе задачи как можно быстрее.

Традиционное решение "в лоб":

Пример 2.1

import time


def fun1(x):
    print(x**2)
    time.sleep(3)
    print('fun1 завершена')


def fun2(x):
    print(x**0.5)
    time.sleep(3)
    print('fun2 завершена')


def main():
    fun1(4)
    fun2(4)


print(time.strftime('%X'))

main()

print(time.strftime('%X'))

Никаких сюрпризов - fun2 честно ждет пока полностью отработает fun1 (и быстрая ее часть, и медленная) и только потом начинает выполнять свою работу. Весь процесс занимает 3 + 3 = 6 секунд. Строго говоря, чуть больше чем 6 за счет "быстрых" арифметических операций, но в выбранном масштабе разницу уловить невозможно.

Теперь попробуем сделать то же самое, но в асинхронном режиме. Пока просто запустите предложенный код, подробно мы его разберем чуть позже.

Пример 2.2

import asyncio
import time


async def fun1(x):
    print(x**2)
    await asyncio.sleep(3)
    print('fun1 завершена')


async def fun2(x):
    print(x**0.5)
    await asyncio.sleep(3)
    print('fun2 завершена')


async def main():
    task1 = asyncio.create_task(fun1(4))
    task2 = asyncio.create_task(fun2(4))

    await task1
    await task2


print(time.strftime('%X'))

asyncio.run(main())

print(time.strftime('%X'))

Сюрприз! Мгновенно выполнились быстрые части обеих функций и затем через 3 секунды (3, а не 6!) одновременно появились оба текстовых сообщения. Полное ощущение, что функции выполнились параллельно (на самом деле нет).

А можно аналогичным образом добавить еще одну функцию-соню? Пожалуйста — хоть сто! Общее время выполнения программы будет по-прежнему определяться самой "тормознутой" из них. Добро пожаловать в асинхронный мир!

Что изменилось в коде?

  1. Перед определениями функций появился префикс async. Он говорит интерпретатору, что функция должна выполняться асинхронно.

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

  3. Перед вызовом асинхронных функций появился префикс await. Он говорит интерпретатору примерно следующее: "я тут возможно немного потуплю, но ты меня не жди — пусть выполняется другой код, а когда у меня будет настроение продолжиться, я тебе маякну".

  4. На базе функций мы при помощи asyncio.create_task создали задачи (что это такое разберем позже) и запустили все это при помощи asyncio.run

Как это работает:

  • выполнилась быстрая часть функции fun1

  • fun1 сказала интерпретатору "иди дальше, я посплю 3 секунды"

  • выполнилась быстрая часть функции fun2

  • fun2 сказала интерпретатору "иди дальше, я посплю 3 секунды"

  • интерпретатору дальше делать нечего, поэтому он ждет пока ему маякнет первая проснувшаяся функция

  • на доли миллисекунды раньше проснулась fun1 (она ведь и уснула чуть раньше) и отрапортовала нам об успешном завершении

  • то же самое сделала функция fun2

Замените "посплю" на "пошлю запрос удаленному сервису и буду ждать ответа" и вы поймете как работает реальное асинхронное приложение.

Возможно в других руководствах вам встретится "старомодный" код типа:

Пример 2.3

import asyncio
import time


async def fun1(x):
    print(x**2)
    await asyncio.sleep(3)
    print('fun1 завершена')


async def fun2(x):
    print(x**0.5)
    await asyncio.sleep(3)
    print('fun2 завершена')


print(time.strftime('%X'))

loop = asyncio.get_event_loop()
task1 = loop.create_task(fun1(4))
task2 = loop.create_task(fun2(4))
loop.run_until_complete(asyncio.wait([task1, task2]))

print(time.strftime('%X'))

Результат тот же самый, но появилось упоминание о каком-то непонятном цикле событий (event loop) и вместо одной asyncio.runиспользуются аж три функции: asyncio.wait, asyncio.get_event_loop и asyncio.run_until_complete. Кроме того, если вы используете python версии 3.10+, в консоль прилетает раздражающее предупреждение DeprecationWarning: There is no current event loop, что само по себе наводит на мысль, что мы делаем что-то слегка не так.

Давайте пока руководствоваться Дзен питона: "Простое лучше, чем сложное", а цикл событий сам придет к нам... в свое время.

Пара слов о "медленных" операциях

Как правило, это все, что связано с вводом выводом: получение результата http-запроса, файловые операции, обращение к базе данных.

Однако следует четко понимать: для эффективного использования с asyncio любой медленный интерфейс должен поддерживать асинхронные функции. Иначе никакого выигрыша в производительности вы не получите. Попробуйте использовать в примере 2.2 time.sleep вместо asyncio.sleep и вы поймете о чем я.

Что касается http-запросов, то здесь есть великолепная библиотека aiohttp, честно реализующая асинхронный доступ к веб-серверу. С файловыми операциями сложнее. В Linux доступ к файловой системе по определению не асинхронный, поэтому, несмотря на наличие удобной библиотеки aiofiles, где-то в ее глубине всегда будет иметь место многопоточный "мостик" к низкоуровневым функциям ОС. С доступом к БД примерно то же самое. Вроде бы, последние версии SQLAlchemy поддерживают асинхронный доступ, но что-то мне подсказывает, что в основе там все тот же старый добрый Threadpool. С другой стороны, в веб-приложениях львиная доля задержек относится именно к сетевому общению, так что "не вполне асинхронный" доступ к локальным ресурсам обычно не является бутылочным горлышком.

Внимательные читатели меня поправили в комментах. В Linux, начиная с ядра 5.1, есть полноценный асинхронный интерфейс io_uring и это прекрасно. Кому интересны детали, рекомендую пройти вот сюда.

3. Асинхронные функции и корутины

Теперь давайте немного разберемся с типами. Вернемся к "неасинхронному" примеру 2.1, слегка модифицировав его:

Пример 3.1

import time


def fun1(x):
    print(x**2)
    time.sleep(3)
    print('fun1 завершена')


def fun2(x):
    print(x**0.5)
    time.sleep(3)
    print('fun2 завершена')


def main():
    fun1(4)
    fun2(4)


print(type(fun1))

print(type(fun1(4)))

Все вполне ожидаемо. Функция имеет тип <class 'function'>, а ее результат - <class 'NoneType'>

Теперь аналогичным образом исследуем "асинхронный" пример 2.2:

Пример 3.2

import asyncio
import time


async def fun1(x):
    print(x**2)
    await asyncio.sleep(3)
    print('fun1 завершена')


async def fun2(x):
    print(x**0.5)
    await asyncio.sleep(3)
    print('fun2 завершена')


async def main():
    task1 = asyncio.create_task(fun1(4))
    task2 = asyncio.create_task(fun2(4))

    await task1
    await task2


print(type(fun1))

print(type(fun1(4)))

Уже интереснее! Класс функции не изменился, но благодаря ключевому слову async она теперь возвращает не <class 'NoneType'>, а <class 'coroutine'>. Ничто превратилось в нечто! На сцену выходит новая сущность - корутина.

Что нам нужно знать о корутине? На начальном этапе немного. Помните как в python устроен генератор? Ну, это то, что функция начинает возвращать, если в нее добавить yield вместо return. Так вот, корутина — это разновидность генератора.

Корутина дает интерпретатору возможность возобновить базовую функцию, которая была приостановлена в месте размещения ключевого слова await.

И вот тут начинается терминологическая путаница, которая попила немало крови добрых разработчиков на собеседованиях. Сплошь и рядом корутиной называют саму функцию, содержащую await. Строго говоря, это неправильно. Корутина — это то, что возвращает функция с await. Чувствуете разницу между f и f()?

С генераторами, кстати, та же самая история. Генератором как-то повелось называть функцию, содержащую yield, хотя по правильному-то она "генераторная функция". А генератор — это именно тот объект, который генераторная функция возвращает.

Далее по тексту мы постараемся придерживаться правильной терминологии: асинхронная (или корутинная) функция — это f, а корутина — f(). Но если вы в разговоре назовете корутиной асинхронную функцию, беды большой не произойдет, вас поймут. "Не важно, какого цвета кошка, лишь бы она ловила мышей" (с) тов. Дэн Сяопин

4. Футуры и задачи

Продолжим исследовать нашу программу из примера 2.2. Помнится, на базе корутин мы там создали какие-то загадочные задачи:

Пример 4.1

import asyncio


async def fun1(x):
    print(x**2)
    await asyncio.sleep(3)
    print('fun1 завершена')


async def fun2(x):
    print(x**0.5)
    await asyncio.sleep(3)
    print('fun2 завершена')


async def main():
    task1 = asyncio.create_task(fun1(4))
    task2 = asyncio.create_task(fun2(4))

    print(type(task1))
    print(task1.__class__.__bases__)

    await task1
    await task2


asyncio.run(main())

Ага, значит задача (что бы это ни значило) имеет тип <class '_asyncio.Task'>. Привет, капитан Очевидность!

А кто ваша мама, ребята? А мама наша — анархия какая-то еще более загадочная футура (<class '_asyncio.Future'>).

В asyncio все шиворот-навыворот, поэтому сначала выясним что такое футура (которую мы видим впервые в жизни), а потом разберемся с ее дочкой задачей (с которой мы уже имели честь познакомиться в предыдущем разделе).

Футура (если совсем упрощенно) — это оболочка для некой асинхронной сущности, позволяющая выполнять ее "как бы одновременно" с другими асинхронными сущностями, переключаясь от одной сущности к другой в точках, обозначенных ключевым словом await

Кроме того футура имеет внутреннюю переменную "результат", которая доступна через .result() и устанавливается через .set_result(value). Пока ничего не надо делать с этим знанием, оно пригодится в дальнейшем.

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

Задача — это частный случай футуры, предназначенный для оборачивания корутины.

Все трагически усложняется

Вернемся к примеру 2.2 и опишем его логику заново, используя теперь уже знакомые нам термины — корутины и задачи:

  • корутину асинхронной функции fun1 обернули задачей task1

  • корутину асинхронной функции fun2 обернули задачей task2

  • в асинхронной функции main обозначили точку переключения к задаче task1

  • в асинхронной функции main обозначили точку переключения к задаче task2

  • корутину асинхронной функции main передали в функцию asyncio.run

Бр-р-р, ужас какой... Воистину: "Во многой мудрости много печали; и кто умножает познания, умножает скорбь" (Еккл. 1:18)

Все счастливо упрощается

А можно проще? Ведь понятие корутина нам необходимо, только чтобы отличать функцию от результата ее выполнения. Давайте попробуем временно забыть про них. Попробуем также перефразировать неуклюжие "точки переключения" и вот эти вот все "обернули-передали". Кроме того, поскольку asyncio.run — это единственная рекомендованная точка входа в приложение для python 3.8+, ее отдельное упоминание тоже совершенно излишне для понимания логики нашего приложения.

А теперь (барабанная дробь)... Мы вообще уберем из кода все упоминания об асинхронности. Я понимаю, что работать не будет, но все же давайте посмотрим что получится:

Пример 4.2 (не работающий)

def fun1(x):
    print(x**2)
    
    # запустили ожидание
    sleep(3)
    
    print('fun1 завершена')


def fun2(x):
    print(x**0.5)
    
    # запустили ожидание
    sleep(3)
    
    print('fun2 завершена')


def main():
    # создали конкурентную задачу из функции fun1
    task1 = create_task(fun1(4))
    
    # создали конкурентную задачу из функции fun2
    task2 = create_task(fun2(4))

    # запустили задачу task1 
    task1
    
    # запустили task2
    task2


main()

Кощунство, скажете вы? Нет, я всего лишь честно выполняю рекомендацию великого и ужасного Гвидо ван Россума:

"Прищурьтесь и притворитесь, что ключевых слова async и await нет"

Звучит почти как: "Наденьте зеленые очки и притворитесь, что стекляшки — это изумруды"

Итак, в "прищуренной вселенной Гвидо":

Задачи — это "ракеты-носители" для конкурентного запуска "боеголовок"-функций.

А если вообще без задач?

Как это? Ну вот так, ни в какие задачи ничего не заворачивать, а просто эвейтнуть в main() сами корутины. А что, имеем право!

Пробуем:

Пример 4.3 (неудачный)

import asyncio
import time


async def fun1(x):
    print(x**2)
    await asyncio.sleep(3)
    print('fun1 завершена')


async def fun2(x):
    print(x**0.5)
    await asyncio.sleep(3)
    print('fun2 завершена')


async def main():
    await fun1(4)
    await fun2(4)


print(time.strftime('%X'))

asyncio.run(main())

print(time.strftime('%X'))

Грусть-печаль... Снова 6 секунд как в давнем примере 1.1, ни разу не асинхронном. Боеголовка без ракеты взлетать отказалась.

Вывод:

В asyncio.run нужно передавать асинхронную функцию с эвейтами на задачи, а не на корутины. Иначе не взлетит. То есть работать-то будет, но сугубо последовательно, без всякой конкурентности.

Пара слов о конкурентности

С точки зрения разработчика и (особенно) пользователя конкурентное выполнение в асинхронных и многопоточных приложениях выглядит почти как параллельное. На самом деле никакого параллельного выполнения чего бы то ни было в питоне нет и быть не может. Кто не верит — погулите аббревиатуру GIL. Именно поэтому мы используем осторожное выражение "конкурентное выполнение задач" вместо "параллельное".

Нет, конечно, если очень хочется настоящего параллелизма, можно запустить несколько интерпретаторов python одновременно (библиотека multiprocessing фактически так и делает). Но без крайней нужды лучше такими вещами не заниматься, ибо издержки чаще всего будут непропорционально велики по сравнению с профитом.

А что есть "крайняя нужда"? Это приложения-числодробилки. В них подавляющая часть времени выполнения расходуется на операции процессора и обращения к памяти. Никакого ленивого ожидания ответа от медленной периферии, только жесткий математический хардкор. В этом случае вас, конечно, не спасет ни изящная асинхронность, ни неуклюжая мультипоточность. К счастью, такие негуманные приложения в практике веб-разработки встречаются нечасто.

5. Асинхронные менеджеры контекста и настоящее асинхронное приложение

Пришло время написать на asyncio не тупой перебор неблокирующих слипов, а что-то выполняющее действительно осмысленную работу. Но прежде чем приступить, разберемся с асинхронными менеджерами контекста.

Если вы умеете работать с обычными менеджерами контекста, то без труда освоите и асинхронные. Тут используется знакомая конструкция with, только с префиксом async, и те же самые контекстные методы, только с буквой a в начале.

Пример 5.1

import asyncio


# имитация  асинхронного соединения с некой периферией
async def get_conn(host, port):
    class Conn:
        async def put_data(self):
            print('Отправка данных...')
            await asyncio.sleep(2)
            print('Данные отправлены.')

        async def get_data(self):
            print('Получение данных...')
            await asyncio.sleep(2)
            print('Данные получены.')

        async def close(self):
            print('Завершение соединения...')
            await asyncio.sleep(2)
            print('Соединение завершено.')

    print('Устанавливаем соединение...')
    await asyncio.sleep(2)
    print('Соединение установлено.')
    return Conn()


class Connection:
    # этот конструктор будет выполнен в заголовке with
    def __init__(self, host, port):
        self.host = host
        self.port = port

    # этот метод будет неявно выполнен при входе в with
    async def __aenter__(self):
        self.conn = await get_conn(self.host, self.port)
        return self.conn

    # этот метод будет неявно выполнен при выходе из with
    async def __aexit__(self, exc_type, exc, tb):
        await self.conn.close()


async def main():
    async with Connection('localhost', 9001) as conn:
        send_task = asyncio.create_task(conn.put_data())
        receive_task = asyncio.create_task(conn.get_data())

        # операции отправки и получения данных выполняем конкурентно
        await send_task
        await receive_task


asyncio.run(main())

Создавать свои асинхронные менеджеры контекста разработчику приложений приходится нечасто, а вот использовать готовые из асинхронных библиотек — постоянно. Поэтому нам полезно знать, что находится у них внутри.

Теперь, зная как работают асинхронные менеджеры контекста, можно написать ну очень полезное приложение, которое узнает погоду в разных городах при помощи библиотеки aiohttp и API-сервиса openweathermap.org:

Пример 5.2

import asyncio
import time
import aiohttp


async def get_weather(city):
    async with aiohttp.ClientSession() as session:
        url = f'http://api.openweathermap.org/data/2.5/weather' \
              f'?q={city}&APPID=2a4ff86f9aaa70041ec8e82db64abf56'

        async with session.get(url) as response:
            weather_json = await response.json()
            print(f'{city}: {weather_json["weather"][0]["main"]}')


async def main(cities_):
    tasks = []
    for city in cities_:
        tasks.append(asyncio.create_task(get_weather(city)))

    for task in tasks:
        await task


cities = ['Moscow', 'St. Petersburg', 'Rostov-on-Don', 'Kaliningrad', 'Vladivostok',
          'Minsk', 'Beijing', 'Delhi', 'Istanbul', 'Tokyo', 'London', 'New York']

print(time.strftime('%X'))

asyncio.run(main(cities))

print(time.strftime('%X'))

"И говорит по радио товарищ Левитан: в Москве погода ясная, а в Лондоне — туман!" (c) Е.Соев

Кстати, ключик к API дарю, пользуйтесь на здоровье.

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

Опрос 12-ти городов на моем канале 100Mb занимает доли секунды.

Обратите внимание, мы использовали два вложенных менеджера контекста: для сессии и для функции get. Так требует документация aiohttp, не будем с ней спорить.

Давайте попробуем реализовать тот же самый функционал, используя классическую синхронную библиотеку requests и сравним скорость:

Пример 5.3

import time
import requests


def get_weather(city):
    url = f'http://api.openweathermap.org/data/2.5/weather' \
          f'?q={city}&APPID=2a4ff86f9aaa70041ec8e82db64abf56'

    weather_json = requests.get(url).json()
    print(f'{city}: {weather_json["weather"][0]["main"]}')


def main(cities_):
    for city in cities_:
        get_weather(city)


cities = ['Moscow', 'St. Petersburg', 'Rostov-on-Don', 'Kaliningrad', 'Vladivostok',
          'Minsk', 'Beijing', 'Delhi', 'Istanbul', 'Tokyo', 'London', 'New York']

print(time.strftime('%X'))

main(cities)

print(time.strftime('%X'))

Работает превосходно, но... В среднем занимает 2-3 секунды, то есть раз в 10 больше чем в асинхронном примере. Что и требовалось доказать.

А может ли асинхронная функция не просто что-то делать внутри себя (например, запрашивать и выводить в консоль погоду), но и возвращать результат? Ту же погоду, например, чтобы дальнейшей обработкой занималась функция верхнего уровня main().

Нет ничего проще. Только в этом случае для группового запуска задач необходимо использовать уже не цикл с await, а функцию asyncio.gather

Давайте попробуем:

Пример 5.4

import asyncio
import time
import aiohttp


async def get_weather(city):
    async with aiohttp.ClientSession() as session:
        url = f'http://api.openweathermap.org/data/2.5/weather' \
              f'?q={city}&APPID=2a4ff86f9aaa70041ec8e82db64abf56'

        async with session.get(url) as response:
            weather_json = await response.json()
            return f'{city}: {weather_json["weather"][0]["main"]}'


async def main(cities_):
    tasks = []
    for city in cities_:
        tasks.append(asyncio.create_task(get_weather(city)))

    results = await asyncio.gather(*tasks)

    for result in results:
        print(result)


cities = ['Moscow', 'St. Petersburg', 'Rostov-on-Don', 'Kaliningrad', 'Vladivostok',
          'Minsk', 'Beijing', 'Delhi', 'Istanbul', 'Tokyo', 'London', 'New York']

print(time.strftime('%X'))

asyncio.run(main(cities))

print(time.strftime('%X'))

Красиво получилось! Обратите внимание, мы использовали выражение со звездочкой *tasks для распаковки списка задач в аргументы функции asyncio.gather.

Пара слов о лишних сущностях

Кажется, я совершил невозможное. Настучал уже почти тысячу строк текста и ни разу не упомянул о цикле событий. Ну, почти ни разу. Один раз все-же упомянул: в примере 2.3 "как не надо делать". А между тем, в традиционных руководствах по asyncio этим самым циклом событий начинают душить несчастного читателя буквально с первой страницы. На самом деле цикл событий в наших программах присутствует, но он надежно скрыт от посторонних глаз высокоуровневыми конструкциями. До сих пор у нас не возникало в нем нужды, вот и я и не стал плодить лишних сущностей, руководствуясь принципом дорогого товарища Оккама.

Но вскоре жизнь заставит нас извлечь этот скелет из шкафа и рассмотреть его во всех подробностях.

Продолжение следует...

Комментарии (42)


  1. Reversaidx
    25.05.2022 00:13

    Очень познавательно, спасибо!


  1. Megakazbek
    25.05.2022 00:30
    +19

    Лично у меня трудность возникает не в том, что это какой-то сложный механизм, а банально в том, что нужны специальные асинхронные функции, для большинства штук асинхронных библиотек нет, в итоге никакой пользы получить от этой фичи нельзя, а более практичным оказывается тупо multiprocessing.


    1. Vindicar
      25.05.2022 01:24
      +6

      Ну справедливости ради, из коробки есть механизмы, позволяющие легко завернуть поток/процесс в асинхронную задачу. Так что вполне можно спрятать один «неудобный» тип операций в мультипроцессинг, и пользоваться асинхронщиной для остального.


    1. Tw1nkleToes
      26.05.2022 13:21

      для большинства штук асинхронных библиотек нет

      Для какого большинства и каких штук?

      Есть огромное кол-во библиотек на 99% кейсов с I/O с которыми можно столкнуться в повседневной работе (БД, htpp клиент/сервер, сокеты)

      Чего ещё не хватает web-разработчику для счастья?


  1. DistortNeo
    25.05.2022 02:26
    +9

    В Linux доступ к файловой системе по определению не асинхронный

    Уже нет. Недавно эта проблема была решена с помощью io_uring.


    1. funca
      25.05.2022 10:37
      +1

      Там есть ещё древнючий aio (aka POSIX asynchronous I/O) - в питоне через caio / aiofile.


  1. amarao
    25.05.2022 09:39
    +1

    В Linux доступ к файловой системе по определению не асинхронный,

    В линуксе много разных методов. Вы говорите про posix, который Линукс (большей частью) поддерживает. Про io_uring выше комментарий.


  1. shirvash
    25.05.2022 10:04
    +4

    Было бы здорово добавить после функций то, что выводилось в консоль. Особенно важно видеть время выполнения функций.


    1. vlakir Автор
      25.05.2022 15:05

      В учебном материале нецелесообразно, я считаю. При знакомстве с конкурентными вычислениями важно видеть процесс в динамике: что отработало быстро, что притормозилось, а что выполнилось как-бы одновременно. Если расставить повсюду временные метки и выводить в консоль тонны текста, то вся это мерцающая красота исчезнет) Ну а так да, в реальном приложении организуйте логирование и спокойно анализируйте бенчмарки постфактум.


      1. shirvash
        26.05.2022 17:02
        +1

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

        А так, статья очень полезная. Спасибо большое, жду продолжение!


        1. vlakir Автор
          26.05.2022 23:27

          Вот именно этого я и добивался) Не запуская код невозможно чему-то научиться. Удачи!


  1. Surgeon76
    25.05.2022 10:42

    Отличная статья! Прочёл с удовольствием!


  1. danilovmy
    25.05.2022 10:53

    зануда_mode = on

    Пример 2.3

    используются три функции библиотеки asyncio вместо одной run в предыдущем примере.

    в примере 2.2 используются asyncio.create_task, asyncio.run, asyncio.sleep. Вероятно, подразумевалось, что все убрано в функции и в главной функции вызывается только одна функция asyncio.

    зануда_mode = off

    вопрос по тексту, можно ли в Пример 5.2?

    async def main(cities_):
      for city in cities_:
        await asyncio.create_task(get_weather(city))

    и если да, то можно ли:

    async def main(cities_):
      yield from (await asyncio.create_task(get_weather(city)) for city in cities)

    но, поскольку, возвращаем "Task" зачем тогда async в main

    def main(cities_):
      yield from (asyncio.create_task(get_weather(city)) for city in cities)

    Или я что-то упустил?


    1. Healer
      25.05.2022 11:42

      зануда_mode = onКод приведён в тексте, можно и попробовать самому.зануда_mode = off

      Первое выполнится синхронно. Второе и третье не выполнится "yield from" not allowed in an async function, "AsyncGenerator[None, None]" is not iterable


      1. danilovmy
        25.05.2022 14:01

        попробовал сам. То, что я хотел, не работает без gather

        async def main(cities_):
            await asyncio.gather(*(asyncio.create_task(get_weather(city)) for city in cities_))

        жаль, что gather не жрет нераспакованные генераторы.


  1. YAKOROLEVAZAMKA
    25.05.2022 11:16

    Статья, вероятно, хорошая (давно хотел погрузиться в асинхронность), но после запуска примера 2.2 я действительно получил сюрприз:

    А после запуска примера 2.3 было принято волевое решение дальше не читать (очень жаль):


    1. vlakir Автор
      25.05.2022 11:20

      У вас какая версия python? Попробуйте 3.10


      1. YAKOROLEVAZAMKA
        25.05.2022 11:29

        Python 3.9.7

        Но это на собственном ноуте, а в проде везде не выше3.6, поэтому, к сожалению, статья про версию 3.10+ для меня мало актуальна) спасибо за ответ


        1. vlakir Автор
          25.05.2022 11:31

          Ну, тут уж ничего не поделаешь. Обратная совместимость - больное место asyncio. Я рассчитываю, что статья проживет долго, поэтому использую конструкции, рекомендованные для самой новой доступной версии питона.

          Учиться вообще лучше на актуальном, мне кажется. Когда набьешь руку, разобраться в старых конструкциях уже не составляет проблемы.


        1. worldmind
          25.05.2022 12:30
          +2

          pyenv вам в руки


    1. ShashkovS
      25.05.2022 11:30
      +2

      asyncio.run появилась в python 3.7 (см. docs.python.org/3/library/asyncio-task.html#asyncio.run)
      До этого нужно было писать

      loop = asyncio.get_event_loop()
      loop.run_until_complete(main())
      loop.close()
      


      Другая возможная проблема — это использование IPython ⩾ 7.0 или соответствующего Jupyter. Там уже запущен свой event loop от ipython'а и нельзя создать новый.
      Соответственно нужно вызывать первую функцию сразу с await'ом
      await main()

      А чтобы получить текущий event loop —
      loop = asyncio.get_running_loop()


      1. YAKOROLEVAZAMKA
        25.05.2022 17:26

        Спасибо, в первом примере помог await main(), во втором -

        import nest_asyncio
        nest_asyncio.apply()

        @EvilsInterrupt

        К сожалению да, Windows :( set_event_loop_policy не помогло


    1. EvilsInterrupt
      25.05.2022 17:03

      Это не вина автора вы используете Windows , а на эту тему есть общеизвестный баг . Попробуйте применить: asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()


    1. maksim_R
      25.05.2022 22:28

      Anaconda (Spyder) сама по себе является асинхронной программой на Питоне. Поэтому внутри неё не вызвать ещё более асинхронный код. О чем и говорит ошибка: cannot be called from running event loop.


    1. TheCellMan
      26.05.2022 04:57

      А вы случайно не в jupyter notebook его запускали?


      1. YAKOROLEVAZAMKA
        26.05.2022 16:18

        конечно в юпитере)

        если бы не юпитер, я бы может никогда и программировать не стал)

        в общем в моём комментарии выше я нашел как убрать ошибки (и даже добиться правильной работы кода), посему статью буду читать дальше)


  1. ShashkovS
    25.05.2022 11:30

  1. aelih
    25.05.2022 11:50

    Автор, вот встретились бы лично - пожал бы руку!!! Только недавно с этим асинх боролся и тут такой подарок! Лучи добра тебе!:)


  1. stgunholy
    25.05.2022 15:16
    +1

    Суперская статья! Действительно самое простое объяснение из тех что я видел


  1. avshkol
    25.05.2022 15:44

    Не понял логики разработчиков библиотеки: зачем нужно каждую функцию отправлять в asyncio.create_task, ведь функция и так объявлена как asinc - увидели, что запускаем asinc -функцию, и выполняйте create_task и всё, что нужно, но под капотом…?


    1. vlakir Автор
      25.05.2022 15:53

      async def my_breakfast():

      print('поставил варить яйцо')

      await asyncio.sleep(300)

      print('съел яйцо')

      #

      async def your_breakfast():

      print('поставил варить яйцо')

      await asyncio.sleep(300)

      print('съел яйцо')

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


      1. avshkol
        25.05.2022 16:00

        Увидев, что мы запускаем acinc- функции fun1 и fun2, они бы сами обернули их в:

            task1 = asyncio.create_task(fun1(4))
            task2 = asyncio.create_task(fun2(4))
        
            await task1
            await task2


        1. vlakir Автор
          25.05.2022 16:09

          То есть, если мы делаем await f(), значит обозначаем точку переключения, а если просто f(), то неявно оборачиваем корутину в задачу? Ну, в принципе такой синтаксический сахар наверно имеет право на жизнь. Становитесь контрибьютором питона и попробуйте реализовать)


          1. funca
            25.05.2022 20:28
            +3

            В JavaScript async / await сделаны жадными как Promise. При вызове async функции автоматически создается задача и отправляется в очередь на исполнение в event loop. await, в свою очередь, просто ждёт результат.

            В питоне асинхронщину задизайнили иначе - лениво.

            Вызов async функции возвращает объект - корутину, - которая ни чего не делает.

            asyncio.run() создаёт event loop, запускает (корневую) корутину и блокирует поток до получения результата.

            await запускает корутину изнутри другой корутины в текущем event loop и ждёт результат.

            Для запуска корутины без ожидания (как это делает Promise) используется asyncio.create_task(coro). Либо asyncio.gather(*aws), если надо запустить сразу несколько. Нужно только следить, чтобы ссылка на возвращаемое значение сохранялась до конца вычисления, иначе его пожрет GC и все оборвется на самом интересном месте (промис бы отработал до конца не смотря ни на что).

            В JS только один event loop, поэтому было вполне разумно закопать его внутрь promise / async / await как деталь реализации, упростив работу прикладному программисту. В питоне отзеркалили более ранний вариант корутин на генераторах, дали возможность использовать разные event loop и выставили все кишки наружу.


    1. funca
      25.05.2022 21:31
      +1

      Feature это адаптер для вычислений, через который с ними взаимодействует event loop. По завершении вычисления в объекте Feature сохраняется его результат (или брошенное на произвол судьбы исключение). В отличие от джаваскриптовых Promise, которые выражают отложенное значение, фичи это процессы вычисления (т.е. их можно принудительно обрывать методом .cancel()).

      Task это адаптер для корутин, представляющий их в виде Feature для отправки в event loop. Промежуточные значения, генерируемые корутиной, отправляются в event loop для дальнейшего вычисления. Результат последнего сохраняется в качестве результата всей задачи.

      Coroutine - можно рассматривать как функцию с многократными выходами и входами. Или по-простому, генератор последовательности значений, где в точках возврата промежуточных значений используется слово await (раньше было yield, и настоящие генераторы asyncio тоже до сих пор поддерживает). Разница в том, что через yield из генератора можно вернуть любое значение, а через await из async функции только awaitable (т.е feature, coro или task) - защита event loop от дурака.


  1. svpcom
    25.05.2022 16:41
    +1

    Столько коментариев, а про twisted даже никто и не вспомнил. Хотя он с в сравнении с asyncio как мерседес с запорожцем. По поводу введения в асинхронное программирование есть отличная статья https://glyph.twistedmatrix.com/2014/02/unyielding.html . Async/await + asyncio хорош тем, что человек ранее писавший только синхронный код легко его может начать писать асинхронный. Но вот дальше будут проблемы с тем, что большинство не понимает в каком контексте этот асинхронный код запускается и что сломается если такой обработчик заблокируется и/или в какой момент передается/возвращается управление в event_loop.

    Ну и в отличии от twisted asyncio это голый event-loop без асинхронной реализации библиотек, протоколов и тд.


    1. funca
      25.05.2022 22:22

      asyncio писали с оглядкой на twisted, чтобы затащить минимальную реализацию низкоуровневых интерфейсов для подобных фреймворков в стандартную библиотеку. Кодить прикладной уровень это вроде как задача для разработчиков сторонних пакетов.


  1. EvilsInterrupt
    25.05.2022 17:11

    @vlakir читаю ваши слова:

    Сплошь и рядом корутиной называют саму функцию, содержащую await. Строго говоря, это неправильно.

    Возможно я что-то неверно понимаю, но офиц. документация в разделе awaitables говорит что

    • coroutine function: an async def function;

    А вот возвращаемый результат именуют:

    • coroutine object: an object returned by calling a coroutine function.

    Да, они помечают это словами "In this documentation " , но мне кажется, что назвать корутиной саму функцию не такая уж и неправильная затея.


    1. Buchachalo
      25.05.2022 17:15

      Ну так автор и написал что с пациентом нечего не случится если обозвать и функцию и ответ корутиной.


  1. alekssamos
    25.05.2022 21:35

    Итак, Пример 2.2.
    Можете объяснить, зачем тогда придумали функцию asyncio gather?
    Типа когда через обычный вызов,
    то сначала выполняется одна строка кода,
    пока она не завершится, вторая не выполнится,
    а начнёт только после завершения первой?
    А при gather эти две строки выполнятся сразу за вдвое меньший промежуток времени (если и у одной, и у второй есть sleep(3) и перейдёт дальше)?


    1. svpcom
      26.05.2022 12:38

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


  1. Denis_Sk
    25.05.2022 22:28

    Статья просто супер. С нетерпением жду продолжения.