FastAPI 是 Python 社区中广泛使用的 web 应用开发框架。在工作中,我们大多数的 web 服务都是使用 FastAPI 实现的。最近,我们被要求优化一个 FastAPI 端点的延迟问题。作为其中的一部分,我们进行了性能基准测试,以识别 sync
和 async
实现的相同端点的延迟。我们还进一步研究了 FastAPI 和 Uvicorn 处理 sync
和 async
端点请求的内部机制。
我们将发现和结论汇总成这篇博客文章的原因有二:
- 帮助某人理解 FastAPI 中
sync
和async
端点请求处理的不同方式。 - 帮助某人(使用 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个请求
正如我们所见,sync
和 async
端点所花费的时间相差无几。事实上,sync
有一些优势,这有些违背直觉,与我们之前的解释不太符合。那么为什么会这样呢?要回答这个问题,我们需要了解 FastAPI 内部是如何处理这些 sync
和 async
端点请求的。
- 当一个请求到达同步端点时,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
实现。
sync
实现的内存消耗高于async
实现,这在多线程的创建、维护以及线程切换开销的情况下是有道理的。sync
端点的CPU吞吐量较低,这一点是有道理的,也就是说,它没有像async
实现那样达到峰值,这也是有道理的,因为在sync
实现中,线程会被IO-bound任务阻塞,而事件循环会在当前请求等待时切换到新的请求。
- 对于短等待请求,
async
和sync
在并发请求数量上几乎表现相同。 - 对于相对较长的等待请求(当端点主要涉及 IO 任务时),推荐使用
async
,因为事件循环不会被 IO 操作阻塞,而相比之下,在sync
实现中线程会被阻塞。
共同学习,写下你的评论
评论加载中...
作者其他优质文章