异步支持

Django 支持编写异步(async)视图;在 ASGI 下运行时,整个请求处理链也支持异步。异步视图同样可以在 WSGI 下工作,但每个请求都会产生少量适配开销,且无法高效处理长时间运行的请求,详见“性能”。

Django 的许多部分都提供异步 API,包括 ORM、缓存框架、身份验证、会话和信号。对于其他代码,sync_to_async() 适配器提供了开销较低的桥接方式,详见“性能”。还可以集成各种原生支持异步的 Python 库。

异步视图

任何视图都可以通过使其可调用部分返回协程来声明为异步 – 通常情况下,可以使用 async def 来实现这一点。对于基于函数的视图,这意味着要使用 async def 来声明整个视图。对于基于类的视图,这意味着要将 HTTP 方法处理程序,如 get() 和 post() 声明为 async def (而不是 __init__() 或 as_view())。

说明

Django 使用 inspect.iscoroutinefunction 判断视图是否为异步。如果通过自定义方式返回协程,请使用 inspect.markcoroutinefunction 标记该函数,确保前者对它返回 True。

在一个 WSGI 服务器下,异步视图将在它们自己的一次性事件循环中运行。这意味着你可以使用异步功能,比如并发的异步 HTTP 请求,而不会出现任何问题,但你不会得到异步堆栈的好处。

主要的好处是能够在不使用 Python 线程的情况下处理数百个连接。这使你能够使用慢速流式传输、长轮询和其他令人兴奋的响应类型。

如果你想使用这些特性,需要使用 ASGI 来部署 Django。

说明

完全异步的请求处理链要求从头到尾都使用异步中间件。如果 ASGI 服务器和异步视图之间存在同步中间件,Django 会在独立线程中运行它来完成适配;相应开销取舍见“性能”。

Django 自带的中间件同时支持同步和异步,第三方中间件则不一定支持。若要查看 Django 适配了哪些中间件,可为 django.request 日志记录器启用调试日志,并查找包含“Asynchronous handler adapted for middleware …”的消息。

在 ASGI 和 WSGI 模式里,你可以始终安全地使用异步支持来并发运行代码而不是串行。这在处理外部 API 或数据存储时特别方便。

如果你想调用仍然是同步的 Django 部分,你需要将其包装在 sync_to_async() 调用中。例如:

from asgiref.sync import sync_to_async

results = await sync_to_async(sync_function, thread_sensitive=True)(pk=123)

如果你在异步视图中意外地尝试从 Django 中调用仅支持同步的部分,你将触发 Django 的 异步安全保护,以保护你的数据免受损坏。

装饰器

以下装饰器可以用于同步和异步视图函数:

例如:

from django.views.decorators.cache import never_cache


@never_cache
def my_sync_view(request): ...


@never_cache
async def my_async_view(request): ...

method_decorator() 可以用于异步方法,包括用 async def 定义的视图处理方法。不过,如果 View 的处理方法为异步,并且装饰的是 dispatch(),那么还需要重写 dispatch,把它也定义成 async def:

class MyClass(View):
    @method_decorator(never_cache)
    async def dispatch(self, *args, **kwargs):
        return super().dispatch(*args, **kwargs)

    async def get(self, request): ...

    async def post(self, request): ...

这里装饰的是 dispatch,而不是各个处理方法,因此需要让 dispatch 也成为异步方法,以便 method_decorator 正确地将结果标记为协程函数。

查询与 ORM

With some exceptions, Django can run ORM queries asynchronously:

async for author in Author.objects.filter(name__startswith="A"):
    book = await author.books.afirst()

详细的说明可以在 异步查询 中找到,简而言之:

  • 所有引发 SQL 查询的 QuerySet 方法都有一个以 a 为前缀的异步变体。

  • async for 在所有的查询集上都得到支持(包括 values() 和 values_list() 的输出结果)。

使用数据库的异步模型方法同样受支持:

async def make_book(*args, **kwargs):
    book = Book(...)
    await book.asave(using="secondary")


async def make_book_with_tags(tags, *args, **kwargs):
    book = await Book.objects.acreate(...)
    await book.tags.aset(tags)

在异步模式下,事务还不可用。如果你有一段需要事务行为的代码,我们建议你将其编写为一个单独的同步函数,并使用 sync_to_async() 调用它。

异步模式下还应禁用通过 CONN_MAX_AGE 设置的持久数据库连接。如果数据库后端提供内置连接池,建议改用连接池;必要时也可以研究第三方连接池方案。与同步 Django 一样,同一进程中的并发请求共享这个池,因此应根据目标在途查询并发量确定连接池大小。

性能

