FastAPI官档精编004 - 并发与异步
本章介绍一些关于路径操作函数的 async def
语法,以及异步、并发、并行的背景知识。
等不及了?
如果需要以如下方式使用 await
调用第三方支持库:
results = await some_library()
就要使用 async def
声明路径操作函数:
@app.get('/')
async def read_results():
results = await some_library()
return results
!!! note "笔记"
只能在 `async def` 创建的函数内部使用 `await`。
如果使用不支持 await
的第三方支持库与(数据库、API、文件系统等)对象通信,(这是绝大多数数据库支持库的现状),就要只能使用 def
把路径操作函数声明为普通函数,如下:
@app.get('/')
def results():
results = some_library()
return results
如果你的应用(不知为何)虽然不与其它对象通信,但仍要等待其响应,也可以使用 async def
。
如果不知道用什么好,就用普通函数。
注意:你可以把普通(def
)路径操作函数与异步(asnyc def
)路径操作函数混在一起使用,为每个路径操作函数选择最适合的方案。FastAPI 能正确区分不同类型的函数。
不管怎么说,对上述任意情况,FastAPI 都是以异步方式运行的,速度极快。
但使用上述方式,它能更好地优化性能。
技术细节
现代 Python 支持“异步编码”,使用的是 async
与 await
关键字,这种方式叫做协程。
下面我们分别介绍这句话里的几个概念:
异步编码
`async` 与`await`
协程
异步编码
异步编码是指编程语言 💬 以某种方式告诉计算机/程序 🤖,在代码中的某个点,它 🤖 必须等待某些对象在某些位置完成某些操作。假设我们把这些对象称为“慢文件” 📝。
在“慢文件” 📝 操作结束前的这段时间里,计算机可以去执行其它操作。
在等待期间,计算机/程序 🤖 经常会查看是可以继续操作,还是要继续等待,或者当它完成那个点能做的所有操作后,计算机/程序 🤖 会查看它所等待的任务是否完成了,并继续执行它本该完成的操作。
接下来,它 🤖 完成第一项任务(比如说,“慢文件” 📝),然后执行后续操作。
"等待某些对象"通常是指(与处理器和内存相比)速度相对较“慢”的 I/O 操作,比如等待如下对象:
通过网络,从客户端发送的数据
通过网络,由程序发送给客户端接收的数据
在操作系统中,从磁盘读取并传递给程序的文件内容
在操作系统中,由程序写入磁盘的内容
远程 API 操作
要完成的数据库操作
数据库查询返回的结果
等
因为执行时主要是等待 "输入与输出" 操作,它们也被称为“I/O 密集型”操作。
称之为 “异步”,是因为计算机/程序不必与慢任务“同步”,什么都不做,只是等待任务完成的那一刻,才获取任务结果并继续执行。
反之,在“异步”系统中,任务完成后,会耗费一点时间(几微秒)等待计算机/ 程序完成当前操作,然后再获取任务返回的结果,继续执行操作。
(与“异步”相反),“同步”通常也使用术语“序列(sequential)”,这是因为计算机/程序在切换到其它任务前,总是按序执行所有操作步骤,即使需要等待这些任务完成。
并发与汉堡
上述异步编码的思路有时也叫作“并发(concurrency)”,它与”并行(parallelism)“不一样。
并发与并行都与“同时发生不同的事情”相关。
但并发与并行的细节完全不同。
为了说明它们之间的区别,我编了个关于汉堡的故事:
并发汉堡
你和女友 😍 一起去吃快餐 🍔,你排队的时候,收银员 💁 为排在你前面的人下单。
轮到你时,你为自己和女友 😍 买了 2 个非常美味的汉堡 🍔。
然后付钱 💸。
收银员通知厨房里的厨师 👨🍳,这样他们就知道要给你做汉堡 🍔 (即便他们现在正在为上一个客户做汉堡)。
收银员 💁 给了你取餐号。
等餐的时候,你回去和女友 😍 找了张桌子坐下,并和女友 😍 聊了半天(因为你的汉堡非常美味,要花些时间烹制 ✨🍔✨)
你一边和女友 😍 聊天,一边等汉堡 🍔。这段时间里,你可以恭维女友又赞、又可爱、又聪明 ✨😍✨。
在等餐和与女友聊天的同时,你还要时不时看下柜台上显示的数字,看看是不是轮到你了。
在某个时点,终于轮到你了。你到柜台上取了汉堡 🍔,回到餐桌。
你和女友 😍 吃着美味的汉堡 🍔 ,享受美好的时光 ✨。
假设你是这个故事里的计算机/程序。
排队时,你处于空闲状态 😴,只是等着排队,没做任何“有意义”的事。但排队速度很快,因为收银员 💁 只负责下单(不做汉堡),所以等一会儿也没什么。
终于轮到你了,此时就能实际做些“有意义”的事了 🤓,你要看菜单,决定买什么,询问女友 😍 吃什么,付钱 💸,检查账单或银行卡是否正确,检查订单里的餐食是否正确,等等。
但此时,你仍没拿到汉堡 🍔,不过,你和收银员 💁 之间的工作“暂停”了,因为要等 🕙 汉堡做好。
但离开柜台,拿着取餐码回到餐桌后,你就可以把注意力切换 🔀 到女友 😍,开始继续这项“工作” ⏯ 🤓。这样,你就又开始做一些非常“有意义”的工作了 🤓,取悦你的女友 😍。
然后,收银员 💁 把你的号码显示在柜台上,告诉你“汉堡 🍔 做好了,请取餐”,但你不会在显示取餐码时像疯了一样地立刻跳过去取餐。你明白不会有人拿走你的汉堡 🍔,因为你有你的号码,他们有他们的号码。
因此,你会先给女友 😍 讲完故事(完成当前要处理的任务 ⏯ 🤓),笑着对她说你要去取汉堡 ⏸。
你来到柜台 🔀,继续完成一开始的任务 ⏯,取汉堡,感谢收银员,并把汉堡 🍔 拿回餐桌。这就完成了与收银员交互的任务 ⏹。接下来,创建一个新任务 - “吃汉堡” 🔀 ⏯,但之前“取汉堡”的任务已经完成了 ⏹。
并行汉堡
现在,把“并发汉堡”换成“并行汉堡”。
你和女友 😍 来吃并行快餐 🍔。
柜台里有好多(比如说 8 个)收银员,但这几位收银员同时还是厨师 👩🍳👨🍳👩🍳👨🍳👩🍳👨🍳👩🍳👨🍳。
在你前面的每个人都要等 🕙 他们的汉堡 🍔 做好才能离开柜台,因为每个收银员接单后立即去烹制汉堡,然后才会接下一单。
终于轮到你啦,你为自己和女友 😍 买了 2 个非常美味的汉堡 🍔。
然后付钱 💸。
收银员去厨房 👨🍳。
你只能站在柜台前耐心等着 🕙,这样别人才不会把你的汉堡 🍔 拿走,因为这里没有取餐号。
你和女友 😍 一直忙着不让别人插队,还要提防别人拿走你们的汉堡 🍔 ,这样一来你就没功夫陪着你的女友 😞了。
这就是“同步”工作机制,你与收银员/厨师 👨🍳“同步”。你只能耐心等待 🕙 ,直到收银员/厨师 👨🍳做好汉堡 🍔 并给你的那一刻,否则,别人就会把你的汉堡 🍔 拿走。
在柜台前等 🕙 了半天,你的收银员/厨师 👨🍳 终于把汉堡 🍔 做好给你了,
你把汉堡 🍔拿回餐桌,回到了女友 😍 的身边。
你们开始享用汉堡,终于吃完了 🍔 ⏹。
在这里,你基本上都是在柜台前等 🕙,没时间和女友 😞 聊天,更别提谈情说爱了。
在并行汉堡的场景下,你是有两个处理器(你和女友 😍)的计算机/程序 🤖,两个人都在等待 🕙 ,并且你们的注意力 ⏯ 长时间都专注于“在柜台前等待 🕙”。
并行快餐店有 8 个处理器(收银员/厨师)👩🍳👨🍳👩🍳👨🍳👩🍳👨🍳👩🍳👨🍳,并发汉堡店只有 2 个(1 名收银员和 1 位厨师)💁 👨🍳。
但它的最终体验并不好 😞。
这就是汉堡 🍔 并行等效的故事。
银行是现实生活中更贴近于此的例子。
直到现在,还有很多银行都设置了多个收银员 👨💼👨💼👨💼👨💼,但往往会排着大长队 🕙🕙🕙🕙🕙🕙🕙🕙。
每个收银员都要为一个又一个客户完成所有的工作 👨💼⏯。
而且你必须排很长时间的队,要不然就过号重排。
所以你最好还是别带着女友去银行办事 😍。
汉堡的结论
在这个“和女友一起吃汉堡”场景中,大部分时间都在等 🕙,所以还是并发系统更靠谱 ⏸🔀⏯。
这种情况适用于绝大多数 Web 应用。
用户很多很多,但你的服务器总是在等待他们使用不咋地的连接发送请求。
然后又是等待 🕙 返回响应。
这种“等待” 🕙 虽然是以微秒为单位的,但把它们加在一起,还是会等很长时间。
这也是为什么开发 Web API 使用异步编码 ⏸🔀⏯ 更靠谱的原因。
大多数流行的 Python 框架(包括 Flask 和 Django)都是在 Python 推出异步功能前开发的。因此,它们的部署支持并行执行方式,还支持一种不如新功能这般强大的旧式异步执行方式。
Python 异步网络的主要规范 (ASGI)是 Django 开发的,还添加了对 WebSockets 的支持。
这种异步方式也是让 NodeJS 广为流行的原因(即使 NodeJS 不支持并行),同时这也是 Go 为什么这么强劲的原因。
FastAPI 也可以提供同等性能。
借助于 Starlette,你可以同时使用并行与异步,从而获得比大多数 NodeJS 框架更高的性能,甚至是能与 GO (更近似于 C 的编译语言)比肩的性能。
并发比并行更好吗?
不!这个故事想表达的不是这个意思。
并发和并行不一样。很多涉及等待的特定场景下,它确实更好。正因如此,对于开发 Web 应用,并发一般要好很多。但不是所有场景并发都比并行更好。
因此,为了平衡,假设下面这个超级短篇小说:
你必须打扫一间又大又脏的房子。
没错,整个故事就这么长。
不用在任何地方等待 🕙,只是房子里的每个房间都有大量要完成的工作。
你可以像汉堡示例一样轮流依次操作,先是客厅、再是厨房,你完全不需要等 🕙 ,只是打扫打扫再打扫,轮流依次操作不会产生任何影响。
是否轮流依次(并发)不会影响完成工作所需的时间量,工作量也是一样的。
但在这种情况下,你一共有 8 位前收银员/厨师,现在是清洁工 👩🍳👨🍳👩🍳👨🍳👩🍳👨🍳👩🍳👨🍳,他们每个人(加上你)都负责房子里的一片区域,你可以以并行的方式完成所有工作,有了额外的帮助,就可以更快干完。
在这种场景下,每位清洁工(包括你自己)都是一个处理器,只做自己的那部分工作。
由于大多数执行时间都是实实在在的工作(而不是等待),计算机的工作都是由一个 CPU 完成的,这种方式被称为 “CPU 密集型”。
CPU 密集型操作的常见案例一般都是需要复杂数学处理的对象。
例如;
处理音频或图像
计算机视觉:图像一般由几百万个像素组成,每个像素都有 3 个值/颜色,处理图像正常都需要对这些像素同时进行计算
机器学习:正常需要进行大量“矩阵”与“向量”乘法。可以把它想像成一个巨大的、包含数字的表格,所有这些数字都在同时相乘
深度学习:机器学习的子领域,因此可以应用相同规则。只不过它不是单个要实现数字相乘的表格,而是一组这样的表格,大多数情况下需要使用特殊处理器来构建或使用这些模型
并发 + 并行:Web + 机器学习
Web 开发经常会用到并发,这是 FastAPI 的优势,也是 NodeJS 的主要优势。
但你还可以在处理机器学习系统中的 CPU 密集型工作负载时利用并行与多进程(多个进程并行运行)的优势。
再加上 Python 现在是数据科学、机器学习,特别是深度学习的主流语言,FastAPI 因此特别适合开发数据科学、机器学习的 Web API 与应用(相对众多框架而言)。
有关如何在生产环境中实现并行的内容,详见部署一章。
`async` 与 `await`
现代 Python 支持以非常直观的方式定义异步代码。这种方式让它看起来就像普通的“序列”代码,但却能让程序在正确的时刻“等待”。
当某个操作在给出结果前要等待时,如果支持 Python 的异步新功能,就可以编写如下代码:
burgers = await get_burgers(2)
这里的关键是 await
。它告诉 Python 把结果存储到 burgers
前,必须要等 ⏸ get_burgers(2)
完成操作 🕙。使用 await
, Python 就知道它可以同时做些别的事情 🔀(比如接收另一个请求)。
await
只有在支持异步的函数内部才能正常使用。为此,要使用 async def
声明函数。
async def get_burgers(number: int):
# 执行异步操作,生成汉堡
return burgers
……以此替换 def
:
# 不是异步函数
def get_sequential_burgers(number: int):
# 执行序列操作生成汉堡
return burgers
使用 async def
, Python 就知道在这个函数内必须要留意 await
表达式,并且 await
表达式可以“暂停”函数执行,并在返回之前可以做些其它事情。
调用 async def
函数时,必须要使用 await
。因此,如下代码不能正常运行。
# 这行代码不能正常运行,因为 get_burgers 是使用 async def 声明的
burgers = get_burgers(2)
因此,使用由 await
调用的支持库,需要使用 async def
创建路径操作函数,代码如下:
@app.get('/burgers')
async def read_burgers():
burgers = await get_burgers(2)
return burgers
更多技术细节
你可能已经注意到 await
只能在 async def
定义的函数内使用。
但同时,由 async def
定义的函数必须要“等待”。因此,使用 async def
的函数只能在由 async def
定义的函数内被调用。
这是个鸡生蛋、蛋生鸡的问题,怎么调用第一个 async
函数呢?
使用 FastAPI 就不需要担心这个问题,因为“第一个”函数就是路径操作函数,而 FastAPI 知道该怎么处理。
但如果不在 FastAPI 中使用 async
/ await
,则要参阅 Python 官档。
其它异步编码形式
async
与 await
这种风格对于 Python 这门编程语言来说也是相对较新的。
但它让异步编码变得更容易了。
现代 JavaScript (在浏览器与 NodeJS 中)最近也添加了几乎一模一样的语法。
但在此之前,处理异步编码超级复杂,而且特别难。
在 Python 以前的版本中,只能使用线程或 Gevent。但这种代码非常复杂难懂,也不方便调试与理解。
在 NodeJS / 浏览器 JavaScript 以前的版本中要使用“回调”,这种方式直通“回调地狱”。
协程
协程只是个非常花哨的术语,指的是由 async def
函数返回的对象。Python 把它识别为可以在某些点启动或终止的函数,但它还可以在内部暂停 ⏸,只要在它的内部包含 await
。
但是使用 async
与 await
的异步编码的这种功能尝尝被统称为“携程”。它与 Go 的核心功能 “Goroutines” 相对应。
结论
我们再回过头来看一下前文的短语。
现代 Python 支持“异步编码”,使用的是
async
与await
关键字,这种方式叫做协程。
现在再看这句话就更有感觉了吧。✨
所有这些技术都(通过 Starlette)为 FastAPI 赋能,让 FastAPI 具有让人叹为观止的性能。
非常细的技术细节
!!! warning "警告"
你可以跳过这段内容。
这些是非常细的技术细节,介绍的是 **FastAPI** 底层运作机制。
如果你了解足够的技术知识(协程、线程、阻塞等),并对 FastAPI 如何处理 `async def` 和普通的 `def` 感兴趣,请继续。
路径操作函数
使用普通 def
替代 async def
声明路径操作函数时,要在等待的外部线程池中运行,不能直接调用(因为它会阻塞服务器)。
如果你之前使用的异步框架不以上述方式运行,或者你习惯了定义琐碎的仅供计算的纯 def
路径操作函数只为了提升些许性能(约 100 纳秒),请注意,在 FastAPI 中的效果正相反。在这些情况下,除非路径操作函数使用执行阻塞I/O的代码,最好使用 async def
。
在这两种情形下,FastAPI 仍会比你之前使用的框架更快,最起码也是差不多的性能。
依赖项
依赖项也可以应用异步编码。如果依赖项是标准 def
函数,而不是 async def
,则是在外部线程池里运行。
子依赖项
你可以声明多个相互依赖的依赖项和子依赖项(作为函数定义的参数)。其中一些可以是 async def
创建的,另一些是由普通的 def
创建的。这样也可以正常运行,使用普通的 def
创建的函数是用外部线程(来自线程池)调用的,而不是“被等待”。
其它工具函数
直接调用的其它任意工具函数都可以用普通的 def
或 async def
创建,FastAPI 不会影响调用工具函数的方式。
这与 FastAPI 为你调用的函数正相反:路径操作函数与依赖项。
如果工具函数是由 def
声明的普通函数,就需要直接调用(与在代码中直接写的一样),而不是从线程池中调用,如果是由 async def
创建的函数,则在代码里调用该函数时,要 await
这个函数。
再次强调,这些是非常深入的技术细节,在深入探索时可能会用的上。
若非如此,你只要熟练掌握上文中等不及了?一节的内容就够了。