同步 vs 异步 Python

本文翻译自 https://blog.miguelgrinberg.com/post/sync-vs-async-python-what-is-the-difference

你有没有听人说过异步 Python 代码比 "普通"(或同步)Python 代码快?这怎么可能呢?在本文中,我将尝试解释什么是异步,以及它与普通 Python 代码的区别。

同步和异步的含义

Web 应用程序经常需要处理许多请求,这些请求都是在很短的时间内从不同的客户端发出的。为了避免处理延迟,人们认为它们必须能够并行处理多个请求,这就是通常所说的并发性。在本文中,我将继续以 Web 应用程序为例,但请记住,还有其他类型的应用也可以从同时执行多个任务中受益,因此,本次讨论并不专门针对 Web 应用。

术语“同步”和“异步”是指编写使用并发的应用程序的两种方式。所谓的“同步”服务器使用底层操作系统对线程和进程的支持来实现这种并发。下面是一个同步部署的示意图:

同步部署图示

在这种情况下,我们有五个客户端,所有客户端都向应用程序发送请求。此应用程序的公共接口是一个 Web 服务器,它作为负载均衡器,将请求分配给 server worker 池,这些 worker 可以是进程、线程或两者的组合,worker 会执行负载平衡器分配给他们的请求。而应用程序的逻辑,比如使用 Web 应用程序框架(例如 Flask 或 Django)所编写的,就位于这些 worker 中。

这种类型的解决方案对于拥有多个 CPU 的服务器来说是非常好的,因为你可以将 worker 的数量配置为 CPU 数量的倍数,通过这种方式,你可以实现核心的均匀利用,而由于全局解释器锁(GIL)的限制,单个 Python 进程无法做到这一点。

就缺点而言,上图清楚地表明了此方法的主要局限性。我们有 5 个客户端,但只有 4 个 worker,如果这 5 个客户端同时发送请求,那么负载均衡器将只能够将其中的 4 个请求发送给 worker 进行处理,而剩下的 1 个请求则需要保留在队列中,并且等待 worker 变得可用后才会进行处理。因此,5 个客户端中的 4 个客户端将及时收到响应,但剩下的 1 个客户端将不得不等待更长的时间。使服务器性能良好的关键在于选择适当数量的 worker,以防止或尽量减少给定预期负载下的阻塞请求。

异步服务器的设置较难绘制,下图可以作为参考:

异步服务器部署图示

这种类型的服务器运行在一个由循环控制的单一进程中。循环是一个非常高效的任务管理器和调度器,它可以创建任务来执行客户端发送的请求。与长时间存在的 server worker 不同,循环会创建一个异步任务来处理特定的请求,当该请求完成时,该任务将被销毁。在任何时候,一个异步服务器可能会有几百个甚至上千个活跃的任务,在循环的管理下处理着自己的工作。

你可能想知道异步任务之间的并行性是如何实现的。这一部分非常有趣,因为异步应用程序为此完全依赖于协同多任务处理( Cooperative multitasking )。这意味着什么呢?当一个任务需要等待一个外部事件时,比如说数据库服务器的响应,它不是像同步 worker 那样等待,而是告诉循环它需要等待什么,然后把控制权返回给它。在这个任务被数据库查询所阻塞时,该循环可以找到另一个已准备好运行的任务。最终,当数据库发回响应时,循环会认为第一个任务已准备好再次运行,并将尽快恢复它。

这种异步任务挂起和恢复执行的能力听起来比较抽象,可能很难理解。为了帮助你将其应用到你可能已经知道的事情上,考虑到在 Python 中,实现这种能力的一种方法是使用 awaityield 关键字,但你稍后可以看到,这并不是唯一的方法。

一个异步应用程序完全在单个进程和单个线程中运行,这简直太神奇了。当然这种类型的并发需要遵循一定的规则,因为你不能让一个任务占用 CPU 太久,否则其余的任务就会终止。为了让异步可以工作,所有的任务都需要主动挂起并及时将控制权返回给循环。要想从异步中获益,应用程序需要有经常被 I/O 阻塞的任务,并且没有过多的 CPU 计算工作。从这一点上来看,Web 应用程序通常是非常合适的,特别是当它们需要处理大量的客户端请求时。

在使用异步服务器时,为了最大限度地提高多个 CPU 的利用率,通常会创建一个混合方案,增加一个负载均衡器,并在每个 CPU 上运行一个异步服务器,如下图所示:

异步服务器混合方案

异步 Python 的两种方案

相信大家都知道,要在 Python 中写一个异步应用,可以使用 asyncio,它建立在协程之上,以实现所有异步应用都需要的挂起和恢复功能。yield 关键字以及较新的 asyncawaitasyncio 异步功能建立的基础。除此之外,Python 生态系统中还有其他基于协程的异步解决方案,例如 TrioCurio。还有 Twisted,它是所有框架中最古老的协程框架,甚至比 asyncio 还要早。

如果你有兴趣写一个异步 Web 应用程序,有很多基于协程的异步框架可供选择,包括 aiohttpsanicFastAPITornado

很多人不知道的是,协程只是 Python 中可用来写异步代码的两种方法之一。第二种方法是基于一个名为 greenlet 的包,你可以使用 pip 进行安装。Greenlet 与协程类似,它也允许 Python 函数暂停执行,并在稍后的时间恢复执行,但它们实现的方式完全不同,这意味着 Python 中的异步生态系统被分为两个大类。

