Книга: FastAPI: веб-разработка на Python
Назад: Глава 3. Обзор FastAPI
Дальше: Глава 5. Pydantic, подсказки типов и обзор моделей

Глава 4. Асинхронность, конкурентность и обзор библиотеки Starlette

Starlette — это легкий ASGI-фреймворк/инструментарий, он идеально подходит для создания асинхронных веб-сервисов на Python.

Том Кристи, создатель Starlette

Обзор

В предыдущей главе был приведен краткий обзор того, с чем может столкнуться разработчик при написании нового приложения FastAPI. Эта глава посвящена базовой для FastAPI библиотеке Starlette. В частности, мы рассмотрим возможность асинхронной обработки с ее помощью. После обзора различных способов делать больше дел одновременно в Python вы узнаете, как ключевые слова async и await были включены в Starlette и FastAPI.

Библиотека Starlette

Бо́льшая часть веб-кода FastAPI основана на созданном Томом Кристи пакете Starlette (https://www.starlette.io). Его можно применять в качестве самостоятельного веб-фреймворка или как библиотеку для других фреймворков, например FastAPI. Как и любой другой веб-фреймворк, Starlette выполняет все обычные операции синтаксического анализа HTTP-запросов и генерации ответов. Он аналогичен лежащему в основе Flask пакету Werkzeug (https://werkzeug.pal­letsprojects.com).

Самая важная его особенность заключается в поддержке современного асинхронного веб-стандарта Python — ASGI (https://asgi.readthedocs.io). До сих пор большинство веб-фреймворков Python, например Flask и Django, основывались на традиционном синхронном стандарте WSGI (https://wsgi.readthedocs.io). ASGI позволяет избежать характерных для приложений на базе WSGI блокировок и напряженного ожидания. Проблемы такого типа связаны с частым подключением веб-приложений к гораздо более медленному коду, например, для доступа к базам данных, файлам и сетям. В результате Starlette и использующие его фреймворки стали самыми быстрыми веб-пакетами Python и составили конкуренцию даже приложениям на Go и Node.js.

Типы конкурентности

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

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

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

Веб-приложения выполняют бо́льшую часть этой медленной работы. Как заставить их или любые другие серверы работать быстрее? В этом разделе рассматриваются некоторые возможности, начиная с общесистемных и заканчивая целевой темой этой главы — реализацией конструкций async и await в FastAPI для Python.

Распределенные и параллельные вычисления

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

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

Процессы в операционной системе

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

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

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

Для приложений Python, требовательных к производительности процессора, обычное решение заключается в использовании нескольких процессов, которые передаются под управление ОС. В Python для этого существует многопроцессорный модуль (https://oreil.ly/YO4YE).

Потоки в операционной системе

Вы также можете запускать потоки управления в рамках одного процесса. Для выполнения таких задач в Python есть пакет работы с потоками (https://oreil.ly/xwVB1).

Применять потоки рекомендуется, если ваша программа связана с операциями ввода-вывода. А использовать множество процессов — в том случае, когда выполнение программы ограничено средой процессора. Но потоки сложны в программировании и могут вызывать трудные для обнаружения ошибки. В книге Introducing Python я сравнивал потоки с призраками, которые бродят по дому с привидениями, — они свободные и невидимые, обнаружить их можно только по их воздействию. Ой, кто передвинул эту свечу?

Традиционно в Python библиотеки на основе процессов и библиотеки на основе потоков были разделены. Разработчикам требовалось изучать все тонкости и нюансы, чтобы использовать их. Более современный пакет под названием concurrent.futures (https://oreil.ly/dT150) представляет собой интерфейс более высокого уровня, упрощающий их применение.

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

Зеленые потоки

Более загадочный механизм представлен такими зелеными потоками, как greenlet (https://greenlet.readthedocs.io), gevent (http://www.gevent.org) и Eventlet (https://eventlet.net). Они являются кооперативными (невытесняющими). Зеленые потоки похожи на потоки ОС, но выполняются в пользовательском пространстве (то есть в вашей программе), а не в ядре ОС. Они работают путем применения к стандартным функциям Python подхода monkey-patching (модификации стандартных функций Python в процессе их выполнения), чтобы параллельный код выглядел как обычный последовательный код, — они отдают управление, когда блокируют ожидание ввода-вывода.

Потоки ОС легче (используют меньше памяти), чем процессы ОС, а зеленые потоки легче, чем потоки ОС. В некоторых бенчмарках (https://oreil.ly/1NFYb) все асинхронные методы в целом оказались быстрее своих синхронных аналогов.

После прочтения этой главы вы можете задать вопрос: какая библиотека лучше — gevent или asyncio? Я не думаю, что существует единое мнение относительно предпочтительности для всех видов использования. Зеленые потоки были реализованы ранее с помощью идей из многопользовательской игры Eve Online. В этой книге рассказывается о стандарте Python asyncio, применяемом в FastAPI. Он проще, чем потоки, и дает хорошую производительность.

Обратные вызовы

Разработчики интерактивных приложений, таких как игры и графические пользовательские интерфейсы, наверняка знакомы с обратными вызовами. Вы пишете функции и привязываете их к какому-либо событию, например к щелчку кнопкой мыши, нажатию клавиши или времени. Выдающимся пакетом Python в этой категории является Twisted (https://twisted.org). Его название говорит о том, что программы, основанные на обратных вызовах, немного «вывернуты наизнанку» и трудно следовать их потоку выполнения.

Генераторы Python

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

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

В одном из эпизодов мультсериала «Симпсоны» Гомер врезается на своей машине в статую оленя, после чего следуют три реплики диалога. Пример 4.1 определяет обычную функцию Python для возврата этих строк с помощью ключевого слова return в виде списка и их итерации вызывающей стороной.

Пример 4.1. Использование ключевого слова return

>>> def doh():

...     return ["Homer: D'oh!", "Marge: A deer!", "Lisa: A female deer!"]

...

>>> for line in doh():

...     print(line)

...

Homer: D'oh!

Marge: A deer!

Lisa: A female deer!

Этот подход отлично работает, когда списки относительно небольшие. Но что, если мы возьмем все диалоги из всех эпизодов «Симпсонов»? Списки занимают много памяти.

В примере 4.2 показано, как функция-генератор будет выдавать строки.

Пример 4.2. Использование ключевого слова yield

>>> def doh2():

...     yield "Homer: D'oh!"

...     yield "Marge: A deer!"

...     yield "Lisa: A female deer!"

...

>>> for line in doh2():

...     print(line)

...

Homer: D'oh!

Marge: A deer!

Lisa: A female deer!

Вместо итерации по списку, возвращаемому простой функцией doh(), мы выполняем итерации по объекту-генератору, возвращаемому функцией-генератором doh2(). Фактическая итерация (for...in) выглядит так же. Python возвращает первую строку из генератора doh2(), но отслеживает, где она находится, для следующей итерации, и так продолжается, пока функция не исчерпает диалог.

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

Ключевые слова async, await и модуль asyncio из Python

Функциональные возможности библиотеки asyncio (https://oreil.ly/cBMAc) языка Python были представлены в различных выпусках. Вы используете как минимум Python версии 3.7, и здесь термины async и await стали зарезервированными ключевыми словами.

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

Пример 4.3. Уныло

>>> import time

>>>

>>> def q():

...     print("Why can't programmers tell jokes?")

...     time.sleep(3)

...

>>> def a():

...     print("Timing!")

...

>>> def main():

...     q()

...     a()

...

>>> main()

Why can't programmers tell jokes?

Timing!

Между вопросом и ответом будет трехсекундный промежуток. Скукота.

Но в асинхронном примере 4.4 все немного иначе.

Пример 4.4. Весело

>>> import asyncio

>>>

>>> async def q():

...     print("Why can't programmers tell jokes?")

...     await asyncio.sleep(3)

...

>>> async def a():

...     print("Timing!")

...

>>> async def main():

...     await asyncio.gather(q(), a())

...

>>> asyncio.run(main())

Why can't programmers tell jokes?

Timing!

На этот раз ответ должен появиться сразу после вопроса, затем наступит трехсекундная тишина — так, как будто это говорит программист. Ха-ха! Гм.

В примере 4.4 я задействовал функции asyncio.gather() и asyncio.run(), но существует несколько способов вызова асинхронных функций. При использовании FastAPI они вам не понадобятся.

Python при выполнении примера 4.4 думает так.

1. Выполню функцию q(). Ну, сейчас это только первая строчка.

2. Ладно, ты, ленивая асинхронная q(), я установил таймер и вернусь к тебе через три секунды.

3. А пока я выполню функцию a() и сразу же выведу ответ.

4. Других ключевых слов await нет, так что следует вернуться к выполнению функции q().

5. Скучный цикл событий! Я буду сидеть здесь и ждать все эти три секунды.

6. Хорошо, наконец-то я закончил.

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

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

Кстати, вы можете объявить функцию как async (асинхронную), даже если она не содержит await-вызова другой асинхронной функции. Это не повредит.

FastAPI и асинхронность

После долгого путешествия по холмам и долам давайте вернемся к FastAPI и к тому, почему все это важно.

Поскольку веб-серверы тратят много времени на ожидание, производительность можно повысить, избежав части этого ожидания — иными словами, с помощью конкурентности. Другие веб-серверы используют многие из упомянутых ранее методов: потоки, gevent и т.д. Одна из причин, по которой FastAPI является одним из самых быстрых веб-фреймворков Python, — это включение асинхронного кода благодаря поддержке протокола ASGI в пакете Starlette и некоторым собственным изобретениям.

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

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

Функции, сопоставляющие URL с кодом, в документации FastAPI называются функциями пути. Я также называл их конечными точками веб-приложения, и вы видели их синхронные примеры в главе 3. Сделаем несколько асинхронных вариантов. Как и в предыдущих примерах, мы будем использовать простые типы, такие как числа и строки. В главе 5 представлены подсказки типов и Pydantic — они понадобятся для работы с более сложными структурами данных.

Пример 4.5 возвращает нас к первой программе FastAPI из предыдущей главы и делает ее асинхронной.

Пример 4.5. Робкая асинхронная конечная точка (greet_async.py)

from fastapi import FastAPI

import asyncio

app = FastAPI()

@app.get("/hi")

async def greet():

    await asyncio.sleep(1)

    return "Hello? World?"

Чтобы запустить этот фрагмент веб-кода, вам нужен веб-сервер, например Uvicorn. Первый способ — запустить Uvicorn в командной строке:

$ uvicorn greet_async:app

Второй, как в примере 4.6, заключается в вызове Uvicorn изнутри кода примера, когда он запускается как основная программа, а не как модуль.

Пример 4.6. Еще одна робкая асинхронная конечная точка (greet_async_uvicorn.py)

from fastapi import FastAPI

import asyncio

import uvicorn

app = FastAPI()

@app.get("/hi")

async def greet():

    await asyncio.sleep(1)

    return "Hello? World?"

if __name__ == "__main__":

    uvicorn.run("greet_async_uvicorn:app")

При запуске в качестве самостоятельной программы Python называет ее main. Выражение if __name__... — это указание Python запустить Uvicorn только при вызове в качестве основной программы. Да, это некрасиво.

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

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

FastAPI сам вызывает асинхронную функцию пути greet(), когда получает GET-запрос на URL /hi. Вам не нужно добавлять ключевое слово await куда-либо. Но для любых других определений функций async def вызывающая сторона должна поместить оператор await перед каждым вызовом.

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

Непосредственное использование Starlette

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

Пример 4.7. Использование Starlette: starlette_hello.py

from starlette.applications import Starlette

from starlette.responses import JSONResponse

from starlette.routing import Route

async def greeting(request):

    return JSONResponse('Hello? World?')

app = Starlette(debug=True, routes=[

    Route('/hi', greeting),

])

Запустите это веб-приложение с помощью команды:

$ uvicorn starlette_hello:app

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

Немного отвлечемся: уборка в доме из игры Clue

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

Ваш клиент купил старинный особняк, построенный в стиле настольной игры Clue, и хочет вскоре устроить там костюмированную вечеринку. Но в доме невероятный беспорядок. Если бы Мариэ Кондо увидела это место, она бы:

• закричала;

• прикрыла рот ладошкой;

• убежала;

• сделала все перечисленное.

Ваш контракт включает в себя бонус за скорость. Как тщательно убрать помещение за минимальное время? Лучше всего было бы получить больше блоков сохранения подсказок (Clue Preservation Units, CPU), но это уже дело ваше.

Поэтому вы можете попробовать один из следующих вариантов.

• Сделать все в одной комнате, затем все в следующей и т.д.

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

Будет ли различаться общее время, затраченное вами при разных подходах? Может быть. Но, возможно, гораздо важнее рассмотреть вопрос о том, приходится ли вам ждать значительное время для выполнения какого-либо шага. Например, если взглянуть под ноги: после чистки ковров и натирания полов воском они должны сохнуть в течение нескольких часов, прежде чем на них можно будет поставить мебель. Итак, вот ваш план для каждой комнаты.

1. Очистить все статичные части (окна и т.п.).

2. Переместить всю мебель из комнаты в холл.

3. Удалить многолетнюю грязь с ковра и/или деревянного пола.

4. Выполнить любой из этих пунктов:

• подождать, пока ковер или воск высохнут, и помахать на прощание своему бонусу;

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

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

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

1. Один забывчивый гость пришел в образе Марио.

2. Вы натерли воском танцпол в бальном зале, а подвыпивший профессор Плам катался в носках, пока не налетел на стол и не пролил шампанское на мисс Скарлет.

Мораль этой истории такова.

• Требования могут быть противоречивыми и/или странными.

• Оценка времени и усилий может зависеть от многих факторов.

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

• Вы будете чувствовать себя прекрасно, когда все будет готово. М-м-м, макароны!

Заключение

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

В следующей главе мы познакомимся со второй частью FastAPI — как Pydantic помогает определять данные.

Назад: Глава 3. Обзор FastAPI
Дальше: Глава 5. Pydantic, подсказки типов и обзор моделей