跳到内容

并发与 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

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


如果你实在拿不准,请使用普通的 def


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

总之,在上述任何情况下,FastAPI 仍将异步工作并保持极高的速度。

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

技术细节

现代版本的 Python 使用称为“协程”的特性,配合 asyncawait 语法,支持“异步代码”

让我们在接下来的章节中逐部分解析这句话。

  • 异步代码
  • asyncawait
  • 协程

异步代码

异步代码意味着该语言 💬 有一种方式可以告知计算机/程序 🤖,在代码的某个点,它 🤖 将不得不等待其他地方某件事完成。假设那件某事被称为“慢速文件” 📝。

因此,在此期间,计算机可以去处理其他工作,同时等待“慢速文件” 📝 完成。

然后,每当计算机/程序 🤖 有机会时(因为它还在等待,或者完成了当前点的所有工作),它 🤖 就会回来。它 🤖 会检查所等待的任务中是否有已经完成的,并执行相应的后续操作。

接着,它 🤖 获取第一个完成的任务(比如我们的“慢速文件” 📝),并继续处理与该任务相关的后续事项。

“等待其他事物”通常指代相对于处理器和内存速度较慢的 I/O 操作,例如等待:

  • 客户端通过网络发送的数据
  • 程序发送给客户端,通过网络接收的数据
  • 系统从磁盘读取并交给程序的文件内容
  • 程序交给系统并写入磁盘的内容
  • 远程 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 在将结果存储到 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 标准库 asyncio,也兼容 Trio

特别是,你可以直接使用 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 声明路径操作函数时,它会在一个外部线程池中运行,然后被 await(等待),而不是被直接调用(因为那样会阻塞服务器)。

如果你来自其他不按上述方式工作的异步框架,并且习惯于用普通的 def 定义琐碎的纯计算路径操作函数以获得微小的性能提升(约 100 纳秒),请注意在 FastAPI 中效果可能恰恰相反。在这些情况下,除非你的路径操作函数使用了执行阻塞式 I/O 的代码,否则最好使用 async def

即便如此,在这两种情况下,FastAPI 都有可能依然比你之前的框架更快(或者至少相当)。

依赖项

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

子依赖项

你可以拥有多个互相依赖的子依赖项(作为函数定义的参数),其中一些可以用 async def 创建,一些可以用普通的 def 创建。这仍然有效,并且用普通 def 创建的那些将在外部线程(来自线程池)中被调用,而不是被“await”。

其他工具函数

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

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

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


再说一次,这些是非常技术性的细节,仅在你专门寻找答案时才有用。

否则,遵循上一节 赶时间? 中的指南即可。