FastAPI框架基本原理<下>

一、快速入门

#1、安装fastapi uvicorn依赖
pip3 install fastapi uvicorn

编写app.py实现一个hello fastapi的demo演示, 代码如下:

import uvicorn
from fastapi import FastAPI

app = FastAPI()


@app.get("/hello")
async def hello():
    return {"message": "hello FastAPI"}


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

直接运行该app.py代码,会在8000端口监听HTTP服务访问如下:  http://localhost:8000/hello

代码就是这么简单, 和Flask有点像哈, 定一个一个FastAPI的app对象, 通过@app.get("/hello")装饰器装饰async def hello()函数, 就是定义了一个URL路由, 一旦访问/hello则执行这个协程函数hello(), 得到响应结果,返回给到HTTP的客户端浏览器。

二、路由函数

1、FastAPI ASGI协程函数入口在哪里?

        直接说结论, 上一篇文章我提到过, ASGI无外乎就是定义了一个规范,无论任何的HTTP请求会首先进来执行入口async协程函数,那么在上面的演示代码里面,ASGI函数在哪?

        答案就是这个app=FastAPI()对象, 不是说函数吗? 这个是对象啊。 别忘了,对象可以通过对象()的方式,变成函数调用, 实现__call__魔术方法即可。 果然看下FastAPI的源码发现,确实符合ASGI协议的入口函数签名:

 2、sync修饰与无async修的路由函数的区别

        FastAPI支持路由函数,可以通过async或者普通函数的方式。 但是两者底层的执行原理是完全不一样的。

        1、async 修饰的路由函数, 底层源码其实使用了await  路由函数, 提交了协程任务给到event_loop进行调度处理,最后得到结果,返回给HTTP客户端。

        2、无async修饰的路由函数(普通函数), 底层是使用了asyncio.run_in_executor()的方式,就是将这个普通函数通过线程池的方式包装成为了一个协程, 最后拿到结果再返回给HTTP客户端。 为什么要通过asyncio.run_in_executor()线程池方式进行包装呢?  答案就是防止同步灾难。  防止这个非async修饰的方法,阻塞event_loop线程,导致其它协程被阻塞。

        相关源码可以看到如下:

        判断handler也就是框架里用过路由拿到的对应的路由函数, 判断是否是async修饰,是则await ,否则通过线程池运行,防止阻塞event_loop调度运行其他协程。

三、async路由函数阻塞灾难测试

        如果我们的async修饰的路由函数存在同步灾难,那么这一次的请求会影响其它接口的请求,直到这个async的同步操作执行完毕,其它请求才能正常被event_loop调度执行。

        举个栗子🌰, 例如我写了接口, 使用sleep进行模拟IO请求很慢的情况。 例如异步编程里面,要用sleep我们应该使用asyncio.sleep而不是time.sleep,  asyncio.sleep支持被event_loop变为协程任务从而进行调度,但是time.sleep不行,time.sleep会被event_loop阻塞。

        例如本来我应该使用asyncio.sleep,但是代码写错了,变成了使用time.sleep,大家看下效果:

        正常使用asyncio.sleep的情况:

import asyncio

import uvicorn
from fastapi import FastAPI

app = FastAPI()


@app.get("/hello")
async def hello():
    # 异步asyncio.sleep(20)  休息20s
    await asyncio.sleep(20)
    return {"message": "hello FastAPI"}

@app.get("/others")
async def others():
    return {"message": "other FastAPI"}


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

        这里写了2个接口, 都是使用async进行修饰。  /hello  接口会模拟IO等待了20s,之后才返回数据。 /others接口,则直接很简单返回数据。 按照正常理解, 我一个窗口访问/hello, 另外一个窗口访问/others,  /hello会等20s才返回数据, /others应该很快直接返回。 那测试一下效果是这样吗?

        确实是的。和我们想象的效果是一样的。 因为我/hello里面使用了支持异步IO的await asyncio.sleep(20)。  所以访问/hello的这个协程会sleep 20s,不影响其他协程的调度请求, /others自然不受到影响。

        那么代码稍微改下, 我把asyncio.sleep(20) 直接改为time.sleep(20),再看看效果:

