跳到内容

并发与 async / await

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

如果你的应用程序(某种程度上)不需要与任何其他东西通信并等待其响应,那么即使你不需要在内部使用 await,也请使用 async def


如果你不知道该用哪个,请使用普通的 def


注意:你可以在你的路径操作函数中随意混合使用 defasync def,并为每个函数选择最适合你的选项。FastAPI 会正确处理它们。

无论如何,在上述任何情况下,FastAPI 仍将异步工作并极其快速。

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

技术细节

现代 Python 版本使用名为“协程”的技术,通过 asyncawait 语法来支持“异步代码”

让我们在下面的部分中逐字看看这句话。

  • 异步代码
  • asyncawait
  • 协程

异步代码

异步代码意味着语言💬有一种方式告诉计算机/程序🤖,在代码的某个点,它🤖将不得不等待其他事物在别处完成。假设这个其他事物叫做“慢文件”📝。

因此,在这段时间内,计算机可以去处理其他工作,而“慢文件”📝正在完成。

然后,计算机/程序🤖会在每次有机会(因为它再次等待)或者完成当前所有工作时回来。它🤖会查看是否有任何它正在等待的任务已经完成,然后处理它们。

接下来,它🤖会处理第一个完成的任务(比如说,我们的“慢文件”📝),并继续处理与该任务相关的工作。

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

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

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

之所以称为“异步”,是因为计算机/程序不必与慢速任务“同步”,它不需要在任务完成的精确时刻什么也不做地等待,以便能够获取任务结果并继续工作。

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

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

并发与汉堡

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

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

并发并行之间的细节却大相径庭。

为了区分它们,想象一下下面关于汉堡的故事:

并发汉堡

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

轮到你了,你为你的暗恋对象和自己点了两个非常花哨的汉堡。🍔🍔

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

你付款。💸

收银员给你一个取餐号。

等待的时候,你和你的暗恋对象去选了一张桌子,坐下并聊了很久(因为你的汉堡非常花哨,需要一些时间准备)。

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

在等待和与暗恋对象聊天时,你不时地查看柜台上的显示屏,看看是不是轮到你了。

然后,在某个时刻,终于轮到你了。你走到柜台,拿到你的汉堡,然后回到桌子。

你和你的暗恋对象吃了汉堡,度过了愉快的时光。✨

信息

精美插画来自 Ketrina Thompson。🎨


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

当你在排队时,你只是闲着😴,等待轮到你,没有做任何“生产性”的事情。但是队伍很快,因为收银员只是在点餐(而不是准备),所以没关系。

然后,当轮到你时,你才开始做实际的“生产性”工作:处理菜单,决定你想要什么,获取你暗恋对象的选择,付款,检查是否给了正确的钞票或卡,检查是否被正确收费,检查订单是否包含正确的物品等等。

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

但是当你离开柜台,带着取餐号坐在桌边时,你可以将注意力切换🔀到你的暗恋对象身上,并“工作”⏯ 🤓 于此。然后你又在做一些非常有“生产性”的事情,比如和你的暗恋对象调情 😍。

然后收银员💁通过在柜台显示屏上显示你的号码,表示“我做完汉堡了”,但你不会在显示号码变为你的号码时立刻像疯了一样跳起来。你知道没人会偷你的汉堡,因为你有取餐号,他们有他们的。

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

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

并行汉堡

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

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

你排队等候,同时有几个(比如说8个)收银员兼厨师在为前面的人点餐。

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

然后终于轮到你了,你为你的暗恋对象和自己点了两个非常花哨的汉堡。

你付款 💸。

收银员去厨房了。

你站在柜台前等待 🕙,这样就没有人会在你的汉堡送来之前拿走,因为没有取餐号。

由于你和你的暗恋对象忙着不让任何人插队和拿走你的汉堡,所以你无法和你的暗恋对象聊天。😞

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

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

你拿着汉堡,和你的暗恋对象一起走到桌边。

你们只是吃完它们,然后就结束了。⏹

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

信息

精美插画来自 Ketrina Thompson。🎨


在这个并行汉堡的场景中,你是一个有两颗处理器(你和你的暗恋对象)的计算机/程序🤖,两者都在等待🕙并将注意力⏯长时间地集中在“等待柜台”🕙上。

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

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


这将是汉堡的并行等价故事。🍔

对于一个更“真实”的例子,想象一下银行。

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

所有柜员都一个接一个地为客户办理所有业务👨‍💼⏯。

而你必须长时间排队等待🕙,否则就会错过你的顺序。

你可能不会想带你的暗恋对象😍和你一起去银行🏦办差事。

汉堡总结

在这种“与暗恋对象一起吃快餐汉堡”的场景中,由于有大量的等待🕙,采用并发系统⏸🔀⏯ 会更有意义。

大多数 Web 应用程序都是这种情况。

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

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

这种“等待”🕙以微秒计算,但最终加起来,总共是大量的等待。

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

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

这就是你使用 FastAPI 获得的相同性能水平。

而且由于你可以同时拥有并行性和异步性,你将获得比大多数经过测试的 NodeJS 框架更高的性能,并与 Go 媲美,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,在将结果存储到 burgers 之前,它必须等待 ⏸ get_burgers(2) 完成其工作 🕙。这样,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 / 浏览器 JavaScript 的早期版本中,你会使用“回调”。这导致了回调地狱

协程

协程只是 async def 函数返回的东西的一个非常花哨的术语。Python 知道它类似于一个函数,可以开始执行并在某个点结束,但内部也可能被暂停 ⏸,每当其中有 await 时。

但是,这种使用 asyncawait 的异步代码功能通常被概括为使用“协程”。它与 Go 语言的主要关键特性“Goroutines”具有可比性。

结论

我们再来看看上面那句话:

现代 Python 版本使用名为“协程”的技术,通过 asyncawait 语法来支持“异步代码”

现在应该更有意义了。✨

所有这些都是 FastAPI(通过 Starlette)的动力源泉,也是其拥有如此惊人性能的原因。

非常技术性的细节

警告

你可以跳过这一部分。

这些是 FastAPI 内部工作方式的非常技术性的细节。

如果你有相当多的技术知识(协程、线程、阻塞等),并且好奇 FastAPI 如何处理 async def 与普通 def,请继续阅读。

路径操作函数

当你使用普通的 def 而不是 async def 声明一个路径操作函数时,它会在一个外部线程池中运行,然后被等待,而不是直接调用(因为那样会阻塞服务器)。

如果你来自另一个不以这种方式工作的异步框架,并且习惯于为了微小的性能提升(大约100纳秒)而使用普通的 def 定义简单的纯计算型路径操作函数,请注意在 FastAPI 中,效果会截然相反。在这些情况下,最好使用 async def,除非你的路径操作函数使用了执行阻塞 I/O 的代码。

尽管如此,在两种情况下,FastAPI 仍然可能比你之前的框架更快(或至少旗鼓相当)。

依赖项

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

子依赖项

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

其他工具函数

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

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

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


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

否则,遵循上面“赶时间?”部分中的指南就足够了。