Асинхронный ввод-вывод в Python: полное прохождение

Асинхронный ввод-вывод в Python: полное прохождение

Асинхронный ввод-вывод - это дизайн параллельного программирования, который получил специальную поддержку в Python, быстро эволюционируя с Python 3.4 до 3.7 иprobably beyond.

Вы можете думать с ужасом: «Параллелизм, параллелизм, многопоточность, многопроцессорность. Это уже много, чтобы понять. Где подходит асинхронный ввод-вывод?

Этот учебник создан, чтобы помочь вам ответить на этот вопрос, давая вам более четкое представление о подходе Python к асинхронному вводу-выводу.

Вот что вы осветите:

  • Asynchronous IO (async IO): не зависящая от языка парадигма (модель), которая имеет реализации на множестве языков программирования

  • async/await: два новых ключевых слова Python, которые используются для определения сопрограмм

  • asyncio: пакет Python, который предоставляет основу и API для запуска и управления сопрограммами.

Сопрограммы (специализированные функции генератора) - это сердце асинхронного ввода-вывода в Python, и мы углубимся в них позже.

Note: в этой статье я использую терминasync IO для обозначения не зависящего от языка дизайна асинхронного ввода-вывода, аasyncio относится к пакету Python.

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

Free Bonus:5 Thoughts On Python Mastery, бесплатный курс для разработчиков Python, который показывает вам план действий и образ мышления, который вам понадобится, чтобы вывести свои навыки Python на новый уровень.

Настройка вашей среды

Вам понадобится Python 3.7 или более поздняя версия, чтобы полностью прочитать эту статью, а также пакетыaiohttp иaiofiles:

$ python3.7 -m venv ./py37async
$ source ./py37async/bin/activate  # Windows: .\py37async\Scripts\activate.bat
$ pip install --upgrade pip aiohttp aiofiles  # Optional: aiodns

Для получения справки по установке Python 3.7 и настройке виртуальной среды ознакомьтесь сPython 3 Installation & Setup Guide илиVirtual Environments Primer.

С этим давайте начнем.

10000-футовый вид асинхронного ввода-вывода

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

Где Async IO подходит?

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

Parallelism состоит из выполнения нескольких операций одновременно. Multiprocessing - это средство обеспечения параллелизма, которое влечет за собой распределение задач по центральным процессорам компьютера (ЦП или ядрам). Многопроцессорность хорошо подходит для задач, связанных с ЦП: строго связанные циклыfor и математические вычисления обычно попадают в эту категорию.

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

Threading - это модель параллельного выполнения, в которой несколькоthreads по очереди выполняют задачи. Один процесс может содержать несколько потоков. Python имеет сложные отношения с потоками из-за егоGIL, но это выходит за рамки этой статьи.

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

Напомним, что параллелизм включает в себя как многопроцессорность (идеально подходит для задач, связанных с процессором), так и многопоточность (подходит для задач, связанных с вводом-выводом). Многопроцессорная обработка - это форма параллелизма, причем параллелизм является специфическим типом (подмножеством) параллелизма. Стандартная библиотека Python уже давно предлагаетsupport for both of these в своих пакетахmultiprocessing,threading иconcurrent.futures.

Теперь пришло время привлечь нового участника к миксу. За последние несколько лет в CPython был более полно встроен отдельный дизайн: асинхронный ввод-вывод, включенный через пакет стандартной библиотекиasyncio и новые ключевые слова языкаasync иawait. Чтобы быть ясным, асинхронный ввод-вывод не является новой концепцией, и он существовал или встраивается в другие языки и среды выполнения, такие какGo,C# илиScala.

Пакетasyncio оплачивается документацией Python какa library to write concurrent code. Однако асинхронный ввод-вывод не является многопоточным и не является многопроцессорным. Он не построен поверх любого из них.

Фактически, асинхронный ввод-вывод является однопоточным, однопроцессным проектом: в нем используетсяcooperative multitasking, термин, который вы конкретизируете к концу этого руководства. Другими словами, было сказано, что асинхронный ввод-вывод дает чувство параллелизма, несмотря на использование одного потока в одном процессе. Сопрограммы (центральная особенность асинхронного ввода-вывода) могут быть запланированы одновременно, но они не являются одновременно параллельными.

Повторюсь, асинхронный ввод-вывод - это стиль параллельного программирования, но это не параллелизм. Он более тесно связан с многопоточностью, чем с многопроцессорной обработкой, но очень сильно отличается от них обоих и является отдельным элементом в пакете трюков параллелизма.

Это оставляет еще один термин. Что значит бытьasynchronous? Это не точное определение, но для наших целей я могу придумать два свойства:

  • Асинхронные подпрограммы могут «приостановить», ожидая своего конечного результата, и позволить тем временем другим подпрограммам работать.

  • Асинхронный код, благодаря вышеуказанному механизму, облегчает параллельное выполнение. Иными словами, асинхронный код создает впечатление параллелизма.

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

Concurrency versus parallelism

На этом я остановлюсь на сравнении моделей параллельного программирования. Это руководство сфокусировано на подкомпоненте, который является асинхронным вводом-выводом, как его использовать, и на API, которые возникли вокруг него. Для более подробного изучения потоковой передачи, многопроцессорности и асинхронного ввода-вывода остановитесь здесь и посмотритеoverview of concurrency in Python Джима Андерсона. Джим намного смешнее меня и сидел на большем количестве встреч, чем я, притом.

Async IO объяснил

Поначалу асинхронный ввод-вывод может показаться нелогичным и парадоксальным. Как что-то, что облегчает параллельный код, использует один поток и одно ядро ​​ЦП? Я никогда не умел придумывать примеры, поэтому я хотел бы перефразировать один из выступления Мигеля Гринберга на PyCon 2017 года, в котором все довольно красиво объясняется:

Мастер по шахматам Юдит Полгар проводит шахматную выставку, на которой она играет нескольких игроков-любителей. У нее два способа проведения выставки: синхронно и асинхронно.

Предположения:

  • 24 противника

  • Юдит делает каждый шахматный ход за 5 секунд

  • Каждый противник делает 55 секунд, чтобы сделать ход

  • Игры в среднем 30 парных ходов (всего 60 ходов)

Synchronous version: Юдит играет по одной игре, никогда в две одновременно, пока игра не будет завершена. Каждая игра занимает(55 + 5) * 30 == 1800 секунд или 30 минут. Вся выставка занимает24 * 30 == 720 минут или12 hours.

Asynchronous version: Юдит переходит от стола к столу, делая по одному ходу за каждым столом. Она покидает стол и позволяет противнику сделать следующий ход во время ожидания. Один ход во всех 24 партиях занимает у Юдит24 * 5 == 120 секунд, или 2 минуты. Вся выставка теперь сокращена до120 * 30 == 3600 секунд или до1 hour. (Source)с

Есть только одна Юдит Полгар, у которой всего две руки, и она делает только один ход за раз. Но игра в асинхронном режиме сокращает время показа с 12 часов до одного. Таким образом, совместная многозадачность - это причудливый способ сказать, что цикл событий программы (подробнее об этом позже) связывается с несколькими задачами, чтобы каждая из них работала в оптимальное время.

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

Async IO - это не просто

Я слышал, как он сказал: «Используйте асинхронный ввод-вывод, когда можете; используйте многопоточность, когда это необходимо ». Правда в том, что создание надежного многопоточного кода может быть сложным и подверженным ошибкам. Асинхронный ввод-вывод позволяет избежать некоторых потенциальных скачков скорости, с которыми вы могли бы столкнуться при работе с резьбой.