当运行模式与视图不匹配时,例如在 WSGI 下运行异步视图,或在 ASGI 下运行传统同步视图,Django 必须模拟另一种调用方式才能执行代码。单次适配的开销较小:在能够复用运行中事件循环的 ASGI 请求路径中,约为数十微秒;在管理命令、后台任务及脚本使用的冷启动路径中,约为数百微秒。相对于通常以毫秒计的请求耗时,这一开销本身很少明显可见;但随着活动线程增加,在 GIL 竞争下可能变得明显。

如果在紧密循环中为每一行或每次操作单独进行适配,请重构代码,让整个循环在一次 sync_to_async() 或 async_to_sync() 跨越中执行。这样,上下文切换的单次成本会分摊到整个循环,基本不再明显。

中间件也有相同的单次适配开销。Django 会尽量减少同步与异步之间的上下文切换。如果使用 ASGI 服务器,但所有中间件和视图都是同步的,那么只会在进入中间件处理链之前切换一次。

但是,如果在 ASGI 服务器和异步视图之间放置同步中间件,Django 就需要先切换到同步模式执行中间件,再切回异步模式执行视图,同时还要保留同步线程,以便传播中间件异常。对于访问 ORM 后立即返回的请求/响应视图,这通常不会产生明显影响。如果使用 ASGI 在单进程中为非 ORM 的 I/O 提供高并发,例如并行向上游发送 HTTP 请求、服务器发送事件或其他长期请求,这种开销就更重要,因为每个请求额外占用的线程会限制并发量。

你应该执行性能测试来观察 ASGI 和 WSGI 对你的代码有什么影响。在一些案例中,即使对于 ASGI 下的纯同步代码库,性能也可能会有所提高,因为请求处理代码仍然全部异步执行。通常,只有当项目有异步代码时,才需要开启 ASGI 模式。

处理断开连接

对于长时间运行的请求,在视图返回响应之前,客户端可能会断开连接。在这种情况下,视图会引发一个 asyncio.CancelledError。如果需要执行任何清理操作,你可以捕获这个错误并处理它:

async def my_view(request):
    try:
        # Do some work
        ...
    except asyncio.CancelledError:
        # Handle disconnect
        raise

你还可以在流式响应中 处理客户端的断开连接。

异步安全

DJANGO_ALLOW_ASYNC_UNSAFE

Django 的某些关键部分具有不感知协程的全局状态,无法在异步环境中安全运行。这些部分被归类为“异步不安全”,Django 会阻止它们在异步环境中执行。ORM 的同步 API 是主要例子,其他部分也受到同样保护。

如果你试着从有运行事件循环的线程中运行这部分中的任何一个,你会得到一个 SynchronousOnlyOperation 错误。注意,不用在异步函数内部就会得到这个错误。如果你从异步函数中调用一个同步函数,而没有使用 sync_to_async() 或类似方法,也会出现这个问题。这是因为你的代码仍然在具有活动事件循环的线程中运行,即使它可能没有被声明为异步代码。

如果遇到这个错误,你应该修改你的代码,以免从异步上下文中调用有问题的代码。相反,你可以编写代码在同步函数中与不安全异步交流,并使用 asgiref.sync.sync_to_async() 调用(或在自己的线程中运行同步代码的任何其他方式)。

在运行你的 Django 代码环境中你可以使用异步上下文语境。例如, Jupyter 笔记本和 IPython 互动环境都是明显地提供了一种激活事件循环,所以与异步 APIs 互动更容易。

如果你正在使用 IPython shell,你可以通过运行以下命令来禁用这个事件循环:

%autoawait off

作为 IPython 提示符下的命令。这将允许你运行同步代码,而不会生成 SynchronousOnlyOperation 错误;但是,你也无法 await 异步 API。要重新启用事件循环,请运行:

%autoawait on

如果你在除了 IPython 之外的环境中(或者因某些原因无法在 IPython 中关闭 autoawait),并且你可以 确定 代码不会同时运行,而且你 绝对 需要从异步上下文中运行同步代码,那么您可以通过将 DJANGO_ALLOW_ASYNC_UNSAFE 环境变量设置为任何值来禁用警告。

警告

如果启用此选项并且对 Django 的异步不安全部分进行并发访问,可能会导致数据丢失或损坏。请非常小心,不要在生产环境中使用此选项。

如果你需要在 Python 中执行此操作,请使用 os.environ :

import os

os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"

异步适配函数

当从异步的上下文中调用同步的代码时,有必要适配调用风格,反之亦然。为此,有两个适配器功能,可从 asgiref.sync 模块中获取:async_to_sync() 和 sync_to_async() 。它们用于调用样式之间转换,同时保持兼容性。

