Python Asyncio 协程对象开销成本

本文总阅读量

前记

最近看到一篇文章《How Much Memory Do You Need to Run 1 Million Concurrent Tasks?》,它介绍了不同语言在运行100万并发时的内存占用,文章列举了多种语言在不同并发环境下的内存占用,让我比较意外的是,在文章最后的结果看到了Go在100万并发时内存的占用比Python还高。
于是我很好奇一个Python Asyncio协程内存占用有多少,以及他的其他创建开销数据,处理性能等。

1.创建协程的开销

注:测试数据因电脑性能、Python版本区别有不同的差异,本次采用的Python环境为Python3.8

Python Asyncio中,一个asyncio.Task就代表是一个协程,而asyncio.ceate_task是便捷的创建asyncio.Task的方法,所以只需要计算通过asyncio.create_task创建n个asyncio.Task需要耗费多少秒,就可以知道Python Asyncio创建协程的开销了。

首先测的是裸创建协程的数据,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import asyncio
from time import process_time as time


async def time_tasks(count: int =100) -> float:

async def nop_task() -> None:
pass

start = time()
# 创建一批asyncio.Task
tasks = [asyncio.create_task(nop_task()) for _ in range(count)]
# 计算创建时间
elapsed = time() - start
# 消费创建的asyncio.Task
await asyncio.gather(*tasks)
return elapsed


async def main() -> None:
for count in range(100_000, 1000_000 + 1, 100_000):
create_time = await time_tasks(count)
create_per_second = 1 / (create_time / count)
print(f"{count:,} tasks \t {create_per_second:0,.0f} tasks per/s")

asyncio.run(main())

在我的电脑运行代码后得出的数据如下:

1
2
3
4
5
6
7
8
9
10
100,000 tasks 	 163,661 tasks per/s
200,000 tasks 163,275 tasks per/s
300,000 tasks 163,771 tasks per/s
400,000 tasks 155,471 tasks per/s
500,000 tasks 169,313 tasks per/s
600,000 tasks 161,779 tasks per/s
700,000 tasks 128,751 tasks per/s
800,000 tasks 173,240 tasks per/s
900,000 tasks 182,140 tasks per/s
1,000,000 tasks 182,871 tasks per/s

可以看到裸创建协程的性能还是ok的,这是由于Python Asyncio的协程是跑在一个线程上的用户级别协程,不需要跟操作系统交互,所以裸创建的协程性能会比较高。

不过这是在Python Asyncio环境比较干净的情况下跑出来的数据,如果移除掉time_tasks函数中的await asyncio.gather(*tasks),就会每一轮测试后Python Asyncio中还存在一些未跑完的asyncio.Task,这些asyncio.Task会的运行会影响到测试的准确度,导致下一轮测试会比较慢了,具体数据如下:

1
2
3
4
5
6
7
8
9
10
100,000 tasks 	 183,077 tasks per/s
200,000 tasks 132,444 tasks per/s
300,000 tasks 130,042 tasks per/s
400,000 tasks 139,849 tasks per/s
500,000 tasks 196,590 tasks per/s
600,000 tasks 139,296 tasks per/s
700,000 tasks 169,764 tasks per/s
800,000 tasks 151,947 tasks per/s
900,000 tasks 154,378 tasks per/s
1,000,000 tasks 147,910 tasks per/s

除了裸创建协程的开销这个指标外,还有一个比较重要的指标就是Python Asyncio能同时处理多少个协程。不过要做这个测试会比较困难,但是把指标改为一秒能处理多少个协程后进行测试就比较简单了,改动方法非常简单,只需要移动下await asyncio.gather(*tasks)的位置即可,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import asyncio
from time import process_time as time


async def time_tasks(count: int =100) -> float:

async def nop_task() -> None:
pass

start = time()
tasks = [asyncio.create_task(nop_task()) for _ in range(count)]
await asyncio.gather(*tasks) # <--移动在elapsed之前
elapsed = time() - start
return elapsed


async def main() -> None:
for count in range(100_000, 1000_000 + 1, 100_000):
create_time = await time_tasks(count)
create_per_second = 1 / (create_time / count)
print(f"{count:,} tasks \t {create_per_second:0,.0f} tasks per/s")

asyncio.run(main())

通过测试后发现,在我的电脑上Python Asyncio每秒能处理接近10w个协程,算还ok(在m1 pro跑的数据是我电脑的3倍)

1
2
3
4
5
6
7
8
9
10
100,000 tasks 	 101,301 tasks per/s
200,000 tasks 85,196 tasks per/s
300,000 tasks 75,686 tasks per/s
400,000 tasks 90,694 tasks per/s
500,000 tasks 91,721 tasks per/s
600,000 tasks 90,821 tasks per/s
700,000 tasks 87,428 tasks per/s
800,000 tasks 84,583 tasks per/s
900,000 tasks 88,311 tasks per/s
1,000,000 tasks 102,782 tasks per/s

