并发与异步/等待¶
关于路径操作函数的 async def 语法细节,以及异步代码、并发和并行的一些背景知识。
赶时间?{ #in-a-hurry }¶
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,即使你不需要在内部使用 await。
如果你不确定,使用普通的 def。
注意:你可以在路径操作函数中随意混合使用 def 和 async def,并根据需要为每个函数选择最佳选项。FastAPI 会正确处理它们。
无论如何,在上述任何情况下,FastAPI 仍然会异步工作并且速度极快。
但通过遵循上述步骤,它将能够进行一些性能优化。
技术细节¶
现代版本的 Python 支持使用称为“协程”的东西,通过 async 和 await 语法来编写“异步代码”。
让我们在下面的部分中逐部分查看这句话:
- 异步代码
async和await- 协程
异步代码¶
异步代码仅仅意味着语言 💬 有一种方式告诉计算机/程序 🤖,在代码的某个点上,它 🤖 将不得不等待其他地方的某些东西完成。假设那个某些东西被称为“慢文件”📝。
因此,在那段时间里,计算机可以去处理其他工作,而“慢文件”📝 正在完成。
然后计算机/程序 🤖 会在每次有机会时返回,因为它再次等待,或者当它 🤖 完成了当时所有的工作时。它会检查它正在等待的任务是否有已经完成的,并执行它必须做的任何事。
接下来,它 🤖 获取第一个完成的任务(比如,我们的“慢文件”📝)并继续处理它必须做的事情。
那种“等待某些东西”通常指的是相对“慢”的 I/O 操作(与处理器和 RAM 内存的速度相比),例如等待:
- 客户端通过网络发送数据
- 你的程序通过网络发送的数据被客户端接收
- 系统读取磁盘上的文件内容并交给你的程序
- 你的程序交给系统要写入磁盘的内容
- 远程 API 操作
- 数据库操作完成
- 数据库查询返回结果
- 等等。
由于执行时间主要消耗在等待 I/O 操作上,因此它们被称为“I/O 密集型”操作。
它被称为“异步”是因为计算机/程序不必与慢任务“同步”,在任务完成的确切时刻无所事事地等待,以便能够获取任务结果并继续工作。
相反,作为一个“异步”系统,任务完成后,可以稍等片刻(几微秒),让计算机/程序完成它正在做的事情,然后返回获取结果并继续处理它们。
对于“同步”(与“异步”相反),它们通常也使用术语“顺序”,因为计算机/程序在切换到其他任务之前按顺序执行所有步骤,即使这些步骤涉及等待。
并发和汉堡¶
上述异步代码的思想有时也称为“并发”。它不同于“并行”。
并发和并行都涉及“不同的事情或多或少同时发生”。
但并发和并行之间的细节有很大不同。
为了看出区别,想象以下关于汉堡的故事:
并发汉堡¶
你和你的 crush 一起去吃快餐,你排队,收银员为你前面的人点餐。😍

然后轮到你了,你为你的 crush 和你自己点了两个非常精致的汉堡。🍔🍔

收银员对厨房里的厨师说了些什么,这样他们就知道要准备你的汉堡了(尽管他们目前正在为之前的客户准备)。

你付款。💸
收银员给你你的号码。

在等待的时候,你和你的 crush 去找了一张桌子,坐下和你的 crush 聊了很长时间(因为你的汉堡非常精致,需要一些时间准备)。
当你和你的 crush 坐在桌旁时,在等待汉堡的同时,你可以利用那段时间欣赏你的 crush 是多么棒、可爱和聪明 ✨😍✨。

在等待和与你的 crush 交谈时,你不时检查计数器上显示的号码,看看是否轮到你。
然后在某个时刻,终于轮到你了。你走到柜台,拿起你的汉堡,回到桌子。

你和你的 crush 吃了汉堡,度过了愉快的时光。✨

Info
精美插图由 Ketrina Thompson 绘制。🎨
想象你是故事中的计算机/程序 🤖。
当你在排队时,你只是空闲 😴,等待你的轮次,没有做任何非常“有成效”的事情。但队伍很快,因为收银员只负责点餐(不准备),所以这没问题。
然后,当轮到你时,你做了实际的“有成效”的工作,你处理菜单,决定你想要什么,获取你的 crush 的选择,付款,检查你给的账单或卡是否正确,检查收费是否正确,检查订单是否有正确的项目,等等。
但是然后,即使你还没有拿到汉堡,你与收银员的工作是“暂停”⏸ 的,因为你必须等待 🕙 你的汉堡准备好。
但当你离开柜台,拿着你的号码坐在桌子旁时,你可以将注意力切换 🔀 到你的 crush 身上,并“工作”⏯ 🤓 于此。然后你再次做一些非常“有成效”的事情,比如和你的 crush 调情 😍。
然后收银员 💁 通过将你的号码放在计数器显示屏上来表示“我做完汉堡了”,但当显示的号码变成你的轮次号码时,你不会立即疯狂地跳起来。你知道没有人会偷你的汉堡,因为你有你的号码,他们也有他们的。
所以你等待你的 crush 讲完故事(完成当前正在处理的工作 ⏯ / 任务 🤓),温柔地微笑,说你要去拿汉堡 ⏸。
然后你走到柜台 🔀,处理现在已完成的最初任务 ⏯,拿起汉堡,表示感谢并将它们拿到桌子上。这就完成了与柜台交互的那个步骤/任务 ⏹。这反过来又创建了一个新任务,“吃汉堡”🔀 ⏯,但之前“拿汉堡”的任务已经完成 ⏹。
并行汉堡¶
现在让我们想象这些不是“并发汉堡”,而是“并行汉堡”。
你和你的 crush 一起去吃并行快餐。
你排队,而几个(比如 8 个)同时是厨师的收银员为你前面的人点餐。
你前面的每个人都在等待他们的汉堡准备好后才离开柜台,因为 8 个收银员中的每一个都会立即去准备汉堡,然后再接下下一个订单。

终于轮到你了,你为你的 crush 和你自己点了两个非常精致的汉堡。
你付款 💸。

收银员走进厨房。
你等待,站在柜台前 🕙,这样就没有人会在你之前拿走你的汉堡,因为没有号码轮次。

由于你和你的 crush 忙于不让任何人插队,并在汉堡到来时拿走你的汉堡,你无法关注你的 crush。😞
这是“同步”工作,你与收银员/厨师 👨🍳“同步”。你必须等待 🕙 并在收银员/厨师 👨🍳 做完汉堡并交给你的确切时刻在场,否则,别人可能会拿走它们。

然后你的收银员/厨师 👨🍳 在柜台前等了很长时间 🕙 后,终于拿着你的汉堡回来了。

你拿起汉堡和你的 crush 走到桌子旁。
你只是吃了它们,然后就结束了。⏹

没有太多谈话或调情,因为大部分时间都花在柜台前等待 🕙。😞
Info
精美插图由 Ketrina Thompson 绘制。🎨
在这个并行汉堡的场景中,你是一个计算机/程序 🤖,有两个处理器(你和你的 crush),两者都在等待 🕙 并将他们的注意力 ⏯ 集中在“在柜台等待”🕙 上很长时间。
快餐店有 8 个处理器(收银员/厨师)。而并发汉堡店可能只有 2 个(一个收银员和一个厨师)。
但尽管如此,最终的体验并不是最好的。😞
这将是汉堡的并行等效故事。🍔
一个更“真实”的例子是银行。
直到最近,大多数银行都有多个出纳员 👨💼👨💼👨💼👨💼 和一个长队 🕙🕙🕙🕙🕙🕙🕙🕙。
所有出纳员都一个接一个地处理客户的工作 👨💼⏯。
你必须等待 🕙 很长时间,否则就会失去轮次。
你可能不想带你的 crush 😍 去银行 🏦 办事。
汉堡结论¶
在这个“和 crush 一起吃快餐汉堡”的场景中,由于有很多等待 🕙,使用并发系统 ⏸🔀⏯ 更有意义。
大多数 Web 应用程序都是这种情况。
很多很多用户,但你的服务器正在等待 🕙 他们不太好的连接发送请求。
然后再次等待 🕙 响应返回。
这种“等待”🕙 是以微秒衡量的,但总之,加起来,最后是很多等待。
这就是为什么为 Web API 使用异步 ⏸🔀⏯ 代码非常有意义。
这种异步性使得 NodeJS 流行起来(尽管 NodeJS 不是并行的),这也是 Go 作为编程语言的优势。
这也是你使用 FastAPI 获得的性能水平。
并且由于你可以同时拥有并行性和异步性,你获得的性能比大多数测试过的 NodeJS 框架更高,并且与 Go 相当,Go 是一种更接近 C 的编译语言 (全部归功于 Starlette)。
并发比并行更好吗?{ #is-concurrency-better-than-parallelism }¶
不!这不是这个故事的寓意。
并发不同于并行。它在涉及大量等待的特定场景中更好。因此,对于 Web 应用程序开发,它通常比并行好得多。但并非适用于所有情况。
所以,为了平衡这一点,想象以下短故事:
你必须打扫一个又大又脏的房子。
是的,这就是整个故事。
没有任何地方需要等待 🕙,只是有很多工作要做,在房子的多个地方。
你可以像汉堡例子中那样轮流,先是客厅,然后是厨房,但由于你没有等待 🕙 任何东西,只是清洁和清洁,轮流不会影响任何事情。
无论有没有轮流(并发),完成所需的时间都是一样的,并且你完成的工作量也是一样的。
但在这种情况下,如果你能带来 8 个前收银员/厨师/现在的清洁工,并且他们每个人(加上你)可以负责房子的一個区域进行清洁,你可以在并行的情况下完成所有工作,借助额外的帮助,并更快地完成。
在这个场景中,每个清洁工(包括你)都是一个处理器,做他们的一部分工作。
由于大部分执行时间被实际工作(而不是等待)占用,并且计算机中的工作是由 CPU 完成的,因此他们称这些问题为“CPU 密集型”。
CPU 密集型操作的常见例子是需要复杂数学处理的事情。
例如:
- 音频或图像处理。
- 计算机视觉:图像由数百万像素组成,每个像素有 3 个值/颜色,处理通常需要同时对这些像素进行计算。
- 机器学习:通常需要大量的“矩阵”和“向量”乘法。想象一个充满数字的巨大电子表格,并同时将它们全部相乘。
- 深度学习:这是机器学习的一个子领域,因此,同样适用。只是它不是单个数字电子表格要相乘,而是一个巨大的集合,并且在许多情况下,你使用特殊的处理器来构建和/或使用这些模型。
并发 + 并行:Web + 机器学习¶
使用 FastAPI,你可以利用 Web 开发中非常常见的并发性(NodeJS 的主要吸引力相同)。
但你也可以利用并行和多处理(多个进程并行运行)的优势来处理CPU 密集型工作负载,例如机器学习系统中的那些。
这一点,加上 Python 是数据科学、机器学习和尤其是深度学习的主要语言这一简单事实,使 FastAPI 非常适用于数据科学/机器学习 Web 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 函数时,你必须“等待”它。所以,这不行:
# 这不行,因为 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,你也可以这样做。
编写你自己的异步代码¶
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)的强大之处,也是使其具有如此令人印象深刻的性能的原因。
非常技术性的细节¶
Warning
你可能会跳过这部分。
这些是关于 FastAPI 底层工作原理的非常技术性的细节。
如果你有相当多的技术知识(协程、线程、阻塞等)并且好奇 FastAPI 如何处理 async def 与普通 def,请继续。
路径操作函数¶
当你使用普通的 def 而不是 async def 声明一个路径操作函数时,它会在一个外部线程池中运行,然后被等待,而不是被直接调用(因为这会阻塞服务器)。
如果你来自另一个不以上述方式工作的异步框架,并且习惯于使用普通的 def 定义琐碎的仅计算的路径操作函数以获得微小的性能提升(大约 100 纳秒),请注意在 FastAPI 中效果可能恰恰相反。在这些情况下,最好使用 async def,除非你的路径操作函数使用执行阻塞性 I/O 的代码。
尽管如此,在这两种情况下,FastAPI 很可能仍然比你之前的框架更快(或至少相当)。
依赖项¶
同样适用于依赖项。如果一个依赖项是标准的 def 函数而不是 async def,它会在外部线程池中运行。
子依赖项¶
你可以有多个依赖项和子依赖项相互需求(作为函数定义的参数),其中一些可能是用 async def 创建的,一些是用普通的 def 创建的。它仍然会工作,并且那些用普通的 def 创建的将在外部线程(来自线程池)上调用,而不是被“等待”。
其他工具函数¶
你直接调用的任何其他工具函数都可以用普通的 def 或 async def 创建,FastAPI 不会影响你调用它的方式。
这与 FastAPI 为你调用的函数相反:路径操作函数和依赖项。
如果你的工具函数是一个带有 def 的普通函数,它将被直接调用(如你在代码中编写的那样),而不是在线程池中,如果函数是用 async def 创建的,那么当你在代码中调用该函数时,你应该 await 它。
再次说明,这些是非常技术性的细节,如果你来找它们,可能很有用。
否则,你应该遵循上面部分的指南:赶时间?。