这些适配器函数在 Django 中被广泛使用。asgiref 包本身是 Django 项目的一部分,当你使用 pip 安装 Django 时,它会自动作为一个依赖项进行安装。

async_to_sync()

async_to_sync(async_function, force_new_loop=False)

使用异步函数并返回包装它的同步函数。可用作直接包装器或装饰器:

from asgiref.sync import async_to_sync


async def get_data(): ...


sync_get_data = async_to_sync(get_data)


@async_to_sync
async def get_other_data(): ...

如果存在异步函数,那么它会在当前线程的事件循环中运行。如果没有当前事件循环,则会为单独异步调用专门启动一个新的事件循环,并且会在它完成后再次关闭。无论哪种情况,异步函数会在调用代码的不同线程上执行。

Threadlocals 和 contextvars 值在两个方向的边界上都保持不变。

async_to_sync() 本质上是 Python 标准库 asyncio.run() 的增强版本。除了保证线程局部变量正常工作外,当 sync_to_async() 包装器在其下层使用时,它还会启用该包装器的 thread_sensitive 模式。在没有运行中事件循环的冷路径中,它与 asyncio.run() 一样需要承担启动新事件循环的成本;已有事件循环运行时,例如 ASGI 请求内部,则会复用现有循环,开销相应降低。

sync_to_async()

sync_to_async(sync_function, thread_sensitive=True)

使用同步函数并返回包装它的异步函数。可用作直接包装器或装饰器:

from asgiref.sync import sync_to_async

async_function = sync_to_async(sync_function, thread_sensitive=False)
async_function = sync_to_async(sensitive_sync_function, thread_sensitive=True)


@sync_to_async
def sync_function(): ...

Threadlocals 和 contextvars 值在两个方向的边界上都保持不变。

假设所有同步功能都在主线程中运行时,则倾向于编写同步功能,因此 sync_to_async() 有两个线程模式:

  • thread_sensitive=True (默认使用):同步函数将与所有其它 thread_sensitive 函数在相同线程里运行。如果主线程是同步的并且你正在使用 async_to_sync() 装饰器,则该同步函数将成为主线程。

  • thread_sensitive=False:同步函数将在一个全新的线程中运行,该线程一旦完成,将会关闭。

Thread-sensitive(线程敏感)模式非常特殊,在同一个线程中运行所有函数需要做很多工作。但是请注意,它依赖于堆栈中它上面的 async_to_sync() 的使用,以便在主线程上正确运行。如果你使用 asyncio.run() 或类似,它将退回到单独共享线程(但不是主线程)中运行 thread-sensitive 函数。

在 Django 中需要这么做的原因是许多库,特别是数据库适配器,要求它们在创建时所在的同一个线程里对其进行访问。许多现有的 Django 代码也假设它都在同一进程中运行(比如中间件将内容添加到请求中以供稍后在视图中使用)。

我们没有引入代码潜在的兼容性问题,而是选择了添加这种模式,以便所有现有的 Django 同步代码都在同一个线程中运行,从而完全兼容异步模式。注意,同步代码始终要与调用它的异步代码保持在不同线程中,所以你应该避免传递原始数据库句柄(handles)或者其他 thread-sensitive 引用。

在同一个请求内部,多次 thread_sensitive 调用会在该请求的工作线程上串行执行。但每个请求都有自己按上下文分配的工作线程,因此并发请求之间不会彼此串行化。这与 Django 的每线程连接模型一致;其他异步数据库库也有相同约束,即同一连接上的并发查询会通过锁串行执行。要支持更多并发请求,应相应增大连接池,而不是禁用 thread_sensitive。

在实际应用中,这意味着在调用 sync_to_async() 时,你不应该传递数据库 connection 对象的特性。这样做将触发线程安全检查:

# DJANGO_SETTINGS_MODULE=settings.py python -m asyncio
>>> import asyncio
>>> from asgiref.sync import sync_to_async
>>> from django.db import connection
>>> # In an async context so you cannot use the database directly:
>>> connection.cursor()
django.core.exceptions.SynchronousOnlyOperation: You cannot call this from
an async context - use a thread or sync_to_async.
>>> # Nor can you pass resolved connection attributes across threads:
>>> await sync_to_async(connection.cursor)()
django.db.utils.DatabaseError: DatabaseWrapper objects created in a thread
can only be used in that same thread. The object with alias 'default' was
created in thread id 4371465600 and this is thread id 6131478528.

相反,您应该将所有数据库访问封装在一个帮助函数中,该函数可以使用 sync_to_async() 调用,而不依赖于调用代码中的连接对象。


原文:异步支持。作者/来源:Django 项目与文档贡献者。本文依据所列原文整理为中文,代码、命令与配置示例保留原文。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容