Рубрики
Без рубрики

Начать работу с Async & Await

Автор оригинала: Arun Ravindran.

Вы читаете пост из серии руководств, состоящей из двух частей, о каналах Django.

  • Часть 1
  • Часть 2

Asyncio

Asyncio – это совместная многозадачная библиотека, доступная в Python с версии 3.6. Celery отлично подходит для выполнения параллельных задач вне процесса, но в некоторых случаях вам может потребоваться выполнить несколько задач в одном потоке внутри единый процесс.

Если вы не знакомы с концепциями async/await (скажем, из JavaScript или C #), тогда потребуется немного сложного обучения. Тем не менее, это стоит вашего времени, так как может значительно ускорить ваш код (если только он полностью не привязан к процессору). Более того, это помогает понять другие библиотеки, построенные на их основе, такие как Django Channels.

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

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

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

import asyncio


async def sleeper_coroutine():
    await asyncio.sleep(5)


if __name__  '__main__':
    loop  asyncio.get_event_loop()
    loop.run_until_complete(sleeper_coroutine())

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

Управление возвращается к сопрограмме в той же строке, когда ожидаемое действие завершается (через пять секунд). Затем сопрограмма возвращается или считается завершенной.

Объясните асинхронность и ждите

[TL; DR; Посмотрите мой скринкаст , чтобы понять этот раздел с большим количеством примеров кода.]

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

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

Вы должны знать две вещи о сопрограммах:

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

В отличие от обычного вызова функции, если вы вызываете сопрограмму, ее тело не будет выполнено сразу. Вместо этого он будет приостановлен и вернет объект сопрограммы. Вызов метода send этой сопрограммы запустит выполнение тела сопрограммы.

>>> async def hi():
...     print("HOWDY!")
...
>>> o  hi()
>>> o

>>> o.send(None)
HOWDY!
Traceback (most recent call last):
  File "", line 1, in 
StopIteration
>>>

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

>>> import asyncio
>>> loop  asyncio.get_event_loop()
>>> o  hi()
>>> loop.run_until_complete(o)
HOWDY!

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

>>> async def sleepy():
...     await asyncio.sleep(3)
...
>>> o  sleepy()
>>> loop.run_until_complete(o)
# After three seconds
>>>

Сопрограмма sleep из модуля asyncio отличается от своего синхронного аналога time.sleep . Это не блокирует. Это означает, что другие сопрограммы могут выполняться, пока эта сопрограмма ожидает завершения сна.

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

Asyncio против потоков

Если вы работали с многопоточным кодом, вы можете задаться вопросом – почему бы просто не использовать потоки? Есть несколько причин, по которым потоки не популярны в Python.

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

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

Во-вторых, сопрограммы легкие. Каждой сопрограмме требуется на порядок меньше памяти, чем потоку. Если вы можете запустить максимум сотни потоков, вы сможете запускать десятки тысяч сопрограмм с одной и той же памятью. Переключение потоков также занимает некоторое время (несколько миллисекунд). Это означает, что вы можете выполнять больше задач или обслуживать больше одновременных пользователей (точно так же, как Node.js работает в одном потоке без блокировки).

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

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

Классический пример веб-парсера

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

Синхронный парсинг веб-страниц

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

Вот код синхронного скребка:

# sync.py
"""Synchronously download a list of webpages and time it"""
from urllib.request import Request, urlopen
from time import time

sites  [
    "https://news.ycombinator.com/",
    "https://www.yahoo.com/",
    "https://github.com/",
]


def find_size(url):
    req  Request(url)
    with urlopen(req) as response:
        page  response.read()
        return len(page)


def main():
    for site in sites:
        size  find_size(site)
        print("Read {:8d} chars from {}".format(size, site))


if __name__  '__main__':
    start_time  time()
    main()
    print("Ran in {:6.3f} secs".format(time() - start_time))

На тестовом ноутбуке выполнение этого кода заняло 5,4 секунды. Это совокупное время загрузки каждого сайта. Посмотрим, как работает асинхронный код.

Асинхронный парсинг веб-страниц

Этот код asyncio требует установки нескольких асинхронных сетевых библиотек Python, таких как aiohttp и aiodns. Они упоминаются в строке документации.

Вот код асинхронного парсера – он построен как можно ближе к синхронной версии, поэтому его легче сравнивать:

# async.py
"""
Asynchronously download a list of webpages and time it

Dependencies: Make sure you install aiohttp using: pip install aiohttp aiodns
"""
import asyncio
import aiohttp
from time import time

# Configuring logging to show timestamps
import logging
logging.basicConfig(format'%(asctime)s %(message)s', datefmt'[%H:%M:%S]')
log  logging.getLogger()
log.setLevel(logging.INFO)

sites  [
    "https://news.ycombinator.com/",
    "https://www.yahoo.com/",
    "https://github.com/",
]


async def find_size(session, url):
    log.info("START {}".format(url))
    async with session.get(url) as response:
        log.info("RESPONSE {}".format(url))
        page  await response.read()
        log.info("PAGE {}".format(url))
        return url, len(page)


async def main():
    tasks  []
    async with aiohttp.ClientSession() as session:
        for site in sites:
            tasks.append(find_size(session, site))
        results  await asyncio.gather(*tasks)
    for site, size in results:
        print("Read {:8d} chars from {}".format(size, site))


if __name__  '__main__':
    start_time  time()
    loop  asyncio.get_event_loop()
    loop.set_debug(True)
    loop.run_until_complete(main())
    print("Ran in {:6.3f} secs".format(time() - start_time))

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

Суммарное время работы этой программы на том же тестовом ноутбуке составляет 1,5 с. Это ускорение в 3,6 раза на одном ядре. Этот удивительный результат можно будет лучше понять, если мы сможем визуализировать, как было потрачено время, как показано ниже:

Сравнение скребков

Упрощенное представление сравнения задач в синхронном и асинхронном парсерах

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

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

Фактически, асинхронный код можно ускорить и дальше. Стандартный цикл событий asyncio написан на чистом Python и предоставляется как эталонная реализация. Вы можете рассмотреть более быстрые реализации, такие как uvloop для дальнейшего ускорения (мое время работы сократилось до 1,3 секунды).

Параллелизм – это не параллелизм

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

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

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

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

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

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

Зачем нужен еще один асинхронный фреймворк?

Asyncio ни в коем случае не первая совместная многозадачность или облегченная библиотека потоков. Если вы использовали gevent или eventlet, вы можете обнаружить, что asyncio требует более явного разделения синхронного и асинхронного кода. Обычно это хорошо.

Gevent полагается на обезьяну-патч, чтобы изменить блокирующие вызовы ввода-вывода на неблокирующие. Это может затруднить обнаружение проблем с производительностью из-за непропатченного блокирующего вызова, замедляющего цикл событий. Как говорит дзэн: «Явное лучше, чем неявное».

Еще одна цель asyncio – предоставить стандартизированную платформу параллелизма для всех реализаций, таких как gevent или Twisted. Это не только сокращает дублирование усилий авторов библиотеки, но также обеспечивает переносимость кода для конечных пользователей.

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

Можем ли мы использовать asyncio в Django?

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

Однако Django Channels все это меняет. В конце концов, Django может вписаться в асинхронный мир. Но это тема другого поста.

Эта статья содержит отрывок из книги Аруна Равиндрана «Шаблоны дизайна и передовые методы Django».