跳到内容

并发和 async / await

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

赶时间?

简而言之

如果您正在使用第三方库,它们会告诉您使用 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,即使您不需要在内部使用 await


如果您不知道,请使用常规的 def


注意:您可以根据需要随意混合使用 defasync def 来定义您的路径操作函数,并为每个函数选择最适合您的选项。FastAPI 会正确处理它们。

无论如何,在上述任何一种情况下,FastAPI 仍然会以异步方式运行,并且速度极快。

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

技术细节

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

让我们在下面的部分中逐一了解这个短语。

  • 异步代码
  • asyncawait
  • 协程

异步代码

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

因此,在此期间,计算机可以去做其他工作,而“慢文件” 📝 则会完成。

然后,每当计算机/程序 🤖 有机会并且它 🤖 再次等待,或者当它 🤖 在那个时候完成了所有工作时,它 🤖 就会回来。它 🤖 会查看它正在等待的任务是否已经完成,并执行它必须做的事情。

接下来,它 🤖 会处理第一个完成的任务(比如我们的“慢文件” 📝),并继续执行它必须做的后续工作。

这种“等待其他一些东西”通常指的是 I/O 操作,这些操作相对“慢”(与处理器和 RAM 内存的速度相比),例如等待:

  • 通过网络发送来自客户端的数据
  • 您的程序发送的数据通过网络被客户端接收
  • 系统读取磁盘上的文件内容并提供给您的程序
  • 您的程序提供给系统的内容写入磁盘
  • 远程 API 操作
  • 数据库操作完成
  • 数据库查询返回结果
  • 等等。

由于执行时间主要消耗在等待 I/O 操作上,因此称它们为“I/O 密集型”操作。

之所以称为“异步”,是因为计算机/程序不必与慢任务“同步”,在等待任务完成的精确时刻,同时无所事事,才能获取任务结果并继续工作。

相反,由于这是一个“异步”系统,一旦完成,任务可能会在后台稍作等待(几微秒),以便计算机/程序完成它正在做的事情,然后回来获取结果并继续处理它们。

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

并发与汉堡

上面描述的异步代码的这个概念也称为“并发”。它与“并行”不同。

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

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

为了看出区别,让我们想象一个关于汉堡的以下故事。

并发汉堡

您和您的心仪对象去快餐店,您排队等待收银员为前面的顾客点餐。😍

然后轮到您了,您点了 2 个非常精致的汉堡,给您心仪的对象和您。🍔🍔

收银员会告诉厨房的厨师,让他们知道需要准备您的汉堡(即使他们目前还在准备前几位顾客的汉堡)。

您付款。💸

收银员会给您您的排队号码。

在等待的过程中,您和您的心仪对象去选一张桌子,坐下来和您的心仪对象聊了很久(因为您的汉堡很精致,需要一些时间来准备)。

当您和您的心仪对象坐在一起时,在等待汉堡的过程中,您可以花时间欣赏您的心仪对象多么棒、多么可爱、多么聪明 ✨😍✨。

在等待和与您的心仪对象聊天时,您会不时查看柜台上的号码,看看是否轮到您了。

然后,在某个时候,终于轮到您了。您走到柜台,拿到汉堡,回到桌子旁。

您和您的心仪对象吃汉堡,度过了愉快的时光。✨

信息

Ketrina Thompson 创作的精美插图。🎨


想象一下,您就是那个故事里的计算机/程序 🤖。

当您在排队时,您只是闲置 😴,等待轮到您,没有做任何“有成效”的事情。但是队伍很短,因为收银员只接受订单(不准备),所以没关系。

然后,当轮到您时,您会进行实际的“有成效”的工作,您会处理菜单,决定想要什么,获取您心仪对象的选择,付款,检查您给的账单或卡是否正确,检查收取的金额是否正确,检查订单是否有正确的物品,等等。

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

但是,当您离开柜台,坐在桌子旁,拿着号码等待时,您可以将注意力转移到您的心仪对象上,并“工作” ⏯ 🤓。然后您又在做非常有成效的事情,那就是和您的心仪对象调情 😍。

然后收银员 💁 说“我把汉堡做好了”,通过在柜台显示屏上显示您的号码,但您不会立即像疯了一样跳起来,因为显示的号码变为了您的号码。您知道没有人会偷您的汉堡,因为您有您的号码,他们也有他们的。

所以您等待您的心仪对象讲完故事(完成当前正在处理的工作 ⏯ / 任务 🤓),温柔地微笑,然后说您要去拿汉堡 ⏸。

然后您走到柜台 🔀,到那个现在已经完成的初始任务 ⏯,拿起汉堡,说声谢谢,然后带回桌子。这样就完成了与柜台的交互步骤/任务 ⏹。这反过来又创建了一个新任务,“吃汉堡” 🔀 ⏯,而之前的“拿汉堡”任务已经完成 ⏹。这个过程是并发的,因为您可以交替处理与心仪对象的聊天和去拿汉堡。

并行汉堡

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

您和您的心仪对象去吃并行快餐。

您排队等待,而有几位(假设 8 位)收银员同时也是厨师,正在为前面的顾客点餐。

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

然后终于轮到您了,您点了 2 个非常精致的汉堡,给您心仪的对象和您。

您付款 💸。

收银员去厨房。

您在柜台前等待 🕙,以免别人在您之前拿走您的汉堡,因为没有排队号码。

