并发与异步/等待¶
关于*路径操作函数*的async def
语法以及一些关于异步代码、并发性和并行性的背景信息。
匆忙之中?¶
TL;DR:
如果你使用的第三方库要求你用await
调用它们,例如:
results = await some_library()
那么,请使用async def
声明你的*路径操作函数*,例如:
@app.get('/')
async def read_results():
results = await some_library()
return results
Note
你只能在用async def
创建的函数内部使用await
。
如果你使用的第三方库与某些东西(数据库、API、文件系统等)通信,并且不支持使用await
(目前大多数数据库库都是这种情况),那么请像往常一样声明你的*路径操作函数*,只需使用def
,例如:
@app.get('/')
def results():
results = some_library()
return results
如果你的应用程序(不知何故)不需要与任何其他东西通信并等待其响应,请使用async def
。
如果你不确定,请使用普通的def
。
注意:你可以在你的*路径操作函数*中根据需要混合使用def
和async def
,并根据你的最佳选择定义每一个。FastAPI会正确处理它们。
无论如何,在上述任何情况下,FastAPI仍然会异步工作并非常快速。
但通过遵循上述步骤,它将能够进行一些性能优化。
技术细节¶
现代版本的Python支持使用称为**“协程”的东西来实现“异步代码”,使用async
和await
**语法。
让我们在下面的章节中逐部分了解这句话:
- 异步代码
async
和await
- 协程
异步代码¶
异步代码只是意味着语言💬有一种方式告诉计算机/程序🤖,在代码的某个点上,它🤖将不得不等待*其他东西*在别处完成。假设*其他东西*被称为“慢文件”📝。
因此,在那段时间里,计算机可以去做其他工作,而“慢文件”📝完成。
然后,每当计算机/程序🤖有机会时,它🤖会回来,因为它又在等待,或者每当它🤖完成了当时所有的任务。然后,它🤖会查看它正在等待的任何任务是否已经完成,并执行它需要做的任何事情。
接下来,它🤖会取第一个完成的任务(比如我们的“慢文件”📝),并继续处理它需要做的任何事情。
这种“等待其他东西”通常指的是相对“慢”的I/O操作(与处理器和RAM内存的速度相比),比如等待:
- 客户端通过网络发送的数据
- 你的程序发送的数据被客户端通过网络接收
- 系统读取磁盘上的文件内容并将其提供给你的程序
- 你的程序提供给系统的内容被写入磁盘
- 远程API操作
- 数据库操作完成
- 数据库查询返回结果
- 等等。
由于执行时间主要消耗在等待I/O操作上,它们被称为“I/O绑定”操作。
它被称为“异步”是因为计算机/程序不需要与慢任务“同步”,等待任务完成的精确时刻,无所事事,以便能够获取任务结果并继续工作。
相反,通过成为一个“异步”系统,一旦任务完成,它可以稍微等待(一些微秒)计算机/程序完成它去做的任何事情,然后回来获取结果并继续工作。
对于“同步”(与“异步”相反),他们通常也使用术语“顺序”,因为计算机/程序在切换到不同任务之前会按顺序执行所有步骤,即使这些步骤涉及等待。
并发与汉堡¶
上述**异步**代码的想法有时也被称为**“并发”。它与“并行”**不同。
**并发**和**并行**都与“不同的事情或多或少同时发生”有关。
但*并发*和*并行*之间的细节有很大不同。
为了理解差异,请想象以下关于汉堡的故事:
并发的汉堡¶
你和你的暗恋对象一起去快餐店,你站在队伍中,收银员从前面的顾客那里接单。😍
然后轮到你了,你为你的暗恋对象和你自己点了两个非常精致的汉堡。🍔🍔
收银员对厨房里的厨师说了些什么,让他们知道需要准备你的汉堡(即使他们目前正在为之前的顾客准备汉堡)。
你付了钱。💸
收银员给了你一个号码,表示你的顺序。
在等待的过程中,你和你的暗恋对象一起去选了一张桌子,坐下来,和你的暗恋对象聊了很久(因为你的汉堡非常精致,需要一些时间来准备)。
当你和暗恋对象坐在桌旁等待汉堡时,你可以利用这段时间欣赏你的暗恋对象是多么的出色、可爱和聪明✨😍✨。
在等待并与你的暗恋对象交谈时,你会不时查看柜台上的号码,看看是否轮到你了。
然后,终于轮到你了。你走到柜台,拿到你的汉堡,然后回到桌子上。
你和你的暗恋对象一起吃汉堡,度过了一段美好的时光。✨
Info
美丽的插图由Ketrina Thompson绘制。🎨
想象一下,你是那个故事中的电脑/程序🤖。
当你在排队时,你只是闲着😴,等待你的轮次,没有做任何非常“有成效”的事情。但队伍移动得很快,因为收银员只负责接单(不负责准备),所以这没关系。
然后,轮到你时,你开始做实际的“有成效”工作,你处理菜单,决定你想要什么,获取你暗恋对象的选择,付款,检查你是否给了正确的账单或卡片,检查你是否被正确收费,检查订单是否有正确的项目,等等。
但随后,即使你还没有拿到汉堡,你与收银员的工作也“暂停”了⏸,因为你必须等待🕙汉堡准备好。
但当你离开柜台,坐在桌旁并拿到一个号码时,你可以将注意力🔀转移到你的暗恋对象上,并“工作”⏯🤓。然后你再次在做一些非常“有成效”的事情,比如和你的暗恋对象调情😍。
然后收银员💁通过将你的号码放在柜台的显示屏上,表示“我已经完成了汉堡的制作”,但你不会在显示的号码变成你的轮次号码时立即疯狂地跳起来。你知道没有人会偷走你的汉堡,因为你有你的轮次号码,他们也有他们的。
所以你等待你的暗恋对象讲完故事(完成当前的工作⏯/正在处理的任务🤓),温柔地微笑并说你要去拿汉堡⏸。
然后你走到柜台🔀,回到现在已经完成的初始任务⏯,拿起汉堡,说谢谢并把它们带到桌子上。这完成了与柜台互动的这一步骤/任务⏹。这反过来又创建了一个新的任务,即“吃汉堡”🔀⏯,但之前的“取汉堡”任务已经完成⏹。
并行汉堡¶
现在让我们想象这些不是“并发汉堡”,而是“并行汉堡”。
你和你的暗恋对象一起去买并行快餐。
你站在队伍中,同时有几位(假设是8位)收银员兼厨师从你前面的人那里接单。
在你之前的每个人都在等待他们的汉堡准备好,然后才离开柜台,因为每个收银员兼厨师都会立即去准备汉堡,然后再接下一个订单。
然后终于轮到你了,你为你的暗恋对象和你自己点了两个非常精致的汉堡。
你付了钱💸。
收银员兼厨师去了厨房。
你站在柜台前等待🕙,这样就没有人会在你之前拿走你的汉堡,因为没有轮次的号码。
当你和你的暗恋对象忙着不让任何人插队并拿走你们的汉堡时,你无法关注你的暗恋对象。😞
这是“同步”工作,你与收银员/厨师👨🍳“同步”。你必须等待🕙并在收银员/厨师👨🍳完成汉堡并把它们交给你时正好在那里,否则别人可能会拿走它们。
然后你的收银员/厨师👨🍳终于带着你的汉堡回来了,在柜台前等了很久🕙。
你拿着汉堡,和你的暗恋对象一起走到桌边。
你只是吃掉了它们,然后就结束了。⏹
由于大部分时间都花在了柜台前等待🕙,所以并没有太多交谈或调情。😞
/// 信息
精美的插图由 Ketrina Thompson 绘制。🎨
///
在这个并行汉堡的场景中,你是一台计算机/程序🤖,拥有两个处理器(你和你的暗恋对象),两人都在等待🕙并将注意力⏯集中在“在柜台前等待”🕙上很长时间。
快餐店有8个处理器(收银员/厨师)。而并发汉堡店可能只有2个(一个收银员和一个厨师)。
但即便如此,最终的体验也不是最好的。😞
这将是汉堡的并行等效故事。🍔
为了更“真实生活”的例子,想象一下银行。
直到最近,大多数银行都有多个出纳员👨💼👨💼👨💼👨💼和一条长长的队伍🕙🕙🕙🕙🕙🕙🕙🕙。
所有的出纳员都在一个接一个地为客户👨💼⏯做所有的工作。
而你必须在队伍中等待🕙很长时间,或者你会失去你的位置。
你可能不想带着你的暗恋对象😍一起去银行🏦办事。
汉堡结论¶
在这个“和暗恋对象一起吃快餐汉堡”的场景中,由于有很多等待🕙,采用并发系统⏸🔀⏯更有意义。
这是大多数网络应用程序的情况。
有很多很多用户,但你的服务器在等待🕙他们不太好的连接发送请求。
然后再次等待🕙响应回来。
这种“等待”🕙是以微秒为单位衡量的,但总的来说,最终还是有很多等待。
这就是为什么为网络API使用异步⏸🔀⏯代码非常有意义。
这种异步性是使NodeJS受欢迎的原因(尽管NodeJS不是并行的),这也是Go作为编程语言的优势所在。
而这就是你通过**FastAPI**获得的表现水平。
由于你可以同时拥有并行性和异步性,你获得了比大多数测试过的NodeJS框架更高的性能,并且与Go相当,Go是一种更接近C的编译语言(这都要归功于Starlette)。
并发比并行更好吗?¶
不!这不是故事的寓意。
并发与并行不同。它在涉及大量等待的**特定**场景中更好。因此,对于网络应用程序开发来说,它通常比并行性好得多。但并非适用于所有情况。
因此,为了平衡这一点,想象一下以下简短的故事:
你必须打扫一间又大又脏的房子。
是的,这就是整个故事。
没有任何地方需要等待🕙,只是有很多工作要做,遍布房子的多个地方。
你可以像汉堡例子中那样轮流进行,先是客厅,然后是厨房,但由于你不是在等待🕙任何事情,只是不停地打扫,轮流不会影响任何事情。
无论有没有轮流(并发),完成的时间和完成的工作量都是一样的。
但在这种情况下,如果你能带上8个前收银员/厨师/现在的清洁工,每个人(加上你)都可以负责打扫房子的一部分,你可以在**并行**中完成所有工作,有了额外的帮助,完成得更快。
在这个场景中,每个清洁工(包括你)都是一个处理器,做他们自己的那部分工作。
由于大部分执行时间都花在了实际工作上(而不是等待),而计算机中的工作是由CPU完成的,他们称这些问题为“CPU绑定”。
CPU绑定操作的常见例子是需要复杂数学处理的事情。
例如:
- 音频**或**图像处理。
- 计算机视觉:图像由数百万像素组成,每个像素有3个值/颜色,处理这些通常需要在所有像素上同时进行某种计算。
- 机器学习:通常需要大量的“矩阵”和“向量”乘法。想象一下一个巨大的数字表格,并同时将它们全部相乘。
- 深度学习:这是机器学习的一个子领域,所以同样适用。只是没有单一的数字表格需要相乘,而是一大堆,在许多情况下,你使用一个特殊的处理器来构建和/或使用这些模型。
并发 + 并行:网络 + 机器学习¶
通过**FastAPI**,你可以利用并发性,这在网络开发中非常常见(与NodeJS的主要吸引力相同)。 但你也可以利用并行和多进程(即多个进程并行运行)的优势来处理**CPU密集型**工作负载,比如机器学习系统中的那些任务。
再加上一个简单的事实,即Python是**数据科学**、机器学习,尤其是深度学习的主要语言,这使得FastAPI非常适合用于数据科学/机器学习网络API和应用程序(以及其他许多领域)。
要了解如何在生产环境中实现这种并行性,请参阅关于部署的部分。
async
和 await
¶
现代版本的Python提供了一种非常直观的定义异步代码的方式。这使得异步代码看起来就像普通的“顺序”代码,并在适当的时刻为你处理“等待”。
当有一个操作在返回结果之前需要等待,并且支持这些新的Python特性时,你可以这样编写代码:
burgers = await get_burgers(2)
这里的关键是await
。它告诉Python,它必须等待⏸get_burgers(2)
完成其工作🕙,然后将结果存储在burgers
中。通过这种方式,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
表达式,并且可以在返回之前“暂停”⏸该函数的执行,去做其他事情🔀。
当你想要调用一个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
定义的函数必须被“await”。因此,使用async def
的函数也只能在用async def
定义的函数内部调用。
那么,关于先有鸡还是先有蛋的问题,你如何调用第一个async
函数呢?
如果你在使用**FastAPI**,你不必担心这个问题,因为那个“第一个”函数将是你的*路径操作函数*,FastAPI会知道如何正确处理。
但如果你想在不使用FastAPI的情况下使用async
/await
,你也可以这样做。
编写你自己的异步代码¶
Starlette(以及**FastAPI**)基于AnyIO,这使得它与Python标准库的asyncio和Trio兼容。
特别是,你可以直接使用AnyIO来处理需要更高级模式的高级并发用例。
即使你不使用FastAPI,你也可以使用AnyIO编写你自己的异步应用程序,以获得高度兼容性和其带来的好处(例如*结构化并发*)。
我在AnyIO之上创建了另一个库,作为其上的一层薄层,以稍微改进类型注解并获得更好的**自动补全**、内联错误**等。它还提供了一个友好的介绍和教程,帮助你**理解**并编写**你自己的异步代码:Asyncer。如果你需要**将异步代码与常规**(阻塞/同步)代码结合使用,它将特别有用。
其他形式的异步代码¶
这种使用async
和await
的风格在语言中相对较新。
但它使得处理异步代码变得容易得多。
这种相同的语法(或几乎相同)最近也被包含在现代版本的JavaScript中(在浏览器和NodeJS中)。
但在那之前,处理异步代码要复杂和困难得多。 在Python的早期版本中,你可以使用线程或Gevent。但代码的理解、调试和思考要复杂得多。
在NodeJS/浏览器JavaScript的早期版本中,你会使用“回调”。这会导致回调地狱。
协程¶
**协程**只是由async def
函数返回的东西的一个非常花哨的术语。Python知道它类似于一个函数,可以启动并在某个时刻结束,但在内部也可能在遇到await
时暂停⏸。
但所有这些使用async
和await
编写异步代码的功能通常被总结为使用“协程”。它与Go的主要特性“Goroutines”相当。
结论¶
让我们看看上面的同一句话:
现代版本的Python支持使用称为**“协程”的“异步代码”,语法为
async
和await
**。
现在应该更容易理解了。✨
所有这些都是FastAPI(通过Starlette)的驱动力,使其具有如此惊人的性能。
非常技术性的细节¶
/// 警告
你可能会跳过这个部分。
这些是非常技术性的细节,关于**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
该函数。
再次强调,这些是非常技术性的细节,如果你是专门来寻找它们的,可能会对你有用。
否则,你应该可以按照上面的章节指南:匆忙中?。