Python同步代码示例比异步快

3

当我将一个生产系统迁移到异步时,我发现同步版本比异步版本快了20倍。我能够创建一个非常简单的示例以可重复的方式演示这一点;

异步版本

import asyncio, time

data = {}

async def process_usage(key):
    data[key] = key

async def main():
    await asyncio.gather(*(process_usage(key) for key in range(0,1000000)))

s = time.perf_counter()
results = asyncio.run(main())
elapsed = time.perf_counter() - s
print(f"Took {elapsed:0.2f} seconds.")

这需要19秒钟。代码通过1M个键循环并构建一个字典,data,其具有相同的键和值。

$ python3.7 async_test.py
Took 19.08 seconds.

同步版本

import time

data = {}

def process_usage(key):
    data[key] = key

def main():
    for key in range(0,1000000):
        process_usage(key)

s = time.perf_counter()
results = main()
elapsed = time.perf_counter() - s
print(f"Took {elapsed:0.2f} seconds.")

这需要0.17秒!并且与上面的做法完全相同。
$ python3.7 test.py
Took 0.17 seconds.

使用 create_task 实现异步版本

import asyncio, time

data = {}

async def process_usage(key):
    data[key] = key

async def main():
    for key in range(0,1000000):
        asyncio.create_task(process_usage(key))

s = time.perf_counter()
results = asyncio.run(main())
elapsed = time.perf_counter() - s
print(f"Took {elapsed:0.2f} seconds.")

此版本将其降至11秒。
$ python3.7 async_test2.py
Took 11.91 seconds.

为什么会发生这种情况?

在我的生产代码中,我将在process_usage函数中进行一个阻塞式调用,将键的值保存到Redis数据库中。


3
首先,您的异步代码必须生成一个带有一百万个参数的函数调用,这需要将其加载到内存中。而您的同步代码只使用高效的range()迭代器。 - Kyle Willmon
@KyleWillmon 我对异步编程不太熟悉,有更好的方法吗?在生产环境中,我还需要遍历100万个键,但不是使用range函数,而是从数据库中获取。 - Jonathan
2
据我所知,要跟踪100万个协程,你总是需要相当大的开销。然而,对于这个微不足道的例子来说,19秒似乎过长了。也许其他人可以更详细地解释一下。 - Kyle Willmon
我已经添加了一个使用create_task的示例,将其降至11秒。目前这是最佳选择。但是我的脚本大量使用redis,我想在process_usage中使用aioredis,但如果它不是异步的,我就无法这样做。 - Jonathan
1
你为什么认为在这里使用asyncio会更快呢?你正在进行完全绑定CPU的工作。 - juanpa.arrivillaga
显示剩余4条评论
2个回答

7
比较这些基准测试时,应注意异步版本是异步的:asyncio花费了相当大的精力来确保您提交的协程可以并发运行。在您的特定情况下,它们实际上不会并发运行,因为process_usage没有等待任何内容,但系统并没有真正关心这一点。另一方面,同步版本则没有做出这样的规定:它只按顺序运行所有内容,进入解释器的快乐路径。
更合理的比较是,将同步版本尝试以同步代码惯用的方式进行并行化:使用线程。当然,您不会能够为每个process_usage创建单独的线程,因为与asyncio的任务不同,操作系统不允许您创建数百万个线程。但是,您可以创建线程池并向其提供任务:
def main():
    with concurrent.futures.ThreadPoolExecutor() as executor:
        for key in range(0,1000000):
            executor.submit(process_usage, key)
        # at the end of "with" the executor automatically
        # waits for all futures to finish

在我的系统上,同步代码需要大约17秒的时间,而asyncio代码需要大约18秒的时间。(更快的asyncio版本只需要大约13秒。)
如果asyncio的速度提升如此之小,那么为什么要使用它呢?区别在于,假设使用惯用代码和IO密集型协程,使用asyncio可以拥有一个几乎无限数量的任务,以非常实际的意义并发执行。您可以同时创建成千上万个异步连接,并且asyncio将使用高质量的轮询器和可扩展的协程调度器来同时处理它们。使用线程池并行执行的任务数量始终受到池中线程数的限制,在最多情况下通常只有几百个。
即使是玩具例子也有价值,至少对于学习而言如此。如果您正在使用这些微基准进行决策,我建议您花费更多精力使示例更加逼真。asyncio示例中的协程应该包含至少一个await,而同步示例应该使用线程来模拟您获得的相同并行性。如果您将两者都调整到与实际用例匹配,则基准实际上能够帮助您做出(更)明智的决定。

谢谢,这帮助我更好地理解为什么会发生这种情况。我的实际生产函数使用aioredis进行异步写入redis,但现在我明白了开销的来源。 - Jonathan
@Jonathan 很有意思能够更详细地研究一下你的原始问题。除了redis本身不堪重负和表现不佳之外,为什么并行的asyncio连接到redis会比相同数量的顺序连接慢,这还远非清楚。也许通过明智地使用信号量或者一个喂送固定数量工作线程的队列,可以最好地提高代码的性能。在asyncio中创建大量并发任务是可能的,但这并不意味着它是每个问题的最佳方法。 - user4815162342
它基本上从字典中读取每个键的使用数据,如果使用量超过1000,则将该键写入“rate_limit”redis数据库。就是这么简单。同步版本需要1秒钟,我正在尝试在脚本的其余部分中混合使用同步和异步(以执行一些批量写入到dynamodb)。希望这有所帮助。 - Jonathan

2

为什么会这样呢?

TL;DR

因为单独使用 asyncio 并不能加速代码。你需要有多个网络I/O相关的操作才能看到与同步版本的差异。

详细解释

asyncio 不是一个可以让你加速任意代码的魔法。无论是否使用 asyncio,你的代码仍然受限于CPU性能。

asyncio 是一种以清晰明了的方式管理多个执行流(协程)的方法。多个执行流允许你在等待其他操作完成之前启动下一个I/O相关操作(如对数据库的请求)。请阅读这个回答以获取更详细的解释。

还请阅读这个回答以了解何时使用 asyncio 是有意义的。

一旦你正确地开始使用 asyncio,使用它的开销应该比并行化I/O操作带来的好处要低得多。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接