不过Asyncio的性能只能算中规中矩,为了在生产环境下拥有更强的性能,大部分服务都会使用uvloop,所以也附上了在我电脑上跑uvloop的数据:

  • 1.裸跑创建asyncio.Task
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    100,000 tasks 	 236,275 tasks per/s
    200,000 tasks 175,045 tasks per/s
    300,000 tasks 195,661 tasks per/s
    400,000 tasks 190,515 tasks per/s
    500,000 tasks 207,637 tasks per/s
    600,000 tasks 203,315 tasks per/s
    700,000 tasks 189,886 tasks per/s
    800,000 tasks 161,822 tasks per/s
    900,000 tasks 241,289 tasks per/s
    1,000,000 tasks 216,428 tasks per/s
  • 2.裸跑创建asyncio.Task(每次开始时仍有未完成的asyncio.Task)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    100,000 tasks 	 236,375 tasks per/s
    200,000 tasks 157,874 tasks per/s
    300,000 tasks 195,601 tasks per/s
    400,000 tasks 163,596 tasks per/s
    500,000 tasks 246,414 tasks per/s
    600,000 tasks 161,369 tasks per/s
    700,000 tasks 219,827 tasks per/s
    800,000 tasks 200,722 tasks per/s
    900,000 tasks 195,852 tasks per/s
    1,000,000 tasks 161,363 tasks per/s
  • 3.每秒处理asyncio.Task能力:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    100,000 tasks 	 138,045 tasks per/s
    200,000 tasks 115,583 tasks per/s
    300,000 tasks 122,949 tasks per/s
    400,000 tasks 134,310 tasks per/s
    500,000 tasks 125,624 tasks per/s
    600,000 tasks 120,950 tasks per/s
    700,000 tasks 123,622 tasks per/s
    800,000 tasks 125,671 tasks per/s
    900,000 tasks 116,234 tasks per/s
    1,000,000 tasks 130,501 tasks per/s

通过上述结果可以看出,在使用uvloop后,三种测试方式的结果都优于Python Asyncio

2.内存占用

一般情况下,为了测试程序的真正内存占用,会选择直接运行程序,然后在终端上通过命令查看程序的内存占用。不过好在Python Asyncio是在线程上运行的用户级别程序,Python Asyncio不会像线程一样每启动一个线程都需要一向系统申请单独的内存和上下文,这意味着可以通过tracemalloc快速的捕获到内存,tramallocPython的标准库中的模块,他能获取到内存的使用情况快照,使用方法非常简单,如下代码:

1
2
3
4
5
6
def demo() -> None:
tracemalloc.start()
demo_dict = {}
snapshot = tracemalloc.take_snapshot()
total_bytes = sum(stat.size for stat in snapshot.statistics('lineno'))
print(total_bytes / 1024.0)

这段代码需要捕获demo_dict占用了多少内存,于是会在demo_dict之前添加tracemalloc.start(),在demo_dict后面添加tramallo.take_snapshot(),然后就可以根据shapshot.statistics来统计demo_dict的内存占用了,具体使用方法可以查看tracemalloc文档

内存占用的统计工具已经找到了,现在就需要编写一个简单的协程函数,由于要防止出现部分协程还没有运行,其他协程已经执行完毕的情况,这个协程函数需要加上一点点料,如下,该函数会遍历10次,且每次都休眠时间为0.1秒:

1
2
3
async def sub_demo() -> None:
for i in range(10):
await asyncio.sleep(0.1)

接着就需要通过asyncio.create_task来创建运行sub_demoasyncio.Task,并且使用tracemalloc.start来统计他们的内存使用:

1
2
3
4
def demo() -> None:
tracemalloc.start()
tasks = [asyncio.create_task(sub_demo()) for _ in range(num)]
snapshot = tracemalloc.take_snapshot()

但是如果直接这样测试,会发现测试结果中平均每个协程的内存占用大约只有1kb左右,这是因为直接创建的asyncio.Task只是被初始化,还未被调度,所以占用的资源是比较少的,测出来的数据也是不准的。
为了解决这个问题,就需要在程序中添加await asyncio.sleep(0)这个最简单的让步方法,使创建的asyncio.Task可以被初始化,最终成品代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import asyncio
import tracemalloc


async def sub_demo() -> None:
for i in range(10):
await asyncio.sleep(0.1)


async def benchmark(num: int) -> float:
tracemalloc.start()
tasks = [asyncio.create_task(sub_demo()) for _ in range(num)]
await asyncio.sleep(0)
snapshot = tracemalloc.take_snapshot()
await asyncio.wait(tasks) # 等待执行完成,防止影响到后面的其他测试
total_bytes = sum(stat.size for stat in snapshot.statistics('lineno'))
return total_bytes / 1024.0


async def main() -> None:
for n in [2000, 5000, 10000, 50000, 100000]:
total_kb = await benchmark(n)
print(f'> coroutines={n:5} used {total_kb:.3f} K/b per:{total_kb / n} K/b')


asyncio.run(main())

运行代码后,可以看到随着生成asyncio.Task对象越多,平均每个asyncio.Task的内存占用越趋向于2K/b,这时可以简单的认为Python Asyncio创建的每个协程的内存占用约为2Kb

1
2
3
4
5
coroutines= 2000 used 4172.743 K/b  per:2.08637158203125 K/b
coroutines= 5000 used 10627.268 K/b per:2.125453515625 K/b
coroutines=10000 used 20718.018 K/b per:2.0718017578125 K/b
coroutines=50000 used 102878.197 K/b per:2.0575639453125 K/b
coroutines=100000 used 205737.854 K/b per:2.05737853515625 K/b

3.附录–解惑

Go 1.4发版说明中已经说了一个Goroutine的内存占用只有2Kb,与上面测试总结Python Asyncio的内存占用是接近的,而在《# How Much Memory Do You Need to Run 1 Million Concurrent Tasks?》文中介绍了100万并发时,Go的内存占用比Python Asyncio还高。
这是因为Goroutine并不是真协程的调用,他与其他语言的协程调度的设计还是有区别的,大部分语言(Python, Js, Dart等)的协程都是交给一个线程去调度,而Go的协程–Goroutine是交给很多线程去调用的,所以大部分语言在100万并发与1万并发使用的线程数量是一样的,而Go在100万并发时除了有约100万个Goroutinue占用了内存空间外,还需要创建很多线程去调度Goroutine,它们会占用内存资源,最终导致在100万并发下Go占用的内存大于Python Asyncio

可以通过goroutines-size了解Goroutinue的内存占用

查看评论