Python Web框架中的异步行为
#python #fastapi #flask #async

使用Python Web框架时,重要的是要了解它们如何处理异步,否则,随着应用程序的扩大,您一定会遇到一些巨大的性能道路颠簸。在此博客中,我探讨了您可能需要了解的一些最大差异,例如烧瓶,Fastapi,Django等。特别是,通过小示例应用程序比较烧瓶和fastapi。

Python中并发和并行性的基础知识

cpython,python最常见的实现本质上不支持并行运行代码。 GIL(全局解释器锁)可以预防这一点,该锁只只允许一个线程一次运行Python代码。这本身就是一个相当大的话题,可以找到更多信息。

这并不是说什么都不能并行运行,某些诸如numpy之类的软件包大部分是在C中编写的,并且在运行时可能会释放GIL,直到他们需要调用Python C API或更新Python对象为止。涉及系统调用的IO结合操作也将发布GIL(因为在这种情况下,内核负责安全处理请求)。

总的来说,您必须牢记,当编写纯Python代码时,A 单个过程可以同时运行您的 Python代码,但不能并行。注意重点,因为您可以创建多个过程以并行处理请求。这是在Web服务器中完成的,通过产生多个工作流程来处理传入请求。通常,您想拥有一个无状态的应用程序来支持这种部署模式。

WSGI和ASGI

Web服务器网关接口(WSGI)是将各种Web框架(例如Blask and Django)选择的标准配置,可以从Gunicorn或Failings等Web服务器的选择中选择。没有标准化,最好的情况就在下面显示在左侧,每个框架都与每个Web服务器兼容。当然,实际上,会缺少链接,而有些则是不兼容的。在右侧,引入了WSGI标准。 Web Frameworks的开发人员只需要实现此界面即可自动支持所有Web服务器,反之亦然。

Image description

这种通过标准化的间接方式非常常见,可以使不同类型的软件组件之间的兼容性更容易。其他一些不错的例子是来自Microsoft和ONNXLSP project代表机器学习模型。第一个提供了一个标准,因此IDE不必为每种编程语言重新发明WEEL。后者将培训框架与推理框架脱离。回到WSGI,如果有兴趣,您可以找到WSGI标准here的广泛理由。

异步服务器网关接口(ASGI)也是标准化,重点是...异步行为。 WSGI的一个缺点是,应用程序必须提供单个同步可可,该可可接受HTTP请求并返回响应。 WSGI中的工人(通常可以使用多个工程流程,每个工程都可以处理请求)将被阻止,直到响应存在为止。在ASGI中,可以释放工人以供其他请求释放,而不是积极等待直到继续。这允许更高的吞吐量,尤其是当IO绑定的请求数量很高时。下图说明了此关键差异。因为WSGI请求1必须在工人应要求2之前完成。

Image description

请注意,在上面的说明中,我们省略了许多WSGI Web服务器的支持的工人。我们将回到那个later on

WSGI框架中的异步行为

可能有些混乱,但是烧瓶(这是WSGI框架)也支持使用异步用于请求处理程序。这与ASGI框架中的异步请求处理程序的起作用截然不同!我将通过在烧瓶(WSGI)和FastApi(ASGI)中实现相同的示例来说明这一点。

在烧瓶中考虑以下Web应用程序。提供两条路线来检索一些资源。让我们假设资源在另一个位置的其他机器上,它们可能需要一段时间才能检索(通过睡眠通话模拟)。

from flask import Flask
from flask import jsonify
import asyncio

app = Flask(__name__)
resource_ids = [1,2,3,4,5]


async def download_resource(id):
    # Dummy method to simulate long running IO
    await asyncio.sleep(5)
    return f"dummy_result_id_{id}" 


@app.route("/resource/<id>")
async def retrieve_resource(id : int):
    """Get all available resources"""
    result = await download_resource(id)
    return jsonify(result)


@app.route("/resources")
async def retrieve_resources():
    """Get a specific resource"""
    async with asyncio.TaskGroup() as tg:
        dl_tasks = [tg.create_task(download_resource(id)) for id in resource_ids]
    # All downloads have completed
    return jsonify([task.result() for task in dl_tasks])

让我们使用WSGI Web Server Gunicorn对此进行旋转(请注意,给定选项是默认的,但在此处给出以强调其值为1):