Но это не значит, что асинхронный ввод-вывод в Python прост. Имейте в виду: когда вы рискуете немного опуститься ниже уровня поверхности, асинхронное программирование тоже может быть трудным! Асинхронная модель Python построена на таких понятиях, как обратные вызовы, события, транспорты, протоколы и будущее - только терминология может быть пугающей. Тот факт, что его API постоянно меняется, делает его не легче.

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

Пакетasyncio иasync /await

Теперь, когда у вас есть некоторый опыт асинхронного ввода-вывода в качестве дизайна, давайте рассмотрим реализацию Python. Пакет Pythonasyncio (представленный в Python 3.4) и два его ключевых слова,async иawait, служат разным целям, но объединяются, чтобы помочь вам объявлять, создавать, выполнять и управлять асинхронным кодом.

Синтаксисasync /await и собственные сопрограммы

A Word of Caution: Будьте осторожны с тем, что вы читаете в Интернете. Асинхронный API ввода-вывода Python быстро эволюционировал от Python 3.4 до Python 3.7. Некоторые старые шаблоны больше не используются, а некоторые вещи, которые сначала были запрещены, теперь разрешены через новые введения. Насколько я знаю, этот урок тоже скоро вступит в клуб устаревших.

В основе асинхронного ввода-вывода лежат сопрограммы. Сопрограмма - это специализированная версия функции генератора Python. Давайте начнем с определения базовой линии, а затем будем строить ее по мере продвижения здесь: сопрограмма - это функция, которая может приостановить свое выполнение до достиженияreturn, и она может косвенно передавать управление другой сопрограмме на некоторое время.

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

Давайте возьмем иммерсивный подход и напишем некоторый асинхронный код ввода-вывода. Эта короткая программа представляет собойHello World асинхронного ввода-вывода, но имеет большое значение для иллюстрации ее основных функций:

#!/usr/bin/env python3
# countasync.py

import asyncio

async def count():
    print("One")
    await asyncio.sleep(1)
    print("Two")

async def main():
    await asyncio.gather(count(), count(), count())

if __name__ == "__main__":
    import time
    s = time.perf_counter()
    asyncio.run(main())
    elapsed = time.perf_counter() - s
    print(f"{__file__} executed in {elapsed:0.2f} seconds.")

Когда вы запускаете этот файл, обратите внимание на то, что выглядит иначе, чем если бы вы определяли функции только сdef иtime.sleep():

$ python3 countasync.py
One
One
One
Two
Two
Two
countasync.py executed in 1.01 seconds.

Порядок этого вывода является сердцем асинхронного ввода-вывода. Разговор с каждым из вызововcount() представляет собой отдельный цикл событий или координатора. Когда каждая задача достигаетawait asyncio.sleep(1), функция обращается к циклу обработки событий и возвращает ему управление, говоря: «Я буду спать 1 секунду. Давай, давай пока что сделаем что-то еще значимое ».

Сравните это с синхронной версией:

#!/usr/bin/env python3
# countsync.py

import time

def count():
    print("One")
    time.sleep(1)
    print("Two")

def main():
    for _ in range(3):
        count()

if __name__ == "__main__":
    s = time.perf_counter()
    main()
    elapsed = time.perf_counter() - s
    print(f"{__file__} executed in {elapsed:0.2f} seconds.")

При выполнении происходит небольшое, но критическое изменение порядка и времени выполнения:

$ python3 countsync.py
One
Two
One
Two
One
Two
countsync.py executed in 3.01 seconds.

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

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

Правила асинхронного ввода-вывода

На этом этапе можно дать более формальное определениеasync,await и функций сопрограмм, которые они создают. Этот раздел немного сложен, но использованиеasync /await очень важно, поэтому вернитесь к нему, если вам нужно:

  • Синтаксисasync def вводит либоnative coroutine, либоasynchronous generator. Выраженияasync with иasync for также допустимы, и вы увидите их позже.

  • Ключевое словоawait передает управление функцией обратно в цикл обработки событий. (Он приостанавливает выполнение окружающей сопрограммы.) Если Python встречает выражениеawait f() в области действияg(), тоawait сообщает циклу событий: «Приостановить выполнениеg() до тех пор, пока не будет возвращено то, что я жду - результатf(). А пока иди, дай что-нибудь еще бежать.

В коде эта вторая точка маркера выглядит примерно так:

async def g():
    # Pause here and come back to g() when f() is ready
    r = await f()
    return r

Также существует строгий набор правил относительно того, когда и как можно и нельзя использоватьasync /await. Это может быть удобно, если вы все еще понимаете синтаксис или уже знакомы с использованиемasync /await:

  • Функция, которую вы вводите с помощьюasync def, является сопрограммой. Он может использоватьawait,return илиyield, но все это необязательно. Объявлениеasync def noop(): pass действительно:

    • Использованиеawait и / илиreturn создает функцию сопрограммы. Чтобы вызвать функцию сопрограммы, вы должныawait, чтобы она получила результаты.

    • Реже (и только недавно в Python) использованиеyield в блокеasync def. Это создаетasynchronous generator, который вы перебираете с помощьюasync for. Забудьте пока об асинхронных генераторах и сосредоточьтесь на синтаксисе функций сопрограмм, которые используютawait и / илиreturn.

    • Все, что определено с помощьюasync def, не может использоватьyield from, что приведет к увеличениюSyntaxError.

  • КакSyntaxError - использоватьyield вне функцииdef, так иSyntaxError - использоватьawait вне сопрограммыasync def . Вы можете использовать толькоawait в теле сопрограмм.

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

async def f(x):
    y = await z(x)  # OK - `await` and `return` allowed in coroutines
    return y

async def g(x):
    yield x  # OK - this is an async generator

async def m(x):
    yield from gen(x)  # No - SyntaxError

def m(x):
    y = await z(x)  # Still no - SyntaxError (no `async def` here)
    return y

Наконец, когда вы используетеawait f(), требуется, чтобыf() был объектом, который являетсяawaitable. Ну, это не очень полезно, не так ли? На данный момент просто знайте, что ожидаемый объект - это либо (1) другая сопрограмма, либо (2) объект, определяющий dunder-метод.__await__(), который возвращает итератор. Если вы пишете программу, в большинстве случаев вам нужно беспокоиться только о первом случае.

Это подводит нас к еще одному техническому различию, которое вы можете увидеть во всплывающем окне: старый способ пометить функцию как сопрограмму - это украсить обычную функциюdef символом@asyncio.coroutine. Результат -generator-based coroutine. Эта конструкция устарела, так как синтаксисasync /await был введен в Python 3.5.

Эти две сопрограммы по существу эквивалентны (обе ожидаются), но первая -generator-based, а вторая -native coroutine:

import asyncio

@asyncio.coroutine
def py34_coro():
    """Generator-based coroutine, older syntax"""
    yield from stuff()

async def py35_coro():
    """Native coroutine, modern syntax"""
    await stuff()

Если вы пишете какой-либо код самостоятельно, предпочитайте нативные сопрограммы ради явного, а не неявного. Сопрограммы на основе генератора будутremoved в Python 3.10.

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

