fastapi 如何控制并发——其三

背景

我也没想到这个系列能出到第三期,最近又双叒叕接了另外一个模型的服务化需求。相比上几个需求,这个模型比较轻,可以在单个pod上创建多个实例。因此单pod可以支持多个并发,自然而然想到的是,直接使用 uvicorn 框架本身支持多进程的能力,例如:

import uvicorn
from threading import Lock
from fastapi import FastAPI, HTTPException

app = FastAPI()

lock = Lock()
import time


@app.get("/hello")
def predict():
    acquired = lock.acquire(blocking=False)
    if acquired:
        try:
            time.sleep(0.1)
        finally:
            lock.release()
        return "world"
    else:
         raise HTTPException(
            status_code=503,
            detail="service busy",
        )


if __name__ == "__main__":
    uvicorn.run(
        app="main:app",
        host="0.0.0.0",
        port=8080,
        log_level="debug",
        workers=4,
    )

进程调度的局限性

上面的代码启动了4个worker进程,按道理来说,如果存在4个客户端对其进行压测,成功率应该是100%,但是实际上却不是。这里使用的开源压测软件 go-stress-testing 进行测试:

./go-stress-testing-linux -c 4 -n 100 -u http://localhost:8080/hello

测试报告为:

处理协程数量: 4
请求总数(并发数*请求数 -c * -n): 400 总请求时间: 10.154 秒 successNum: 159 failureNum: 241
tp90: 101.000
tp95: 101.000
tp99: 107.000

成功率连一半都不到,这显然不符合需求的预期。一开始我以为是因为调度不均匀导致导致的错误,但是通过日志排查,发现4个worker都是有机会被调度到的。正如之前所讨论的,uvicorn 使用的是 ASGI 协议,多个请求是可以重入单个worker的,uvicorn 并没有办法了解进程是否繁忙。因此需要额外的调度策略以及跨进程通信来读写worker的状态,这样会复杂许多。这里 提供了一些开源的进程管理器,但是我没有深入调研是否存在可实现我们需求的框架,有了解的同学可以在评论区留言。

队列管理线程实例

既然进程通信比较复杂,那么我在worker进程内进行线程通信应该简单吧!因此我们转而使用队列管理模型实例:

import uvicorn
import queue
import time
from threading import Lock
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException

lock = Lock()
pool = queue.Queue()
max_workers = 4


class Worker:
    def do(self):
        time.sleep(0.1)
        pass


@asynccontextmanager
async def lifespan(app: FastAPI):
    for i in range(max_workers):
        pool.put(Worker())
    logging.info(f"initialize model done, qsize {pool.qsize()}")
    yield
    logging.info("woker exist, quit now")


app = FastAPI(lifespan=lifespan)


@app.get("/hello")
def hello():
    with lock:
        try:
            item = pool.get(block=False)
        except queue.Empty:
            raise HTTPException(
                status_code=403,
                detail="service busy",
            )
    if item is not None:
        try:
            response = item.do()
        except Exception as e:
            print(f"catch unexpected error: {e}")
            raise HTTPException(
                status_code=500,
                detail="internal error",
            )
        finally:
            with lock:
                pool.put(item)
                pool.task_done()
        print(f"send 200")
        return response
    else:
        raise HTTPException(
            status_code=500,
            detail="internal error",
        )


if __name__ == "__main__":

    uvicorn.run(
        app="main_pool:app",
        host="0.0.0.0",
        port=8080,
        workers=1,
        log_level="debug",
    )

注意,我们在 lifespan 中注册加载当前的模型实例队列,为什么不是在 main 中呢?因为worker进程是主进程的子进程,两者并不是同一个进程,因此不能保证端口起来之前(主进程ready),模型是否加载完毕(子进程ready),这里可以从多worker的 uvicorn 的启动日志中看出来:

INFO:     Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
INFO:     Started parent process [626757]
INFO:     Started server process [626760]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Started server process [626762]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Started server process [626761]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Started server process [626763]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

父进程是先于子进程启动的。ok,那这里的压测情况是否符合预期呢?

处理协程数量: 4
请求总数(并发数*请求数 -c * -n): 400 总请求时间: 10.394 秒 successNum: 400 failureNum: 0
tp90: 104.000
tp95: 105.000
tp99: 105.000

在并发为4的情况下,模型实例数也为4的情况下成功率100%,完全符合预期,也能如预期的拒绝6并发的情况下过载的请求:

处理协程数量: 6
请求总数(并发数*请求数 -c * -n): 600 总请求时间: 4.672 秒 successNum: 163 failureNum: 437
tp90: 104.000
tp95: 104.000
tp99: 106.000

性能分析

上面我们使用的是多线程编程,正如我们之前所说的,对于计算密集型服务,python的多进程就是个笑话,这是由于 GIL 的存在。因此我们有必要测试在多核机器上,多线程方案的表现。

客户端并发数服务端实例数QPS平均耗时
1816.7659.67
489.73411
8815.8506
4413.84289.11
2220.4557.09
1120.8248.04

这里的测试机器时16核,可以看到在并发打满情况下,实例数越多平均耗时越大,而且是显著增加。起初,我们这里猜想的是,客户端并发数越多,越容易触发线程调度,引入GIL次数越多,平均耗时增加越明显。

随后我们改用多进程的方式去测试,在客户端并发数为4,服务端进程数为8的情况下,平均耗时也有347ms,并且成功率只有22%,这里的347s存在水分,很多失败的请求只有1~2ms,存在摊还的情况。那可能真的是在并发数上来的情况下确实是耗时增大了,我们猜测是并发多了后,CPU成为了瓶颈,我们在使用多进程方案时,观察CPU利用率,发现确实已经占满了。再次切换到多线程方案,测试发现CPU利用率也是满的。这不是很奇怪吗?由于GIL的存在,每个线程只能在一个CPU核心上面跑,为什么16个核心都占满了呢?这里从GPT获得了最可能的答案:

外部调用:如果您的计算线程涉及到外部调用,例如调用C/C++扩展或其他并行库,这些外部调用可能会在多个CPU核心上并行执行,而不受全局解释器锁(GIL)的限制。这可能导致您看到所有CPU核心都被充分利用。

说明这里的模型实例本身就可以充分利用多个CPU核心,因此GIL的限制也就不存在了!因此对于本身就能充分利用多核进行计算的模型,多线程方案部署并不比多进程方案性能表现差

结论

uvicorn多进程部署方案的调度策略存在局限性,主进程不能直接获取worker进程的状态,需要额外的通信手段才能实现预期的调度。使用多线程可以解决这一问题,尤其当模型本身就能充分利用多核计算,不受GIL限制,多线程的方案性能并不比多进程差。

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值