一、背景
文档地址: https://fastapi.tiangolo.com/
Github地址: https://github.com/fastapi/fastapi
最近使用到FastAPI框架进行项目开发。 因为我们提供的相关RESTFUL API都是和LLM大模型相关的。 团队整体技术栈其实是Java,但是为什么选择Python呢?
首先,我们项目初期的定位就是和LLM大模型紧密相关,在生态、私有化大模型等相关考虑下,Python还是这条赛道的主流编程语言,不排除后期可能和自研模型结合,如PyTorch、tensorflow、LangChain等等,Python的支持度还有成熟度都是有背书的。Java与AI结合,这块不是说不能做,但是确实不是太主流,后期如果切换成本太高。
其次,我们的核心API接口没有那么多,Python的Web领域用得比较多的有Django、Flask, 这两者早期确实占据了Python Web领域的绝大部分市场, 一开始也有考虑到。 Django虽然很全,但是对于我们的需求,太重,没必要。 Flask虽然轻量,但是性能和后面调研的FastAPI相差太大。
FastAPI这个框架主要是利用了Python的asyncio异步编程进行实现,通过协程并发的模型而不是多进程、多线程的并发模型。 在资源占用方面少,性能方面高,官方的说法是,性能可以媲美/接近Golang。
二、FastAPI的介绍
FastAPI 是一个用于构建 API 的现代、快速(高性能)的 Web 框架,基于 Python 3.7+ 类型提示。它被设计为易于使用且具有强大的功能集,特别适合创建微服务或开发 RESTful APIs。
以下是 FastAPI 的一些关键特性:
-
高性能:根据官方文档,它的速度接近于 NodeJS 和 Go,在许多情况下,它比其他Python框架(如Flask或Django REST framework)更快。
-
快速编码:可以将编码时间减少约40%。这是因为其自动生成文档(支持OpenAPI和JSON Schema)以及自动完成和数据验证的功能。
-
少写代码:对于接收的数据进行校验和错误处理等功能,只需要定义好模型类,剩下的工作大部分都由 FastAPI 自动完成。
-
易于学习和使用:由于其简单直观的设计,即使是初学者也能快速上手。
-
安全:支持 OAuth2 和 JWT 等多种认证方式,内置了对常见安全问题的防护。
-
可测试性:提供了一个易于使用的测试客户端,类似于 Flask 的测试客户端,使得编写单元测试和集成测试变得容易。
-
异步支持:完全支持 async/await 语法,允许你编写异步的路径操作函数,这对于需要高并发的应用来说非常有用。
-
自动交互式API文档:提供了自动化的交互式API文档(Swagger UI 和 ReDoc),方便开发者测试和调试API。
-
广泛的社区支持和插件生态:虽然相对年轻,但发展迅速,并且与 Starlette 和 Pydantic 等库有良好的兼容性,这些库分别提供了 web 开发的基础和数据验证及设置管理的强大能力。
三、asyncio原理剖析与ASGI协议
1、asyncio原理剖析
asyncio是官方内置模块,按照我的理解就是,提供了python协程的整体实现,包括协程调度器。 这个协程调度器就是event_loop(字面意思, 事件循环), 利用单线程对协程进行自动调度。 一旦遇到IO操作(如HTTP请求、连接操作数据库、连接操作Redis、读写文件等等), 就在等待IO的这个时间段,去调度执行其他非IO操作的协程, 等IO操作完毕的协程进入再次可以被调度的状态之后,再进行调度, 从而同一个时间内, 主线程的利用率变高, 不需要傻傻的等待IO操作而不去执行其它任务。
基本执行原理图:
2、event_loop同步阻塞灾难
协程调度带来了更高的并发效率,能够让遇到IO的时候无须等待这个IO执行完毕才能调度其他任务,而是充分利用这个时间间隙去执行其它非IO的代码,从而提高性能。
但是这里要注意一个灾难性的问题!!!
因为event_loop是单线程,那就意味着,如果你的IO操作必须也是支持异步的。否则如果你的主函数是async异步修饰,但是函数内部使用同步的库(不支持async、不支持协程调度的库)例如requests请求库, 这个http请求库,我们再熟悉不过了,但是requests是同步库,不支持被协程调度器event_loop调度。
不支持被event_loop调度的同步库,会阻塞整个evet_loop线程,只要执行了这个同步代码,其它所有的协程都无法被调度,只能等待这个同步操作执行完毕才能被再次调度执行!!!!!
这个就是同步灾难,等于整个event_loop只能干等着你的requests执行完毕,其它协程都处于等待状态了。 那么也就无法发挥出协程的威力, 甚至回退到比多线程、多进程更差的性能体验。 所以在使用异步编程的时候, 要考虑到,你使用到的第三方库、模块,必须也是支持异步的,才能发挥出协程的性能。
这里做个类比就知道事情的严重性。 整个工厂就1条流水线在高速运转,停留1秒钟工厂都瞬损失严重, 但就因为你的操作是阻塞的(例如10几秒),OK, 那么这个流水线上其他人的事情也别做了, 等你的操作完毕, 流水线才恢复正常! 这你想,老板不得干死你....
所以,使用异步框架编程,要十分小心同步灾难,写代码特别是用到第三方库,考虑第三方库要支持异步操作。 与单线程操作redis类似, 你的耗时操作会直接影响整个redis。
那如果还真的无法避免遇到第三方库不支持异步的情况呢? 整个项目就黄了? 不慌,下一章会讲解这种情况怎么处理。
3、同步编程与异步编程的对比
传统顺序执行, 总共耗时4秒。 首先http_request(1)函数执行完毕,才能执行第二个函数http_request(2), 一个函数执行耗时大概2s, 那么2s+2s = 4s
import time
def http_request(user_id):
print(f"{user_id} start....")
time.sleep(2)
print(f"{user_id} end....")
def main():
t1 = time.time()
http_request(1)
http_request(2)
t2 = time.time()
print(f"mian()耗时: {t2-t1}")
if __name__ == "__main__":
main()
利用异步编程方式,同时把2个协程任务提交给event_loop,并发执行。 event_loop遇到asyncio.sleep(2)[模拟遇到IO操作], 那么趁着IO等待的这个时间间隙,会先去调度别的非IO协程进行执行, 所以2个协程的asyncio.sleep(2) 这段代码几乎是同时执行的, 那么到最后结束也是耗时2s.
因为2s时间是一起sleep的,而不是像同步代码,前面sleep(2)结束后, 后面任务再slee(2)导致耗时增加。
这里异步的方式,最后总耗时2s。 这不就是性能得到极大提升了嘛。 如果这个任务是1000个, 那么是不是也是2s左右就完成了,但是如果是传统同步方式, 1000*2 = 2000s才能完成1000个任务, 差距不是一般的大。
import asyncio
import time
async def http_request(user_id):
print(f"{user_id} start....")
# 这里模拟IO耗时, 例如HTTP请求,查询mysql、读写文件等等
await asyncio.sleep(2)
print(f"{user_id} end....")
async def main():
t1 = time.time()
await asyncio.gather(http_request(1), http_request(2))
t2 = time.time()
print(f"mian()耗时: {t2-t1}")
if __name__ == "__main__":
asyncio.run(main())
4、同步、异步的解释
以前很多人理解同步、异步其实都有过混淆的或者理解不清楚的地方。 有部分的理解就很疑惑, 例如我正常代码逻辑就是1、2、3、4这几个步骤往下执行, 例如第2步代码依赖第1步,第3步依赖第2步,第4步依赖第3步如此才能完成我的正确业务逻辑✅。
我在第2步,例如执行await 之后,会不会影响我后续的代码逻辑执行? 完全不影响。 我们不要理解错了, await提交异步任务给event_loop只是让它趁着这个IO时间间隙去调度其它非IO任务,等你的IO任务执行完毕,又被调度到了之后,代码3、4步骤还是会得到执行的,不会影响你的代码逻辑执行顺序。
还有说一下后面提到的FastAPI和ASGI协议, 每个请求过来都是执行了一个async 协程函数, 意味着每个客户端的一次请求就对应执行一个协程, 协程的内部代码逻辑会正常执行, 只是这些协程不会相互影响, 不会出现阻塞的情况。 协程1(请求1) 、协程2(请求2) 都可以同时被event_loop调度执行, 而不是等协程1(请求1)执行完毕,才能处理协程2(请求2), 所以并发能力得到提升。
5、WSGI与ASGI协议
1、WSGI协议
WSGI 是一种规范,它描述了web服务器如何与web应用程序交互以及web应用程序如何处理请求。它旨在促进web框架和web服务器之间的可移植性。这意味着你可以在不同的WSGI服务器(如Gunicorn、uWSGI)上运行任何符合WSGI标准的Python web应用(如Flask、Django),而无需修改代码。
WSGI、uWSGI大家肯定比较熟悉的。 本质就是每个HTTP请求对应的就是一个进程处理或者一个线程处理, 整体并发模型是多进程、多线程。 每个HTTP请求过来执行入口函数, 入口函数签名如下:
def application(environ, start_response):
# 处理请求并返回响应
-
environ
:这是一个字典,包含了所有由web服务器提供的关于HTTP请求的信息。这些信息包括请求方法(GET、POST等)、路径、查询字符串、请求头以及其它环境变量。 -
start_response
:这是一个可调用对象(callable),用于开始HTTP响应。它接受两个必需的参数:- 状态码(status):一个字符串,表示HTTP响应的状态码和原因短语,如
"200 OK"
或"404 Not Found"
。 - 响应头(response_headers):一个包含元组的列表,每个元组代表一个HTTP头部及其值,例如
[('Content-Type', 'text/html')]
。
- 状态码(status):一个字符串,表示HTTP响应的状态码和原因短语,如
该函数应该返回一个可迭代的对象,其中每个元素都是HTTP响应体的一部分。通常情况下,这只是一个包含整个响应体的单个字符串的列表。
无论你是用Django、Flask或者其它Python HTTP框架, 是的就是这么简单, 就是一个函数入口, 至于你说的框架结构啊、路由注册啊等等,只是在这个入口函数内做了大量的逻辑处理,最后返回HTTP响应内容。 大家想想是不是这个道理。
所以这里就存在一个逻辑,那就是每个HTTP进来,要么是单进程单线程去执行这个入口函数,要么就是多进程多线程的方式, 一个HTTP请求对应一个线程来执行这个函数。 应用的并发能力其实取决于底层的进程池、线程池, 进程池、线程池满了,那么新的请求只能等待资源池空闲,才能得到处理,否则排队等待, 给用户的直观感受就是,为啥查看这个页面或者查询数据这么慢,其实这个时候就是在排队等空闲的线程,才能处理请求。
2、ASGI协议
ASGI是作为WSGI的一个扩展被提出的,以支持异步编程模型,包括WebSocket、HTTP/2等需要长时间连接的协议。ASGI不仅能够处理传统的HTTP请求,还可以处理异步请求和流式数据传输。 ASGI是2018年左右才提出的, 相比WSGI出现比较晚,所以现在的nginx都没还没支持ASGI协议。
ASGI协议的实现有uvicorn, 使用uvicorn可以运行我们的异步HTTP编程接口函数。 ASGI入口函数签名如下:
async def app(scope, receive, send):
# 根据scope中的类型处理请求,并使用receive和send来接收和发送消息
-
scope
:这是一个字典,类似于WSGI的environ
,但是更加通用,可以包含HTTP请求信息、WebSocket连接信息等。scope
中的type
字段指明了当前连接的类型,可能是'http'
、'websocket'
等。 -
receive
:这是一个异步可调用对象,用于从客户端接收消息。每次调用都会等待下一个传入的消息。消息本身是一个字典,格式取决于scope['type']
。 -
send
:这是另一个异步可调用对象,用于向客户端发送消息。你将传递给它的也是一个字典,格式同样依赖于scope['type']
。
对于HTTP请求,典型的流程是从receive
读取一个或多个HTTP请求消息,然后通过send
发送HTTP响应。对于WebSocket连接,则可能涉及到多次调用receive
和send
来处理不同的事件,比如建立连接、接收消息、关闭连接等。
与WSGI协议类似, 这个也存在入口函数。每个请求过来就执行这个协程函数,得到响应数据,HTTP协议返回给客户端。 这个入口函数通过async进行修饰, 表示是一个协程函数, scope可以获取协议请求相关信息如HTTP请求头等。 每个请求就是对应一个协程的执行, 通过event_loop协程并发模型,而非WSGI的多进程、多线程, 不必担心线程池、进程池满了。
FastAPI框架也就是封装了这么一个符合ASGI规范的入口函数罢了,无外乎它帮我们做了很多封装工作,例如获取请求头信息,如何响应数据,如何进行请求拦截等等。 归根结底,每个请求就是这么一个入口协程函数的执行和调度而已。
四、总结
这章先给大家抛砖引玉,如果异步编程和注意事项不清楚,还是使用传统编写Flask、Django的方式直接使用FastAPI, 那么你可能会遇到同步灾难, 一个接口不小心写同步阻塞了,你整个项目的其它接口都会收到影响,这可不是开玩笑的, 毕竟event_loop就是一个单线程。
下一章讲解FastAPI路由阻塞同步测试,让大家有这个感受和代码的注意事项。