Не увязните в сопрограммах на основе генератора, которые былиdeliberately outdated наasync /await. У них есть свой небольшой набор правил (например,await нельзя использовать в сопрограмме на основе генератора), которые в значительной степени не имеют значения, если вы придерживаетесь синтаксисаasync /await.

Без дальнейших церемоний, давайте возьмем еще несколько примеров.

Вот один пример того, как асинхронный ввод-вывод сокращает время ожидания: учитывая сопрограммуmakerandom(), которая продолжает генерировать случайные целые числа в диапазоне [0, 10], пока одно из них не превысит пороговое значение, вы хотите разрешить множественные вызовы этой сопрограмме не нужно ждать, пока друг друга выполнят подряд. Вы можете в значительной степени следовать шаблонам из двух приведенных выше сценариев с небольшими изменениями:

#!/usr/bin/env python3
# rand.py

import asyncio
import random

# ANSI colors
c = (
    "\033[0m",   # End of color
    "\033[36m",  # Cyan
    "\033[91m",  # Red
    "\033[35m",  # Magenta
)

async def makerandom(idx: int, threshold: int = 6) -> int:
    print(c[idx + 1] + f"Initiated makerandom({idx}).")
    i = random.randint(0, 10)
    while i <= threshold:
        print(c[idx + 1] + f"makerandom({idx}) == {i} too low; retrying.")
        await asyncio.sleep(idx + 1)
        i = random.randint(0, 10)
    print(c[idx + 1] + f"---> Finished: makerandom({idx}) == {i}" + c[0])
    return i

async def main():
    res = await asyncio.gather(*(makerandom(i, 10 - i - 1) for i in range(3)))
    return res

if __name__ == "__main__":
    random.seed(444)
    r1, r2, r3 = asyncio.run(main())
    print()
    print(f"r1: {r1}, r2: {r2}, r3: {r3}")

Цветной вывод говорит намного больше, чем я, и дает вам представление о том, как выполняется этот скрипт:

Эта программа использует одну основную сопрограммуmakerandom() и запускает ее одновременно с 3 разными входами. Большинство программ содержат небольшие модульные сопрограммы и одну функцию-обертку, которая служит для связывания каждой из меньших сопрограмм вместе. Затемmain() используется для сбора задач (фьючерсов) путем сопоставления центральной сопрограммы с некоторой итерацией или пулом.

В этом миниатюрном примере пулrange(3). В более полном примере, представленном ниже, это набор URL-адресов, которые необходимо запрашивать, анализировать и обрабатывать одновременно, аmain() инкапсулирует всю эту процедуру для каждого URL-адреса.

Хотя «создание случайных целых чисел» (которое больше всего связано с процессором), возможно, не лучший выбор в качестве кандидата наasyncio, именно присутствиеasyncio.sleep() в примере, предназначенном для имитации Процесс, связанный с вводом-выводом, в котором присутствует неопределенное время ожидания. Например, вызовasyncio.sleep() может представлять отправку и получение не очень случайных целых чисел между двумя клиентами в приложении сообщений.

Async IO Design Patterns

Async IO поставляется с собственным набором возможных конструкций сценариев, с которыми вы познакомитесь в этом разделе.

Цепные сопрограммы

Ключевой особенностью сопрограмм является то, что они могут быть связаны вместе. (Помните, что объект сопрограммы ожидается, поэтому другая сопрограмма можетawait.) Это позволяет вам разбивать программы на более мелкие, управляемые, перерабатываемые сопрограммы:

#!/usr/bin/env python3
# chained.py

import asyncio
import random
import time

async def part1(n: int) -> str:
    i = random.randint(0, 10)
    print(f"part1({n}) sleeping for {i} seconds.")
    await asyncio.sleep(i)
    result = f"result{n}-1"
    print(f"Returning part1({n}) == {result}.")
    return result

async def part2(n: int, arg: str) -> str:
    i = random.randint(0, 10)
    print(f"part2{n, arg} sleeping for {i} seconds.")
    await asyncio.sleep(i)
    result = f"result{n}-2 derived from {arg}"
    print(f"Returning part2{n, arg} == {result}.")
    return result

async def chain(n: int) -> None:
    start = time.perf_counter()
    p1 = await part1(n)
    p2 = await part2(n, p1)
    end = time.perf_counter() - start
    print(f"-->Chained result{n} => {p2} (took {end:0.2f} seconds).")

async def main(*args):
    await asyncio.gather(*(chain(n) for n in args))

if __name__ == "__main__":
    import sys
    random.seed(444)
    args = [1, 2, 3] if len(sys.argv) == 1 else map(int, sys.argv[1:])
    start = time.perf_counter()
    asyncio.run(main(*args))
    end = time.perf_counter() - start
    print(f"Program finished in {end:0.2f} seconds.")

Обратите особое внимание на вывод, гдеpart1() спит в течение переменного времени, аpart2() начинает работать с результатами по мере их появления:

$ python3 chained.py 9 6 3
part1(9) sleeping for 4 seconds.
part1(6) sleeping for 4 seconds.
part1(3) sleeping for 0 seconds.
Returning part1(3) == result3-1.
part2(3, 'result3-1') sleeping for 4 seconds.
Returning part1(9) == result9-1.
part2(9, 'result9-1') sleeping for 7 seconds.
Returning part1(6) == result6-1.
part2(6, 'result6-1') sleeping for 4 seconds.
Returning part2(3, 'result3-1') == result3-2 derived from result3-1.
-->Chained result3 => result3-2 derived from result3-1 (took 4.00 seconds).
Returning part2(6, 'result6-1') == result6-2 derived from result6-1.
-->Chained result6 => result6-2 derived from result6-1 (took 8.01 seconds).
Returning part2(9, 'result9-1') == result9-2 derived from result9-1.
-->Chained result9 => result9-2 derived from result9-1 (took 11.01 seconds).
Program finished in 11.01 seconds.

В этой настройке время выполненияmain() будет равно максимальному времени выполнения задач, которые он собирает вместе и планирует.

Использование очереди

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

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

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

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

Note: хотя очереди часто используются в многопоточных программах из-за поточной безопасностиqueue.Queue(), вам не нужно беспокоиться о поточной безопасности, когда дело касается асинхронного ввода-вывода. (Исключение составляют случаи, когда вы комбинируете два, но в этом руководстве это не сделано.)

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

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

Асинхронная версияasyncq.py приведена ниже. Сложная часть этого рабочего процесса заключается в том, что потребителям должен быть сигнал о том, что производство завершено. В противном случаеawait q.get() будет зависать на неопределенное время, потому что очередь будет полностью обработана, но потребители не будут знать, что производство завершено.

(Большое спасибо за некоторую помощь от StackOverflowuser за помощь в выравниванииmain(): ключ кawait q.join(), который блокируется, пока все элементы в очереди не будут получены и обработаны, и затем отменить потребительские задачи, которые в противном случае зависли бы и бесконечно ждали появления дополнительных элементов очереди.)

Вот полный сценарий:

#!/usr/bin/env python3
# asyncq.py

import asyncio
import itertools as it
import os
import random
import time

async def makeitem(size: int = 5) -> str:
    return os.urandom(size).hex()

async def randsleep(a: int = 1, b: int = 5, caller=None) -> None:
    i = random.randint(0, 10)
    if caller:
        print(f"{caller} sleeping for {i} seconds.")
    await asyncio.sleep(i)

