异步任务的开销是多少?

What is the overhead of an asyncio task?

任何 asyncio 任务在内存和速度方面的开销是多少?在不需要同时 运行 的情况下是否值得最小化任务数量?

Task 本身只是一个很小的 ​​Python 对象。它需要大量内存和 CPU。另一方面,运行 被 Task 操作(Task 通常 运行 是协程)可能会消耗其自身显着的资源,例如:

  • 网络带宽如果我们谈论网络操作(网络read/write)
  • CPU/memory 如果我们谈论操作 运行 在单独的进程中使用 run_in_executor

通常 (*) 您不必考虑任务的数量,例如,您通常不会考虑 Python 脚本中的函数调用数量。

当然,您应该始终考虑异步程序的总体工作方式。如果它要同时发出很多 I/O 请求或同时产生很多 threads/processes 你应该使用 Semaphore 来避免同时获取太多资源。


(*) 除非您正在做一些非常特别的事情并计划创建数十亿个任务。在这种情况下,您应该使用 Queue 或类似的东西懒惰地创建它们。

What is the overhead of any asyncio task in terms of memory and speed?

TL;DR 内存开销似乎可以忽略不计,但时间开销可能很大,尤其是当等待的协程选择不挂起时。

假设您正在测量与直接等待的协程相比任务的开销,例如:

await some_coro()                       # (1)
await asyncio.create_task(some_coro())  # (2)

没有理由直接写 (2),但是当使用自动 "futurize" 他们收到的等待对象的 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 机器上 运行ning 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 倍!所以事实证明,与等待协程对象相比,等待任务的 time 开销是不可忽略的。这有两个原因:

  • 创建任务比协程对象更昂贵,因为它们需要初始化基础 Future,然后是 Task 本身的属性,最后将任务插入到事件循环,有自己的簿记。

  • 一个新创建的任务处于挂起状态,其构造函数具有 scheduled it to start executing the coroutine at the first opportunity. Since the task owns the coroutine object, awaiting a fresh task cannot just start executing the coroutine; it has to suspend and wait for the task to get around to executing it. The awaiting coroutine will only resume after a full event loop iteration, even when awaiting a coroutine that chooses not to suspend at all! An event loop iteration is expensive because it goes through all runnable tasks and polls the kernel for IO and timeout activities. Indeed, strace of cpu1 shows two million calls to epoll_wait(2)cpu2 另一方面,只有偶尔与分配相关的 mmap() 才会进入内核,总共有几千。

    相反,直接等待协程进入事件循环,除非等待的协程本身决定挂起。相反,它会立即继续并开始执行协程,就好像它是一个普通函数一样。

因此,如果您的协程的快乐路径不涉及挂起(如非竞争同步原语或从有数据提供的非阻塞套接字读取流的情况),等待它的成本是与函数调用的成本相当。这比等待任务所需的事件循环迭代要快得多,并且在延迟很重要时会有所作为。