为了账号安全,请及时绑定邮箱和手机立即绑定

在 FastAPI 中,我们何时定义端点为同步或异步?

标签:
Python Flask API

照片由 Matt Bero 来自 Unsplash 提供

FastAPI 处理同步和异步端点请求的内部工作机制

FastAPI 是 Python 社区中广泛使用的 web 应用开发框架。在工作中,我们大多数的 web 服务都是使用 FastAPI 实现的。最近,我们被要求优化一个 FastAPI 端点的延迟问题。作为其中的一部分,我们进行了性能基准测试,以识别 syncasync 实现的相同端点的延迟。我们还进一步研究了 FastAPI 和 Uvicorn 处理 syncasync 端点请求的内部机制。

我们将发现和结论汇总成这篇博客文章的原因有二:

  • 帮助某人理解 FastAPI 中 syncasync 端点请求处理的不同方式。
  • 帮助某人(使用 FastAPI)决定他们的端点应该采用 sync 还是 async 实现。

我们将使用一个工作中的玩具示例来展示基准测试结果和结论。

注意:任何人都可以阅读这篇博客文章,但部分内容较为技术性,因此为了更好地理解它,最好对线程、异步编程以及FastAPI有一定的基本了解。有很多很好的资源可供参考。这个是我个人最喜欢的一个资源。

背景设定完成后,让我们不再多说,直接开始。

让我们来考虑以下FastAPI应用,它包含两个实现几乎相同的端点。

    # app.py
    import time
    import asyncio
    from fastapi import FastAPI

    app = FastAPI()

    @app.get("/sync_endpoint")
    def sync_endpoint():
        print('Inside Endpoint')
        time.sleep(0.3)
        return {"message": "Hello, FastAPI!"}

    @app.get("/async_endpoint")
    async def async_endpoint():
        print('Inside Endpoint')
        await asyncio.sleep(0.3)
        return {"message": "Hello, FastAPI!"}

    # 如果直接运行此脚本,则使用 uvicorn 运行应用程序
    if __name__ == "__main__":
        import uvicorn
        uvicorn.run(app, host="0.0.0.0", port=8000)

定义了两个具有相同实现的端点,它们首先打印一条消息,先睡眠0.3秒,最后返回响应。sync端点通过阻塞调用来睡眠,而async端点通过非阻塞调用来睡眠。
一般来说,每当有一个IO-bound任务(如从其他服务获取数据、数据库连接等),在我们的示例应用程序中,使用sleep来模拟时,async表现更佳。

原因在于,在sync代码中,整个线程会被阻塞,直到操作完成。在此期间,没有任何其他操作可以被执行。而在async代码中,单个线程可以在任务等待I/O时,在任务之间进行“切换”,从而管理多个任务。这种切换快速且高效,无需承担线程管理的额外开销。

让我们编写一个简单的脚本来并发请求我们的端点,并为每个端点的延迟进行基准测试。

    import sys  
    import httpx  
    import asyncio  
    import time  
    import itertools  

    async def send_request(client, url):  
        try:  
            response = await client.get(url)  
            return response.status_code, response.text  
        except Exception as e:  
            print(f'请求出现错误: {e}')  
        return None, None  

    async def send_concurrent_requests(url, num_requests):  
        limits = httpx.Limits(max_connections=200, max_keepalive_connections=200)  
        async with httpx.AsyncClient(limits=limits, timeout=None) as client:  
            responses = await asyncio.gather(  
              *[send_request(client, url) for _ in range(num_requests)])  
            return responses  

    if __name__ == "__main__":  
        endpoint, num_request = sys.argv[1], int(sys.argv[2])  
        start_time = time.time()  
        responses = asyncio.run(  
            send_concurrent_requests(  
                f'http://localhost:8000/{endpoint}',   
                num_request  
            )  
        )  
        end_time = time.time()  
        print(f'在 {end_time - start_time:.2f} 秒内完成了 {num_request} 个请求')  
        successful = [res for res in responses if res[0] == 200]  
        print(f'成功的响应: {len(successful)}, 失败的响应: {num_request - len(successful)}')

