同步执行I/O是asyncio&aposs的超级能力之一(如果不是其主要的超级能力的话)。这是通过创建和使用asyncio任务直接或间接(通过像asyncio.gather 这样的助手)完成的。即使你从来没有自己创建过asyncio任务或调用过gather,你也使用过它们,因为每个Web框架都会产生它们来处理请求。
那么问题出在哪里呢?
让我介绍三个小的程序,一个依次执行两个异步操作,两个使用任务(通过gather或手动)并发地执行它们。
from asyncio import gather, sleep, create_task
async def job_a():
await sleep(1)
async def in_sequence():
await job_a()
await job_a()
async def concurrently_gather():
await gather(job_a(), job_a())
async def concurrently_tasks():
t1, t2 = create_task(job_a()), create_task(job_a())
await t1
await t2
所有这些程序都等待job_a 两次;第二和第三个程序使用任务来并发地等待。如果job_a 需要N秒才能完成(例如,它向一个缓慢的第三方服务执行请求),concurrently 的变体也将在大约N秒内完成,而in_sequence 将需要大约2 * N秒才能完成。这通常被称为壁挂时间;它是指操作运行时在现实世界中经过的时间量。它是指如果你看着墙上的时钟,你会看到经过的时间量。所以在这种情况下,concurrently 的墙面时间性能是in_sequence 的两倍。这是否意味着你应该总是使用gather (或创建asyncio任务)来做并发的操作?
首先,我们需要引入一个额外的概念;CPU时间的概念。
CPU时间大致上是指一个CPU核心花在执行工作上的时间。在上面的例子中,运行in_sequence ,需要大约两秒钟的时间来完成,但在这两秒钟中,CPU只执行了几十微秒的实际工作。其余的CPU时间都是在等待。
CPU时间很重要,因为它是AWS、谷歌等云供应商向你出售的主要资源之一,你对它的利用决定了你需要配置多少虚拟机来处理你的工作负荷。你在一个请求上花费的CPU时间越多,你就需要为你的工作负载提供更多的核心,以便在你扩展时保持可接受的延迟。
想象一下,in_sequence 用1毫秒的CPU时间来运行自己,而concurrently 用1000毫秒。考虑到Python受到全局解释器锁的限制,这意味着你的一个服务实例,在一个核心上运行,可以用in_sequence ,为1000个并发的客户提供服务,用concurrently ,为1个并发的客户提供服务。换句话说,使用较少CPU时间的方法可以处理更多的并发客户端,并且能够更有效地扩展。
因此,让我们对这两个程序进行基准测试,看看使用的CPU时间有什么不同。像往常一样,我们可以使用pyperf 工具,它在上一个版本中增加了对coroutine进行基准测试的能力。我们还可以把sleep 改为return 0 ,以消除任何等待,从而使壁挂时间(也就是pyperf报告的内容)和CPU时间相等。我将使用我的Ubuntu台式机,用pyperf system tune 、Python 3.10和uvloop 事件循环进行调整,因为它在生产部署中非常常用。
from asyncio import create_task, gather
async def job_a():
return 0
async def in_sequence():
await job_a()
await job_a()
async def concurrently_gather():
await gather(job_a(), job_a())
async def concurrently_tasks():
t1, t2 = create_task(job_a()), create_task(job_a())
await t1
await t2
import uvloop
from pyperf import Runner
uvloop.install()
runner = Runner()
runner.bench_async_func("in_sequence", in_sequence)
runner.bench_async_func("concurrently_gather", concurrently_gather)
runner.bench_async_func("concurrently_tasks", concurrently_tasks)
结果是这样的。
in_sequence: Mean +- std dev: 478 ns +- 16 ns
concurrently_gather: Mean +- std dev: 20.7 us +- 0.5 us
concurrently_tasks: Mean +- std dev: 8.87 us +- 0.20 us
gather 变体使用的CPU时间是顺序版本的40倍,而裸任务变体使用的CPU时间是20倍。不足为奇的是,启用并发性所需的机器需要花费一些CPU来使用。
那么,永远不要gather ?
当然不是。就像之前提到的,提出并发请求的能力是asyncio&aposs的超能力之一。而20微秒绝对不是一个大数字,但也不是零。不过,你应该做的是明智地使用任务/并发。
如果通过并发获得的延迟能够实质性地改善你的产品,比如让你的网站反应更快,或者让你的手机游戏更快通过加载屏幕,你肯定应该付出代价,并为你有这样的选择而高兴。然而,如果通过并发性获得的延迟并不重要,可以考虑不做并发性的事情。
你的Web框架提供给你的并发性足以释放asyncio的所有操作优势。因此,如果一个非常热的端点需要10毫秒的上墙时间,但如果没有额外的并发性,则需要20毫秒,你的用户可能甚至无法注意到这种差异,你可以得到一些CPU时间。
另一个常见的工作负载的例子是后台作业,延迟并不那么重要。如果一个后台作业在5秒或6秒内完成,谁会在乎它为其他终端留下更多的CPU时间?
在我们的工作中经常出现这样的情况,你是否应该使用一个功能,取决于它。希望你现在明白了其中的利弊,你会更接近于做出一个明智的决定。