async def produce(name: int, q: asyncio.Queue) -> None:
    n = random.randint(0, 10)
    for _ in it.repeat(None, n):  # Synchronous loop for each single producer
        await randsleep(caller=f"Producer {name}")
        i = await makeitem()
        t = time.perf_counter()
        await q.put((i, t))
        print(f"Producer {name} added <{i}> to queue.")

async def consume(name: int, q: asyncio.Queue) -> None:
    while True:
        await randsleep(caller=f"Consumer {name}")
        i, t = await q.get()
        now = time.perf_counter()
        print(f"Consumer {name} got element <{i}>"
              f" in {now-t:0.5f} seconds.")
        q.task_done()

async def main(nprod: int, ncon: int):
    q = asyncio.Queue()
    producers = [asyncio.create_task(produce(n, q)) for n in range(nprod)]
    consumers = [asyncio.create_task(consume(n, q)) for n in range(ncon)]
    await asyncio.gather(*producers)
    await q.join()  # Implicitly awaits consumers, too
    for c in consumers:
        c.cancel()

if __name__ == "__main__":
    import argparse
    random.seed(444)
    parser = argparse.ArgumentParser()
    parser.add_argument("-p", "--nprod", type=int, default=5)
    parser.add_argument("-c", "--ncon", type=int, default=10)
    ns = parser.parse_args()
    start = time.perf_counter()
    asyncio.run(main(**ns.__dict__))
    elapsed = time.perf_counter() - start
    print(f"Program completed in {elapsed:0.5f} seconds.")

Первые несколько сопрограмм являются вспомогательными функциями, которые возвращают случайную строку, счетчик производительности с долей секунды и случайное целое число. Производитель помещает от 1 до 5 элементов в очередь. Каждый элемент представляет собой кортеж(i, t), гдеi - случайная строка, аt - время, в которое производитель пытается поместить кортеж в очередь.

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

Имейте в виду, чтоasyncio.sleep() используется для имитации некоторой другой, более сложной сопрограммы, которая съедала бы время и блокировала бы все другие исполнения, если бы это была обычная функция блокировки.

Вот тестовый запуск с двумя производителями и пятью потребителями:

$ python3 asyncq.py -p 2 -c 5
Producer 0 sleeping for 3 seconds.
Producer 1 sleeping for 3 seconds.
Consumer 0 sleeping for 4 seconds.
Consumer 1 sleeping for 3 seconds.
Consumer 2 sleeping for 3 seconds.
Consumer 3 sleeping for 5 seconds.
Consumer 4 sleeping for 4 seconds.
Producer 0 added <377b1e8f82> to queue.
Producer 0 sleeping for 5 seconds.
Producer 1 added <413b8802f8> to queue.
Consumer 1 got element <377b1e8f82> in 0.00013 seconds.
Consumer 1 sleeping for 3 seconds.
Consumer 2 got element <413b8802f8> in 0.00009 seconds.
Consumer 2 sleeping for 4 seconds.
Producer 0 added <06c055b3ab> to queue.
Producer 0 sleeping for 1 seconds.
Consumer 0 got element <06c055b3ab> in 0.00021 seconds.
Consumer 0 sleeping for 4 seconds.
Producer 0 added <17a8613276> to queue.
Consumer 4 got element <17a8613276> in 0.00022 seconds.
Consumer 4 sleeping for 5 seconds.
Program completed in 9.00954 seconds.

В этом случае элементы обрабатываются за доли секунды. Задержка может быть вызвана двумя причинами:

  • Стандартные, в значительной степени неизбежные накладные расходы

  • Ситуации, когда все потребители спят, когда товар появляется в очереди

Что касается второй причины, то, к счастью, совершенно нормально масштабироваться до сотен или тысяч потребителей. У вас не должно возникнуть проблем сpython3 asyncq.py -p 5 -c 100. Дело в том, что теоретически вы можете иметь разных пользователей в разных системах, контролирующих управление производителями и потребителями, причем очередь служит центральной пропускной способностью.

До сих пор вы были брошены прямо в огонь и видели три связанных примера вызоваasyncio сопрограмм, определенных с помощьюasync иawait. Если вы не совсем следуете или просто хотите углубиться в механику того, как современные сопрограммы появились в Python, вы начнете с нуля со следующего раздела.

Корни Async IO в генераторах

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

import asyncio

@asyncio.coroutine
def py34_coro():
    """Generator-based coroutine"""
    # No need to build these yourself, but be aware of what they are
    s = yield from stuff()
    return s

async def py35_coro():
    """Native coroutine, modern syntax"""
    s = await stuff()
    return s

async def stuff():
    return 0x10, 0x20, 0x30

В качестве эксперимента, что произойдет, если вы вызоветеpy34_coro() илиpy35_coro() самостоятельно, безawait или без каких-либо вызововasyncio.run() или другогоasyncio «фарфор» »Функции? Вызов сопрограммы изолированно возвращает объект сопрограммы:

>>>

>>> py35_coro()

Это не очень интересно на первый взгляд. Результатом вызова сопрограммы является ожидаемыйcoroutine object.

Время для викторины: какая еще особенность Python выглядит следующим образом? (Какая особенность Python на самом деле не «делает много», когда он вызывается сам по себе?)

Надеюсь, вы думаете оgenerators как о ответе на этот вопрос, потому что сопрограммы - это улучшенные генераторы под капотом. Поведение аналогично в этом отношении:

>>>

>>> def gen():
...     yield 0x10, 0x20, 0x30
...
>>> g = gen()
>>> g  # Nothing much happens - need to iterate with `.__next__()`

>>> next(g)
(16, 32, 48)

Функции-генераторы, как это часто бывает, являются основой асинхронного ввода-вывода (независимо от того, объявляете ли вы сопрограммы сasync def, а не с более старой оболочкой@asyncio.coroutine). Техническиawait более похож наyield from, чем наyield. (Но помните, чтоyield from x() - это просто синтаксический сахар для заменыfor i in x(): yield i.)

Одной из важнейших особенностей генераторов в отношении асинхронного ввода-вывода является то, что они могут быть эффективно остановлены и перезапущены по желанию. Например, вы можетеbreak не выполнять итерацию по объекту-генератору, а затем возобновить итерацию для оставшихся значений позже. Когда функция генератора достигаетyield, она выдает это значение, но затем остается в бездействии, пока ей не будет предложено выдать свое последующее значение.

Это можно конкретизировать на примере:

>>>

>>> from itertools import cycle
>>> def endless():
...     """Yields 9, 8, 7, 6, 9, 8, 7, 6, ... forever"""
...     yield from cycle((9, 8, 7, 6))

>>> e = endless()
>>> total = 0
>>> for i in e:
...     if total < 30:
...         print(i, end=" ")
...         total += i
...     else:
...         print()
...         # Pause execution. We can resume later.
...         break
9 8 7 6 9 8 7 6 9 8 7 6 9 8

>>> # Resume
>>> next(e), next(e), next(e)
(6, 9, 8)

Ключевое словоawait ведет себя аналогичным образом, отмечая точку останова, в которой сопрограмма приостанавливает свою работу и позволяет другим сопрограммам работать. «Приостановлено» в данном случае означает сопрограмму, которая временно уступила контроль, но не полностью завершена или завершена. Имейте в виду, чтоyield, а такжеyield from иawait, обозначают точку останова в выполнении генератора.

