体验 Python FastAPI 的并发能力及线/进程模型

0 / 177

本文进行实际测试 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