跳至内容

并发和异步/等待

关于 路径操作函数async def 语法以及异步代码、并发和并行的一些背景知识的详细信息。

时间紧迫?

TL;DR

如果你正在使用第三方库,它们告诉你使用 await 调用它们,比如

results = await some_library()

那么,用 async def 声明你的 路径操作函数,比如

@app.get('/')
async def read_results():
    results = await some_library()
    return results

注意

你只能在使用 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 密集型”操作。

它被称为“异步”,因为计算机/程序不必与缓慢的任务“同步”,等待任务完成的确切时刻,同时什么也不做,才能获取任务结果并继续工作。

相反,作为“异步”系统,一旦完成,任务可以等待几微秒(一些微秒),等待计算机/程序完成它所做的任何事情,然后回来获取结果并继续使用它们。

对于“同步”(与“异步”相反),它们通常也使用术语“顺序”,因为计算机/程序在切换到其他任务之前,会按顺序执行所有步骤,即使这些步骤涉及等待。

并发与汉堡包

上面描述的异步代码的概念有时也称为“并发”。它不同于“并行”

并发并行都与“多件事情或多或少同时发生”有关。

并发并行之间的细节却大不相同。

为了看到差异,想象一下关于汉堡包的以下故事

并发汉堡

你和你的暗恋对象一起去吃快餐,你排队等候,收银员为前面的人点餐。 😍

然后轮到你了,你点了两个非常精致的汉堡,一个给你的暗恋对象,一个给你。 🍔🍔

收银员对厨房里的厨师说了一些话,以便他们知道必须准备你的汉堡(即使他们目前正在准备之前顾客的汉堡)。

你付了钱。 💸

收银员给了你一个号码。

在你等待的时候,你和你的暗恋对象一起找了一张桌子,你坐下来,和你的暗恋对象聊了很久(因为你的汉堡很精致,需要一些时间来准备)。

当你和你的暗恋对象坐在桌子旁,等待汉堡的时候,你可以花时间欣赏你的暗恋对象是多么的棒,多么的可爱,多么的聪明 ✨😍✨。

在等待和和你的暗恋对象交谈的时候,你偶尔会看看柜台上的显示屏,看看是否轮到你了。

然后,在某一时刻,终于轮到你了。你去柜台,取了你的汉堡,然后回到桌子旁。

你和你的暗恋对象一起吃汉堡,度过了一段美好的时光。 ✨

信息

美丽的插图由 Ketrina Thompson 提供。 🎨


想象一下你是故事中的计算机/程序🤖。

当你排队的时候,你只是闲着 😴,等待你的回合,没有做任何“有成效”的事情。但排队速度很快,因为收银员只是点餐(没有准备),所以这没问题。

然后,当你轮到的时候,你做了一些真正“有成效”的工作,你处理菜单,决定你想要什么,得到你暗恋对象的喜好,付款,检查你是否给出了正确的账单或卡,检查你是否被正确收费,检查订单是否包含正确的物品,等等。

但是,即使你还没有得到你的汉堡,你与收银员的工作已“暂停” ⏸,因为你必须等待 🕙 你的汉堡准备好。

但当你离开柜台,带着你的号码坐到桌子上,你可以将注意力 🔀 转移到你的暗恋对象身上,并在那里“工作” ⏯ 🤓。然后你又做了一些非常“有成效”的事情,比如与你的暗恋对象调情 😍。

然后收银员 💁 说“我已经完成汉堡了”,在柜台的显示屏上显示了你的号码,但你并没有在显示屏上的号码变成你的号码时就疯狂地跳起来。你知道没有人会偷你的汉堡,因为你拥有你的号码,而他们拥有他们的号码。

所以你等待你的暗恋对象完成故事(完成正在处理的当前工作 ⏯ / 任务 🤓),轻轻地微笑,说你要去取汉堡 ⏸。

然后你走到柜台 🔀,去到现在已经完成 ⏯ 的初始任务,拿起汉堡,道谢,然后把它们带到桌子旁。这完成了与柜台交互的这个步骤/任务 ⏹。这反过来又创建了一个新任务,“吃汉堡” 🔀 ⏯,但之前的“取汉堡”任务已经完成 ⏹。

并行汉堡

现在让我们想象一下这些不是“并发汉堡”,而是“并行汉堡”。

你和你的暗恋对象一起去吃并行的快餐。

你排队等候,同时有几个(比如 8 个)收银员,他们同时也是厨师,从你前面的人那里点餐。

在你前面的人都在等待他们的汉堡准备好,然后才离开柜台,因为 8 个收银员中的每一个都立即去准备汉堡,然后才开始下一个订单。

然后终于轮到你了,你点了两个非常精致的汉堡,一个给你的暗恋对象,一个给你。

你付了钱 💸。

收银员走到厨房。

你站在柜台前等待 🕙,这样没有人能在你的汉堡送到之前取走它们,因为没有号码。

由于你和你的暗恋对象都忙于不让人插队,并在汉堡送到时取走它们,你不能关注你的暗恋对象。 😞

这是“同步”工作,你与收银员/厨师 👨‍🍳 “同步”。你必须等待 🕙 并待在那里,直到收银员/厨师 👨‍🍳 做完汉堡并把它们给你,否则别人可能会拿走它们。

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

你拿了你的汉堡,和你的暗恋对象一起走到桌子旁。

你只是吃掉它们,然后你就完成了。 ⏹

没有多少交谈或调情,因为大部分时间都花在了柜台前等待 🕙。 😞

信息

美丽的插图由 Ketrina Thompson 提供。 🎨


在这个并行汉堡的场景中,你是一台拥有两个处理器的计算机/程序🤖(你和你的暗恋对象),你们都在等待 🕙 并将注意力 ⏯ 投入到“柜台前等待” 🕙 很长一段时间。