Это принципиальное различие между функциями и генераторами. Функция «все или ничего». После запуска он не остановится, пока не достигнетreturn, а затем отправит это значение вызывающей стороне (функции, которая его вызывает). С другой стороны, генератор приостанавливается каждый раз, когда достигаетyield, и больше не работает. Он не только может отправить это значение в стек вызовов, но и сохранить свои локальные переменные, когда вы возобновите его, вызвав для негоnext().

Есть еще одна и менее известная особенность генераторов, которая также имеет значение. Вы также можете отправить значение в генератор с помощью его метода.send(). Это позволяет генераторам (и сопрограммам) вызывать (await) друг друга без блокировки. Я не буду вдаваться в подробности этой функции, потому что она имеет значение главным образом для реализации сопрограмм за кулисами, но вам никогда не нужно использовать ее непосредственно самостоятельно.

Если вы хотите узнать больше, вы можете начать сPEP 342, где были официально представлены сопрограммы. How the Heck Does Async-Await Work in Python Бретта Кэннона также хорошо читается, как иPYMOTW writeup on asyncio. Наконец, естьCurious Course on Coroutines and Concurrency Дэвида Бизли, который подробно описывает механизм, с помощью которого работают сопрограммы.

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

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

  • Сопрограммы - этоrepurposed generators, которые используют особенности методов генератора.

  • Старые сопрограммы на основе генератора используютyield from для ожидания результата сопрограммы. Современный синтаксис Python в собственных сопрограммах просто заменяетyield from наawait в качестве средства ожидания результата сопрограммы. await аналогиченyield from, и часто помогает думать о нем как о таковом.

  • Использованиеawait - это сигнал, который отмечает точку останова. Это позволяет сопрограмме временно приостановить выполнение и позволяет программе вернуться к ней позже.

Другие особенности:async for и асинхронные генераторы + понимания

Наряду с простымasync /await, Python также позволяетasync for выполнять итерацию поasynchronous iterator. Назначение асинхронного итератора состоит в том, чтобы он мог вызывать асинхронный код на каждом этапе, когда он повторяется.

Естественным продолжением этой концепции являетсяasynchronous generator. Напомним, что вы можете использоватьawait,return илиyield в собственной сопрограмме. Использованиеyield в сопрограмме стало возможным в Python 3.6 (через PEP 525), который представил асинхронные генераторы с целью разрешить использованиеawait иyield в одном теле функции сопрограммы:

>>>

>>> async def mygen(u: int = 10):
...     """Yield powers of 2."""
...     i = 0
...     while i < u:
...         yield 2 ** i
...         i += 1
...         await asyncio.sleep(0.1)

И последнее, но не менее важное: Python включаетasynchronous comprehension сasync for. Как и его синхронный родственник, это в значительной степени синтаксический сахар:

>>>

