一、快速入门
#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框架