快餐店有 8 个处理器(收银员/厨师)。而并发汉堡店可能只有 2 个(一个收银员和一个厨师)。

但最终的体验仍然不是最好的。 😞


这就是汉堡的并行等效故事。 🍔

为了更“现实”地举例,想象一下银行。

直到最近,大多数银行都有多个收银员 👨‍💼👨‍💼👨‍💼👨‍💼 和一条长长的队伍 🕙🕙🕙🕙🕙🕙🕙🕙。

所有收银员都按顺序为一个客户服务 👨‍💼⏯。

你必须在队伍中等待 🕙 很长时间,否则你就失去了你的位置。

你可能不希望带着你的暗恋对象 😍 去银行 🏦 办事。

汉堡结论

在这个“和你的暗恋对象一起吃快餐汉堡”的场景中,由于有很多等待 🕙,拥有并发系统 ⏸🔀⏯ 更有意义。

这适用于大多数网络应用程序。

很多用户,但你的服务器正在等待 🕙 他们不太好的连接发送他们的请求。

然后再次等待 🕙 响应返回。

这种“等待” 🕙 是用微秒来衡量的,但仍然,将所有时间加起来,最终还是有很多等待。

这就是为什么使用异步 ⏸🔀⏯ 代码来构建网络 API 更有意义的原因。

这种异步性正是 NodeJS 变得流行的原因(即使 NodeJS 不是并行的),也是 Go 作为编程语言的优势。

FastAPI 可以达到相同的性能水平。

而且由于你可以在同一时间拥有并行性和异步性,因此你可以获得比大多数测试过的 NodeJS 框架更高的性能,并且与 Go 相当,Go 是一种更接近 C 的编译语言 (这都要归功于 Starlette).

并发比并行好吗?

不!这不是故事的寓意。

并发不同于并行。在涉及大量等待的特定场景中,它比并行更好。因此,它通常比网络应用程序开发的并行性要好得多,但不适用于所有情况。

因此,为了平衡这一点,想象一下以下简短的故事

你必须打扫一个又大又脏的房子。

是的,这就是整个故事.


这里没有等待 🕙,只有很多工作要做,而且要打扫房子的多个地方。

你可以像汉堡的例子一样轮流,先客厅,然后厨房,但因为你没有等待 🕙 任何东西,只是在不停地打扫,轮流不会有任何影响。

无论是否轮流(并发),完成工作所花费的时间都是一样的,你所做的工作量也是一样的。

但在这种情况下,如果你能找来 8 个前收银员/厨师/现在的清洁工,每个人(包括你)都可以负责房子的一部分进行打扫,你就可以并行地完成所有工作,有额外的帮助,而且会更快完成。

在这个场景中,每个清洁工(包括你)都是一个处理器,负责自己的工作。

由于大部分执行时间都花在了实际工作上(而不是等待),而计算机中的工作是由 CPU 完成的,因此他们将这些问题称为“CPU 密集型”。


CPU 密集型操作的常见示例是需要复杂数学处理的操作。

例如

  • 音频图像处理
  • 计算机视觉:图像由数百万个像素组成,每个像素有 3 个值/颜色,处理这些像素通常需要对所有像素进行计算。
  • 机器学习:通常需要大量的“矩阵”和“向量”乘法。想象一下一个巨大的电子表格,里面都是数字,并且同时将所有数字乘在一起。
  • 深度学习:这是机器学习的一个子领域,因此,同样适用。只是没有单个数字电子表格要相乘,而是一组庞大的数字,并且在许多情况下,您会使用特殊的处理器来构建和/或使用这些模型。

并发 + 并行:Web + 机器学习

使用FastAPI,您可以利用并发,这是 Web 开发中非常常见的一种(与 NodeJS 的主要吸引力相同)。

但您也可以利用并行和多处理(并行运行多个进程)的优势来处理CPU 密集型工作负载,例如机器学习系统中的那些工作负载。

再加上 Python 是数据科学、机器学习,尤其是深度学习的主要语言这一简单事实,使 FastAPI 非常适合数据科学/机器学习 Web 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):
    # Do some asynchronous stuff to create the burgers
    return burgers

...而不是def

# This is not asynchronous
def get_sequential_burgers(number: int):
    # Do some sequential stuff to create the burgers
    return burgers

使用async def,Python 知道,在该函数内部,它必须注意await表达式,并且它可以“暂停”⏸该函数的执行,并在返回之前去做其他事情🔀。

当您想调用async def函数时,您必须“等待”它。因此,这将不起作用

# This won't work, because get_burgers was defined with: 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 的标准库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中,效果会完全相反。在这些情况下,最好使用async def,除非您的路径操作函数使用执行阻塞I/O的代码。

尽管如此,在这两种情况下,FastAPI很有可能仍然比(或至少与)您之前的框架一样快。

依赖项

对于依赖项也是如此。如果依赖项是标准的def函数而不是async def,则它将在外部线程池中运行。

子依赖项

您可以在彼此之间需要(作为函数定义的参数)多个依赖项和子依赖项,其中一些可能使用async def创建,而另一些可能使用普通的def创建。它仍然可以工作,并且使用普通的def创建的那些将被调用在外部线程(来自线程池)中,而不是被“等待”。

其他实用函数

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

这与 FastAPI 为您调用的函数形成对比:路径操作函数和依赖项。

如果您的实用函数是使用def的普通函数,它将被直接调用(如您在代码中编写的那样),而不是在线程池中,如果该函数使用async def创建,那么在您在代码中调用该函数时,您应该await它。


再说一次,这些都是非常技术性的细节,如果您在搜索它们时可能会发现它们有用。

否则,您应该遵循上面部分中的准则:赶时间吗?