本文进行实际测试 FastAPI 的并发能力,即同时能处理多少个请求,另外还能接收多少请求放在等待队列当中; 并找到如何改变默认并发数; 以及它是如何运用线程或进程来处理请求。我们可以此与 Flask 进行对比,参考 Python Flask 框架的并发能力及线,进程模型,是否真如传说中所说的 FastAPI 性能比 Flask 强, FastAPI 是否对得起它那道闪电的 Logo。
本文使用 JMeter 进行测试,测试机器为 MacBook Pro, CPU 6 核超线程,内存 16 Gb。
对于每一种类型 Web 服务基本的测试是每秒发送 2 个请求,连续发送 1000 个,500 秒发送完所有请求,程序中 API 方法接受到请求后 sleep 800 秒,保证在全部 1000 个请求送出之前一直占着连接,并有充足的时间对连接进行分析。在测试极端并发数时,由于在 Mac OS X 尽管设置了 ulimit 最多也只能创建 4000 多一点线程,所以在模拟更多用户数时,JMeter 在远程 Linux(Docker 或虚拟机) 上运行测试用例。
请求的 URL 是 http://localhost:8080/?id=${count}, 带一个自增序列用以识别不同的请求, JMeter 的 Thread Group 配置为 Number of Threads (users): 1000, Ramp-up period (seconds): 500
首先安装依赖:
pip install fastapi
pip install uvicorn[standard]
当前安装的最新版本 fastapi==0.94.1, uvicorn==0.21.1
测试同步方法
app.py
from fastapi import FastAPI, Query
import threading
import time
from datetime import datetime
import os
import uvicorn
app = FastAPI()
global_request_counter = 0
@app.get("/")
def index(request_id: str = Query(..., alias="id")):
global global_request_counter
global_request_counter += 1
thread_name = threading.current_thread().name
print(f"{datetime.now()} - {os.getpid()}-{thread_name}: #{global_request_counter} processing request id[{request_id}], sleeping...")
time.sleep(800)
print(f"{datetime.now()} - {os.getpid()}-{thread_name}: done request id[{request_id}]")
return "hello"
# 或者用命令方式启动 uvicorn app:app --host 0.0.0.0 --port 8080
if __name__ == '__main__':
uvicorn.run(app, host="0.0.0.0", port=8080)
启动 FastAPI:
python app.py
JMeter 500 秒发送 1000 个请求
2023-03-18 12:49:17.223115 - 22645-AnyIO worker thread: #1 processing request id[1], sleeping...
2023-03-18 12:49:17.691865 - 22645-AnyIO worker thread: #2 processing request id[2], sleeping...
2023-03-18 12:49:18.194323 - 22645-AnyIO worker thread: #3 processing request id[3], sleeping...
.............................................
2023-03-18 12:49:36.194418 - 22645-AnyIO worker thread: #39 processing request id[39], sleeping...
2023-03-18 12:49:36.693199 - 22645-AnyIO worker thread: #40 processing request id[40], sleeping...
40 个请求便到头了, 也就是只有 40 个请求能被同时处理,其余某些都陆续进到等待队列中去了。
查看到 127.0.0.1:8080 的连接,还一直在增长。
netstat -na|grep "0 192.168.86.141.8080" | grep ESTABLISHED | wc -l
353
一直可以达到 1000
netstat -na|grep "0 192.168.86.141.8080" | grep ESTABLISHED | wc -l
1000
1000 个请求被全部收纳下,只是前 40 被处理,后面的 960 个乖乖的在队列中等待着空闲线程。
问题来了,到底能在等待队列中放多少个请求呢?
测试 30 秒发 10000 个请求看看(又需要 JMeter 远程测试,所以启动 FastAPI 时需要指定 host="0.0.0.0"),测试中输出的 request id 是完全乱序的,但同时只能处理 40 个请求是不变的。可达到 9992 个连接。
netstat -na|grep "0 192.168.86.141.8080" | grep ESTABLISHED | wc -l
9992
40 正被处理,其余的来者不拒,只是那 9952 个请求只能在门外等着。要注意 FastAPI 的这个超长的等待队列,可能直接造成 Load Balance 请求超时。
我们还可以测试一下 AnyIO worker 的线程是否能被重用,打印中输出 threading.current_thread().name
看到的都是同样的线程名称,打印 threading.current_thread().native_id
的话发现 FastAPI 的线程是重用的,实质上是一个大小为 40 的线程池。
2023-03-18 14:27:05.280884 - 8256-67082: #1 processing request id[1], sleeping...
.......
2023-03-18 14:40:25.288589 - 8256-67082: done request id[1]
INFO: 192.168.86.141:53993 - "GET /?id=1 HTTP/1.1" 200 OK
2023-03-18 14:40:25.291820 - 8256-67082: #41 processing request id[1000], sleeping...
FastAPI 可以修改默认的并发数 40(https://github.com/tiangolo/fastapi/issues/4221),FastAPI 当前是通过 starlette 来使用 anyio 的,下面代码可以把同时处理的请求数修改为 200, 与 Tomcat 看齐。
from anyio.lowlevel import RunVar
from anyio import CapacityLimiter
@app.on_event("startup")
def startup():
print("start")
RunVar("_default_thread_limiter").set(CapacityLimiter(200))
现在可以达到 200。
2023-03-18 14:44:45.350316 - 9931-AnyIO worker thread: #1 processing request id[1], sleeping...
2023-03-18 14:44:45.433839 - 9931-AnyIO worker thread: #2 processing request id[2], sleeping...
2023-03-18 14:44:45.449986 - 9931-AnyIO worker thread: #3 processing request id[3], sleeping...
...............................
2023-03-18 14:44:57.210514 - 9931-AnyIO worker thread: #199 processing request id[199], sleeping...
2023-03-18 14:44:57.274213 - 9931-AnyIO worker thread: #200 processing request id[200], sleeping...
至于那个请求等待队列的长度,目前尚未找到解决方案。
RunVar
的应用要查阅 AnyIO 的代码(agronholm/anyio), 只找到其他几个 RunVar, RunVar("_root_task"), RunVar("_threadpool_workers"), RunVar("read_events"), RunVar("write_events"), 但没找到与 HTTP 请求队列长度相关的参数。
多进程时
启动 FastAPI 用
if __name__ == '__main__':
uvicorn.run("app:app", host="0.0.0.0", workers=2, port=8080)
使用了 workers
参数后,application 必须换成字符串形式的 <module>:<attribute>
。它将会启动 workers + 1 个进程,一个主进程与 workers 个服务进程,像下面的输出:
INFO: Started parent process [55964]
INFO: Started server process [55967]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Started server process [55966]
INFO: Waiting for application startup.
INFO: Application startup complete.
2023-03-18 23:24:20.345108 - 55966-AnyIO worker thread: #1 processing request id[1], sleeping...
2023-03-18 23:24:20.401789 - 55967-AnyIO worker thread: #1 processing request id[2], sleeping...
主进程承担了进程管理器和请求分发的功能,如服务进程处理多少个请求后重启。每个进程可以默认同时处理 40 个请,求除此之外,每个服务进程与没有使用 workers 参数时是一样的,它们都是使用 AnyIO worker 线程。
用 Hypercorn 启动 FastAPI
FastAPI 是基于实现了 ASGI 规范的 Starlette 之上的 Web 框架,除了可以用 Uvicorn 启动 FastAPI, 还能借助于 Hypercorn 来启动。
安装 hypercorn:
pip install --upgrade hypercorn[trio]
当前 hypercorn 版本是 0.14.3,启动命令:
hypercorn app:app -b 0.0.0.0:8080
除了启动时显示的信息不同外,线程模型是一样的。
[2023-03-18 23:32:06 -0500] [56702] [INFO] Running on http://0.0.0.0 (CTRL + C to quit)
2023-03-18 23:32:08.685665 - 56702-AnyIO worker thread: #1 processing request id[1], sleeping...
2023-03-18 23:32:08.732002 - 56702-AnyIO worker thread: #2 processing request id[2], sleeping...
......
hypercorn 的 --worker-class
默认为 asyncio
, 所以这种情况下修改默认的 40 个同时处理请求数目的方式和用 Uvicorn 启动 FastAPI 是一样的,RunVar("_default_thread_limiter").set(CapacityLimiter(200))
。
然而,如果用 trio
作为 worker-class 的话, --worker-class trio
命令:
hypercorn app:app -b 0.0.0.0:8080 --worker-class trio
启动后访问:
[2023-03-18 23:33:51 -0500] [56959] [INFO] Running on http://0.0.0.0 (CTRL + C to quit)
/Users/yanbin/tests/python-web-test/fastapi-web/.venv/lib/python3.10/site-packages/anyio/_backends/_trio.py:164: TrioDeprecationWarning: trio.MultiError is deprecated since Trio 0.22.0; use BaseExceptionGroup (on Python 3.11 and later) or exceptiongroup.BaseExceptionGroup (earlier versions) instead (Issue #2211 · python-trio/trio)
class ExceptionGroup(BaseExceptionGroup, trio.MultiError):
2023-03-18 23:33:55.009472 - 56959-Trio worker thread 0: #1 processing request id[1], sleeping...
2023-03-18 23:33:55.061132 - 56959-Trio worker thread 1: #2 processing request id[2], sleeping...
......
仍然是默认只能同时处理 40 个请求。由于不再是使用 AnyIO,所以无法通过设置。RunVar("_default_thread_limiter").set(CapacityLimiter(200))
来修改并发请求的数量。
hypercorn 相应的代码启动方式为:
if __name__ == '__main__':
from hypercorn import Config, run
config = Config()
config.worker_class = "trio"
config.application_path = "app:app"
config.bind = ["0.0.0.0:8080"]
run.run(config)
关于 hypercorn 使用 trio 时如何修改同时处理的请求数目,又是一个难题,暂未找到解决办法。
hypercorn 还能选择用 --worker-class uvloop, 它最终看到的线程名也是 AnyIO, 也是默认 40 的工作线程,同样能由 RunVar("_default_thread_limiter").set(CapacityLimiter(200))
修改工作线程数,唯独使用 trio 作为 --worker-class 需要另辟溪径。
测试 async 方式
在 index() 函数前加上 async 关键字:
@app.get("/")
async def index(request_id: str = Query(..., alias="id")):
....
300 秒发送 1000 个请求:
2023-02-18 12:41:28.159685 - 44580-MainThread: #1 processing request id[1], sleeping...
一个请求,直接堵死,和 Flask 的 threaded=False, processes=1 一样的效果。使用 async 的时候一定要谨慎。
FastAPI 的 async 接口需要其调用的方法也是 async 的,这样在 await 的时候才能让出线程出来。我们做下面的测试。
新的 app.py
import os
import threading
from datetime import datetime
import uvicorn
from fastapi import FastAPI, Query
import asyncio
app = FastAPI()
global_request_counter = 0
async def foo(request_id):
value = await asyncio.sleep(5, result=f'hello #{request_id}')
return value
@app.get("/")
async def index(request_id: str = Query(..., alias="id")):
global global_request_counter
global_request_counter += 1