当您和您的心仪对象忙于不让任何人插队并随时取走您的汉堡时,您无法关注您的心仪对象。😞

这是“同步”工作,您与收银员/厨师 👨‍🍳“同步”。您必须在那里等待 🕙,并在收银员/厨师 👨‍🍳 完成汉堡并交给您时在那儿,否则别人可能会拿走。

然后您的收银员/厨师 👨‍🍳 经过长时间的等待 🕙 后,终于带着您的汉堡回来了。

您拿到汉堡,然后和您的心仪对象一起去桌子旁。

您只是吃完它们,就完成了。⏹

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

信息

Ketrina Thompson 创作的精美插图。🎨


在这个并行汉堡的场景中,您是一个有两个处理器(您和您的心仪对象)的计算机/程序 🤖,它们都在等待 🕙 并投入注意力 ⏯ 在柜台前 🕙 等待很长时间。

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

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


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

对于更“现实生活”的例子,想象一下银行。

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

所有收银员都一位接一位地处理所有客户 👨‍💼⏯。

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

您可能不想带您的心仪对象 😍 去银行 🏦 办杂事。

汉堡结论

在这个“和心仪对象吃快餐汉堡”的场景中,因为有大量的等待 🕙,所以拥有一个并发系统 ⏸🔀⏯ 会更有意义。

这就是大多数 Web 应用程序的情况。

许多许多用户,但您的服务器在等待他们的连接(不太好)发送他们的请求。

然后再次等待 🕙 回复。

这种“等待” 🕙 以微秒为单位,但总计起来,最终还是有很多等待。

这就是为什么为 Web API 使用异步 ⏸🔀⏯ 代码非常有意义。

这种异步性使 NodeJS 流行起来(尽管 NodeJS 不是并行的),这也是 Go 语言的优势所在。

而这正是 FastAPI 带来的性能水平。

而且由于您可以同时拥有并行和异步,因此您的性能比大多数 NodeJS 框架都高,并且与 Go(一种更接近 C 的编译语言)相当 (所有这些都归功于 Starlette)

并发比并行更好吗?

不!这并不是故事的寓意。

并发与并行不同。它在涉及大量等待的特定场景中表现更好。因此,它通常比并行更适合 Web 应用程序开发。但并非万能。

所以,为了平衡这一点,想象一下以下短故事。

您必须打扫一栋大而脏的房子。

是的,这就是全部故事。.


这里没有等待 🕙,只是有很多工作要做,在房子的多个地方。

您可以像汉堡示例那样轮流进行,先是客厅,然后是厨房,但由于您没有等待 🕙 任何东西,只是不停地打扫,轮流进行不会有任何影响。

完成所需的时间与有或没有轮流(并发)的时间相同,您完成的工作量也相同。

但是,在这种情况下,如果您能请来 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 函数时,您必须“await”它。所以,这样做行不通:

# 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 定义的函数也必须被“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 / Browser JavaScript 的早期版本中,您会使用“回调”。这会导致“回调地狱”。

协程

协程只是 async def 函数返回的东西的一个非常花哨的术语。Python 知道它就像一个函数,它可以启动它,它会在某个时候结束,但它可能会在内部被暂停 ⏸,只要里面有一个 await

但是,使用 asyncawait 的这种异步代码功能很多时候被概括为使用“协程”。它相当于 Go 的主要关键特性“Goroutines”。

总结

让我们看看上面相同的短语。

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

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

所有这些都是通过 Starlette 为 FastAPI 提供支持的,也是它具有如此令人印象深刻性能的原因。

非常技术性细节

警告

您可能可以跳过这一部分。

这些是关于 FastAPI 底层工作原理的非常技术性的细节。

如果您拥有相当多的技术知识(协程、线程、阻塞等),并且对 FastAPI 如何处理 async def 与普通 def 感兴趣,请继续。

路径操作函数

当您使用常规 def 而不是 async def 来声明路径操作函数时,它会在外部线程池中运行,然后被 await,而不是直接调用(因为它会阻塞服务器)。

如果您来自另一个异步框架,它不像上述那样工作,并且您习惯于使用普通的 def 定义纯计算的路径操作函数以获得微小的性能提升(约 100 纳秒),请注意,在 FastAPI 中,效果恰恰相反。在这种情况下,最好使用 async def,除非您的路径操作函数使用了执行阻塞 I/O 的代码。

尽管如此,在这两种情况下,FastAPI 很可能仍然比您以前的框架更快,或至少与之相当。

依赖项

这同样适用于依赖项。如果依赖项是标准的 def 函数而不是 async def,它将在外部线程池中运行。

子依赖项

您可以拥有多个相互依赖的依赖项和子依赖项(作为函数定义的参数),其中一些可能使用 async def 创建,一些则使用常规 def 创建。它们仍然可以正常工作,而使用常规 def 创建的将从外部线程(从线程池)调用,而不是被“await”。

其他实用函数

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

这与 FastAPI 为您调用的函数(路径操作函数和依赖项)不同。

如果您的实用函数是带有 def 的常规函数,它将被直接调用(如您在代码中所写),而不是在线程池中;如果函数是用 async def 创建的,那么您在代码中调用它时应该“await”该函数。


同样,这些是非常技术性的细节,如果您是特意搜索它们,可能会对您有用。

否则,您应该遵循上面部分的指南:赶时间?