Andrew Godwin опубликовал DEP 0009: Async-capable Django 9 мая, а 21 июля он был принят техническим советом Django, так что можно надеяться, что к выходу Django 3.0 успеют сделать что-нибудь интересное. Он уже упоминался где-то в комментариях Хабра, но я решил донести эту новость до более широкой аудитории путём его перевода — в первую очередь для тех, кто, как и я, не особо следит за новостями Django.
Асинхронный Python разрабатывался много лет, и в экосистеме Django мы экспериментировали с ним в Channels с ориентацией в первую очередь на поддержку вебсокетов.
По мере развития экосистемы стало очевидно, что, хотя нет насущной необходимости расширять Django для поддержки отличных от HTTP протоколов, таких как вебсокеты, поддержка асинхронности даст много преимуществ для традиционной model-view-template структуры Django.
Преимущества описаны в разделе «Мотивация» ниже, но общий вывод, к которому я пришёл, заключается в том, что мы получим так много от асинхронного Django, что это стоит того большого труда, который потребуется. Я также считаю, что очень важно делать изменения итеративным, поддерживаемым сообществом путём, который не будет зависеть от одного-двух старых контрибьюторов, которые могут выгореть.
Хотя документ обозначен как «Feature» DEP, всё это означает, что он также частично является Process DEP. Масштаб предлагаемых ниже изменений невероятно велик, и запуск их как традиционного single-feature процесса скорее всего провалится.
Конечно, на протяжении всего этого документа важно помнить философию Django, которая заключается в том, чтобы всё оставалось безопасным и обратно совместимым. План состоит не в том, чтобы убрать синхронный Django — план состоит в том, чтобы сохранить его в текущем виде, но добавить асинхронность в качестве опции для тех, кто считает, что им нужна дополнительная производительность или гибкость.
Это гигантская работа? Конечно. Но я чувствую, что это позволяет существенно изменить будущее Django — у нас есть возможность взять и проверенный фреймворк, и невероятное сообщество, и внедрить совершенно новый набор опций, которые раньше были невозможны.
Веб изменился, и Django должен измениться вместе с ним, но в соответствии с нашими идеалами быть доступным, безопасным по умолчанию и гибким по мере роста проектов и изменения их потребностей. В мире облачных хранилищ данных, сервис-ориентированной архитектуры и бэкенда как основы сложной бизнес-логики возможность выполнять вещи конкурентно является ключевой.
Этот DEP обрисовывает в общих чертах план, который, я думаю, приведёт нас туда. Это видение, в которое я очень верю и с которым я буду работать, чтобы помочь сделать всё возможное. В то же время тщательный анализ и скептицизм оправданы; я прошу вашей конструктивной критики, а также ваше доверие. Django опирается на сообщество людей и создаваемых ими приложений, и, если мы должны определить путь на будущее, мы должны сделать это вместе.
Краткое описание
Мы собираемся добавить в Django поддержку асинхронных представлений, middleware, ORM и других важных элементов.
Это будет сделано путём запуска синхронного кода в потоках с постепенной его заменой на асинхронный код. Синхронные API продолжат существовать и полностью поддерживаться, а со временем превратятся в синхронные обёртки для изначально асинхронного кода.
Режим ASGI будет запускать Django как нативное асинхронное приложение. Режим WSGI будет запускать отдельный event loop при каждом обращении к Django, чтобы асинхронный слой был совместим с синхронным сервером.
Многопоточность вокруг ORM сложна и требует новой концепции контекстов соединения и липких потоков (sticky threads) для запуска синхронного кода ORM.
Многие части Django будут продолжать работать синхронно, и наш приоритет будет заключаться в поддержке пользователей, пишущих представления в обоих стилях, позволяя им выбирать лучший стиль для представления, над которым они работают.
Некоторые функции, такие как шаблоны и кэширование, будут нуждаться в собственных отдельных DEP и исследованиях, как их сделать полностью асинхронными. Этот DEP в основном фокусируется на HTTP-middleware-view flow и на ORM.
Будет полная обратная совместимость. Стандартный проект Django 2.2 должен запускаться в асинхронном Django (будь то 3.0 или 3.1) без изменений.
Это предложение ориентировано на выполнение небольшими, итеративными частями с постепенным их помещением в master-ветку, чтобы избежать проблем с долгоживущим форком и позволить нам менять курс по мере обнаружения проблем.
Это хорошая возможность привлечь новых участников. Мы должны финансировать проект, чтобы это произошло быстрее. Финансирование должно быть в масштабе, к которому мы не привыкли.
Спецификация
Общая цель состоит в том, чтобы каждая отдельная часть Django, которая может быть блокирующей — то есть не просто CPU-bound вычисления — стала асинхронной (запускалась в асинхронном event loop без блокировок).
Это включает в себя следующие функции:
- Промежуточные слои (Middleware)
- Представления (Views)
- ORM
- Шаблоны
- Тестирование
- Кэширование
- Валидация форм
Однако сюда не входят такие вещи, как интернационализация, которая не принесёт никакого выигрыша в производительности, поскольку это CPU-bound задача, которая к тому же выполняется быстро, или миграции, которые являются однопоточными при запуске через management command.
Каждая отдельная функция, которая становится асинхронной внутри, также будет предоставлять синхронный интерфейс, который обратно совместим с текущим API (в 2.2) в обозримом будущем — мы могли бы со временем изменить его, чтобы сделать их лучше, но синхронные API никуда не пропадут.
Обзор того, как это достигается технически, приведен ниже, а затем приведены конкретные детали реализации для конкретных областей. Он не является исчерпывающим для всех функций Django, но если мы достигнем этой первоначальной цели, то мы включим почти все варианты использования.
В заключительной части этого раздела, «Порядок действий», также рассматривается, как эти изменения могут выполняться постепенно и несколькими группами разработчиков параллельно, что важно для завершения этих изменений с помощью волонтёров в разумные сроки.
Технический обзор
Принцип, который позволяет нам поддерживать синхронную и асинхронную реализации параллельно, — это возможность запускать один стиль внутри другого.
Каждая функция будет проходить три этапа реализации:
- Только синхронная (мы находимся здесь)
- Синхронная реализация с асинхронной обёрткой
- Асинхронная реализация с синхронной обёрткой
Асинхронная обёртка
Сначала существующий синхронный код будет обёрнут в асинхронный интерфейс, который запускает синхронный код в пуле потоков. Это позволит нам спроектировать и предоставить асинхронный интерфейс относительно быстро, без необходимости переписывать весь имеющийся код на асинхронность.
Инструментарий для этого уже доступен в asgiref в виде функции sync_to_async
, которая поддерживает штуки вроде обработки исключений или threadlocals (подробнее об этом ниже).
Выполнение кода в потоках скорее всего не приведёт к увеличению производительности — появившиеся накладные расходы, вероятно, немного замедлят его в случае, когда вы просто запускаете нормальный линейный код, — но это позволит разработчикам начать запускать что-нибудь конкурентно и привыкнуть к новым возможностям.
Кроме того, есть несколько частей Django, которые чувствительны к запуску в том же потоке при повторном обращении; например, обработка транзакций в БД. Если бы мы обернули в atomic()
какой-нибудь код, который затем обращался бы к ORM через случайные потоки, взятые из пула, транзакция не имела бы эффекта, поскольку она привязана к соединению внутри того потока, в котором была запущена транзакция.
В таких ситуациях требуется «липкий поток» ("sticky thread"), в котором асинхронный контекст последовательно вызывает весь синхронный код в одном и том же потоке вместо распихивания его по пулу потоков, сохраняя правильное поведение ORM и других чувствительных к потокам частей. Все части Django, которые, как мы подозреваем, нуждаются в этом, в том числе весь ORM, будут использовать версию sync_to_async
, которая учитывает это, так что всё по умолчанию безопасно. Пользователи смогут выборочно отключать это для конкурентного выполнения запросов — подробнее см. «ORM» ниже.
Асинхронная реализация
Следующий шаг — переписать реализацию функции на асинхронный код и затем представить синхронный интерфейс через обёртку, которая выполняет асинхронный код в одноразовом event loop. Это уже доступно в asgiref в виде функции async_to_sync
.
Необязательно переписывать сразу все функции для быстрого перехода к третьему этапу. Мы можем сосредоточить свои усилия на тех частях, которые мы умеем делать хорошо и которые имеют поддержку сторонними библиотеками, параллельно помогая остальной экосистеме Python в вещах, которые требуют большей работы для реализации нативной асинхронности; это рассматривается ниже.
Этот общий обзор работает почти со всеми функциями Django, которые должны стать асинхронными, за исключением тех мест, для которых Python не предоставляет асинхронных эквивалентов функций, которые мы уже используем. Результатом будет либо изменение в том, как Django представляет свой API в асинхронном режиме, либо работа с core-разработчиками Python, чтобы помочь в разработке асинхронных возможностей Python.
Threadlocals
Одна из базовых деталей реализации Django, которую необходимо упомянуть отдельно от большинства описанных ниже функций, — это threadlocals. Как следует из названия, threadlocals работают в пределах потока, и хотя Django держит объект HttpRequest
за пределами threadlocal, мы помещаем туда несколько других вещей — например, соединения с базой данных или текущий язык.
Использование threadlocals может быть разделено на два варианта:
- «Context locals», где значение необходимо в пределах некоторого stack-based контекста, такого как запрос. Это нужно для установки текущего языка.
- «True threadlocals», где защищаемый код в самом деле небезопасен для вызова из другого потока. Это нужно для соединений с базой данных.
На первый взгляд может показаться, что «context locals» могут быть решены с помощью нового модуля contextvars в Python, но Django 3.0 ещё должен будет поддерживать Python 3.6, в то время как этот модуль появился в 3.7. Кроме того, contextvars
специально предназначен для избавления от контекста, когда происходит переключение, например, в новый поток, в то время как нам нужно сохранить эти значения, чтобы позволить функциям sync_to_async
и async_to_sync
нормально работать в качестве обёрток. Когда Django станет поддерживать только 3.7 и новее, мы могли бы рассмотреть возможность использования contextvars
, но это потребовало бы значительной работы в Django.
Это уже решено с помощью asgiref Local
, который совместим с сопрограммами и потоками. Сейчас он не использует contextvars
, но мы можем переключить его на работу с бэкпортом для 3.6 после некоторого тестирования.
«True threadlocals», с другой стороны, могут просто продолжить работать в текущем потоке. Тем не менее, мы должны быть более осторожными, чтобы предотвратить утечку таких объектов в другой поток; когда представление больше не выполняется в одном и том же потоке, а порождает поток для каждого вызова ORM (во время этапа «синхронная реализация, асинхронная обёртка»), некоторые вещи, которые были возможны в синхронном режиме, будут невозможны в асинхронном.
Это потребует отдельного внимания и запрета некоторых ранее возможных операций в асинхронном режиме; случаи, о которых мы знаем, описаны ниже в конкретных разделах.
Одновременная поддержка синхронного и асинхронного интерфейсов
Одна из больших проблем, с которой мы столкнемся при попытке портировать Django, заключается в том, что Python не позволяет сделать синхронную и асинхронную версии функции с одним и тем же именем.
Это значит, что нельзя просто так взять и сделать такой API, который бы работал примерно так:
# Синхронная версия
value = cache.get("foo")
# Асинхронная версия
value = await cache.get("bar")
Это неудачное ограничение того способа, которым реализована асинхронность в Python, и здесь нет очевидного пути обхода. Когда что-то вызывается, вы не знаете, будут ли вас await'ить или нет, поэтому нет возможности определить, что именно нужно возвращать.
(Примечание: это связано с тем, что Python реализует асинхронные функции как «синхронный callable который возвращает сопрограмму», а не как что-то вроде «вызов метода __acall__
у объекта». Асинхронные контекстные менеджеры и итераторы не имеют такой проблемы, потому что у них есть отдельные методы __aiter__
и __aenter__
.)
С учётом этого мы должны поместить пространства имён синхронной и асинхронной реализаций отдельно друг от друга, чтобы они не конфликтовали. Мы могли бы сделать это с помощью именованного аргумента sync=True
, но это приводит к запутанным телам функций/методов и не даёт использовать async def
, а также позволяет случайно забыть написать этот аргумент. Случайный вызов синхронного метода, когда вы хотели вызвать его асинхронно, опасен.
Предлагаемое решение для большинства мест в кодовой базе Django состоит в предоставлении суффикса для имён асинхронных реализаций функций — например, cache.get_async
в дополнение к синхронному cache.get
. Хотя это уродливое решение, оно позволяет очень легко обнаружить ошибки при просмотре кода (вы должны использовать await
с _async
-методом).
Представления и обработка HTTP
Представления (views), возможно, являются краеугольным камнем полезности асинхронности, и мы ожидаем, что большинство пользователей будут делать выбор между асинхронным и синхронным кодом.
Django будет поддерживать два вида представлений:
- Синхронные представления, определяемые, как и сейчас, синхронной функцией или классом с синхронным
__call__
- Асинхронные представления, определяемые асинхронной функцией (возвращающей сопрограмму) или классом с асинхронным
__call__
.
Их будет обрабатывать BaseHandler
, который будет проверять представление, полученное от URL resolver, и вызывать его соответствующим образом. Базовый обработчик должен быть первой частью Django, которая станет асинхронной, и нам нужно будет изменить обработчик WSGI, чтобы он вызывал его в своём собственном event loop, используя async_to_sync
.
Промежуточные слои (middleware) или настройки вроде ATOMIC_REQUESTS
, которые оборачивают представления в не асинхронно-безопасный код (например, блок atomic()
), продолжат работать, но будет влияние на их скорость (например, запрет параллельных вызовов ORM внутри представления при atomic()
).
Существующий класс StreamingHttpResponse
будет изменён, чтобы иметь возможность принимать либо синхронный, либо асинхронный итератор, и тогда его внутренняя реализация всегда будет асинхронной. Аналогично и для FileResponse
. Поскольку это потенциальная точка обратной несовместимости для стороннего кода, который непосредственно обращается к объектам Response, нам всё равно нужно будет предоставить синхронный __iter__
для переходного периода.
WSGI по-прежнему будет поддерживаться Django в течение неопределённого времени, но обработчик WSGI перейдет к запуску асинхронных middleware и представлений в своём собственном одноразовом event loop. Это, вероятно, приведёт к небольшому снижению производительности, но в первоначальных экспериментах это не оказало слишком большого влияния.
Все функции асинхронного HTTP будут работать внутри WSGI, включая long-polling и медленные ответы, но они будут такими же неэффективными, как и сейчас, занимая по потоку/процессу на каждое соединение. Серверы ASGI будут единственными, кто сможет эффективно поддерживать множество одновременных запросов, а также обрабатывать не-HTTP протоколы, такие как WebSocket, для использования такими расширениями вроде Channels.
Промежуточные слои
В то время как в предыдущем разделе обсуждался в основном путь request/response, для middleware необходим отдельный раздел из-за сложности, заложенной в их текущем дизайне.
Django middleware сейчас устроены в виде стека, в котором каждый middleware получает get_response
для запуска следующего по порядку middleware (или представления для самого нижнего middleware в стеке). Однако нам необходимо поддерживать смесь синхронных и асинхронных middleware для обратной совместимости, и эти два типа не смогут обращаться друг к другу нативно.
Таким образом, чтобы обеспечить работу middleware, нам придется вместо этого инициализировать каждый middleware плейсхолдером get_response, который вместо этого возвращает управление обратно в обработчик и обрабатывает как передачу данных между middleware и представлением, так и проброс исключения. В некотором смысле это в конечном итоге будет выглядеть как middleware эпохи Django 1.0 с внутренней точки зрения, хотя, конечно, пользовательский API останется прежним.
Мы можем объявить синхронные middleware устаревшими, но я рекомендую не делать этого в ближайшее время. Если и когда мы дойдём до конца цикла их устаревания, мы могли бы затем вернуть реализацию middleware в чисто рекурсивную стековую модель, как сейчас.
ORM
ORM — самая большая часть Django по размеру кода и самая сложная для преобразования в асинхронную.
Во многом это связано с тем, что лежащие в основе драйверы баз данных являются синхронными by design, и продвижение будет медленным к набору зрелых, стандартизированных, асинхронных драйверов баз данных. Вместо этого мы должны спроектировать будущее, в котором драйверы баз данных изначально будут синхронными, и сделать фундамент для контрибьюторов, которые в дальнейшем будут разрабатывать асинхронные драйверы итеративным путём.
Проблемы с ORM делятся на две основные категории — потоки и неявные блокировки.
Потоки
Основная проблема с ORM в том, что Django разработан вокруг единого глобального объекта connections
, который магически выдаёт вам подходящее соединение для вашего текущего потока.
В асинхронном мире — где все сопрограммы работают в одном и том же потоке — это не только раздражает, но и просто опасно. Без какой-либо дополнительной безопасности пользователь, обращающийся к ORM как обычно, рискует сломать объекты соединений обращением из нескольких разных мест.
К счастью, объекты соединений хотя бы переносимы между потоками, хоть их и нельзя вызвать из двух потоков одновременно. Django уже заботится о thread-safety для драйверов баз данных в коде ORM, и поэтому у нас есть место для изменения его поведения для правильной работы.
Мы изменим объект connections
так, чтобы он понимал как сопрограммы, так и потоки — повторно используя некоторый код из asgiref.local
, но с добавлением дополнительной логики. Соединения будут совместно использоваться в асинхронном и синхронном коде, который вызывает друг друга — с передачей контекста через sync_to_async
и async_to_sync
— и синхронный код будет принудительно выполняться последовательно в одном «липком потоке» ("sticky thread"), так что это всё не сможет работать одновременно и ломать thread-safety.
Это подразумевает, что нам нужно решение вроде контекстного менеджера для открытия и закрытия подключения к базе данных, подобно atomic()
. Это позволит нам обеспечить последовательный вызов и sticky threads в этом контексте и позволить пользователям создавать несколько контекстов, если они хотят открыть несколько соединений. Это также дает нам потенциальный путь избавления от магического глобального connections
, если мы хотим развивать это дальше.
На данный момент Django не имеет управления жизненным циклом соединений, которое не зависит от сигналов класса обработчика, и поэтому мы будем использовать их для создания и очистки этих «контекстов соединения». Документация также будет обновлена, чтобы было понятнее, как правильно обрабатывать соединения вне цикла запрос/ответ; даже в текущем коде многие пользователи не знают, что любая долго работающая management-команда должна периодически вызывать close_old_connections
для корректной работы.
Обратная совместимость означает, что мы должны разрешать пользователям доступ к connections
из любого случайного кода в любое время, но мы разрешим это только для синхронного кода; мы обеспечим, чтобы код был обёрнут в «контекст соединения», если он асинхронный, с первого дня.
Может показаться, что было бы неплохо добавить в дополнение к transaction.atomic()
ещё и transaction.autocommit()
и требовать у пользователя запускать весь код внутри одного из них, но это может привести к путанице по поводу того, что происходит, если вложить один из них внутрь другого.
Вместо этого я предлагаю создать новый контекстный менеджер db.new_connections()
, который включает это поведение, и заставить его создавать новое подключение всякий раз, когда он вызывается, и позволить произвольное вложение atomic()
внутри него.
Каждый раз при входе в блок new_connections()
Django настраивает новый контекст с новыми подключениями к базе данных. Все транзакции, которые выполнялись за пределами блока, продолжаются; любые вызовы ORM внутри блока работают с новым подключением к базе данных и будут видеть базу данных с этой точки зрения. Если в базе данных включена изоляция транзакций, как это обычно делается по умолчанию, это означает, что новые соединения внутри блока могут не видеть изменений, внесённых какими-либо незафиксированными транзакциями за её пределами.
Кроме того, соединения внутри этого блока new_connections
могут сами использовать atomic()
для запуска дополнительных транзакций на этих новых соединениях. Разрешается любое вложение этих двух диспетчеров контекста, но каждый раз, когда используется new_connections
, ранее открытые транзакции «приостанавливаются» и не влияют на вызовы ORM до тех пор, пока не будет завершён новый блок new_connections
.
Пример, как этот API может выглядеть:
async def get_authors(pattern):
# Create a new context to call concurrently
async with db.new_connections():
return [
author.name
async for author in Authors.objects.filter(name__icontains=pattern)
]
async def get_books(pattern):
# Create a new context to call concurrently
async with db.new_connections():
return [
book.title
async for book in Book.objects.filter(name__icontains=pattern)
]
async def my_view(request):
# Query authors and books concurrently
task_authors = asyncio.create_task(get_authors("an"))
task_books = asyncio.create_task(get_books("di"))
return render(
request,
"template.html",
{
"books": await task_books,
"authors": await task_authors,
},
)
Это несколько многословно, но цель также состоит в том, чтобы добавить высокоуровневые шорткаты, чтобы включить такое поведение (а также охватить переход от asyncio.ensure_future
в Python 3.6 к asyncio.create_task
в 3.7).
С помощью этого контекстного менеджера и «липких потоков» в пределах одного контекста соединения мы гарантируем, что весь код будет настолько безопасным, насколько мы сможем сделать это по умолчанию; есть вероятность, что пользователь может использовать соединение в одном потоке для двух разных частей запроса, используя yield
, но это и так возможно сейчас.
Неявные блокировки
Другая проблема текущего дизайна ORM в том, что в экземплярах моделей встречаются блокирующие (связанные с сетью) операции, в частности чтение связанных полей.
Если вы берёте экземпляр модели и затем обращаетесь к model_instance.related_field
, Django прозрачно подгрузит содержимое связанной модели и вернёт его вам. Однако это невозможно в асинхронном коде — блокирующий код не должен выполняться в главном потоке, а асинхронного варианта доступа к атрибутам нет.
К счастью, у Django уже есть выход из этого — select_related
, который подгружает связанные поля заранее, и prefetch_related
для отношений «многие ко многим». Если вы используете ORM асинхронно, мы запретим любые неявно блокирующие операции, такие как фоновый доступ к атрибутам, и вместо этого вернём ошибку, сообщающую, что вы должны предварительно извлечь поле.
Это даёт дополнительное преимущество, заключающееся в предотвращении медленного кода, который выполняет N запросов в цикле for
, что является частой ошибкой многих начинающих программистов на Django. Это поднимает входной барьер, но помните, что асинхронный Django будет необязательным — пользователи по-прежнему смогут писать синхронный код, если они пожелают (и это будет поощряться в учебнике, так как в синхронном коде намного сложнее ошибиться).
QuerySet
, к счастью, может запросто реализовать асинхронные генераторы и прозрачно поддерживать и синхронность, и асинхронность:
async def view(request):
data = []
async for user in User.objects.all():
data.append(await extract_important_info(user))
return await render("template.html", data)
Другое
Части ORM, связанные с изменением схемы, не будут асинхронными; они должны вызываться только из management-команд. Некоторые проекты уже вызывают их в представлениях, но это в любом случае не очень хорошая идея.
Шаблоны
Шаблоны сейчас полностью синхронные, и план состоит в том, чтобы оставить их такими на первом этапе. Написание асинхронного языка шаблонов возможно, но это будет значительный объем работы, заслуживающий отдельного обсуждения и DEP.
Стоит отметить, что Jinja2 уже поддерживает асинхронность, так что было бы неплохо рассмотреть вариант официально рекомендовать его для некоторых случаев использования.
Учитывая это, мы добавим асинхронную обёртку в текущую библиотеку шаблонов Django и её различные точки входа, но всё же будем запускать шаблонов синхронно. Движок Jinja2 будет обновлён для использования его собственной асинхронности, и будет добавлена документация, чтобы позволить всем остальным делать то же самое, если они того пожелают.
Нам нужно будет изменить сигнатуру движка шаблонов, включив в неё метод render_async
, аналогичный методу render
; при этом будет вызываться асинхронная реализация, если она определена, и тогда шаблон будет рендериться в асинхронном режиме.
Кэширование
В механизм кэширования Django нужно будет добавить асинхронный вариант — к движкам кэширования добавляются _async
-варианты методов (например, get_async
, set_async
).
Реализации по умолчанию, которые просто обращаются к уже существующему API через sync_to_async
, будут добавлены в BaseCache
.
Кажется, нет никаких проблем с thread-safety в том API кэширования, который предоставляет Django, но мы должны изучить сторонние библиотеки и убедиться, что есть все нужные для них механизмы. Те же самые утилиты, которые мы пишем для ORM, вероятно, помогут в подобной ситуации для кэшей.
Формы
Хотя базовая библиотека форм не нуждается в поддержке асинхронности, проверка и сохранение форм могут быть переопределены пользователем, и как этот код, так и несколько частей ModelForm
используют ORM для связи с базой данных.
Это означает, что в какой-то момент методы clean
и save
, как минимум, должны вызываться асинхронно. Однако, как и в случае с шаблонами, я полагаю, что это не является критически важным для достижения цели в рамках первого этапа и поэтому может быть решено с помощью отдельных рабочей группы и DEP.
Отправка электронной почты является одной из основных частей Django, которая особенно выиграет от асинхронного интерфейса. Можно добавить вариант send_mail_async
функции send_mail
, а также async
-варианты всех основных функций для работы с почтой (например, mail_admins
).
Это должна быть одна из наиболее независимых частей Django для преобразования, и уже есть асинхронно-совместимые библиотеки SMTP, если мы решим их использовать. Опять же, однако, у этой задачи более низкий приоритет, и она может быть решена отдельно, когда придёт время.
Тестирование
Тестирование асинхронных приложений довольно сложно, и некоторые части фреймворка тестирования Django потребуют обновления.
На базовом уровне сырые ASGI-приложения можно тестировать с помощью asgiref.testing.ApplicationCommunicator
. Он позаботится о запуске сопрограммы приложения вместе с тестом и позволит выполнить assert'ы на выходе.
Однако большинство пользователей Django будут использовать тестовый клиент для тестирования своего сайта, поэтому его необходимо обновить, чтобы он работал в асинхронном режиме. Что интересно, это не является жёстким требованием — тестовый клиент в том виде, в каком он есть, будет обновлён для запуска асинхронного ядра обработки HTTP в своём собственном event loop, чтобы соответствовать обработчику WSGI.
Основным преимуществом наличия асинхронного тестового клиента будет более быстрое тестирование и возможность более прямого инспектирования сопрограмм. Это означает, что однажды это должно быть сделано, но в начале это не критично.
Что действительно критично, так это способность запускать тесты, которые сами по себе являются асинхронными. Прямо сейчас это возможно путём декорирования async def
теста с помощью @async_to_sync
, но его самого нужно тщательно протестировать и, может, получше интегрировать в Django test runner.
Также должна быть возможность включать режимы отладки asyncio (которые обнаруживают заблокированные loop'ы и сопрограммы, которые никогда не запускались) во время тестов, и, вероятно, также при DEBUG=True
. Это средство отладки просто печатает в консоль по умолчанию — нам нужно выяснить, можем ли мы сделать его более явным, чтобы помочь нашим пользователям писать безопасный код.
WebSockets
Поддержки вебсокетов не будет в самом Django; вместо этого мы позаботимся о том, чтобы у Channels были все хуки, необходимые для чистой интеграции и работы с ASGI, чтобы он мог обрабатывать вебсокеты самостоятельно.
Цель состоит в том, чтобы не только обеспечить простой перенос вебсокетов в Channels, но и позволить другим приложениям принимать другие протоколы, которые могут предоставлять серверы ASGI.
Порядок действий
Как видно из приведенных выше разделов, каждая функция имеет свои проблемы, которые необходимо преодолеть. Если бы мы решали всё это последовательно, прошли бы годы до появления даже первоначальной версии всего этого.
Тем не менее, возможность добавлять асинхронные обёртки вокруг синхронных функций позволяет нам быть гораздо более итеративными во всём этом. На самом деле есть только две основные части работы, которые необходимо выполнить в первую очередь — иметь возможность создания асинхронных представлений и включить асинхронные тесты.
После того, как обе этих задачи будут завершены, мы можем работать над всеми остальными функциями параллельно и выпускать их по мере готовности. Даже в некоторых функциях, таких как ORM, мы можем сначала сделать базовые операции асинхронными, выпустить их, а затем реализовывать всё остальное с учётом отзывов наших пользователей.
Предлагаемый порядок таков:
Первый этап (вероятно в 3.0)
- Обработка HTTP, промежуточных слоёв и представлений (асинхронная реализация с синхронной обёрткой)
- Безопасность async и обнаружение использования нескольких потоков в ORM
- Поддержка асинхронных тестов
Второй этап (вероятно в 3.1)
- ORM (асинхронная обёртка вокруг текущего синхронного ядра)
- Шаблоны (асинхронная обёртка вокруг текущего синхронного ядра)
- Кэширование (асинхронная обёртка вокруг текущего синхронного ядра)
Последующие отдельные проекты
- ORM (асинхронная реализация с синхронной обёрткой для обратной совместимости)
- Кэширование (асинхронная реализация с синхронной обёрткой)
- Формы
Крайне важно не пытаться выпустить это как одно гигантское изменение; мы выиграем гораздо больше от того, что это будет постепенно. Будут неудачи на этом пути, и обеспечение того, чтобы каждая функция была изолирована от других, означает, что задержки в разработке разных функций не будут влиять друг на друга.
Может оказаться так, что мы посчитаем невозможным сделать какую-то функцию изначально асинхронной; в этом случае мы не должны бояться оставить её синхронной как есть, но с поддерживаемой асинхронной обёрткой, которая безопасно работает с функцией в пуле потоков. Цель состоит в том, чтобы сделать доступной асинхронность для разработчиков, использующих Django, а не сделать сам Django идеальным async-only проектом.
Некоторые из упомянутых проектов, скорее всего, будут иметь собственные DEP для реализации, включая уровень кэширования, шаблоны, email и формы. Асинхронный слой БД может также потребовать асинхронной версии DBAPI — это то, что как минимум требует некоторого обсуждения с core Python и, возможно, PEP, хотя уже была проделана определенная работа в этом направлении.
Мотивация
Софт живёт в меняющемся мире, и это, пожалуй, особенно относится к вебу. Нынешний дизайн Django хорошо работает уже более десяти лет, и он по-прежнему является отличным дизайном для решения многих задач, которые должны выполнять бэкэнд-разработчики; он поддерживает миллиардные компании и вдохновил фреймворки на других языках использовать похожий дизайн.
Однако мы должны всегда думать о будущем и о том, как мы можем помочь развить веб-разработку снова. Хотя некоторые из этих изменений мы никогда не увидим, асинхронный код — это то, что уже появилось и существует некоторое время, и мы сейчас находимся в центре событий.
Асинхронный код даёт возможность преодолеть один из основных недостатков Python — его неэффективную многопоточность. Веб-серверы на Python должны тщательно балансировать между достаточным количеством потоков, чтобы обеспечить эффективное обслуживание запросов, и поддерживать низкие расходы на переключение контекста.
Хотя Python не является идеальным асинхронным языком и есть некоторые недостатки в дизайне asyncio
, вокруг него выросло немало библиотек и модулей, и мы получаем выгоду от работы с большим сообществом. В то же время важно, чтобы у нас был план, дающий немедленную выгоду нашим пользователям, вместо того, чтобы пытаться написать совершенно новый Django-size фреймворк с нуля.
Что это даёт
Мы не просто добавляем асинхронность в Django, чтобы сделать её более «быстрой»; цель состоит в том, чтобы предоставить такие возможности, к которым у наших пользователей — тех, кто ведёт разработку на базе Django — просто не было доступа ранее.
Ключевой частью этого является то, что наши пользователи смогут запускать вещи конкурентно. Будь то запросы к базе данных, запросы к внешним API или обращение к ряду микросервисов, большинству проектов Django приходится выполнять параллельную работу в какой-то момент внутри представления.
Очень немногие фреймворки приблизились к тому, чтобы сделать конкурентность доступной и безопасной, и Django имеет возможность пересечь эту границу. Если мы можем сделать конкурентную отправку запросов к базе данных так же просто, как это делает Django ORM сейчас, мы можем поднять планку того, что значит иметь фреймворк, который позволит вам написать быстрое веб-приложение.
Другая часть, которую нужно помнить, — это способность удерживать открытые соединения в течение длительного времени без лишнего потребления ресурсов. Даже без вебсокетов по-прежнему есть много long-poll соединений или server-sent events. Асинхронный Django позволил бы нашим пользователям писать приложения для обработки этих сценариев, не задумываясь о реверс-прокси для разгрузки трафика во время ожидания.
Синхронность всё ещё имеет значение
Важно предоставить это как добавление поддержки асинхронности к Django; мы не переписываем его и не удаляем поддержку синхронности. На самом деле, я считаю, что синхронный код безопаснее и легче писать и что мы должны поощрять его, чтобы он был первым способом написания кода в большинстве случаев.
Django всегда отличался своей адаптивностью по мере роста сайтов и проектов, написанных с его помощью. Асинхронность должна учитывать это; поскольку Django-проект расширяется и усложняется, наши пользователи должны иметь возможность просто обратиться к документации, касающейся асинхронности, и использовать такие же интерфейсы, которые они знают и любят, чтобы продолжать делать свой проект.
Если мы не позволим им смешиваться, мы теряем много преимуществ наличия фреймворка всё-в-одном вроде Django и слишком сильно поднимаем входной барьер.
Обратная совместимость
Как всегда, обратная совместимость невероятно важна. Мы никогда не собираемся выпускать «асинхронный Django», которым наши существующие пользователи не смогут воспользоваться; когда мы делаем этот релиз, он должен идти с традиционными примечаниями к выпуску, парой небольших примечаний по обновлению и в целом просто работать.
Количество переделок, которые мы должны сделать под капотом, чтобы сделать асинхронность и поддерживаемость, сделает обновление немного сложнее чем обычно для тех, кто использует недокументированные API Django, но мы должны приложить все усилия, чтобы извлечь уроки из таких вещей, как переход на Python 3, и обеспечить не только обратную совместимость публичного API, но и работу всех самых популярных пакетов Django и Python.
Помощь Python
Python охватывает огромный спектр различных специальностей. Хотя традиционно веб играет большую роль в популярности и использовании Python, другие области, такие как научные вычисления, значительно выросли за последние годы.
Тем не менее, Django — и сообщество веб-программистов Python в целом — имеют много возможностей, чтобы помочь продвижению Python, и привлечение нового сообщества пользователей к асинхронному Python поможет его развитию. Поддержка асинхронности библиотеками уже достаточно хороша, но нет ничего более хорошего для экосистемы, чем целая куча сайтов, использующих асихнонность в продакшене новыми и интересными путями.
Привлечение новых участников
Я и многие из моих коллег, постоянных контрибьюторов Django, набили руку на реализациях больших функций или путем подачи серий патчей для исправления ошибок. По мере того, как Django рос и развивался, эти возможности становились всё меньше и дальше.
Куча новой работы над функциями даёт нам широкие возможности привлечь новых участников и помочь им освоиться с контрибьютингом — тем более, что все, кто вносит свой вклад, даже предыдущие контрибьюторы, в любом случае должны будут освоить асинхронный код.
При условии, что мы правильно выполним проект — и предоставим места для запуска контрибьютинга, обучения и вознаграждений, — у нас будет одна из самых больших возможностей за последние годы расширить базу контрибьюторов Django (а также помочь увеличить количество людей, готовых внести свой вклад в асинхронный Python в целом).
Что такое Django?
В конечном счёте мы должны рассмотреть, что такое Django. Если это то, что небольшая группа разработчиков намеревалась сделать в Lawrence Journal-World все эти годы — до появления динамичного веба, потокового вещания, SPA — тогда мы, вероятно, можем назвать его законченным. Поддерживать его, полировать, заботиться о безопасности, но в конечном счёте говорить, что все функции уже реализованы.
Но если мы говорим, что роль Django состоит в том, чтобы сделать веб-разработку проще, безопаснее и приятнее — даже когда меняются веб и стили программирования — тогда мы должны научиться адаптироваться. Асинхронность, вероятно, даже не самое большое изменение в этой области; подумайте, что может означать работа Django частично на стороне клиента, например.
Тем не менее, в неопределённом будущем асинхронный Django является важной частью фундаментальной работы. Это даёт нам немедленные выгоды, но также закладывает основу для будущих изменений. Это можно делать постепенно, поэтому мы можем не только обеспечить его устойчивое развитие, но и изменить курс, если нам нужно — убедиться, что мы установили курс на следующее десятилетие того, чем станет веб.
Обоснование
Вопросы перспективы асинхронного Django несколько раз поднимались в темах на django-developers и в недавней ветке получили почти всеобщее одобрение, но с некоторыми сомнениями по поводу конкретной реализации, на которые, как мы надеемся, ответит этот DEP.
Есть несколько способов решения вопроса асинхронности, но, в конечном счёте, на это повлияли несколько ключевых целей:
- Итеративность: этот подход допускает регулярные коммиты в master-ветку и возможность использовать асинхронные возможности в релизах Django по мере их готовности.
- Обратная совместимость: необходимость придерживаться существующего дизайна Django мешает нам сделать красивый, чистый дизайн асинхронного фреймворка, но в конечном итоге, если мы этого не сделаем, мы не сможем назвать это Django и люди не будут его использовать его.
- Устойчивость: асинхронность довольно сложно понять, и мы должны убедиться, что мы поддерживаем не только Django, но и проекты, использующие Django. Этот подход использует асинхронность там, где это необходимо, но оставляет вещи, для которых синхронность тоже вполне подходит, без изменений.
Моя работа с Channels за эти годы также повлияла на предлагаемые в этом DEP решения; различные попытки более тесно интегрироваться с представлениями Django выявили многие из проблем и решений, изложенных выше.
Тем не менее, всегда будут проблемы, которых мы никак не ожидали. Этот DEP менее конкретен в реализации, чем большинство других, потому что он должен быть таким — по мере движения к асинхронному Django мы будем сталкиваться с проблемами и при необходимости сможем исправить курс.
Предложенный способ реализации асинхронности может также привести к небольшой потере производительности для пользователей с полностью синхронным кодом. Внедрение асинхронного кода в Django, вероятно, снизит производительность для тех пользователей, которые продолжать использовать WSGI и полностью синхронные представления, так как асинхронный event loop должен будет запускаться каждый раз, когда им нужно запустить асинхронный код. В данном случае целью является снижение производительности на 10% или менее — если падение окажется слишком велико, мы можем посвятить время его улучшению. План не состоит в том, чтобы реализовать асинхронность ценой ухудшения синхронности.
Также стоит подумать о том, что произойдёт, если реализация проекта будет прекращена (из-за недоступности участников, изменения экосистемы Python или по другим причинам). Постепенная реализация изменений означает, что в этом случае Django вряд ли окажется в плохом состоянии; может быть несколько влитых изменений, которые следует отменить, но намерение состоит в том, чтобы политика Django в отношении master-ветки минимизировала вероятность такого события.
Кроме того, даже если мы просто запустим асинхронные представления и больше ничего (без ORM, без шаблонов и т.д.), это всё равно будет успешным проектом; это само по себе открывает большой потенциал и даёт доступ почти ко всей экосистеме асинхронности в Python.
Другим потенциальным долгосрочным эффектом этого проекта является то, что он использует людей и энергию, которые могли быть использованы в других проектах Django, что может привести к «выгоранию» некоторых контрибьюторов. Хотя это риск, о котором мы всегда должны помнить, подход к этому проекту с учётом устойчивости и финансирования сведёт к минимуму это и, мы надеемся, даст большой выигрыш для людей.
Альтернативы
Здесь указаны некоторые альтернативные подходы или решения, которые были отклонены, с объяснением, почему.
Асинхронные модули вместо _async функций
Вместо довольно уродливого суффикса для методов и функций, которые требуют как асинхронного, так и синхронного варианта (например, django.core.cache.cache.get_async
), мы могли бы создать целые отдельные асинхронные пространства имен аналогичными именами и просто поменять импорты:
from django.core.cache_async import cache
cache.get("foo")
Это смотрится чище, но проблема возникает при попытке использовать обе реализации в одном файле; становится трудно отследить, что конкретно вы используете, и случайно сделанный синхронный вызов становится трудноуловимой ошибкой.
Тем не менее, это одно из решений, к которому я был ближе всего; у него всё ещё есть некоторые достоинства.
Форк Django
Хард-форк будет невозможно поддерживать, а также потребует огромной траты ресурсов; вполне вероятно, что было бы почти невозможно влить результат обратно, учитывая огромное отклонение от основной ветки кода, и разделение базы пользователей и поддержки — ужасная идея.
Для любого крупного рефакторинга вроде данного единственный способ надёжно добиться результата, особенно в сообществе, состоящем в основном из добровольцев, — сделать его постепенным, а это означает отсутствие полных форков.
Расширение Channels
У людей популярно мнение, что можно расширить Channels для достижения многих из этих целей, «внешних» для Django. Надеюсь, что если вы прочли что-то из этого объёмного текста выше, то понимаете, насколько нецелесообразно было бы реализовывать это за пределами Django; даже если бы мы игнорировали ORM, поддержка отдельного HTTP/middleware flow была бы очень хрупкой.
Не asyncio
Существуют другие асинхронные фреймворки и event loop'ы для Python, которые не asyncio
и которые часто принимали более удачные дизайнерские решения некоторых задач, которыми занимается Django. Ключевые слова await
и async
в Python фактически не зависят от event loop и его реализации.
Однако популярность библиотек, основанных на asyncio
, делает его единственным приемлемым выбором; Django не может оставаться в одиночестве, мы должны полагаться на исследования и работу других, чтобы добиться успеха. В то же время большая часть проводимой реструктуризации Django всё равно будет применима к другому асинхронному решению; если ситуация изменится позже, работа, необходимая для адаптации к другому async runtime, будет не такой сложной, как этот начальный переход.
Greenlets/Gevent
Стоит отдельно поговорить о гринлетах и Gevent, так как они являются реализацией конкурентности, которая не использует асинхронный синтаксис Python.
Хотя эта идея на первый взгляд кажется привлекательной, с таким подходом есть много тонких проблем. Отсутствие явного yield
или await
означает, что сложный API, такой как в Django, в основном становится непредсказуемым в отношении того, будет ли он блокировать текущий контекст выполнения или нет. Это приводит к гораздо более высокому риску состояния гонки и взаимных блокировок без тщательного программирования, что я испытал на себе.
Проблемы с сопрограммами, совместно использующими соединения с базой данных, упомянутые выше, также могут возникнуть с гринлетами. Мы должны были бы обеспечить greenlet-safe всей системы Django ORM и сделать что-то похожее на new-connection-context, описанный выше.
Кроме того, сторонняя поддержка этого стиля намного слабее. Хотя перемещение Django к нему может вызвать «эффект ореола» и возродить популярность gevent, этого, вероятно, будет недостаточно для поддержки всех библиотек, которые нам понадобятся.
Финансирование
С проектом такого масштаба важно рассматривать финансирование как важнейшую часть реализации этого DEP.
Несмотря на то, что он предназначен для реализации небольшими итерациями — в том числе если он окажется частично заброшен, — наибольшая ценность заключается в том, что он выполняется как единая непрерывная работа в относительно короткий период времени (год или около того).
Это означает, что проект получит значительную выгоду от того, что кто-то заплатит за координацию и контрибьютинг на условиях частичной или полной занятости. Такая работа для Django Fellows не оплачивается; их задача вместо этого — сортировка и обслуживание, и поэтому нам нужно либо увеличить их финансирование и время (если они захотят), либо, что более вероятно, искать что-то ещё.
Предыдущие крупные инициативы привлекали разовое финансирование — например, кампании на Kickstarter для migrations
и contrib.postgres
, а также грант MOSS (Mozilla) для Channels. С таким заголовком, как асинхронный Django, вполне вероятно, что мы могли бы собрать приличную сумму для этого проекта.
Также стоит подумать о том, кто может помочь в этом проекте. Асинхронность — всё ещё относительно новая область Python, и многие контрибьюторы Django — старые и новые — не имеют большого опыта работы с ней. Мы должны составить бюджет не только для людей, имеющих опыт работы с Django/async, но также и для обучения и адаптации участников.
Характер работы позволяет ей быть в высокой степени распараллеливаемой по сравнению с первоначальной работой над HTTP/middleware/view flow, и поэтому мы должны убедиться, что любой, кто заинтересован, может помочь в составе небольшой «рабочей группы», без необходимости понимать всю систему сразу.
Я не претендую на то, чтобы иметь ответ о том, кто должен управлять этим проектом, сколько людей должно быть оплачено и как они должны быть оплачены (будь то прямое финансирование, как Fellows, контракты/вознаграждения за работу над отдельными функциями, как мы делали с Channels, или частичная или полная занятость у работодателей, платящих им зарплату), но я знаю, что оплачиваемые взносы будут иметь большое значение.
Хотя проект может быть успешно реализован только силами волонтёров, он в таком случае будет реализовываться намного медленнее и, как я ожидаю, гораздо менее эффективно реагировать на изменения по пути, и мы также можем потерять пользователей Django, если это займёт слишком много времени.
Обратная совместимость
Цель, конечно, состоит в том, чтобы не было проблем с обратной совместимостью, и мы обеспечим это в меру наших возможностей на задокументированных публичных API.
Тем не менее, вероятно, будут небольшие побочные эффекты от изменения внутренних частей, в частности, HTTP/middleware flow. Любой, кто использует недокументированные API, включая отчеты об ошибках и интеграцию APM, должен будет обновить свой код.
Стоит отметить, что любой, кто использует Django с асинхронным кодом прямо сейчас, скорее всего столкнётся с несовместимостью, поскольку мы усиливаем безопасность основных компонентов. Любое приложение, которое вызывает ORM из сопрограммы, например, перестанет работать — но такое приложение уже было ошибочным, поскольку ORM полностью синхронный и цикл событий приложения был бы полностью заблокирован в любом случае.
Эталонная реализация
Данный DEP слишком велик, чтобы предоставить эталонную реализацию; он включает в себя значительное переписывание Django в течение нескольких лет и версий.
Тем не менее, много базового кода уже написано в asgiref, в том числе тяжёлая работа по тестированию, обработке потоков и переключению между синхронным и асинхронным мирами. Эта библиотека была официальным проектом Django в течение нескольких лет и станет обязательной зависимостью Django.
Также немало работы проведено в проекте Channels, которому удалось внедрить некоторые из этих концепций в Django, даже не имея возможности прикоснуться к самому коду ядра Django.
Авторские права
Этот документ (и этот перевод) опубликован как общественное достояние по лицензии CC0 1.0 Universal.