Skip to content

并发与异步/等待

关于*路径操作函数*的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


注意:你可以在你的*路径操作函数*中根据需要混合使用defasync def,并根据你的最佳选择定义每一个。FastAPI会正确处理它们。

无论如何,在上述任何情况下,FastAPI仍然会异步工作并非常快速。

但通过遵循上述步骤,它将能够进行一些性能优化。

技术细节

现代版本的Python支持使用称为**“协程”的东西来实现“异步代码”,使用asyncawait**语法。

让我们在下面的章节中逐部分了解这句话:

  • 异步代码
  • asyncawait
  • 协程

异步代码

异步代码只是意味着语言💬有一种方式告诉计算机/程序🤖,在代码的某个点上,它🤖将不得不等待*其他东西*在别处完成。假设*其他东西*被称为“慢文件”📝。

因此,在那段时间里,计算机可以去做其他工作,而“慢文件”📝完成。

然后,每当计算机/程序🤖有机会时,它🤖会回来,因为它又在等待,或者每当它🤖完成了当时所有的任务。然后,它🤖会查看它正在等待的任何任务是否已经完成,并执行它需要做的任何事情。

接下来,它🤖会取第一个完成的任务(比如我们的“慢文件”📝),并继续处理它需要做的任何事情。

这种“等待其他东西”通常指的是相对“慢”的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和应用程序(以及其他许多领域)。

要了解如何在生产环境中实现这种并行性,请参阅关于部署的部分。

asyncawait

现代版本的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标准库的asyncioTrio兼容。

特别是,你可以直接使用AnyIO来处理需要更高级模式的高级并发用例。

即使你不使用FastAPI,你也可以使用AnyIO编写你自己的异步应用程序,以获得高度兼容性和其带来的好处(例如*结构化并发*)。

我在AnyIO之上创建了另一个库,作为其上的一层薄层,以稍微改进类型注解并获得更好的**自动补全**、内联错误**等。它还提供了一个友好的介绍和教程,帮助你**理解**并编写**你自己的异步代码Asyncer。如果你需要**将异步代码与常规**(阻塞/同步)代码结合使用,它将特别有用。

其他形式的异步代码

这种使用asyncawait的风格在语言中相对较新。

但它使得处理异步代码变得容易得多。

这种相同的语法(或几乎相同)最近也被包含在现代版本的JavaScript中(在浏览器和NodeJS中)。

但在那之前,处理异步代码要复杂和困难得多。 在Python的早期版本中,你可以使用线程或Gevent。但代码的理解、调试和思考要复杂得多。

在NodeJS/浏览器JavaScript的早期版本中,你会使用“回调”。这会导致回调地狱

协程

**协程**只是由async def函数返回的东西的一个非常花哨的术语。Python知道它类似于一个函数,可以启动并在某个时刻结束,但在内部也可能在遇到await时暂停⏸。

但所有这些使用asyncawait编写异步代码的功能通常被总结为使用“协程”。它与Go的主要特性“Goroutines”相当。

结论

让我们看看上面的同一句话:

现代版本的Python支持使用称为**“协程”“异步代码”,语法为asyncawait**。

现在应该更容易理解了。✨

所有这些都是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创建的那些会在外部线程(从线程池)中调用,而不是被“等待”。

其他工具函数

你直接调用的任何其他工具函数都可以用普通的defasync def创建,FastAPI不会影响你调用它的方式。

这与FastAPI为你调用的函数(*路径操作函数*和依赖项)形成对比。

如果你的工具函数是一个普通的def函数,它将直接调用(就像你在代码中写的那样),而不是在线程池中,如果函数是用async def创建的,那么你应该在调用它时await该函数。


再次强调,这些是非常技术性的细节,如果你是专门来寻找它们的,可能会对你有用。

否则,你应该可以按照上面的章节指南:匆忙中?