一个asyncio任务的开销是什么?

16

在内存和速度方面,任何asyncio任务的开销是多少?在不需要并发运行的情况下,减少任务数量是否值得考虑?


2
这是一个相当广泛的问题...问题是,它对你来说是否足够高效?串行执行相同的任务可能意味着整个操作将花费更长时间;而异步执行它们可能会更快地完成所有任务。当然,这涉及到资源与时间的权衡。您需要确定哪种资源对您更为珍贵,以及您可以承担多少成本。您最好通过基准测试实际代码来确定这一点。 - deceze
1
关于什么?线程?普通函数?进程?全部? - Charming Robot
1
@deceze 在 SO 上出现的短语“它对你来说是否足够高效?” 是最让人沮丧的之一。开发者经常无法回答这个问题,但了解事物的相对开销对于避免“千刀万剐的性能死亡”至关重要。对于这个问题,只有三个资源在起作用:CPU 时间、内存、端到端时间。既然该问题仅询问“开销”,而不是更广泛的权衡讨论,因此似乎很容易回答,也不会过于宽泛。 - Philip Couling
2个回答

32
任何异步任务在内存和速度方面的开销是多少?
TL;DR 内存开销似乎可以忽略不计,但时间开销可能很大,特别是当等待的协程选择不挂起时。
假设您正在测量任务与直接等待的协程之间的开销,例如:
await some_coro()                       # (1)
await asyncio.create_task(some_coro())  # (2)

没有理由直接编写(2),但是在使用自动"将可等待对象转换为future"的API时(例如asyncio.gatherasyncio.wait_for),创建不必要的任务很容易出现。(我怀疑这个问题背后建立或使用了这样的抽象。)
测量两种变体之间的内存和时间差异很简单。例如,下面的程序创建了一百万个任务,进程的内存消耗可以除以一百万来估算一个任务的内存成本:
async def noop():
    pass

async def mem1():
    tasks = [asyncio.create_task(noop()) for _ in range(1000000)]
    time.sleep(60)  # not asyncio.sleep() in this case - we don't
                    # want our noop tasks to exit immediately

在我的64位Linux机器上运行Python 3.7,该进程大约消耗1 GiB的内存。这大约是每个任务+协程1 KiB的内存,它同时计算任务的内存和其事件循环记录中的条目的内存。以下程序测量了仅协程开销的近似值:
async def mem2():
    coros = [noop() for _ in range(1000000)]
    time.sleep(60)

上述过程大约需要消耗550 MiB的内存,或者仅有0.55 KiB每个协程。因此,虽然任务并不完全免费,但它对协程的内存开销并不会很大,特别是要记住上面的协程是空的。如果协程有一些状态,那么开销将会小得多(相对而言)。
那么CPU开销呢?创建和等待任务与仅等待协程相比需要多长时间?让我们进行简单的测量:
async def cpu1():
    t0 = time.time()
    for _ in range(1000000):
        await asyncio.create_task(noop())
    t1 = time.time()
    print(t1-t0)

在我的电脑上,平均需要27秒才能运行(波动很小)。没有任务的版本看起来像这样:

async def cpu2():
    t0 = time.time()
    for _ in range(1000000):
        await noop()
    t1 = time.time()
    print(t1-t0)

这个只需要0.16秒,相当于 ~170 的因子!所以结果表明,与等待协程对象相比,等待任务的时间开销是不可忽略的。这是由于两个原因:

  • 与协程对象相比,任务的创建成本更高,因为它们需要初始化基本Future,然后是Task本身的属性,并最终将任务插入事件循环中,带有自己的簿记。

  • 新创建的任务处于挂起状态,其构造函数scheduled在第一次机会时开始执行协程。由于任务拥有协程对象,因此等待新任务不能立即开始执行协程;它必须暂停并等待任务开始执行它。即使等待不挂起的协程,等待的协程也只会在完整的事件循环迭代后恢复!事件循环迭代代价高昂,因为它要遍历所有可运行的任务和轮询内核以获取IO和超时活动。事实上,cpu1strace显示了两百万次对epoll_wait(2)的调用。另一方面,cpu2仅偶尔向内核请求与分配相关的mmap(),总共只有几千个。

    相比之下,直接等待协程doesn't yield到事件循环,除非被等待的协程本身decides挂起。相反,它会立即开始执行协程,就像它是一个普通函数。

因此,如果您的协程的正常路径不涉及挂起(例如非争用同步原语或从具有数据提供的非阻塞套接字读取流),则等待它的成本与函数调用的成本相当。这比等待任务所需的事件循环迭代要快得多,在延迟很重要时可能会产生差异。

谢谢您提供的详细信息。不过我有一个问题,coros = [noop() for _ in range(1000000)] 是否会安排所有 noop 运行? - Michal Charemza
1
@MichalCharemza 这并不是这样的,自动调度是高级Task的属性,而不是低级协程对象的属性。在内存基准测试中,创建一百万个协程只是为了使内存使用量显现出来,并没有假设实际等待它们的运行时语义是相同的。 - user4815162342
2
暂停似乎是最重要的部分:如果我将代码更改为async def noop():asyncio.sleep(0),则会得到10秒而不是30秒。我不确定我是否同意“协程足够简单”的论点:如果它不需要挂起,特别是数百万个,那么就没有必要创建协程。还是感谢您的研究! - Mikhail Gerasimov
2
@MikhailGerasimov 如果不需要挂起,就没有必要创建协程 我并不考虑永远不会挂起的协程,而是可能不会典型地挂起的协程。答案提到了stream.read()作为一个例子,它的工作方式正是如此,但还有其他例子,比如queue.getqueue.put,许多异步上下文管理器的__aenter__方法,非争用情况下的同步方法等等。有许多低级协程在等待时不会每次都挂起。 - user4815162342
@MikhailGerasimov 有理怀疑。即使观察到这种“开销”测量方法是误导性的,也许是纯粹错误的。你正在测量端到端时间,而不是资源使用时间。创建任务的方法将所有工作分散到200万次事件循环迭代中。而直接的await只需1次事件循环迭代就能完成所有工作。所以即使是 @MikhailGerasimov 的10秒30秒也是错误的,因为它将其分布在100万次事件循环迭代与300万次事件循环迭代之间。 - Philip Couling

1
Task本身只是一个微小的Python对象。它需要极少量的内存和CPU。然而,由Task运行的操作(通常运行协程Task)可能会消耗自己的资源,例如:
  • 如果我们谈论网络操作(网络读/写),则需要网络带宽
  • 如果我们使用run_in_executor在单独的进程中运行操作,则需要CPU/内存
通常(*)您不必像考虑Python脚本中的函数调用数量那样考虑任务数量。
但是当然,您应该始终考虑异步程序的整体工作方式。如果它将进行大量并行I/O请求或生成大量并行线程/进程,则应使用Semaphore来避免同时获取过多资源。

(*) 除非你正在进行非常特殊的操作并计划创建数十亿个任务。在这种情况下,您应该使用Queue或类似方法懒惰地创建它们。


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