脚本使用num_request个并发用户请求来发起对提供的endpoint的访问。以下是我们在不同并发请求数量下执行脚本时的基准测试结果。

同步端点基准测试结果:  
在29.42秒内完成了1000个请求  
在58.71秒内完成了1500个请求  

异步端点基准测试结果:  
在30.27秒内完成了1000个请求  
在62.13秒内完成了1500个请求

正如我们所见,syncasync 端点所花费的时间相差无几。事实上,sync 有一些优势,这有些违背直觉,与我们之前的解释不太符合。那么为什么会这样呢?要回答这个问题,我们需要了解 FastAPI 内部是如何处理这些 syncasync 端点请求的。

当端点以同步方式定义时的请求的处理
  • 当一个请求到达同步端点时,Uvicorn 会检测到它并将它分配给线程池中的一个线程。
  • 当这个线程正在处理请求时,它不能处理其他任何请求。
  • 一旦线程完成处理请求,响应发送回客户端,然后该线程将返回到线程池,准备处理另一个请求。
  • 因此,我们的应用程序能处理的并发同步请求数量受限于线程池的大小(默认大小为 40)。如果所有线程都在处理请求,新的请求将不得不等待,直到有空闲的线程。
异步定义的端点的请求处理方式
  • 当请求到达异步端点时,Uvicorn会检测到请求,并将其分配给事件循环,事件循环管理协程(异步函数)的执行。事件循环可以同时处理多个异步请求而不阻塞。
  • 当异步端点遇到一个 await 语句(例如,等待IO操作)时,事件循环可以切换到处理其他请求。这使得多个请求看似并行处理,即使它们是在单线程上运行的。与同步端点不同,Uvicorn不会为每个请求创建新线程。

除了参考官方文档之外,你还可以查看此回答以获取更多信息。

因此,同步端点中的阻塞时间(通过sleep模拟)通过更大的线程池大小得到了补偿,从而使两种实现具有相似的时间。

这意味着,如果实现变得更加IO密集型(等待时间进一步增加),将会导致线程的阻塞时间更长,从而进一步增加同步端点的延迟。为了进一步验证这一点,让我们将睡眠时间从0.3秒增加到1秒,以模拟一个更长的IO密集型操作。

    # app.py  
    # 所有内容保持不变,只是睡眠时间被延长了  

    # -- 相同的代码  
    @app.get("/sync_endpoint")  
    def sync_endpoint():  
        print('Inside Endpoint')  
        time.sleep(1)  
        return {"message": "你好,FastAPI!"}  

    @app.get("/async_endpoint")  
    async def async_endpoint():  
        print('Inside Endpoint')  
        await asyncio.sleep(1)  
        return {"message": "你好,FastAPI!"}  

    # -- 相同的代码

现在,让我们重用我们的基准脚本以获取这两个端点的延迟,通过向它们发送一些并发请求来获取延迟。

同步端点基准测试结果:  
在41.32秒内完成1000个请求  
在78.12秒内完成1500个请求  

异步端点基准测试结果:  
在34.37秒内完成1000个请求  
在67.69秒内完成1500个请求

如我们所见,async 实现已经开始显示出更低的延迟时间。随着越来越多的 IO 密集型任务被添加到实现中,这种差异将进一步扩大。因此,如果端点实现是 IO 密集型任务的,强烈建议使用 async 实现,而不是 sync 实现。

关于CPU和内存使用情况的附记

sync实现的内存消耗高于async实现,这在多线程的创建、维护以及线程切换开销的情况下是有道理的。sync端点的CPU吞吐量较低,这一点是有道理的,也就是说,它没有像async实现那样达到峰值,这也是有道理的,因为在sync实现中,线程会被IO-bound任务阻塞,而事件循环会在当前请求等待时切换到新的请求。

结论部分
  • 对于短等待请求,asyncsync 在并发请求数量上几乎表现相同。
  • 对于相对较长的等待请求(当端点主要涉及 IO 任务时),推荐使用 async,因为事件循环不会被 IO 操作阻塞,而相比之下,在 sync 实现中线程会被阻塞。
点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消