import asyncio
import time

import uvicorn
from fastapi import FastAPI

app = FastAPI()


@app.get("/hello")
async def hello():

    #通过time.sleep(20)
    time.sleep(20)
    return {"message": "hello FastAPI"}

@app.get("/others")
async def others():
    return {"message": "other FastAPI"}


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

 

        这里就出现问题了。 /others 这么简单的接口,在这里一直转圈圈,等/hello响应结束后, /others才正常返回。 这就是同步灾难!!!  生产环境无法接受的情况。  一个请求/hello, 把其他人与之无关的请求都阻塞了,这还得了。整个项目的QPS严重下降。

四、非async路由函数,底层线程池性能影响

        非async路由函数,底层是通过线程池的机制实现的。 那么性能相对async修饰的路由函数较差, 但是有时候没办法, 例如第三方库不支持异步操作,但是我又上了FastAPI,总不能无法实现这个需求了吧。

        这种情况下使用费async路由函数修饰是一个好办法,虽然性能比async修饰要差,但是至少能实现需求,只是退而求其次,降到了线程池的并发模型。 那怎么测试这个并发模型的性能会变差了呢? 底层依赖于线程池的话, 一旦高并发上来,线程池满了之后, 新进来的请求也是被排队等待空闲线程,直到有空闲线程才能处理这个HTTP请求, 给用户体验就是慢。

        测试代码如下:

import asyncio
import time

import uvicorn
from fastapi import FastAPI
from starlette.requests import Request

app = FastAPI()

@app.middleware("http")
async def middleware(request: Request, call_next):
    """
    中间件记录,每次请求的耗时情况,打印出来
    :param request: 
    :param call_next: 
    :return: 
    """
    start_time = time.time()
    response = await call_next(request)
    end_time = time.time()
    print(f"接口耗时:{end_time - start_time}")
    return response

@app.get("/hello")
async def hello():
    return {"message": "hello FastAPI"}

@app.get("/others")
def others():
    """
    非async修饰,传统路由函数
    :return:
    """
    time.sleep(10)
    return {"message": "other FastAPI"}


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

        首先,others路由函数会被底层通过协程转换为线程池运行,不会阻塞影响其他路由函数,例如/hello路径。 你先访问/others的同时,也访问/hello, /hello正常快速返回,不受到/others sleep(10)的影响, 大家可以自行测试一下。

        那现在我使用apache ab来压测/others看下日志输出,还有P90的耗时情况,看下耗时是不是大部分都超过了10s,因为线程池满了,好多请求会被排队,所以前面进来的请求耗时会在10s左右,但是后面被排队的请求,可不止10s了。

ab -n 200 -n 100 http://127.0.0.1:8000/others

        测试结果和预料的一样。  首先压测并发过来之后,首先塞满线程池的请求得到执行,自然耗时就是10s出头, 但是后面的请求不一样了,他们都还得等拿到空闲的线程来处理请求, 那么耗时自然增加了, 先等前面10s,自己再sleep(10) 耗时就变20s了。  

        压测结果:  200个请求 100个并发, 0次失败, 200次都成功  200个请求总耗时60s测试完毕, qps是3.33    P90耗时是30028ms, 将近大部分请求都是30s耗时了。

 五、总结

        1、async修饰的路由函数, 必须要使用异步处理库或者第三方支持的异步库,否则阻塞会影响其他路由函数的执行. 影响的路由函数,无论是async修饰的或者无async修饰的, 都会影响全部

        2、非async修饰的路由函数,底层采用线程池方式进行协程化,如果某个接口使用到的第三方库是同步的,可以使用此方式来兼容FastAPI框架

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员Rocky

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值