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 |
|
在我的电脑运行代码后得出的数据如下:
1 |
|
可以看到裸创建协程的性能还是ok的,这是由于Python Asyncio
的协程是跑在一个线程上的用户级别协程,不需要跟操作系统交互,所以裸创建的协程性能会比较高。
不过这是在Python Asyncio
环境比较干净的情况下跑出来的数据,如果移除掉time_tasks
函数中的await asyncio.gather(*tasks)
,就会每一轮测试后Python Asyncio
中还存在一些未跑完的asyncio.Task
,这些asyncio.Task
会的运行会影响到测试的准确度,导致下一轮测试会比较慢了,具体数据如下:
1 |
|
除了裸创建协程的开销这个指标外,还有一个比较重要的指标就是Python Asyncio
能同时处理多少个协程。不过要做这个测试会比较困难,但是把指标改为一秒能处理多少个协程后进行测试就比较简单了,改动方法非常简单,只需要移动下await asyncio.gather(*tasks)
的位置即可,代码如下:
1 |
|
通过测试后发现,在我的电脑上Python Asyncio
每秒能处理接近10w个协程,算还ok(在m1 pro跑的数据是我电脑的3倍)
1 |
|
不过Asyncio
的性能只能算中规中矩,为了在生产环境下拥有更强的性能,大部分服务都会使用uvloop
,所以也附上了在我电脑上跑uvloop
的数据:
- 1.裸跑创建
asyncio.Task
1
2
3
4
5
6
7
8
9
10100,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
10100,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
10100,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
快速的捕获到内存,tramalloc
是Python
的标准库中的模块,他能获取到内存的使用情况快照,使用方法非常简单,如下代码:
1 |
|
这段代码需要捕获demo_dict
占用了多少内存,于是会在demo_dict
之前添加tracemalloc.start()
,在demo_dict
后面添加tramallo.take_snapshot()
,然后就可以根据shapshot.statistics
来统计demo_dict
的内存占用了,具体使用方法可以查看tracemalloc
文档。
内存占用的统计工具已经找到了,现在就需要编写一个简单的协程函数,由于要防止出现部分协程还没有运行,其他协程已经执行完毕的情况,这个协程函数需要加上一点点料,如下,该函数会遍历10次,且每次都休眠时间为0.1秒:
1 |
|
接着就需要通过asyncio.create_task
来创建运行sub_demo
的asyncio.Task
,并且使用tracemalloc.start
来统计他们的内存使用:
1 |
|
但是如果直接这样测试,会发现测试结果中平均每个协程的内存占用大约只有1kb左右,这是因为直接创建的asyncio.Task
只是被初始化,还未被调度,所以占用的资源是比较少的,测出来的数据也是不准的。
为了解决这个问题,就需要在程序中添加await asyncio.sleep(0)
这个最简单的让步方法,使创建的asyncio.Task
可以被初始化,最终成品代码如下:
1 |
|
运行代码后,可以看到随着生成asyncio.Task
对象越多,平均每个asyncio.Task
的内存占用越趋向于2K/b,这时可以简单的认为Python Asyncio
创建的每个协程的内存占用约为2Kb
1 |
|
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
的内存占用
- 本文作者:So1n
- 本文链接:http://so1n.me/2023/05/29/python_asyncio_lib_overhead/index.html
- 版权声明:本博客所有文章均采用 BY-NC-SA 许可协议,转载请注明出处!