>>> async def main():
...     # This does *not* introduce concurrent execution
...     # It is meant to show syntax only
...     g = [i async for i in mygen()]
...     f = [j async for j in mygen() if not (j // 3 % 5)]
...     return g, f
...
>>> g, f = asyncio.run(main())
>>> g
[1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
>>> f
[1, 2, 16, 32, 256, 512]

Это важное различие:neither asynchronous generators nor comprehensions make the iteration concurrent. Все, что они делают, - это обеспечивают внешний вид своих синхронных аналогов, но с возможностью для рассматриваемого цикла передать управление циклу событий для запуска некоторой другой сопрограммы.

Другими словами, асинхронные итераторы и асинхронные генераторы не предназначены для одновременного отображения какой-либо функции через последовательность или итератор. Они просто предназначены для того, чтобы позволить сопрограмме вмещать другие задачи по очереди. Операторыasync for иasync with необходимы только в той степени, в которой использование простогоfor илиwith «нарушит» природуawait в сопрограмме. Это различие между асинхронностью и параллелизмом является ключевым для понимания.

Цикл событий иasyncio.run()

Вы можете думать о цикле событий как о циклеwhile True, который отслеживает сопрограммы, принимает отзывы о том, что неактивно, и ищет вещи, которые можно выполнить в это время. Он может разбудить простую сопрограмму, когда становится доступным все, что ожидает эта сопрограмма.

До настоящего времени все управление циклом событий неявно обрабатывалось одним вызовом функции:

asyncio.run(main())  # Python 3.7+

asyncio.run(), представленный в Python 3.7, отвечает за получение цикла событий, выполнение задач до тех пор, пока они не будут отмечены как завершенные, а затем закрытие цикла событий.

Существует более сложный способ управления циклом событийasyncio с помощьюget_event_loop(). Типичный шаблон выглядит так:

loop = asyncio.get_event_loop()
try:
    loop.run_until_complete(main())
finally:
    loop.close()

Вы, вероятно, увидитеloop.get_event_loop() плавающим в старых примерах, но если у вас нет особой потребности в точной настройке контроля над управлением циклом событий,asyncio.run() должно быть достаточно для большинства программ.

Если вам действительно нужно взаимодействовать с циклом событий в программе Python,loop - это старый добрый объект Python, который поддерживает интроспекцию с помощьюloop.is_running() иloop.is_closed(). Вы можете манипулировать им, если вам нужно получить более точный контроль, например, вscheduling a callback, передав цикл в качестве аргумента.

Что более важно, так это понимание чуть ниже поверхности о механике цикла событий. Вот несколько моментов, на которые стоит обратить внимание в цикле событий.

#1: Сопрограммы мало что делают сами по себе, пока они не привязаны к циклу событий.

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

>>>

>>> import asyncio

>>> async def main():
...     print("Hello ...")
...     await asyncio.sleep(1)
...     print("World!")

>>> routine = main()
>>> routine

Не забудьте использоватьasyncio.run() для фактического принудительного выполнения путем планирования сопрограммыmain() (будущего объекта) для выполнения в цикле событий:

>>>

>>> asyncio.run(routine)
Hello ...
World!

(Другие сопрограммы могут выполняться сawait. Обычно простоmain() оборачивается вasyncio.run(), и оттуда будут вызываться связанные сопрограммы сawait.)

#2: По умолчанию цикл событий асинхронного ввода-вывода выполняется в одном потоке и на одном ядре ЦП. Обычно одного однопоточного цикла обработки событий в одном ядре процессора более чем достаточно. Также возможно запускать циклы событий на нескольких ядрах. Посмотрите этотtalk by John Reese, чтобы узнать больше, и имейте в виду, что ваш ноутбук может самопроизвольно воспламениться.

#3. Циклы событий подключаемые. То есть, если вы действительно хотите, вы можете написать свою собственную реализацию цикла событий и запускать задачи точно так же. Это замечательно продемонстрировано в пакетеuvloop, который является реализацией цикла событий в Cython.

Вот что понимается под термином «подключаемый цикл событий»: вы можете использовать любую работающую реализацию цикла событий, не связанную со структурой самих сопрограмм. Сам пакетasyncio поставляется сtwo different event loop implementations, при этом значение по умолчанию основано на модулеselectors. (Вторая реализация построена только для Windows.)

Полная программа: асинхронные запросы

Вы сделали это так далеко, и теперь пришло время для веселой и безболезненной части. В этом разделе вы создадите сборщик URL-адресов для очистки веб-страниц,areq.py, используяaiohttp, невероятно быструю асинхронную HTTP-среду клиент / сервер. (Нам просто нужна клиентская часть.) Такой инструмент можно использовать для сопоставления соединений между кластером сайтов, при этом ссылки образуютdirected graph.

Note: Вам может быть интересно, почему пакет Pythonrequests несовместим с асинхронным вводом-выводом. requests построен на основеurllib3, который, в свою очередь, использует модули Pythonhttp иsocket.

По умолчанию операции с сокетами блокируются. Это означает, что Python не понравитсяawait requests.get(url), потому что.get() не является ожидаемым. Напротив, почти все вaiohttp - это ожидаемая сопрограмма, напримерsession.request() иresponse.text(). В остальном это отличный пакет, но вы оказываете себе медвежью услугу, используяrequests в асинхронном коде.

Структура программы высокого уровня будет выглядеть так:

  1. Прочтите последовательность URL-адресов из локального файлаurls.txt.

  2. Отправьте GET-запросы на URL-адреса и расшифруйте полученный контент. Если это не удается, остановитесь там для URL.

  3. Найдите URL-адреса в тегахhref в HTML-коде ответов.

  4. Запишите результаты вfoundurls.txt.

  5. Делайте все вышеперечисленное как можно более асинхронно и одновременно. (Используйтеaiohttp для запросов иaiofiles для добавления файлов. Это два основных примера ввода-вывода, которые хорошо подходят для асинхронной модели ввода-вывода.)

Вот содержимоеurls.txt. Он не очень большой и содержит в основном сайты с высокой посещаемостью:

$ cat urls.txt
https://regex101.com/
https://docs.python.org/3/this-url-will-404.html
https://www.nytimes.com/guides/
https://www.mediamatters.org/
https://1.1.1.1/
https://www.politico.com/tipsheets/morning-money
https://www.bloomberg.com/markets/economics
https://www.ietf.org/rfc/rfc2616.txt

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

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

Давайте посмотрим на полную программу. Мы пройдемся по шагам после:

#!/usr/bin/env python3
# areq.py

"""Asynchronously get links embedded in multiple pages' HMTL."""

import asyncio
import logging
import re
import sys
from typing import IO
import urllib.error
import urllib.parse

import aiofiles
import aiohttp
from aiohttp import ClientSession

logging.basicConfig(
    format="%(asctime)s %(levelname)s:%(name)s: %(message)s",
    level=logging.DEBUG,
    datefmt="%H:%M:%S",
    stream=sys.stderr,
)
logger = logging.getLogger("areq")
logging.getLogger("chardet.charsetprober").disabled = True

HREF_RE = re.compile(r'href="(.*?)"')

async def fetch_html(url: str, session: ClientSession, **kwargs) -> str:
    """GET request wrapper to fetch page HTML.

    kwargs are passed to `session.request()`.
    """

    resp = await session.request(method="GET", url=url, **kwargs)
    resp.raise_for_status()
    logger.info("Got response [%s] for URL: %s", resp.status, url)
    html = await resp.text()
    return html

async def parse(url: str, session: ClientSession, **kwargs) -> set:
    """Find HREFs in the HTML of `url`."""
    found = set()
    try:
        html = await fetch_html(url=url, session=session, **kwargs)
    except (
        aiohttp.ClientError,
        aiohttp.http_exceptions.HttpProcessingError,
    ) as e:
        logger.error(
            "aiohttp exception for %s [%s]: %s",
            url,
            getattr(e, "status", None),
            getattr(e, "message", None),
        )
        return found
    except Exception as e:
        logger.exception(
            "Non-aiohttp exception occured:  %s", getattr(e, "__dict__", {})
        )
        return found
    else:
        for link in HREF_RE.findall(html):
            try:
                abslink = urllib.parse.urljoin(url, link)
            except (urllib.error.URLError, ValueError):
                logger.exception("Error parsing URL: %s", link)
                pass
            else:
                found.add(abslink)
        logger.info("Found %d links for %s", len(found), url)
        return found

async def write_one(file: IO, url: str, **kwargs) -> None:
    """Write the found HREFs from `url` to `file`."""
    res = await parse(url=url, **kwargs)
    if not res:
        return None
    async with aiofiles.open(file, "a") as f:
        for p in res:
            await f.write(f"{url}\t{p}\n")
        logger.info("Wrote results for source URL: %s", url)

async def bulk_crawl_and_write(file: IO, urls: set, **kwargs) -> None:
    """Crawl & write concurrently to `file` for multiple `urls`."""
    async with ClientSession() as session:
        tasks = []
        for url in urls:
            tasks.append(
                write_one(file=file, url=url, session=session, **kwargs)
            )
        await asyncio.gather(*tasks)

if __name__ == "__main__":
    import pathlib
    import sys

    assert sys.version_info >= (3, 7), "Script requires Python 3.7+."
    here = pathlib.Path(__file__).parent

    with open(here.joinpath("urls.txt")) as infile:
        urls = set(map(str.strip, infile))

    outpath = here.joinpath("foundurls.txt")
    with open(outpath, "w") as outfile:
        outfile.write("source_url\tparsed_url\n")

    asyncio.run(bulk_crawl_and_write(file=outpath, urls=urls))

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

КонстантаHREF_RE - это регулярное выражение для извлечения того, что мы в конечном итоге ищем, теговhref в HTML:

>>>

>>> HREF_RE.search('Go to Real Python')

Сопрограммаfetch_html() - это оболочка вокруг запроса GET, чтобы сделать запрос и декодировать полученный HTML-код страницы. Он делает запрос, ожидает ответа и сразу же поднимается в случае статуса не-200:

resp = await session.request(method="GET", url=url, **kwargs)
resp.raise_for_status()

Если статус в порядке,fetch_html() возвращает HTML-код страницы (astr). Примечательно, что в этой функции не выполняется обработка исключений. Логика заключается в том, чтобы передать это исключение вызывающей стороне и разрешить его обработку там:

html = await resp.text()

Мыawaitsession.request() иresp.text(), потому что они ожидают сопрограммы. В противном случае цикл запроса / ответа был бы частью приложения с длинным хвостом, занимающей много времени, но с асинхронным вводом-выводомfetch_html() позволяет циклу событий работать с другими легко доступными заданиями, такими как синтаксический анализ и запись URL-адресов, которые уже был доставлен.

Затем в цепочке сопрограмм идетparse(), который ожидаетfetch_html() для данного URL-адреса, а затем извлекает все тегиhref из HTML-кода этой страницы, проверяя, что каждый из них действителен и форматируя его как абсолютный путь.

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

В этом конкретном случае этот синхронный код должен быть быстрым и незаметным. Но просто помните, что любая строка в данной сопрограмме будет блокировать другие сопрограммы, если эта строка не используетyield,await илиreturn. Если синтаксический анализ был более интенсивным процессом, вы можете рассмотреть возможность запуска этой части в отдельном процессе сloop.run_in_executor().

Затем сопрограммаwrite() принимает файловый объект и единственный URL-адрес и ждет, покаparse() вернетset проанализированных URL-адресов, записывая каждый в файл асинхронно вместе с его исходным URL-адресом. за счет использованияaiofiles, пакета для асинхронного ввода-вывода файлов.

Наконец,bulk_crawl_and_write() служит основной точкой входа в цепочку сопрограмм скрипта. Он использует один сеанс, и для каждого URL-адреса создается задача, которая в конечном итоге считывается изurls.txt.

Вот несколько дополнительных моментов, которые заслуживают упоминания:

  • По умолчаниюClientSession имеетadapter с максимум 100 открытыми соединениями. Чтобы изменить это, передайте экземплярasyncio.connector.TCPConnector вClientSession. Вы также можете указать ограничения для каждого хоста.

  • Вы можете указать maxtimeouts как для сеанса в целом, так и для отдельных запросов.

  • В этом скрипте также используетсяasync with, который работает сasynchronous context manager. Я не посвятил этому разделу целый раздел, потому что переход от синхронных к асинхронным контекстным менеджерам довольно прост. Последний должен определять.__aenter__() и.__aexit__(), а не.__exit__() и.__enter__(). Как и следовало ожидать,async with можно использовать только внутри функции сопрограммы, объявленной с помощьюasync def.

Если вы хотите узнать немного больше, кcompanion filesдля этого руководства на GitHub также прилагаются комментарии и строки документации.

Вот пример выполнения во всей красе, посколькуareq.py получает, анализирует и сохраняет результаты для 9 URL менее чем за секунду:

$ python3 areq.py
21:33:22 DEBUG:asyncio: Using selector: KqueueSelector
21:33:22 INFO:areq: Got response [200] for URL: https://www.mediamatters.org/
21:33:22 INFO:areq: Found 115 links for https://www.mediamatters.org/
21:33:22 INFO:areq: Got response [200] for URL: https://www.nytimes.com/guides/
21:33:22 INFO:areq: Got response [200] for URL: https://www.politico.com/tipsheets/morning-money
21:33:22 INFO:areq: Got response [200] for URL: https://www.ietf.org/rfc/rfc2616.txt
21:33:22 ERROR:areq: aiohttp exception for https://docs.python.org/3/this-url-will-404.html [404]: Not Found
21:33:22 INFO:areq: Found 120 links for https://www.nytimes.com/guides/
21:33:22 INFO:areq: Found 143 links for https://www.politico.com/tipsheets/morning-money
21:33:22 INFO:areq: Wrote results for source URL: https://www.mediamatters.org/
21:33:22 INFO:areq: Found 0 links for https://www.ietf.org/rfc/rfc2616.txt
21:33:22 INFO:areq: Got response [200] for URL: https://1.1.1.1/
21:33:22 INFO:areq: Wrote results for source URL: https://www.nytimes.com/guides/
21:33:22 INFO:areq: Wrote results for source URL: https://www.politico.com/tipsheets/morning-money
21:33:22 INFO:areq: Got response [200] for URL: https://www.bloomberg.com/markets/economics
21:33:22 INFO:areq: Found 3 links for https://www.bloomberg.com/markets/economics
21:33:22 INFO:areq: Wrote results for source URL: https://www.bloomberg.com/markets/economics
21:33:23 INFO:areq: Found 36 links for https://1.1.1.1/
21:33:23 INFO:areq: Got response [200] for URL: https://regex101.com/
21:33:23 INFO:areq: Found 23 links for https://regex101.com/
21:33:23 INFO:areq: Wrote results for source URL: https://regex101.com/
21:33:23 INFO:areq: Wrote results for source URL: https://1.1.1.1/

Это не так уж плохо! В качестве проверки работоспособности вы можете проверить количество строк на выходе. В моем случае это 626, но имейте в виду, что это может колебаться:

$ wc -l foundurls.txt
     626 foundurls.txt

$ head -n 3 foundurls.txt
source_url  parsed_url
https://www.bloomberg.com/markets/economics https://www.bloomberg.com/feedback
https://www.bloomberg.com/markets/economics https://www.bloomberg.com/notices/tos

Next Steps: если вы хотите поднять ставку, сделайте этот веб-сканер рекурсивным. Вы можете использоватьaio-redis, чтобы отслеживать, какие URL-адреса были просканированы в дереве, чтобы не запрашивать их дважды, и соединять ссылки с библиотекой Pythonnetworkx.

Не забудьте быть милым. Отправка 1000 одновременных запросов на маленький, ничего не подозревающий сайт - это плохо, плохо, плохо. Есть способы ограничить количество одновременных запросов, которые вы делаете в одном пакете, например, используя объектыsempahore изasyncio или используя шаблонlike this one. Если вы не прислушаетесь к этому предупреждению, вы можете получить огромную партию исключенийTimeoutError и в конечном итоге только навредит вашей собственной программе.

Асинхронный ввод-вывод в контексте

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

Когда и почему Async IO - правильный выбор?

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

Битва за асинхронный ввод-вывод и многопроцессорность вовсе не битва. Фактически это может бытьused in concert. Если у вас есть несколько довольно однородных задач, связанных с процессором (отличный пример -grid search в таких библиотеках, какscikit-learn илиkeras), многопроцессорность должна быть очевидным выбором.

Простое размещениеasync перед каждой функцией - плохая идея, если все функции используют блокирующие вызовы. (Это может фактически замедлить ваш код.) Но, как упоминалось ранее, есть места, где асинхронный ввод-вывод и многопроцессорность могутlive in harmony.

Борьба между асинхронным вводом-выводом и многопоточностью немного более прямая. Я упоминал во введении, что «многопоточность трудна». Вся история в том, что даже в тех случаях, когда многопоточность кажется простой в реализации, она, тем не менее, может привести к печально известным ошибкам, которые невозможно отследить из-за условий гонки и использования памяти.

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

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

  • Сетевой ввод-вывод, независимо от того, является ли ваша программа серверной или клиентской

  • Бессерверные проекты, такие как одноранговая, многопользовательская сеть, такая как групповой чат

  • Read/write operations where you want to mimic a “fire-and-forget” style but worry less about holding a lock on whatever you’re reading and writing to

Самая большая причина не использовать его в том, чтоawait поддерживает только определенный набор объектов, которые определяют конкретный набор методов. Если вы хотите выполнять операции асинхронного чтения с определенной СУБД, вам необходимо найти не просто оболочку Python для этой СУБД, но такую, которая поддерживает синтаксисasync /await. Сопрограммы, содержащие синхронные вызовы, блокируют выполнение других сопрограмм и задач.

Краткий список библиотек, которые работают сasync /await, можно найти вlist в конце этого руководства.

Async IO Да, но какой?

В этом руководстве основное внимание уделяется асинхронному вводу-выводу, синтаксисуasync /await и использованиюasyncio для управления циклами событий и определения задач. asyncio, безусловно, не единственная библиотека асинхронного ввода-вывода. Это наблюдение от Натаниэля Дж. Смит много говорит:

[Через] несколько летasyncio может оказаться одной из тех библиотек stdlib, которых избегают опытные разработчики, напримерurllib2.

По сути, я утверждаю, чтоasyncio - жертва собственного успеха: когда он был разработан, он использовал наилучший возможный подход; но с тех пор работа, вдохновленнаяasyncio - например, добавлениеasync /await - изменила ситуацию, так что мы можем добиться еще большего, и теперьasyncio скован взятыми ранее обязательствами. (Source)с

С этой целью есть несколько известных альтернатив, которые делают то, что делаетasyncio, хотя и с разными API и разными подходами, - этоcurio иtrio. Лично я считаю, что если вы создаете простую программу среднего размера, простого использованияasyncio будет достаточно и понятно, и вы сможете избежать добавления еще одной большой зависимости за пределами стандартной библиотеки Python.

Но обязательно посмотритеcurio иtrio, и вы можете обнаружить, что они делают то же самое, но более интуитивно понятным для вас как пользователя. Многие из представленных здесь концепций, не зависящих от пакетов, должны распространяться и на альтернативные пакеты асинхронного ввода-вывода.

Шансы и Концы

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

Другие функции верхнего уровняasyncio

Помимоasyncio.run(), вы видели несколько других функций уровня пакета, таких какasyncio.create_task() иasyncio.gather().

Вы можете использоватьcreate_task() для планирования выполнения объекта сопрограммы, за которым следуетasyncio.run():

>>>

>>> import asyncio

>>> async def coro(seq) -> list:
...     """'IO' wait time is proportional to the max element."""
...     await asyncio.sleep(max(seq))
...     return list(reversed(seq))
...
>>> async def main():
...     # This is a bit redundant in the case of one task
...     # We could use `await coro([3, 2, 1])` on its own
...     t = asyncio.create_task(coro([3, 2, 1]))  # Python 3.7+
...     await t
...     print(f't: type {type(t)}')
...     print(f't done: {t.done()}')
...
>>> t = asyncio.run(main())
t: type 
t done: True

В этом шаблоне есть тонкость: если вы не укажетеawait t в пределахmain(), он может закончиться до того, какmain() сигнализирует о его завершении. Посколькуasyncio.run(main())calls loop.run_until_complete(main()), цикл событий заботится (без наличияawait t) только о том, чтоmain() выполнен, а не о том, что задачи, созданные вmain(), являются сделанный. Безawait t другие задачи циклаwill be cancelled, возможно, до их завершения. Если вам нужно получить список ожидающих в настоящее время задач, вы можете использоватьasyncio.Task.all_tasks().

Note:asyncio.create_task() был представлен в Python 3.7. В Python 3.6 или ниже используйтеasyncio.ensure_future() вместоcreate_task().

Отдельно естьasyncio.gather(). Хотя он не делает ничего особенного,gather() предназначен для того, чтобы аккуратно поместить коллекцию сопрограмм (фьючерсов) в единое будущее. В результате он возвращает один объект будущего, и, если выawait asyncio.gather() и указываете несколько задач или сопрограмм, вы ждете, пока все они будут выполнены. (Это несколько похоже наqueue.join() из нашего предыдущего примера.) Результатомgather() будет список результатов для входных данных:

>>>

>>> import time
>>> async def main():
...     t = asyncio.create_task(coro([3, 2, 1]))
...     t2 = asyncio.create_task(coro([10, 5, 0]))  # Python 3.7+
...     print('Start:', time.strftime('%X'))
...     a = await asyncio.gather(t, t2)
...     print('End:', time.strftime('%X'))  # Should be 10 seconds
...     print(f'Both tasks done: {all((t.done(), t2.done()))}')
...     return a
...
>>> a = asyncio.run(main())
Start: 16:20:11
End: 16:20:21
Both tasks done: True
>>> a

Вы, наверное, заметили, чтоgather() ожидает всего набора результатов фьючерсов или сопрограмм, которые вы ему передаете. В качестве альтернативы вы можете перебратьasyncio.as_completed(), чтобы получить задачи по мере их выполнения в порядке завершения. Функция возвращает итератор, который возвращает задачи по мере их завершения. Ниже результатcoro([3, 2, 1]) будет доступен до завершенияcoro([10, 5, 0]), чего нельзя сказать оgather():

>>>

>>> async def main():
...     t = asyncio.create_task(coro([3, 2, 1]))
...     t2 = asyncio.create_task(coro([10, 5, 0]))
...     print('Start:', time.strftime('%X'))
...     for res in asyncio.as_completed((t, t2)):
...         compl = await res
...         print(f'res: {compl} completed at {time.strftime("%X")}')
...     print('End:', time.strftime('%X'))
...     print(f'Both tasks done: {all((t.done(), t2.done()))}')
...
>>> a = asyncio.run(main())
Start: 09:49:07
res: [1, 2, 3] completed at 09:49:10
res: [0, 5, 10] completed at 09:49:17
End: 09:49:17
Both tasks done: True

Наконец, вы также можете увидетьasyncio.ensure_future(). Он может вам понадобиться редко, потому что это низкоуровневый API-интерфейс сантехники, который в значительной степени замененcreate_task(), который был представлен позже.

Приоритетawait

Хотя они ведут себя примерно одинаково, ключевое словоawait имеет значительно более высокий приоритет, чемyield. Это означает, что, поскольку он более жестко связан, есть ряд случаев, когда вам понадобятся круглые скобки в оператореyield from, которые не требуются в аналогичном оператореawait. Для получения дополнительной информации см.examples of await expressions из PEP 492.

Заключение

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

  • Асинхронный ввод-вывод как языково-независимая модель и способ обеспечить параллелизм, позволяя сопрограммам косвенно взаимодействовать друг с другом

  • Особенности новых ключевых слов Pythonasync иawait, используемых для обозначения и определения сопрограмм

  • asyncio, пакет Python, который предоставляет API для запуска и управления сопрограммами.

Ресурсы

Особенности версии Python

Асинхронный ввод-вывод в Python стремительно развивается, и может быть трудно отслеживать, что и когда появилось. Вот список изменений и нововведений минорной версии Python, связанных сasyncio:

  • 3.3: The yield from expression allows for generator delegation.

  • 3.4: asyncio was introduced in the Python standard library with provisional API status.

  • 3.5: async and await became a part of the Python grammar, used to signify and wait on coroutines. Это еще не зарезервированные ключевые слова. (Вы все еще можете определять функции или переменные с именамиasync иawait.)

  • 3.6: Asynchronous generators and asynchronous comprehensions were introduced. APIasyncio был объявлен стабильным, а не временным.

  • 3.7: async and await became reserved keywords. (Их нельзя использовать в качестве идентификаторов.) Они предназначены для замены декоратораasyncio.coroutine(). asyncio.run() был введен в пакетasyncio средиa bunch of other features.

Если вы хотите быть в безопасности (и иметь возможность использоватьasyncio.run()), используйте Python 3.7 или выше, чтобы получить полный набор функций.

статьи

Вот тщательно подобранный список дополнительных ресурсов:

В нескольких разделах PythonWhat’s New более подробно объясняется мотивация изменений языка:

От Дэвида Бизли:

YouTube говорит:

Библиотеки, работающие сasync /await

Изaio-libs:

  • aiohttp: фреймворк асинхронный HTTP-клиент / сервер

  • aioredis: поддержка Async IO Redis

  • aiopg: поддержка Async IO PostgreSQL

  • aiomcache: клиент memcached для асинхронного ввода-вывода

  • aiokafka: клиент Async IO Kafka

  • aiozmq: поддержка Async IO ZeroMQ

  • aiojobs: Планировщик заданий для управления фоновыми задачами

  • async_lru: простой кеш LRU для асинхронного ввода-вывода

Изmagicstack:

  • uvloop: сверхбыстрый цикл обработки событий асинхронного ввода-вывода

  • asyncpg: (Также очень быстро) поддержка async IO PostgreSQL

От других хостов:

  • trio: более удобныйasyncio, предназначенный для демонстрации радикально более простого дизайна

  • aiofiles: ввод-вывод асинхронного файла

  • asks: HTTP-библиотека, подобная асинхронным запросам

  • asyncio-redis: поддержка Async IO Redis

  • aioprocessing: интегрирует модульmultiprocessing сasyncio

  • umongo: клиент Async IO MongoDB

  • unsync: несинхронизироватьasyncio

  • aiostream: какitertools, но асинхронно