gunicorn flask_async:app --workers 1 --threads 1

现在,我们将同时发送3个请求并衡量总响应时间

$ time curl --parallel --parallel-immediate --parallel-max 3 -X GET http://localhost:8000/resource/1 http://localhost:8000/resource/2 http://localhost:8000/resource/3   -H 'accept: application/json'
"dummy_result_id_3"
"dummy_result_id_1"
"dummy_result_id_2"

real    0m15,038s
user    0m0,006s
sys     0m0,000s

请注意,在这种情况下,异步路线如何真正做任何事情。但是,如果我们使用其他路线在单个请求中获取所有资源:

$ time curl -X GET http://localhost:8000/resources -H 'accept: application/json'
["dummy_result_id_1","dummy_result_id_2","dummy_result_id_3","dummy_result_id_4","dummy_result_id_5"]

real    0m5,014s
user    0m0,005s
sys     0m0,000s

在这里,我们看到了我们想要的,并同时处理多个请求。在解释这一点之前,让我们在Fastapi中查看同一程序,这是ASGI应用程序,

from fastapi import FastAPI
from pydantic import Field
import asyncio

app = FastAPI()
resource_ids = [1,2,3,4,5]


async def download_resource(id):
    # Dummy method to simulate long running IO
    await asyncio.sleep(5)
    return f"dummy_result_id_{id}" 


@app.get("/resource/{id}")
async def retrieve_resource(id : int = Field(ge=1)):
    """Get all available resources"""
    result = await download_resource(id)
    return result


@app.get("/resources")
async def retrieve_resources():
    """Get a specific resource"""
    async with asyncio.TaskGroup() as tg:
        dl_tasks = [tg.create_task(download_resource(id)) for id in resource_ids]
    # All downloads have completed
    return [task.result() for task in dl_tasks]

要旋转它,我们使用了一个名为Uvicorn的Asgi Web服务器。

uvicorn fastapi_async:app --workers 1

为此,我们再次运行3个同时请求:

$ time curl --parallel --parallel-immediate --parallel-max 3 -X GET http://localhost:8000/resource/1 http://localhost:8000/resource/2 http://localhost:8000/resource/3   -H 'accept: application/json'
"dummy_result_id_3""dummy_result_id_1""dummy_result_id_2"
real    0m5,018s
user    0m0,003s
sys     0m0,003s

请注意,虽然路线与烧瓶相同(在与异步行为无关的框架上搁置差异),此处的响应时间仅为5秒,而与前15秒相比!

可以在设置异步事件循环的位置解释此差异。对于烧瓶,作为WSGI,它是在请求处理程序本身中设置的。这意味着,如果我们在请求处理程序中等待单个结果,则将控制权回到事件循环中,但是除了等待,别无其他。必须对请求进行一个接一个处理,因为唯一的工人每次被阻止5秒钟。对于请求所有资源的单一路线,可以在同一请求中立即完成3个下载。相比之下,ASGI的Fastapi将设置事件循环一个级别。当请求等待结果时,工人将立即免费处理新的请求。

在使用不同的Web框架时,请理解此示例至关重要,尤其是如果您处理一个混合WSGI应用程序的较大项目(例如,仪表板作为前端,使用烧瓶)和FastApi作为后端。

线程的注释

烧瓶可以运行多个工作线程,这是使用flask --app flask_async run运行开发服务器时的默认线程。当使用带有烧瓶的线时,有两个后端:

  • gthread:常规意义上的线程,由操作系统管理和安排。这很昂贵,它直接将您可以处理到n_worker_processes * n_threads的并发请求的数量联系在一起
  • greenlet:这些是实施的伪线程,而无需任何操作系统参与。用户可以决定何时将控件移交给其他线程,并且/或阻止操作可以自动更改(使用monkeypatch)。您可以说,使用ASGI,异步行为是明确的,而WSGI则是隐含的。代码看起来可能与同步代码完全相同,同时仍获得异步好处。明智的是,很难说哪一个更好,因为两者都依赖于Python共同行驶的相同骨干。可以找到更多信息here11

结论

可以说,同时运行Python代码时,有很多可能性。如果您遇到响应性问题,请确保您知道开发设置和生产设置之间的差异(使用了多少工人和线程,正在使用什么样的工人)。还要注意决定在异步路由时使用的框架使用的基本标准(WSGI或ASGI)。