在异步开发中,协程和 greenlet 之间有趣的区别在于,前者需要特定的关键字和Python语言的特性才能工作,而后者则不需要。我的意思是,基于协程的应用程序需要使用非常特殊的语法来编写,而基于 greenlet 的应用程序看起来就像普通的 Python 代码一样。这是非常酷的,因为在某些条件下,它可以使同步代码异步执行,这是基于协程的解决方案如 asyncio 所无法做到的。

那么在 greenlet 方面,asyncio 相当于什么呢?我知道有三个基于 greenlet 的 async 包: Gevent, EventletMeinheld, 虽然最后一个更像是一个 Web 服务器,而不是一个通用的异步库。它们都有自己的异步循环的实现,并且它们提供了一个有趣的 "猴子补丁 "功能,即用基于 greenlet 实现的等效的非阻塞函数来替换 Python 标准库中的阻塞函数,比如那些处理网络请求和线程的函数。如果你有一段想要异步运行的同步代码,这些包可以帮助你实现。

你一定会对此感到惊讶:据我所知,唯一一个对 greenlet 有明确支持的 Web 框架不是他者,正是 Flask。这个框架会自动检测你是否运行在一个 greenlet Web 服务器上,并相应地调整自己,不需要任何配置。这种情况下,请注意不要调用阻塞函数,如果你调用了,那么就用猴子补丁来 "修复 "这些阻塞函数。
但 Flask 并不是唯一能与 greenlet 良好结合的框架。其他的 Web 框架,如 DjangoBottle,它们没有针对 greenlet 进行适配,但当与 greenlet Web 服务器配对使用时,如果你对阻塞函数进行了猴子补丁处理,也是可以异步运行的。

异步比同步快吗?

在同步和异步应用程序的性能方面,有一个广为人知的误解:人们认为异步应用比同步应用要快得多。

我需要澄清这一点,Python 代码无论以同步或异步方式编写,其运行速度都是完全一样的。除了代码之外,还有两个因素会影响并发应用程序的性能:上下文切换和可扩展性。

上下文切换

多个正在运行的任务如何合理地共享 CPU 的计算能力,也就是所谓的上下文切换,会影响应用程序的性能。对于同步应用来说,这项工作是由操作系统来完成的,基本上是一个没有配置或微调选项的黑盒子。对于异步应用,上下文切换是由循环来完成的。

asyncio 提供的默认循环实现是用 Python 编写的,被认为效率不高。uvloop 提供了一个替代循环,部分代码采用 C 语言实现,以达到更好的性能。Gevent 和 Meinheld 使用的事件循环也是用 C 语言编写的,Eventlet 使用的则是用 Python 编写的。

一个高度优化的异步循环在做上下文切换时可能比操作系统更有效率,但根据我的经验,要想看到明显的性能提升,你必须在真正的高并发下运行。对于大多数应用来说,我认为同步和异步上下文切换之间的性能差异并不明显。

可扩展性

我认为 ”异步更快“ 的说法的来源是,异步应用往往能更有效地利用 CPU,这是因为异步应用的扩展能力要比同步应用好得多,而且方式更灵活。

考虑一下,如果上图所示的同步服务器同时接收 100 个请求,会发生什么情况。这个服务器一次不能处理超过 4 个请求,所以这些请求中的大部分会在队列中停留一段时间,然后才能得到分配的 worker 进行处理。

而异步服务器则不同,异步服务器会立即创建 100 个任务(如果使用混合模式,则 4 个异步 worker 中的每一个都有 25 个任务)。使用异步服务器,所有的请求都会立即开始处理,而无需等待(针对实际情况而言,可能会有其他瓶颈使处理变慢,例如对活动数据库连接数的限制)。

如果这 100 个任务需要密集使用 CPU 的计算能力,那么同步方案和异步方案的性能会差不多,因为 CPU 的运行速度是固定的,Python 执行代码的速度总是一样的,应用程序要做的工作也是一样的。但如果任务需要做大量的 I/O 操作,那么同步服务器只做 4 个并发请求,可能无法达到很高的 CPU 利用率。而另一方面,异步服务器因为可以并行运行所有的 100 个请求,因此更能充分地利用 CPU。

你可能会疑惑,为什么不能同时运行 100 个同步 worker,这样两个服务器的并发量是一样的。考虑到每个 worker 需要有自己的 Python 解释器和所有与之相关的资源,再加上一个单独的应用程序副本,你的服务器和你的应用程序的大小将决定你可以运行多少个 worker 实例,但一般来说,这个数字不会很高。而异步任务则是非常轻量级的,而且都是在一个worker 进程的上下文中运行,所以它们有明显的优势。

因此请牢记,只有在给定的场景下,异步才有可能比同步快:

  • 有高负载(如果没有高负载,就没有访问高并发的优势);
  • 任务是 I/O 密集型的(如果任务是 CPU 密集型的,那么并发量超过 CPU 数量就无济于事);
  • 你需要关注单位时间内处理的平均请求数。如果你只看单个请求的处理时间,你不会看到很大的差异,异步甚至可能会稍微慢一些,因为有更多的并发任务在争夺 CPU。

总结

我希望这篇文章能澄清一些关于异步代码的困惑和误解,我希望你能记住以下两点:

  • 一个异步应用只有在高负载下才会比同步应用做得更好;
  • 多亏了 greenlet,即使你写的是普通的代码,使用 Flask 或 Django 等传统框架,也可以从异步中获益。

如果你想更详细地了解异步系统是如何工作的,可以在 YouTube 上查看 Miguel Grinberg 的 PyCon 演讲 Asynchronous Python for the Complete Beginner